CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge. Renaming it broke every inbound link — and the fix that shipped for that (task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]` text inside the body of every note that linked to the renamed one. That works while an explicit title exists to hold still. It stops being defensible the moment a note's name is just its first body line, which is where M13 is going: fixing a typo in your opening sentence would silently edit other notes' words, with nothing to opt out to. So this lands first, before the title comes out, and that window never ships. `note_links` gains `target_id`, bound when the link is written. `target_norm` stays and is what an UNRESOLVED link carries — linking to a note that doesn't exist yet is a supported way to create one, so a link has to be able to name a target that isn't there. Resolution reads the id, falling back to the name only where nothing was bound, which is what lets a forward link connect the moment its target appears. `_claim_unresolved_links` then binds it, so the fallback is a transitional state rather than a permanent one. `_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them touches link rows only: a note's text is never modified by something happening to a different note. The client can no longer resolve links for itself, and that is the point. It used to look `[[text]]` up in a client-side name index, which only held together BECAUSE renaming rewrote the text everywhere. Now the written text can name something the target is no longer called, and only the server holds the binding — so each note serializes its resolved links (`norm`, `id`, and the target's name as it stands NOW). A renamed note reads correctly everywhere it is linked from, without a single body having been edited. Unresolved links are simply absent and fall through to the create-on-click affordance that already existed; so does the offline desktop store, which derives links at query time and has no binding to send. The name-fallback join is owner-scoped everywhere it appears. Bound ids were resolved owner-scoped when written, but matching on display_title alone would have let two users who each have a note called "Groceries" see the other's id and name through an unresolved link (rule 47). The new behaviour is all SQL and this suite runs without a database, so the dead helpers' tests are removed rather than replaced. This repo has no integration lane to hold that ground — noted, not papered over.
423 lines
15 KiB
Python
423 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,
|
|
)
|
|
|
|
|
|
@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") == []
|
|
|
|
|
|
# `rewrite_link_title` and `_rename_inbound_links` are gone (M13 step 1). They kept
|
|
# backlinks alive across a rename by editing the [[text]] inside every note that
|
|
# linked to the renamed one — which meant one note's edit silently rewrote another's
|
|
# words. Links are bound to a note id now, so a rename needs no repair at all.
|
|
#
|
|
# What replaced them (`_resolve_target`, `_claim_unresolved_links`, and the id-first
|
|
# resolution in backlinks / the graph / serialization) is all SQL, and this suite runs
|
|
# without a database, so it is deliberately not asserted here. See the task log: that
|
|
# behaviour was checked by hand, and this repo has no integration lane to hold it.
|
|
|
|
|
|
def test_parse_link_titles_ignores_nesting():
|
|
# The link regex refuses [ and ] inside a token, so a malformed nest yields the
|
|
# inner name rather than something spanning both — worth pinning, since this is
|
|
# the string that becomes a link's stored target.
|
|
assert parse_link_titles("[[outer [[inner]] ]]") == ["inner"]
|
|
|
|
|
|
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"}]
|