Files
habit-tracker/tests/test_pages_journal.py
T
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00

366 lines
15 KiB
Python

from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
from app.services import journal_service
def _make_category(db_session, user_id, name="일상", content_template=None):
return journal_service.create_category(
db_session, user_id, JournalCategoryCreate(name=name, content_template=content_template)
)
def _make_entry(db_session, user_id, category_id, entry_date=None, content="기록"):
data = JournalEntryCreate(category_id=category_id, entry_date=entry_date or date.today(), content=content)
return journal_service.create_entry(db_session, user_id, data)
def _png_file():
buf = BytesIO()
Image.new("RGB", (400, 300), "green").save(buf, format="PNG")
buf.seek(0)
return buf
def test_journal_page_requires_login(client):
response = client.get("/journal", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_journal_page_creates_default_category_and_renders(auth_client):
response = auth_client.get("/journal")
assert response.status_code == 200
assert journal_service.DEFAULT_CATEGORY_NAME in response.text
def test_journal_page_embeds_category_content_template(auth_client):
response = auth_client.get("/journal")
assert response.status_code == 200
assert "Story" in response.text
assert "메타인지" in response.text
def test_journal_page_template_with_double_quotes_does_not_break_x_data_attribute(
auth_client, db_session, test_user
):
baseline_form_count = auth_client.get("/journal").text.count("<form")
_make_category(db_session, test_user.id, name="따옴표카테고리", content_template='내용에 "큰따옴표"가 있어요')
response = auth_client.get("/journal")
assert response.status_code == 200
# x-data="..." 속성 안에 이스케이프 안 된 "가 섞이면 속성이 거기서 끊겨 뒤 마크업이 전부
# 속성값으로 흡수되는데, 그러면 폼의 <form 태그들이 열린 속성값 텍스트에 파묻혀 개수가
# 줄어든다 — 카테고리 하나 늘었다고 <form 개수가 그대로인지로 attribute-injection 여부를 검증한다.
assert response.text.count("<form") == baseline_form_count
assert "\\&#34;큰따옴표\\&#34;" in response.text
def test_create_entry_via_multipart_form(auth_client, db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id, name="투자")
today = date.today()
response = auth_client.post(
"/journal/new",
data={
"category_id": str(category.id),
"entry_date": today.isoformat(),
"title": "첫 기록",
"content": "오늘의 투자 회고",
"moods": "happy,tired",
"tags": "투자, 회고",
},
files={"files": ("photo.png", _png_file(), "image/png")},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == f"/journal?year={today.year}&month={today.month}"
entries = journal_service.list_entries(db_session, test_user.id)
assert len(entries) == 1
assert entries[0].title == "첫 기록"
assert {t.name for t in entries[0].tags} == {"투자", "회고"}
assert {m.mood.value for m in entries[0].moods} == {"happy", "tired"}
assert len(entries[0].attachments) == 1
def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
response = auth_client.post(
"/journal/new",
data={"category_id": str(category.id), "entry_date": date.today().isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
"/journal/new",
data={
"category_id": str(others_category.id),
"entry_date": date.today().isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="오늘의 기록")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "오늘의 기록" in response.text
def test_journal_day_detail_renders_content_as_markdown(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="**굵은 글씨** 테스트")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<strong>굵은 글씨</strong>" in response.text
def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(
db_session, test_user.id, category.id, entry_date=today, content='<script>alert(1)</script>본문'
)
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<script" not in response.text
def test_journal_day_detail_requires_login(client):
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_preview_renders_markdown(auth_client):
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
assert response.status_code == 200
assert "<strong>굵게</strong>" in response.text
def test_preview_strips_script_tags(auth_client):
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
assert response.status_code == 200
assert "<script" not in response.text
def test_preview_shows_placeholder_for_blank_content(auth_client):
response = auth_client.post("/journal/preview", data={"content": " "})
assert response.status_code == 200
assert "미리보기가 여기에 표시돼요" in response.text
def test_preview_requires_login(client):
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_edit_entry_updates_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "수정된 내용",
"tags": "",
},
)
assert response.status_code == 200
assert "수정된 내용" in response.text
db_session.refresh(entry)
assert entry.content == "수정된 내용"
def test_edit_entry_blank_content_keeps_form_open_with_error(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
db_session.refresh(entry)
assert entry.content == "원래 내용"
def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": "해킹 시도"},
)
assert response.status_code == 404
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(others_category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
db_session.refresh(entry)
assert entry.category_id == category.id
assert entry.content == "원래 내용"
def test_delete_entry_removes_it(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 200
assert "지울 기록" not in response.text
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 404
assert journal_service.get_entry(db_session, entry.id, other_user.id) is not None
def test_delete_attachment_removes_it(auth_client, db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
upload = UploadFile(file=_png_file(), filename="a.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
response = auth_client.post(f"/journal/{entry.id}/attachments/{attachment.id}/delete")
assert response.status_code == 200
assert journal_service.get_attachment(db_session, attachment.id, test_user.id) is None
def test_create_category_and_reject_duplicate(auth_client):
response = auth_client.post("/journal/categories/new", data={"name": "투자"}, follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
dup_response = auth_client.post("/journal/categories/new", data={"name": "투자"})
assert dup_response.status_code == 200
assert "이미 같은 이름" in dup_response.text
def test_journal_page_manage_tab_shows_delete_button_for_categories(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal?tab=manage")
assert response.status_code == 200
assert "/journal/categories/" in response.text
assert "/delete" in response.text
def test_journal_page_default_tab_hides_category_management(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal")
assert response.status_code == 200
assert "/journal/categories/" not in response.text
def test_delete_category_removes_it_and_its_entries(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="지울 카테고리")
entry = _make_entry(db_session, test_user.id, category.id, content="딸려서 지워질 기록")
response = auth_client.post(f"/journal/categories/{category.id}/delete", follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
assert journal_service.get_category(db_session, category.id, test_user.id) is None
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/delete")
assert response.status_code == 404
assert journal_service.get_category(db_session, category.id, other_user.id) is not None
def test_edit_category_updates_name_and_color(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="원래 이름")
response = auth_client.post(
f"/journal/categories/{category.id}/edit",
data={"name": "바뀐 이름", "color": "#123456"},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
db_session.refresh(category)
assert category.name == "바뀐 이름"
assert category.color == "#123456"
def test_edit_category_rejects_duplicate_name(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
category = _make_category(db_session, test_user.id, name="일기")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "투자"})
assert response.status_code == 200
assert "이미 같은 이름" in response.text
def test_edit_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "가로채기"})
assert response.status_code == 404
def test_manage_tab_shows_entry_count_per_category(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="투자")
_make_entry(db_session, test_user.id, category.id, content="1")
_make_entry(db_session, test_user.id, category.id, content="2")
response = auth_client.get("/journal?tab=manage")
assert "기록 2개" in response.text