319 lines
11 KiB
Python
319 lines
11 KiB
Python
import sqlite3
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src import main
|
|
from src.later_store import LaterStore
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
|
|
async def user():
|
|
return {"login": "timmy"}
|
|
|
|
class BusyStore:
|
|
def apply(self, *_args, **_kwargs):
|
|
raise sqlite3.OperationalError("database is busy")
|
|
|
|
monkeypatch.setattr(main, "current_user", user)
|
|
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
|
|
payload = main.LaterOperation(
|
|
operation_id="retry-me",
|
|
action="defer",
|
|
item_id="issue:r:1:",
|
|
wake_at="2026-08-10T09:00:00.000Z",
|
|
)
|
|
|
|
with pytest.raises(main.HTTPException) as raised:
|
|
await main.update_later_plan(payload)
|
|
|
|
assert raised.value.status_code == 503
|
|
assert raised.value.headers == {"Retry-After": "1"}
|
|
|
|
|
|
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
|
path = tmp_path / "later.sqlite3"
|
|
store = LaterStore(path)
|
|
|
|
deferred = store.apply(
|
|
"Timmy",
|
|
"op-1",
|
|
"defer",
|
|
"issue:stackchain/dashboard:363:",
|
|
wake_at="2026-08-10T09:00:00.000Z",
|
|
)
|
|
duplicate = store.apply(
|
|
"timmy",
|
|
"op-1",
|
|
"defer",
|
|
"issue:stackchain/dashboard:363:",
|
|
wake_at="2026-08-11T09:00:00.000Z",
|
|
)
|
|
|
|
assert duplicate == deferred == {
|
|
"revision": 1,
|
|
"records": {
|
|
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
|
},
|
|
}
|
|
assert LaterStore(path).get("timmy") == deferred
|
|
assert store.get("alexander") == {"revision": 0, "records": {}}
|
|
|
|
assert store.apply(
|
|
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
|
) == {"revision": 2, "records": {}}
|
|
|
|
|
|
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
|
store = LaterStore(tmp_path / "later.sqlite3")
|
|
|
|
legacy = store.apply(
|
|
"timmy", "legacy", "defer", "issue:r:1:",
|
|
wake_at="2026-08-17T09:00:00.000Z",
|
|
)
|
|
handoff = store.apply(
|
|
"timmy", "wrap-up", "defer", "issue:r:2:",
|
|
wake_at="2026-08-17T09:00:00.000Z",
|
|
handoff="today",
|
|
)
|
|
|
|
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
|
|
assert handoff["records"] == {
|
|
"issue:r:1:": "2026-08-17T09:00:00.000Z",
|
|
"issue:r:2:": {
|
|
"wake_at": "2026-08-17T09:00:00.000Z",
|
|
"handoff": "today",
|
|
},
|
|
}
|
|
assert LaterStore(store.path).get("timmy") == handoff
|
|
|
|
|
|
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
|
path = tmp_path / "later.sqlite3"
|
|
store = LaterStore(path, timeout=0.05)
|
|
expected = store.apply(
|
|
"timmy", "seed", "defer", "issue:r:1:",
|
|
wake_at="2026-08-10T09:00:00.000Z",
|
|
)
|
|
|
|
writer = sqlite3.connect(path)
|
|
writer.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
assert LaterStore(path, timeout=0.05).get("timmy") == expected
|
|
finally:
|
|
writer.rollback()
|
|
writer.close()
|
|
|
|
|
|
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
|
store = LaterStore(tmp_path / "later.sqlite3")
|
|
operations = [
|
|
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
|
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
|
|
]
|
|
|
|
result = store.apply_batch("timmy", operations)
|
|
assert result == {
|
|
"revision": 2,
|
|
"records": {},
|
|
"accepted_operation_ids": ["first", "second"],
|
|
"duplicate_operation_ids": [],
|
|
"rejected_operations": [],
|
|
}
|
|
replay = store.apply_batch("timmy", operations)
|
|
assert replay["revision"] == 2
|
|
assert replay["accepted_operation_ids"] == []
|
|
assert replay["duplicate_operation_ids"] == ["first", "second"]
|
|
|
|
|
|
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
|
|
store = LaterStore(tmp_path / "later.sqlite3")
|
|
first = store.apply_batch("timmy", [{
|
|
"operation_id": "newer-device",
|
|
"action": "defer",
|
|
"item_id": "issue:r:1:",
|
|
"wake_at": "2026-08-12T09:00:00.000Z",
|
|
"base_revision": 0,
|
|
}])
|
|
|
|
replay = store.apply_batch("timmy", [
|
|
{
|
|
"operation_id": "stale-device",
|
|
"action": "defer",
|
|
"item_id": "issue:r:1:",
|
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
|
"base_revision": 0,
|
|
},
|
|
{
|
|
"operation_id": "unrelated-item",
|
|
"action": "defer",
|
|
"item_id": "issue:r:2:",
|
|
"wake_at": "2026-08-11T09:00:00.000Z",
|
|
"base_revision": 0,
|
|
},
|
|
])
|
|
|
|
assert first["revision"] == 1
|
|
assert replay == {
|
|
"revision": 2,
|
|
"records": {
|
|
"issue:r:1:": "2026-08-12T09:00:00.000Z",
|
|
"issue:r:2:": "2026-08-11T09:00:00.000Z",
|
|
},
|
|
"accepted_operation_ids": ["unrelated-item"],
|
|
"duplicate_operation_ids": [],
|
|
"rejected_operations": [
|
|
{"operation_id": "stale-device", "reason": "stale_intent"}
|
|
],
|
|
}
|
|
duplicate = store.apply_batch("timmy", [{
|
|
"operation_id": "stale-device",
|
|
"action": "restore",
|
|
"item_id": "issue:r:2:",
|
|
"base_revision": 2,
|
|
}])
|
|
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
|
|
assert duplicate["revision"] == 2
|
|
assert duplicate["records"] == replay["records"]
|
|
|
|
|
|
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
|
path = tmp_path / "later.sqlite3"
|
|
with sqlite3.connect(path) as connection:
|
|
connection.execute(
|
|
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
|
"PRIMARY KEY (login, operation_id))"
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
|
)
|
|
|
|
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
|
for index in range(4):
|
|
store.apply(
|
|
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
|
wake_at="2026-08-10T09:00:00.000Z",
|
|
)
|
|
store.apply(
|
|
"alexander", "other", "defer", "issue:r:99:",
|
|
wake_at="2026-08-10T09:00:00.000Z",
|
|
)
|
|
|
|
with sqlite3.connect(path) as connection:
|
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
|
timmy = connection.execute(
|
|
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
|
).fetchall()
|
|
alexander = connection.execute(
|
|
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
|
).fetchall()
|
|
|
|
assert "created_at" in columns
|
|
assert timmy == [("op-2",), ("op-3",)]
|
|
assert alexander == [("other",)]
|
|
|
|
|
|
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
|
clock = [1_000.0]
|
|
path = tmp_path / "later.sqlite3"
|
|
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
|
store.apply("timmy", "old", "restore", "issue:r:1:")
|
|
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
|
clock[0] += 61
|
|
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
|
|
|
with sqlite3.connect(path) as connection:
|
|
timmy = connection.execute(
|
|
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
|
).fetchall()
|
|
alexander = connection.execute(
|
|
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
|
).fetchall()
|
|
|
|
assert timmy == [("fresh",)]
|
|
assert alexander == [("other-old",)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(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_LATER_DB", str(tmp_path / "later.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"}
|
|
)
|
|
forbidden = await client.patch(
|
|
"/api/v1/later",
|
|
json={
|
|
"operation_id": "mobile-1",
|
|
"action": "defer",
|
|
"item_id": "issue:stackchain/dashboard:363:",
|
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
|
},
|
|
)
|
|
changed = await client.patch(
|
|
"/api/v1/later",
|
|
json={
|
|
"operations": [
|
|
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
|
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
|
|
],
|
|
},
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
},
|
|
)
|
|
stale = await client.patch(
|
|
"/api/v1/later",
|
|
json={
|
|
"operations": [{
|
|
"operation_id": "offline-stale",
|
|
"action": "restore",
|
|
"item_id": "issue:stackchain/dashboard:363:",
|
|
"base_revision": 0,
|
|
}],
|
|
},
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
},
|
|
)
|
|
fetched = await client.get("/api/v1/later")
|
|
|
|
assert forbidden.status_code == 403
|
|
assert changed.status_code == 200
|
|
assert changed.json() == {
|
|
"revision": 2,
|
|
"records": {
|
|
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
|
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
|
},
|
|
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
|
"duplicate_operation_ids": [],
|
|
"rejected_operations": [],
|
|
}
|
|
assert stale.json()["rejected_operations"] == [
|
|
{"operation_id": "offline-stale", "reason": "stale_intent"}
|
|
]
|
|
assert stale.json()["revision"] == 2
|
|
assert fetched.json() == {"revision": 2, "records": {
|
|
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
|
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
|
}}
|
|
assert fetched.headers["cache-control"] == "no-store"
|