feat: log corrected Today recap time (Closes #587)
This commit is contained in:
1 parent
c170240057
commit
697f72da09
9 files changed
+518
-10
No files matched your search
@@ -7,6 +7,26 @@ from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"id": 1})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
|
||||
assert requests[0].read() == b'{"time":2520}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
|
||||
@@ -13,6 +13,20 @@ ROOT = Path(__file__).parents[1]
|
||||
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
||||
|
||||
|
||||
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])
|
||||
@@ -85,6 +99,127 @@ async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped
|
||||
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@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"]}
|
||||
created = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
|
||||
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 = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
|
||||
|
||||
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))});
|
||||
@@ -119,6 +254,112 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
||||
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))});
|
||||
|
||||
Reference in new issue
Block a user