fix: recover Tomorrow promotion after missed rollover (Closes #1154)
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 3m50s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-19 23:27:07 +00:00
1 parent 559fd2dc89
commit 7deb1c1de9
6 files changed
+188 -22

No files matched your search

+64 -3
View File
@@ -1,15 +1,20 @@
import sqlite3
from datetime import datetime, timezone
import pytest
from src import main
from src.today_store import TodayStore, TomorrowPlanConflict, TodayPromotionConflict
from src.today_store import TodayPromotionConflict, TodayStore, TomorrowPlanConflict
def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(tmp_path):
path = tmp_path / "today.sqlite3"
key = b"n" * 32
store = TodayStore(path, encryption_key=key)
store = TodayStore(
path,
encryption_key=key,
clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(),
)
store.apply("timmy", "today-1", "add", "issue:r:1:")
tomorrow = store.replace_tomorrow(
@@ -53,7 +58,11 @@ def test_tomorrow_plan_is_encrypted_scoped_and_promotes_atomically_exactly_once(
def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_today(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
store = TodayStore(
tmp_path / "today.sqlite3",
encryption_key=b"s" * 32,
clock=lambda: datetime(2026, 8, 21, tzinfo=timezone.utc).timestamp(),
)
original_today = store.apply("timmy", "today", "add", "issue:r:1:")
first = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
@@ -77,6 +86,27 @@ def test_tomorrow_edit_and_promotion_reject_stale_revisions_without_touching_tod
assert store.get_tomorrow("timmy") == first
def test_tomorrow_promotion_rejects_the_day_before_in_the_saved_timezone(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3",
clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(),
)
today = store.apply("timmy", "today", "add", "issue:r:1:")
tomorrow = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles",
)
with pytest.raises(ValueError, match="Tomorrow plan is not due"):
store.promote_tomorrow(
"timmy", promotion_id="tomorrow-2026-08-20-r1",
tomorrow_revision=tomorrow["revision"], today_revision=today["revision"],
)
assert store.get("timmy") == today
assert store.get_tomorrow("timmy") == tomorrow
def test_tomorrow_validation_is_bounded_and_date_specific(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3")
with pytest.raises(ValueError, match="ISO calendar date"):
@@ -110,3 +140,34 @@ async def test_tomorrow_api_round_trip_and_conflict_contract(monkeypatch, tmp_pa
await main.replace_tomorrow_plan(payload)
assert raised.value.status_code == 409
assert raised.value.detail == {"code": "tomorrow_changed", "snapshot": saved}
@pytest.mark.anyio
async def test_tomorrow_api_reports_an_early_promotion_without_mutation(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(
tmp_path / "today.sqlite3",
clock=lambda: datetime(2026, 8, 20, 6, 59, tzinfo=timezone.utc).timestamp(),
)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
today = store.apply("timmy", "today", "add", "issue:r:1:")
tomorrow = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:2:"], capacity_minutes=60,
estimates={}, plan_date="2026-08-20", timezone="America/Los_Angeles",
)
with pytest.raises(main.HTTPException) as raised:
await main.promote_tomorrow_plan(main.TomorrowPromotion(
promotion_id="tomorrow-2026-08-20-r1",
tomorrow_revision=tomorrow["revision"], today_revision=today["revision"],
))
assert raised.value.status_code == 409
assert raised.value.detail == {
"code": "tomorrow_not_due", "plan_date": "2026-08-20"
}
assert store.get("timmy") == today
assert store.get_tomorrow("timmy") == tomorrow
+53 -13
View File
@@ -57,32 +57,71 @@ console.log(JSON.stringify({loaded,saved,requests}));
assert all(request["url"] != "api/v1/today" for request in result["requests"])
def test_tomorrow_planner_promotes_only_on_matching_local_date():
def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
requests.push({url,body:options.body ? JSON.parse(options.body) : null});
return {ids:['issue:r:2:'],revision:8};
};
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const early=await before.promote(7);
const due=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',createId:()=> 'rollover-id'});
due.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await due.promote(7);
console.log(JSON.stringify({early,promoted,requests}));
const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'});
overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await overdue.promote(7);
const retried=await overdue.promote(7);
console.log(JSON.stringify({early,promoted,retried,requests}));
""")
assert result["early"] is False
assert result["promoted"]["revision"] == 8
assert result["requests"] == [{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "rollover-id",
"tomorrow_revision": 3,
"today_revision": 7,
assert result["retried"]["revision"] == 8
assert result["requests"] == [
{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "tomorrow-2026-08-20-r3",
"tomorrow_revision": 3,
"today_revision": 7,
},
},
}]
{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "tomorrow-2026-08-20-r3",
"tomorrow_revision": 3,
"today_revision": 7,
},
},
]
def test_tomorrow_rollover_checks_startup_reconnect_foreground_and_next_midnight_single_flight():
result = run_controller("""
const listeners={};
const timers=[];
const cleared=[];
let checks=0;
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
const windowObject={addEventListener:(name,fn)=>listeners['window:'+name]=fn};
const documentObject={hidden:false,addEventListener:(name,fn)=>listeners['document:'+name]=fn};
const lifecycle=planner.startLifecycle({
windowObject,documentObject,check:async()=>{checks+=1;await Promise.resolve();return true;},
setTimer:(fn,delay)=>{timers.push({fn,delay});return timers.length;},
clearTimer:id=>cleared.push(id),nextDelay:()=>1234,
});
await lifecycle.run();
const reconnect=listeners['window:online']();
const foreground=listeners['document:visibilitychange']();
await Promise.all([reconnect,foreground]);
await timers[timers.length-1].fn();
console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared}));
""")
assert result["checks"] == 3
assert result["delays"] == [1234, 1234, 1234]
assert result["cleared"] == [1, 2]
def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
@@ -98,3 +137,4 @@ def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_plan
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
assert "tomorrowPlan.load()" in dashboard
assert "tomorrowPlan.promote(plan.revision)" in dashboard
assert "tomorrowPlan.startLifecycle" in dashboard