stackchain-dashboard/tests/test_today_recap.py
timmy b3a359760c
Some checks failed
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / release-candidate (pull_request) Has been skipped
feat: share private Today summaries (Closes #1090)
2026-08-18 18:39:01 +00:00

903 lines
41 KiB
Python

import asyncio
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"
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 = []
first_wave_started = asyncio.Event()
release_first_wave = asyncio.Event()
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)
if len(attempted) == 3:
first_wave_started.set()
try:
await release_first_wave.wait()
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)
request = asyncio.create_task(
client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
)
await asyncio.wait_for(first_wave_started.wait(), timeout=1)
await asyncio.sleep(0)
assert active == 3
assert peak == 3
assert len(attempted) == 3
assert not request.done()
release_first_wave.set()
response = await request
assert response.status_code == 200
assert 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 & wrap up" 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 "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" 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