import pytest from thoughtsync.app import create_app from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.notes import ( _escape_like, _parse_iso_dt, _slugify, derive_display_title, is_empty_note, normalize_color, parse_link_titles, parse_list_items, parse_tags, rewrite_link_title, ) @pytest.fixture def app(): return create_app() def test_is_empty_note(): assert is_empty_note(None, None) assert is_empty_note("", " ") assert not is_empty_note("title", "") assert not is_empty_note("", "body") def test_normalize_color(): assert normalize_color("blue") == "blue" assert normalize_color("chartreuse") == "default" assert normalize_color(None) == "default" assert normalize_color(123) == "default" def test_palette_has_core_colors(): for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"): assert c in NOTE_COLORS def test_serialize_shape(): n = Note(title="t", body="b", color="blue", pinned=True, archived=False) s = n.serialize() assert s["title"] == "t" assert s["body"] == "b" assert s["color"] == "blue" assert s["pinned"] is True assert s["archived"] is False assert s["trashed"] is False async def test_notes_list_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes") assert resp.status_code == 401 async def test_notes_create_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes", json={"body": "hi"}) assert resp.status_code == 401 async def test_search_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/search?q=hello") assert resp.status_code == 401 async def test_add_item_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"}) assert resp.status_code == 401 async def test_upload_attachment_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments") assert resp.status_code == 401 async def test_reorder_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/reorder", json={"ids": []}) assert resp.status_code == 401 def test_parse_link_titles(): titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again") assert titles == ["alpha", "beta"] def test_parse_link_titles_empty(): assert parse_link_titles(None) == [] assert parse_link_titles("no links here") == [] def test_rewrite_link_title(): body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]" assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]" def test_rewrite_link_title_noop(): assert rewrite_link_title("", "alpha", "Gamma") == "" assert rewrite_link_title(None, "alpha", "Gamma") == "" assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here" def test_derive_display_title_explicit_wins(): assert derive_display_title("My Title", "some body line") == "My Title" assert derive_display_title(" Padded ", "body") == "Padded" def test_derive_display_title_from_first_body_line(): assert derive_display_title(None, "first line\nsecond line") == "first line" assert derive_display_title("", " spaced first \nnext") == "spaced first" # leading blank/whitespace lines are skipped to the first line with content assert derive_display_title(None, "\n \nreal line\nmore") == "real line" # a whitespace-only title falls through to the body assert derive_display_title(" ", "body wins") == "body wins" def test_derive_display_title_empty(): assert derive_display_title(None, None) == "" assert derive_display_title("", "") == "" assert derive_display_title(" ", " \n ") == "" def test_derive_display_title_caps_length(): long = "x" * 300 assert derive_display_title(None, long) == "x" * 200 assert derive_display_title(long, "body") == "x" * 200 def test_parse_tags(): assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"] # case-insensitive dedup, first spelling wins assert parse_tags("#Work then #work") == ["Work"] # url fragments, mid-word #, purely-numeric, and a bare # are not tags assert parse_tags("frag http://x/#nope mid#word #2024 #") == [] assert parse_tags(None) == [] assert parse_tags("#a #b #a") == ["a", "b"] def test_parse_list_items(): assert parse_list_items(["milk", " eggs ", "", " ", "bread"]) == ["milk", "eggs", "bread"] assert parse_list_items("not a list") == [] assert parse_list_items(None) == [] assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"] def test_escape_like(): # LIKE wildcards in user input must be neutralized so they match literally. assert _escape_like("100%") == "100\\%" assert _escape_like("a_b") == "a\\_b" assert _escape_like("c:\\path") == "c:\\\\path" assert _escape_like("plain") == "plain" def test_parse_iso_dt(): # A full ISO instant round-trips (used to validate the Timeline date range). d = _parse_iso_dt("2026-07-19T12:30:00+00:00") assert (d.year, d.month, d.day, d.hour, d.minute) == (2026, 7, 19, 12, 30) assert d.tzinfo is not None # a trailing Z is accepted as UTC assert _parse_iso_dt("2026-07-19T00:00:00Z").tzinfo is not None # a plain calendar date parses to midnight assert _parse_iso_dt("2026-07-19").hour == 0 # garbage raises (the endpoint turns this into a 400) with pytest.raises(ValueError): _parse_iso_dt("not-a-date") async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles") assert resp.status_code == 401 async def test_link_search_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/link-search?q=hi") assert resp.status_code == 401 async def test_graph_requires_auth(app): client = app.test_client() resp = await client.get("/api/graph") assert resp.status_code == 401 async def test_reminders_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/reminders") assert resp.status_code == 401 def test_slugify(): assert _slugify("My Great Note!") == "my-great-note" assert _slugify(" spaced / weird __name ") == "spaced-weird-name" assert _slugify("") == "note" # empty falls back assert _slugify("!!!") == "note" # all punctuation strips to empty → fallback assert len(_slugify("x" * 100)) == 60 async def test_export_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/export") assert resp.status_code == 401 async def test_list_revisions_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions") assert resp.status_code == 401 async def test_restore_revision_requires_auth(app): client = app.test_client() resp = await client.post( "/api/notes/00000000-0000-0000-0000-000000000000/revisions/00000000-0000-0000-0000-000000000001/restore" ) assert resp.status_code == 401