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>
This commit is contained in:
2026-08-05 12:24:18 +09:00
co-authored by Claude Sonnet 5
parent c466cc6639
commit bdf9d0bae7
18 changed files with 564 additions and 30 deletions
+83
View File
@@ -101,6 +101,22 @@ def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
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()
@@ -111,12 +127,58 @@ def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
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="원래 내용")
@@ -163,6 +225,27 @@ def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user)
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="지울 기록")