- Mood switches from a single enum column to a many-to-many JournalEntryMood table so an entry can carry several feelings at once; the vocabulary is trimmed to 9 named emotions (dropped the overlapping satisfaction scale) with unified noun-style labels. - Categories can define a content_template that pre-fills the "new entry" textarea when selected (only if the user hasn't started typing), seeded with a Story/Feelings/Decisions/Insights/Actions reflection template on the default "일상" category. - Fixes a real attribute-injection bug found while building the template feature: Jinja's built-in |tojson filter doesn't escape double quotes, which breaks a double-quoted x-data="..." attribute when the JSON payload contains one; added |forceescape and a regression test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
12 KiB
Python
283 lines
12 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 "\\"큰따옴표\\"" 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_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_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_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_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
|