import json import subprocess from pathlib import Path import httpx import pytest from src import main from src.today_store import TodayStore ROOT = Path(__file__).parents[1] TODAY_RECAP = ROOT / "frontend" / "today-recap.js" def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path): now = [1_000.0] store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0]) first = { "session_id": "session-1", "items": [ {"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}, {"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12}, ], } saved = store.save_recap("Timmy", first["session_id"], first["items"]) replay = store.save_recap("timmy", first["session_id"], first["items"]) now[0] += 1 store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}]) now[0] += 1 store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}]) store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}]) assert saved == replay assert saved["session_id"] == "session-1" assert saved["estimated_minutes"] == 30 assert saved["actual_minutes"] == 54 assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"] assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"] @pytest.mark.parametrize( "items,message", [ ([], "at least one"), ([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"), ([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"), ([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"), ], ) def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message): store = TodayStore(tmp_path / "today.sqlite3") with pytest.raises(ValueError, match=message): store.save_recap("timmy", "session", items) @pytest.mark.anyio async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3")) monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3")) async def user(): return {"id": 1, "login": "Timmy"} monkeypatch.setattr(main, "current_user", user) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"}) headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]} payload = {"session_id": "mobile-session-1", "items": [ {"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42} ]} created = await client.post("/api/v1/today/recaps", json=payload, headers=headers) replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers) history = await client.get("/api/v1/today/recaps") assert created.status_code == replay.status_code == 200 assert created.json() == replay.json() assert history.json()["recaps"] == [created.json()] assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store" def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const calls=[]; let cleared=0; const recap=createRecap({{ save:payload=>{{calls.push(payload); return Promise.resolve(payload);}}, clear:()=>{{cleared += 1;}}, makeId:()=> 'session-fixed', }}); const draft=recap.begin([ {{identity:'issue:r:1:',elapsed_ms:42*60000}}, {{identity:'pull:r:2:',elapsed_ms:12*60000}}, ], {{'issue:r:1:':30}}); const invalidLow=recap.correct('issue:r:1:',-1); const invalidHigh=recap.correct('issue:r:1:',1441); const corrected=recap.correct('issue:r:1:',45); recap.save().then(saved=>process.stdout.write(JSON.stringify({{ draft,invalidLow,invalidHigh,corrected,saved,calls,cleared }}))).catch(error=>{{console.error(error);process.exit(1);}}); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr output = json.loads(result.stdout) assert output["draft"]["estimated_minutes"] == 30 assert output["draft"]["actual_minutes"] == 54 assert output["draft"]["variance_minutes"] == 24 assert output["invalidLow"] is False assert output["invalidHigh"] is False assert output["corrected"] is True assert output["calls"][0]["session_id"] == "session-fixed" assert output["calls"][0]["items"][0]["actual_minutes"] == 45 assert output["cleared"] == 1 @pytest.mark.anyio async def test_dashboard_renders_mobile_today_recap_flow(): html = main.FRONTEND_BUILD.dashboard_html css = (ROOT / "frontend" / "dashboard.css").read_text() timer = (ROOT / "frontend" / "today-timer.js").read_text() recap_source = TODAY_RECAP.read_text() dashboard = (ROOT / "frontend" / "dashboard.js").read_text() assert 'id="today-recap-sheet" role="dialog"' in html assert 'id="today-recap-history"' in html assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources assert "timer.recapEntries()" in recap_source assert "isActive: () => workSession.checkpointed()" in dashboard assert "openTodayRecapAfterSession(view" in recap_source assert "api/v1/today/recaps" in recap_source assert "recapEntries()" in timer and "clearRecap()" in timer assert ".today-recap-header button { min-height:44px;" in css assert ".today-recap-actions button { min-height:44px;" in css assert "overflow-x:hidden" in css