import asyncio import json import subprocess import time 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" async def recap_time_grant(client, headers, payload): challenge = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers=headers ) assert challenge.status_code == 428 authorization = await client.post( "/api/v1/fresh-authorization", json={ "access_token": "correct horse battery staple", "action": "log_recap_time", "target": challenge.json()["detail"]["target"], }, headers=headers, ) assert authorization.status_code == 201 return authorization.json()["grant"] def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in(): html = (ROOT / "frontend" / "index.html").read_text() source = TODAY_RECAP.read_text() css = (ROOT / "frontend" / "dashboard.css").read_text() assert 'id="save-log-today-recap"' in html assert "Log selected time to Gitea" in html assert 'type="checkbox"' in source assert "data-recap-log-identity" in source assert "item.actual_minutes > 0" in source assert "querySelectorAll('[data-recap-log-identity]:checked')" in source assert ".today-recap-log" in css and "min-height:44px" in css 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" @pytest.mark.anyio async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization( 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")) monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3")) calls = [] async def user(): return {"id": 1, "login": "Timmy"} async def log_time(identity, seconds): calls.append((identity, seconds)) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) payload = { "session_id": "authorization-session", "items": [{ "identity": "issue:stackchain/dashboard:589:", "estimate_minutes": 30, "actual_minutes": 42, }], "log_identities": ["issue:stackchain/dashboard:589:"], } 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"]} missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) history_before = await client.get("/api/v1/today/recaps") target = missing.json()["detail"]["target"] authorization = await client.post( "/api/v1/fresh-authorization", json={ "access_token": "correct horse battery staple", "action": "log_recap_time", "target": target, }, headers=headers, ) grant = authorization.json()["grant"] changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]} mismatched = await client.post( "/api/v1/today/recaps/log-time", json=changed, headers={**headers, "X-Step-Up-Grant": grant}, ) accepted = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": grant}, ) replay = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": grant}, ) assert missing.status_code == mismatched.status_code == replay.status_code == 428 assert missing.json()["detail"]["action"] == "log_recap_time" assert target.startswith("today-recap:") and len(target) == 44 assert history_before.json()["recaps"] == [] assert accepted.status_code == 200 assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)] @pytest.mark.anyio async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes( 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")) security_db = tmp_path / "security.sqlite3" monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db)) async def user(): return {"id": 1, "login": "Timmy"} async def log_time(identity, _seconds): if ":590:" in identity: raise httpx.ReadTimeout("response was lost after sending") if ":591:" in identity: raise httpx.ConnectError("request was not sent") monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) payload = { "session_id": "private-recap-session", "items": [ {"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes} for number, minutes in [(589, 11), (590, 12), (591, 13)] ], "log_identities": [ f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591) ], } 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"]} challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) authorization = await client.post( "/api/v1/fresh-authorization", json={ "access_token": "correct horse battery staple", "action": "log_recap_time", "target": challenge.json()["detail"]["target"], }, headers=headers, ) response = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]}, ) events = (await client.get("/api/v1/security-events")).json()["events"] assert response.json()["time_logs"] == [ {"identity": "issue:stackchain/dashboard:589:", "status": "logged"}, {"identity": "issue:stackchain/dashboard:590:", "status": "verify"}, {"identity": "issue:stackchain/dashboard:591:", "status": "retry"}, ] time_events = [event for event in events if event["kind"] == "gitea_time_logged"] assert {(event["target"], event["status"]) for event in time_events} == { ("stackchain/dashboard#590", "pending"), ("stackchain/dashboard#589", "completed"), } persisted = security_db.read_bytes() assert b"private-recap-session" not in persisted assert challenge.json()["detail"]["target"].encode() not in persisted @pytest.mark.anyio async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurrency( 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")) monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3")) active = 0 peak = 0 attempted = [] async def user(): return {"id": 1, "login": "Timmy"} async def log_time(identity, _seconds): nonlocal active, peak active += 1 peak = max(peak, active) attempted.append(identity) try: await asyncio.sleep(0.1) finally: active -= 1 monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) identities = [f"issue:stackchain/dashboard:{number}:" for number in range(607, 612)] payload = { "session_id": "concurrent-recap-session", "items": [ {"identity": identity, "estimate_minutes": 10, "actual_minutes": 11} for identity in identities ], "log_identities": identities, } 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"]} grant = await recap_time_grant(client, headers, payload) started = time.perf_counter() response = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": grant}, ) elapsed = time.perf_counter() - started assert response.status_code == 200 assert elapsed < 0.35 assert 1 < peak <= 3 assert set(attempted) == set(identities) assert response.json()["time_logs"] == [ {"identity": identity, "status": "logged"} for identity in identities ] @pytest.mark.anyio async def test_explicit_recap_time_logging_posts_corrected_seconds_once(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")) calls = [] async def user(): return {"id": 1, "login": "Timmy"} async def log_time(identity, seconds): calls.append((identity, seconds)) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) payload = { "session_id": "mobile-session-1", "items": [ {"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}, {"identity": "pull:stackchain/dashboard:586:", "estimate_minutes": 10, "actual_minutes": 0}, ], "log_identities": ["issue:stackchain/dashboard:587:"], } 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"]} first_grant = await recap_time_grant(client, headers, payload) created = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": first_grant}, ) replay_grant = await recap_time_grant(client, headers, payload) replay = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": replay_grant}, ) assert created.status_code == replay.status_code == 200 assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)] assert created.json()["time_logs"] == [{ "identity": "issue:stackchain/dashboard:587:", "status": "logged" }] assert replay.json()["time_logs"] == [{ "identity": "issue:stackchain/dashboard:587:", "status": "logged" }] assert created.headers["cache-control"] == "no-store" @pytest.mark.anyio async def test_ambiguous_time_log_failure_is_not_reposted_automatically(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")) calls = 0 async def user(): return {"id": 1, "login": "Timmy"} async def log_time(_identity, _seconds): nonlocal calls calls += 1 raise httpx.ReadTimeout("response was lost after sending") monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) payload = { "session_id": "ambiguous-session", "items": [{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}], "log_identities": ["issue:stackchain/dashboard:587:"], } 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"]} first_grant = await recap_time_grant(client, headers, payload) first = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": first_grant}, ) replay_grant = await recap_time_grant(client, headers, payload) replay = await client.post( "/api/v1/today/recaps/log-time", json=payload, headers={**headers, "X-Step-Up-Grant": replay_grant}, ) assert calls == 1 assert first.json()["time_logs"] == replay.json()["time_logs"] == [{ "identity": "issue:stackchain/dashboard:587:", "status": "verify" }] @pytest.mark.anyio async def test_recap_time_logging_rejects_noncanonical_and_zero_targets_before_upstream(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")) calls = [] async def user(): return {"id": 1, "login": "Timmy"} async def log_time(identity, seconds): calls.append((identity, seconds)) monkeypatch.setattr(main, "current_user", user) monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) 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"]} malformed = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={ "session_id": "bad-target", "items": [{"identity": "issue:https://evil.test:1:", "estimate_minutes": 5, "actual_minutes": 5}], "log_identities": ["issue:https://evil.test:1:"], }) zero = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={ "session_id": "zero-target", "items": [{"identity": "issue:stackchain/dashboard:1:", "estimate_minutes": 5, "actual_minutes": 0}], "log_identities": ["issue:stackchain/dashboard:1:"], }) history = await client.get("/api/v1/today/recaps") assert malformed.status_code == zero.status_code == 422 assert calls == [] assert history.json()["recaps"] == [] 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_logs_only_selected_nonzero_items_and_retains_failed_draft(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const calls=[]; let fail=true; let cleared=0; const recap=createRecap({{ save:payload=>Promise.resolve(payload), saveAndLog:payload=>{{calls.push(payload); return fail ? Promise.reject(new Error('offline')) : Promise.resolve({{ time_logs:payload.log_identities.map(identity=>({{identity,status:'logged'}})) }});}}, clear:()=>{{cleared += 1;}}, makeId:()=> 'log-session', }}); recap.begin([ {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:42*60000}}, {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:0}}, ]); (async()=>{{ let error=''; try {{ await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (caught) {{ error=caught.message; }} const retained=recap.snapshot(); fail=false; const result=await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); process.stdout.write(JSON.stringify({{error,retained,result,calls,cleared,after:recap.snapshot()}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr output = json.loads(run.stdout) assert output["error"] == "offline" assert output["retained"]["session_id"] == "log-session" assert output["calls"][0]["log_identities"] == ["issue:stackchain/dashboard:587:"] assert output["result"]["time_logs"] == [{ "identity": "issue:stackchain/dashboard:587:", "status": "logged" }] assert output["cleared"] == 1 assert output["after"] is None def test_recap_controller_exposes_per_item_partial_logging_results_for_retry(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const recap=createRecap({{ save:payload=>Promise.resolve(payload), clear:()=>{{}}, makeId:()=> 'partial-session', saveAndLog:payload=>Promise.resolve({{time_logs:[ {{identity:payload.log_identities[0],status:'logged'}}, {{identity:payload.log_identities[1],status:'retry'}}, ]}}), }}); recap.begin([ {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}}, {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}}, ]); recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']) .then(()=>{{throw new Error('expected retry');}}) .catch(error=>process.stdout.write(JSON.stringify({{message:error.message,draft:recap.snapshot()}}))); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr output = json.loads(run.stdout) assert output["message"] == "1 time entry logged; 1 needs retry." assert output["draft"]["time_logs"] == { "issue:stackchain/dashboard:587:": "logged", "pull:stackchain/dashboard:586:": "retry", } def test_recap_partial_time_logging_intent_survives_reload_until_every_item_is_logged(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const values=new Map(); const calls=[]; let attempt=0; let cleared=0; const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; const saveAndLog=payload=>{{ calls.push(payload.log_identities); attempt += 1; return Promise.resolve({{time_logs:payload.log_identities.map((identity,index)=>({{ identity,status:attempt === 1 && index === 1 ? 'retry' : 'logged' }}))}}); }}; const options={{save:payload=>Promise.resolve(payload),saveAndLog,clear:()=>{{cleared += 1;}},storage,getLogin:()=> 'timmy'}}; const first=createRecap({{...options,makeId:()=> 'durable-log'}}); first.begin([ {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}}, {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}}, ]); (async()=>{{ try {{ await first.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (_error) {{}} const restored=createRecap(options); const pending=restored.snapshot(); await restored.saveAndLog(['pull:stackchain/dashboard:586:']); process.stdout.write(JSON.stringify({{pending,calls,cleared,after:restored.snapshot(),stored:[...values.keys()]}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr output = json.loads(run.stdout) assert output["pending"]["log_identities"] == [ "issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:" ] assert output["pending"]["time_logs"]["pull:stackchain/dashboard:586:"] == "retry" assert output["calls"] == [ ["issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"], ["pull:stackchain/dashboard:586:"], ] assert output["cleared"] == 1 assert output["after"] is None assert output["stored"] == [] def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const rows=createRecap.feedbackRows({{ items:[ {{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}}, {{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}}, ] }}, identity => identity.startsWith('issue:') ? {{ title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583' }} : null); process.stdout.write(JSON.stringify(rows)); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr assert json.loads(run.stdout) == [ { "identity": "issue:stackchain/dashboard:583:", "label": "Turn recap feedback into a plan", "context": "stackchain/dashboard#583", "estimate_minutes": 30, "actual_minutes": 52, "variance_minutes": 22, }, { "identity": "pull:stackchain/api:9:", "label": "pull:stackchain/api:9:", "context": "Work details unavailable", "estimate_minutes": None, "actual_minutes": 12, "variance_minutes": None, }, ] 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} def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); let fail=true; const recap=createRecap({{ save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}), clear:()=>{{}}, makeId:()=> 'replan-session' }}); recap.begin([ {{identity:'issue:r:1:',elapsed_ms:52*60000}}, {{identity:'issue:r:2:',elapsed_ms:7*60000}}, ], {{'issue:r:1:':30,'issue:r:2:':7}}); (async()=>{{ let failed; try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }} const retained=recap.snapshot(); fail=false; const confirmed=await recap.saveForReplan(); process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr output = json.loads(run.stdout) assert output["failed"] == "offline" assert output["retained"]["session_id"] == "replan-session" assert output["confirmed"] == { "result": {"saved": True}, "actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7}, } assert output["after"] is None def test_recap_controller_restores_account_scoped_pending_replan_until_completed(): 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 options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}}; const first=createRecap({{...options,makeId:()=> 'saved-session'}}); first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}}); (async()=>{{ await first.saveForReplan(); const afterSave=first.pendingReplan(); const restored=createRecap(options).pendingReplan(); login='alexander'; const otherAccount=createRecap(options).pendingReplan(); login='timmy'; const retainedAfterCancel=createRecap(options).pendingReplan(); createRecap(options).completeReplan(); const afterComplete=createRecap(options).pendingReplan(); process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr output = json.loads(run.stdout) pending = { "session_id": "saved-session", "actual_minutes": {"issue:r:1:": 52}, } assert output == { "afterSave": pending, "restored": pending, "otherAccount": None, "retainedAfterCancel": pending, "afterComplete": None, "keys": [], } def test_recap_controller_removes_malformed_or_unbounded_pending_replan(): script = f""" const createRecap = require({json.dumps(str(TODAY_RECAP))}); const key='stackchain.today-recap-handoff.v1.timmy'; const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries( Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10]) )}})]]); const storage={{getItem: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({{pending:recap.pendingReplan(),hasKey:values.has(key)}})); """ run = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert run.returncode == 0, run.stderr assert json.loads(run.stdout) == {"pending": None, "hasKey": False} @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 assert "Save recap & adjust plan" in html assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source assert 'class="today-recap-variance"' in recap_source assert "await recap.saveForReplan()" in recap_source assert "adjustPlan(handoff.actual_minutes)" in recap_source assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard assert ".today-recap-row { grid-template-columns:1fr; }" in css @pytest.mark.anyio async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback(): html = main.FRONTEND_BUILD.dashboard_html dashboard = (ROOT / "frontend" / "dashboard.js").read_text() recap_source = TODAY_RECAP.read_text() assert 'id="discard-recap-replan"' in html assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard assert "todayRecapView.completeReplan();" in dashboard assert "todayRecapView.discardReplan();" in dashboard assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();") assert "pendingReplan:recap.pendingReplan" in recap_source assert "discardReplan:recap.discardReplan" in recap_source