CLI
This commit is contained in:
131
tests/test_cli.py
Normal file
131
tests/test_cli.py
Normal 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
169
tests/test_templates.py
Normal 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
107
tests/test_tui.py
Normal 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"]
|
||||
Reference in New Issue
Block a user