This commit is contained in:
schmeeve
2026-09-26 22:06:28 -07:00
parent bc365267d0
commit 0758095046
14 changed files with 1392 additions and 0 deletions

131
tests/test_cli.py Normal file
View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import importlib.util
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
import pytest
def _load_cli():
path = Path(__file__).parent.parent / "cli" / "snotes.py"
spec = importlib.util.spec_from_file_location("snotes_cli", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
cli = _load_cli()
# -- parse_date --------------------------------------------------------------
def test_parse_date_defaults_to_today():
assert cli.parse_date(None) == date.today()
assert cli.parse_date("today") == date.today()
def test_parse_date_relative_words():
assert cli.parse_date("yesterday") == date.today() - timedelta(days=1)
assert cli.parse_date("tomorrow") == date.today() + timedelta(days=1)
def test_parse_date_offsets():
assert cli.parse_date("+3") == date.today() + timedelta(days=3)
assert cli.parse_date("-7") == date.today() - timedelta(days=7)
assert cli.parse_date("0") == date.today()
def test_parse_date_iso():
assert cli.parse_date("2026-09-26") == date(2026, 9, 26)
def test_parse_date_invalid():
with pytest.raises(cli.CliError, match="bad date"):
cli.parse_date("next friday-ish")
# -- render_template ---------------------------------------------------------
def test_render_template_date():
body = "# Journal for %date%\n$journal\n\n- \n"
out = cli.render_template(body, date(2026, 9, 26), "schmeeve")
assert out.startswith("# Journal for 2026-09-26\n")
def test_render_template_strips_marker_lines():
body = "# T\n$journal\nmiddle\n $other \nend\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert "$journal" not in out
assert "$other" not in out
assert "middle" in out
assert "end" in out
def test_render_template_keeps_inline_markers():
# only standalone marker lines are stripped
body = "# T\nmentions $journal inline\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert "$journal inline" in out
def test_render_template_vars():
now = datetime(2026, 9, 26, 14, 30)
body = "%date% | %time% | %datetime% | %user%"
out = cli.render_template(body, date(2026, 1, 2), "ilya", now=now)
assert out == "2026-01-02 | 14:30 | 2026-01-02 14:30 | ilya\n"
def test_render_template_collapses_blank_edges():
body = "$journal\n\n# Title\n\n"
out = cli.render_template(body, date(2026, 1, 1), "u")
assert out == "# Title\n"
# -- extract_title -----------------------------------------------------------
def test_extract_title_heading():
assert cli.extract_title("# Hello\n\nbody") == "Hello"
def test_extract_title_first_line():
assert cli.extract_title("plain line\nmore") == "plain line"
def test_extract_title_untitled():
assert cli.extract_title(" \n\n") == "Untitled"
# -- config ------------------------------------------------------------------
def test_config_round_trip(tmp_path, monkeypatch):
path = tmp_path / "cfg" / "config.json"
monkeypatch.setattr(cli, "CONFIG_PATH", path)
cfg = cli.Config(url="http://sno.tes/", user="schmeeve", token="tok123")
cli.save_config(cfg)
loaded = cli.load_config()
assert loaded.url == "http://sno.tes" # trailing slash stripped
assert loaded.user == "schmeeve"
assert loaded.token == "tok123"
assert (path.stat().st_mode & 0o777) == 0o600
def test_load_config_missing(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "nope.json")
with pytest.raises(cli.CliError, match="no config"):
cli.load_config()
def test_load_config_bad_json(tmp_path, monkeypatch):
path = tmp_path / "config.json"
path.write_text("{not json")
monkeypatch.setattr(cli, "CONFIG_PATH", path)
with pytest.raises(cli.CliError, match="bad config"):
cli.load_config()

169
tests/test_templates.py Normal file
View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import aiosqlite
from backend import notes
from backend.config import settings
def test_extract_templates():
assert notes.extract_templates("Daily log\n$journal\n# Journal for %date%") == ["journal"]
def test_extract_templates_dedup_and_case():
assert notes.extract_templates("$Journal $journal $JOURNAL") == ["journal"]
def test_extract_templates_multiple():
assert notes.extract_templates("$journal and $standup") == ["journal", "standup"]
def test_extract_templates_ignores_prices():
# first char must be a letter — "$5" is not a template marker
assert notes.extract_templates("Lunch was $5, coffee $3") == []
def test_templates_are_not_tags():
body = "$journal #diary"
assert notes.extract_templates(body) == ["journal"]
assert notes.extract_tags(body) == ["diary"]
async def test_templates_endpoint_lists_definitions(client):
r = await client.post("/api/notes", json={"body": "# Journal for %date%\n\n$journal\n\n- "})
assert r.status_code == 200
r = await client.get("/api/templates")
assert r.status_code == 200
names = [t["name"] for t in r.json()]
assert "journal" in names
tpl = next(t for t in r.json() if t["name"] == "journal")
assert tpl["username"] == "schmeeve"
assert tpl["title"] == "Journal for %date%"
async def test_get_template_returns_body(client):
await client.post("/api/notes", json={"body": "# Journal for %date%\n\n$journal\n"})
r = await client.get("/api/templates/journal")
assert r.status_code == 200
assert r.json()["body"].startswith("# Journal for %date%")
assert r.json()["name"] == "journal"
async def test_get_template_404(client):
r = await client.get("/api/templates/nope")
assert r.status_code == 404
async def test_get_template_name_normalization(client):
await client.post("/api/notes", json={"body": "# T\n$Journal\n"})
r = await client.get("/api/templates/$JOURNAL")
assert r.status_code == 200
assert r.json()["name"] == "journal"
async def test_newest_note_wins_for_duplicate_template(client):
await client.post("/api/notes", json={"body": "# Old journal\n$journal\nold"})
r = await client.post("/api/notes", json={"body": "# New journal\n$journal\nnew"})
new_id = r.json()["id"]
r = await client.get("/api/templates/journal")
assert r.json()["id"] == new_id
assert "new" in r.json()["body"]
async def test_user_space_beats_common_for_templates(client):
await client.post(
"/api/notes", json={"body": "# Common journal\n$journal\ncommon", "username": "common"}
)
r = await client.post("/api/notes", json={"body": "# My journal\n$journal\nmine"})
own_id = r.json()["id"]
r = await client.get("/api/templates/journal")
assert r.json()["id"] == own_id
assert r.json()["username"] == "schmeeve"
async def test_removing_marker_removes_template(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
await client.put(f"/api/notes/{note_id}", json={"body": "# J\nno marker\n"})
r = await client.get("/api/templates/journal")
assert r.status_code == 404
async def test_deleting_note_removes_template(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
await client.delete(f"/api/notes/{note_id}")
r = await client.get("/api/templates/journal")
assert r.status_code == 404
async def test_lookup_by_title(client):
await client.post("/api/notes", json={"body": "# Journal for 2026-09-26\nentry\n"})
r = await client.get("/api/notes/lookup", params={"title": "Journal for 2026-09-26"})
assert r.status_code == 200
assert "entry" in r.json()["body"]
r = await client.get("/api/notes/lookup", params={"title": "Journal for 1999-01-01"})
assert r.status_code == 404
async def test_lookup_scoped_to_user(client):
await client.post("/api/notes", json={"body": "# Shared title\n", "username": "common"})
r = await client.get("/api/notes/lookup", params={"title": "Shared title"})
assert r.status_code == 404 # schmeeve's space only
async def test_lookup_requires_auth(remote_client):
r = await remote_client.get("/api/notes/lookup", params={"title": "x"})
assert r.status_code == 401
async def test_templates_require_auth(remote_client):
assert (await remote_client.get("/api/templates")).status_code == 401
assert (await remote_client.get("/api/templates/x")).status_code == 401
async def test_backfill_templates(client):
r = await client.post("/api/notes", json={"body": "# J\n$journal\n"})
note_id = r.json()["id"]
async with aiosqlite.connect(settings.database_path) as db:
await db.execute("DELETE FROM note_templates WHERE note_id = ?", (note_id,))
await db.commit()
await notes.backfill_templates()
r = await client.get("/api/templates/journal")
assert r.status_code == 200
assert r.json()["id"] == note_id
async def test_cli_install_script(client):
r = await client.get("/schmeeve/cli")
assert r.status_code == 200
assert "__SNOTES_URL__" not in r.text
assert "__SNOTES_USER__" not in r.text
assert 'SNOTES_USER="schmeeve"' in r.text
assert 'SNOTES_URL="http://test"' in r.text
assert "curl -fsSL" in r.text
async def test_cli_install_script_unknown_user(client):
r = await client.get("/ghost/cli")
assert r.status_code == 404
async def test_cli_install_script_is_public(remote_client):
r = await remote_client.get("/schmeeve/cli")
assert r.status_code == 200
assert 'SNOTES_USER="schmeeve"' in r.text
async def test_cli_script_served(client):
r = await client.get("/cli/snotes.py")
assert r.status_code == 200
assert "# /// script" in r.text
assert "textual" in r.text

107
tests/test_tui.py Normal file
View File

@@ -0,0 +1,107 @@
"""Headless smoke tests for the snotes TUI (Textual pilot)."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
textual = pytest.importorskip("textual") # noqa: F841
def _load_cli():
path = Path(__file__).parent.parent / "cli" / "snotes.py"
spec = importlib.util.spec_from_file_location("snotes_cli", path)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
cli = _load_cli()
NOTES = [
{"id": "a1", "title": "Shopping list", "updated_at": "2026-09-26 09:00:00", "tags": ["home"]},
{
"id": "b2",
"title": "Journal for 2026-09-26",
"updated_at": "2026-09-25 22:00:00",
"tags": ["journal"],
},
]
BODIES = {
"a1": "# Shopping list\n\n- milk\n- eggs\n",
"b2": "# Journal for 2026-09-26\n\nA fine day.\n",
}
class FakeApi:
def __init__(self):
self.cfg = cli.Config(url="http://test", user="schmeeve")
self.deleted: list[str] = []
def recent(self, limit: int = 30) -> list[dict]:
return NOTES[:limit]
def search(self, q: str) -> list[dict]:
return [n for n in NOTES if q.lower() in n["title"].lower()]
def get_note(self, note_id: str) -> dict:
return {"id": note_id, "title": "t", "body": BODIES[note_id]}
def create(self, body: str) -> dict: # pragma: no cover - not exercised
return {"id": "new", "title": body}
def update(self, note_id: str, body: str) -> dict: # pragma: no cover
return {"id": note_id}
def delete(self, note_id: str) -> None:
self.deleted.append(note_id)
async def test_tui_boots_with_recent_notes():
app = cli.make_app(FakeApi(), None)
async with app.run_test() as pilot:
await pilot.pause()
from textual.widgets import ListView
lv = app.query_one("#notes", ListView)
assert len(lv.children) == 2
assert app.title == "snotes"
async def test_tui_initial_query_filters():
app = cli.make_app(FakeApi(), "journal")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.pause(0.4) # debounce
from textual.widgets import Input, ListView
assert app.query_one("#search", Input).value == "journal"
assert len(app.query_one("#notes", ListView).children) == 1
async def test_tui_preview_shows_note_body():
app = cli.make_app(FakeApi(), None)
async with app.run_test() as pilot:
await pilot.pause()
from textual.widgets import Markdown
preview = app.query_one("#preview", Markdown)
await pilot.pause(0.2)
assert "Shopping list" in preview.source or "milk" in preview.source
async def test_tui_delete_flow():
fake = FakeApi()
app = cli.make_app(fake, None)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("d")
await pilot.pause()
await pilot.press("y")
await pilot.pause(0.3)
assert fake.deleted == ["a1"]