fix: recover Tomorrow promotion after missed rollover (Closes #1154)
This commit is contained in:
1 parent
559fd2dc89
commit
7deb1c1de9
6 files changed
+188
-22
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user