The 1574-line notes.py becomes a `notes/` package. The heavy shared logic moves
into focused modules; the route handlers + blueprint registration stay together
in __init__ so registration is trivially correct (most routes have no CI
auth-test that would otherwise catch a route silently dropping out):
- notes/_bp.py — the Blueprint (isolated so route modules could import it
without a cycle; also the seam for a later route split).
- notes/serialize.py — note (+labels/items/attachments/previews) serialization.
- notes/links.py — [[wiki-link]] + #tag parsing and reconciliation.
- notes/recurrence.py — recurring-reminder next-occurrence math.
- notes/helpers.py — display-title/empty/filter/owner-fetch + filename/slug utils.
- notes/import_export.py — export markdown + Keep/native import specs + zip budget.
- notes/__init__.py — the `/api/notes` routes + re-exports the external surface
(app.py imports `bp`; sync.py + tests import helpers).
Pure reorganization — no behavior change (routes/helpers moved verbatim). Callers
(app.py, sync.py, test_notes.py) are unchanged: `from thoughtsync.notes import X`
resolves via the package __init__ (rule 22 — the package replaces the module).
No import cycle (nothing in the package's dep chain imports notes; only app.py +
sync.py consume it). New test_all_note_routes_registered asserts all 29 route
endpoints are attached, so CI catches any module that fails to register. Runtime
DB behavior operator-verified on deploy (no Postgres CI lane).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
417 lines
15 KiB
Python
417 lines
15 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.common import coerce_bool, parse_dt
|
|
from thoughtsync.models.note import NOTE_COLORS, Note
|
|
from thoughtsync.notes import (
|
|
_attachment_ext,
|
|
_escape_like,
|
|
_header_filename,
|
|
_keep_spec,
|
|
_native_spec,
|
|
_safe_filename,
|
|
_slugify,
|
|
_usec_to_dt,
|
|
derive_display_title,
|
|
is_empty_note,
|
|
next_occurrence,
|
|
normalize_color,
|
|
normalize_recurrence,
|
|
parse_link_titles,
|
|
parse_list_items,
|
|
parse_tags,
|
|
rewrite_link_title,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
def test_all_note_routes_registered(app):
|
|
# Guards the notes package split: every route handler must still be attached to the
|
|
# blueprint. A route whose module isn't imported by notes/__init__ would silently
|
|
# 404 at runtime, and most routes have no auth-guard test to otherwise catch it.
|
|
registered = {r.endpoint for r in app.url_map.iter_rules()}
|
|
expected = {
|
|
f"notes.{name}"
|
|
for name in (
|
|
"list_notes", "search_notes", "list_reminders", "complete_reminder",
|
|
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
|
"link_search", "note_backlinks", "reorder_notes", "create_note",
|
|
"get_note", "update_note", "list_revisions", "restore_revision",
|
|
"set_note_labels", "add_item", "update_item", "delete_item",
|
|
"reorder_items", "upload_attachment", "get_attachment",
|
|
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
|
|
"restore_note", "delete_note",
|
|
)
|
|
}
|
|
assert expected <= registered, f"unregistered note routes: {expected - registered}"
|
|
|
|
|
|
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_dt():
|
|
# A full ISO instant round-trips (used to validate the Timeline date range).
|
|
d = parse_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_dt("2026-07-19T00:00:00Z").tzinfo is not None
|
|
# a plain calendar date parses to midnight
|
|
assert parse_dt("2026-07-19").hour == 0
|
|
# garbage / non-strings return None (the endpoint turns this into a 400)
|
|
assert parse_dt("not-a-date") is None
|
|
assert parse_dt("") is None
|
|
assert parse_dt(None) is None
|
|
|
|
|
|
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
|
|
|
|
|
|
async def test_import_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post("/api/notes/import")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_safe_filename():
|
|
assert _safe_filename("report.pdf") == "report.pdf"
|
|
assert _safe_filename("/etc/passwd") == "passwd" # path components stripped
|
|
assert _safe_filename("a\\b\\c.doc") == "c.doc" # windows separators too
|
|
assert _safe_filename("") == "file" # fallback
|
|
assert _safe_filename(None) == "file"
|
|
|
|
|
|
def test_attachment_ext():
|
|
assert _attachment_ext("report.pdf", "application/pdf") == ".pdf"
|
|
assert _attachment_ext("memo.m4a", "audio/mp4") == ".m4a"
|
|
# no extension in the name → fall back to a known image mime, else empty
|
|
assert _attachment_ext("noext", "image/png") == ".png"
|
|
assert _attachment_ext("noext", "application/octet-stream") == ""
|
|
|
|
|
|
def test_header_filename():
|
|
# Quotes/newlines are stripped so the Content-Disposition header can't be broken.
|
|
assert _header_filename('a"b\r\n.pdf') == "ab.pdf"
|
|
assert _header_filename("") == "file"
|
|
|
|
|
|
def test_coerce_bool():
|
|
assert coerce_bool("true") and coerce_bool("1") and coerce_bool("yes") and coerce_bool("on")
|
|
assert coerce_bool(True)
|
|
assert not coerce_bool("false")
|
|
assert not coerce_bool(None)
|
|
assert not coerce_bool("")
|
|
assert not coerce_bool(False)
|
|
|
|
|
|
def test_normalize_recurrence():
|
|
for v in ("daily", "weekly", "monthly", "yearly"):
|
|
assert normalize_recurrence(v) == v
|
|
assert normalize_recurrence("none") is None
|
|
assert normalize_recurrence("") is None
|
|
assert normalize_recurrence(None) is None
|
|
assert normalize_recurrence("hourly") is None
|
|
|
|
|
|
def test_next_occurrence_daily_weekly():
|
|
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
|
|
after = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) # same day, later
|
|
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 2, 9, 0, tzinfo=timezone.utc)
|
|
assert next_occurrence(base, "weekly", after) == datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_next_occurrence_skips_missed():
|
|
base = datetime(2026, 7, 1, 9, 0, tzinfo=timezone.utc)
|
|
after = datetime(2026, 7, 10, 12, 0, tzinfo=timezone.utc) # 9+ days later
|
|
# Rolls forward past every missed day to the first fire strictly after `after`.
|
|
assert next_occurrence(base, "daily", after) == datetime(2026, 7, 11, 9, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_next_occurrence_monthly_clamps_month_end():
|
|
base = datetime(2026, 1, 31, 8, 0, tzinfo=timezone.utc)
|
|
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
|
|
# Jan 31 + 1 month → Feb 28 (clamped to the shorter month).
|
|
assert next_occurrence(base, "monthly", after) == datetime(2026, 2, 28, 8, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_next_occurrence_yearly_and_none():
|
|
base = datetime(2026, 3, 15, 7, 0, tzinfo=timezone.utc)
|
|
after = datetime(2026, 3, 16, tzinfo=timezone.utc)
|
|
assert next_occurrence(base, "yearly", after) == datetime(2027, 3, 15, 7, 0, tzinfo=timezone.utc)
|
|
assert next_occurrence(base, "none", after) is None
|
|
|
|
|
|
async def test_complete_reminder_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/reminder/complete")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_snooze_reminder_requires_auth(app):
|
|
client = app.test_client()
|
|
resp = await client.post(
|
|
"/api/notes/00000000-0000-0000-0000-000000000000/reminder/snooze", json={"minutes": 10}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_usec_to_dt():
|
|
# Google Keep timestamps are microseconds since the epoch (UTC).
|
|
d = _usec_to_dt(1600000000000000)
|
|
assert d is not None and d.year == 2020 and d.tzinfo is not None
|
|
# garbage / missing → None (the note still imports, just without the timestamp)
|
|
assert _usec_to_dt("nope") is None
|
|
assert _usec_to_dt(None) is None
|
|
|
|
|
|
def test_keep_spec_list_note():
|
|
kn = {
|
|
"title": "Groceries",
|
|
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
|
"labels": [{"name": "shopping"}],
|
|
"color": "TEAL",
|
|
"isPinned": True,
|
|
"isArchived": False,
|
|
"isTrashed": False,
|
|
"createdTimestampUsec": 1600000000000000,
|
|
"userEditedTimestampUsec": 1600000100000000,
|
|
}
|
|
spec = _keep_spec(kn, "Takeout/Keep")
|
|
assert spec["kind"] == "list"
|
|
assert spec["color"] == "teal"
|
|
assert spec["pinned"] is True
|
|
assert spec["archived"] is False
|
|
assert spec["trashed"] is False
|
|
assert spec["items"] == [{"text": "Milk", "checked": False}, {"text": "Eggs", "checked": True}]
|
|
assert spec["labels"] == ["shopping"]
|
|
assert spec["created_at"].year == 2020
|
|
|
|
|
|
def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
|
|
kn = {
|
|
"textContent": "Read this later",
|
|
"annotations": [{"url": "https://example.com"}],
|
|
"color": "BROWN", # no brown in our palette → nearest (orange)
|
|
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
|
|
}
|
|
spec = _keep_spec(kn, "Takeout/Keep")
|
|
assert spec["kind"] == "text"
|
|
assert "https://example.com" in spec["body"]
|
|
assert spec["color"] == "orange"
|
|
# attachment path is resolved relative to the note JSON's folder
|
|
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
|
|
|
|
|
|
def test_native_spec_roundtrip_fields():
|
|
n = {
|
|
"title": "T",
|
|
"body": "b",
|
|
"color": "blue",
|
|
"kind": "text",
|
|
"pinned": True,
|
|
"archived": False,
|
|
"created_at": "2026-07-19T00:00:00+00:00",
|
|
"labels": ["x"],
|
|
"items": [],
|
|
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
|
|
}
|
|
spec = _native_spec(n)
|
|
assert spec["title"] == "T"
|
|
assert spec["body"] == "b"
|
|
assert spec["color"] == "blue"
|
|
assert spec["pinned"] is True
|
|
assert spec["trashed"] is False # exports only carry live notes
|
|
assert spec["created_at"].year == 2026
|
|
assert spec["labels"] == ["x"]
|
|
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
|