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 def test_recap_controller_restores_corrected_draft_with_stable_retry_identity(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const values = new Map(); const storage = {{ getItem:key => values.has(key) ? values.get(key) : null, setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key), }}; const options = {{ save:payload => Promise.resolve(payload), clear:() => {{}}, storage, getLogin:() => ' Timmy ', makeId:() => 'stable-session', }}; const first = createRecap(options); first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}}); first.correct('issue:r:1:', 17); const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot(); process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr output = json.loads(result.stdout) assert output["restored"] == { "session_id": "stable-session", "items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}], "estimated_minutes": 20, "actual_minutes": 17, "variance_minutes": -3, } assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"] def test_recap_controller_removes_corrupt_persisted_draft(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map([[key, '{{not-json']]); const storage={{ getItem:key => values.has(key) ? values.get(key) : null, setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key), }}; const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}}); process.stdout.write(JSON.stringify({{draft:recap.snapshot(),hasKey:values.has(key)}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == {"draft": None, "hasKey": False} def test_recap_controller_restores_after_account_confirmation(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{ session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}] }})]]); const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; let login=''; const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}}); const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot(); process.stdout.write(JSON.stringify({{before,didRestore,after}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "before": None, "didRestore": True, "after": { "session_id": "offline-session", "items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}], "estimated_minutes": 0, "actual_minutes": 9, "variance_minutes": 9, }, } def test_recap_controller_never_exposes_draft_after_account_switch(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const values=new Map(); const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; let login='timmy'; const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}}); recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]); login='alexander'; process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True} def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true; const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; const recap=createRecap({{ save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload), clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session' }}); recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]); (async()=>{{ try {{ await recap.save(); }} catch (_error) {{}} const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}}; fail=false; await recap.save(); process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),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["afterFailure"]["draft"]["session_id"] == "retry-session" assert output["afterFailure"]["stored"] is True assert output["afterFailure"]["cleared"] == 0 assert output["afterSuccess"] == {"draft": None, "stored": False, "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 "storage:localStorage" in recap_source assert "() => planningOwnerLogin" in dashboard 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