6d92b87bf3
A repeating period was a single row that the planner expanded into one window per year at read time, so no year had anywhere to differ from the others. Editing one year rewrote them all, and removing a year in the middle had to split the wish in two. Neither survives contact with the thing this app is for: Easter moves, and the cheapest week is not the same week every year. Each year is now its own row, linked by a series id. Editing one year touches one row; editing the series re-projects the rest from it. Removing a year is one delete. The weekday snap is worked out once, when the rows are made, and stored, so a row now says what it means rather than being re-derived on every read. Recurrence stops being a stored field and becomes what it always was in practice — an instruction at create and update time; whether a period repeats is whether it belongs to a series. Migration 004 rebuilds the table and expands the existing rows, keeping each original row as its first year so the days already planned against it still point somewhere. A Feb 29 anchor recurring into a common year used to raise out of the allocator; it clamps now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
243 lines
9.4 KiB
Python
243 lines
9.4 KiB
Python
"""Tests for the unit-of-work mechanism (`app.db.connection`) and the
|
|
batched `PlannedLeaveRepository` methods it exists to support.
|
|
|
|
See `scratchpad/audit/backend-perf.md` §3: the app runs on one shared
|
|
connection, so a multi-statement write needs (a) mutual exclusion for the
|
|
whole unit of work and (b) the per-row `commit()` calls suppressed while one
|
|
is in progress. These tests exercise that mechanism directly against an
|
|
in-memory connection, plus the `PlanningService.apply_proposed_days`
|
|
atomicity it is meant to guarantee end-to-end.
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import aiosqlite
|
|
import pytest
|
|
|
|
from app.db.connection import commit, ensure_schema, unit_of_work
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.services.planning_service import PlanningService, ProposedDayInput
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
@pytest.fixture
|
|
async def uow_connection() -> aiosqlite.Connection:
|
|
conn = await aiosqlite.connect(":memory:")
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.execute("PRAGMA foreign_keys = ON;")
|
|
await ensure_schema(conn)
|
|
try:
|
|
yield conn
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def _workspace_count(conn: aiosqlite.Connection) -> int:
|
|
cursor = await conn.execute("SELECT COUNT(*) FROM workspace")
|
|
row = await cursor.fetchone()
|
|
return int(row[0])
|
|
|
|
|
|
async def _insert_workspace(conn: aiosqlite.Connection, workspace_id: str) -> None:
|
|
await conn.execute(
|
|
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
|
|
(workspace_id, "Test", datetime.now().isoformat()),
|
|
)
|
|
|
|
|
|
class TestUnitOfWork:
|
|
async def test_commits_on_success(self, uow_connection: aiosqlite.Connection) -> None:
|
|
async with unit_of_work(uow_connection):
|
|
await _insert_workspace(uow_connection, "ws-commit")
|
|
assert await _workspace_count(uow_connection) == 1
|
|
|
|
async def test_rolls_back_on_exception(self, uow_connection: aiosqlite.Connection) -> None:
|
|
with pytest.raises(ValueError):
|
|
async with unit_of_work(uow_connection):
|
|
await _insert_workspace(uow_connection, "ws-rollback")
|
|
raise ValueError("simulated failure")
|
|
assert await _workspace_count(uow_connection) == 0
|
|
|
|
async def test_commit_helper_is_noop_inside_uow(
|
|
self, uow_connection: aiosqlite.Connection
|
|
) -> None:
|
|
"""`commit()` must not durably commit inside a unit of work — otherwise
|
|
a later rollback in the same block could only undo writes made after
|
|
that no-op `commit()` call, not before it (see backend-perf audit §3,
|
|
"Is BEGIN/COMMIT ... safe on the shared connection? No, not alone.").
|
|
"""
|
|
with pytest.raises(RuntimeError):
|
|
async with unit_of_work(uow_connection):
|
|
await _insert_workspace(uow_connection, "ws-noop-commit")
|
|
await commit(uow_connection) # must be a no-op here
|
|
raise RuntimeError("still rolls back everything, including the row above")
|
|
assert await _workspace_count(uow_connection) == 0
|
|
|
|
async def test_concurrent_calls_serialize(self, uow_connection: aiosqlite.Connection) -> None:
|
|
"""Two concurrent `unit_of_work()` blocks on the same connection never
|
|
interleave — the second blocks at entry until the first fully exits.
|
|
"""
|
|
events: list[str] = []
|
|
|
|
async def worker(name: str, hold: float) -> None:
|
|
async with unit_of_work(uow_connection):
|
|
events.append(f"{name}-start")
|
|
await asyncio.sleep(hold)
|
|
events.append(f"{name}-end")
|
|
|
|
await asyncio.gather(worker("a", 0.05), worker("b", 0))
|
|
|
|
assert events in (
|
|
["a-start", "a-end", "b-start", "b-end"],
|
|
["b-start", "b-end", "a-start", "a-end"],
|
|
)
|
|
|
|
|
|
class TestPlannedLeaveRepositoryBatchMethods:
|
|
async def test_create_many_then_delete_many(
|
|
self,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
now = datetime.now()
|
|
entities = [
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 3, day),
|
|
day_type="sjukpenning",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
for day in (1, 2, 3)
|
|
]
|
|
|
|
await planned_leave_repo.create_many(entities)
|
|
rows = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
|
|
assert len(rows) == 3
|
|
|
|
deleted = await planned_leave_repo.delete_many([entities[0].id, entities[1].id])
|
|
assert deleted == 2
|
|
remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
|
|
assert [r.id for r in remaining] == [entities[2].id]
|
|
|
|
async def test_create_many_empty_list_is_noop(
|
|
self,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
await planned_leave_repo.create_many([])
|
|
assert await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) == []
|
|
|
|
async def test_delete_many_is_idempotent(
|
|
self, planned_leave_repo: SQLitePlannedLeaveRepository
|
|
) -> None:
|
|
"""Unlike `delete()`, a missing id is silently ignored rather than raising."""
|
|
deleted = await planned_leave_repo.delete_many([uuid4(), uuid4()])
|
|
assert deleted == 0
|
|
|
|
async def test_update_metadata_many(
|
|
self,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
now = datetime.now()
|
|
entity = PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 4, 1),
|
|
day_type="vacation",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
await planned_leave_repo.create(entity)
|
|
|
|
wish = await leave_wish_repo.create(
|
|
LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
name="Test wish",
|
|
window_start_month=4,
|
|
window_start_day=1,
|
|
window_end_month=4,
|
|
window_end_day=30,
|
|
target_weeks=1,
|
|
first_year=2026,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
window_mode="fixed",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
|
|
await planned_leave_repo.update_metadata_many([(entity.id, wish.id, True)])
|
|
|
|
refreshed = await planned_leave_repo.get_by_id(entity.id)
|
|
assert refreshed is not None
|
|
assert refreshed.wish_id == wish.id
|
|
assert refreshed.is_pinned is True
|
|
# day_type/is_dubbeldag are untouched by the metadata-only update.
|
|
assert refreshed.day_type == "vacation"
|
|
|
|
|
|
class TestApplyProposedDaysAtomicity:
|
|
async def test_mid_batch_failure_leaves_nothing_persisted(
|
|
self,
|
|
planning_service_with_uow: PlanningService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A batch that replaces an existing row (delete + create, both inside
|
|
stage 4) must not leave the delete applied if the create half fails —
|
|
the whole `async with self._uow():` block must roll back together.
|
|
Uses `planning_service_with_uow` (a real `SQLiteUnitOfWork`), not the
|
|
default `planning_service` fixture, which wires a no-op unit of work
|
|
and would (correctly, given a no-op) leave the delete committed.
|
|
"""
|
|
existing = await planning_service_with_uow.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 5, 1),
|
|
day_type="sjukpenning",
|
|
)
|
|
|
|
async def boom(_items: list[PlannedLeaveDay]) -> None:
|
|
raise RuntimeError("simulated mid-batch failure")
|
|
|
|
monkeypatch.setattr(planned_leave_repo, "create_many", boom)
|
|
|
|
days = [
|
|
ProposedDayInput(
|
|
date=date(2026, 5, 1),
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
child_id=seeded_ids.child_id,
|
|
day_type="lagsta", # differs from the existing row -> collision -> to_replace
|
|
)
|
|
]
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await planning_service_with_uow.apply_proposed_days(
|
|
workspace_id=seeded_ids.workspace_id, days=days, replace=True
|
|
)
|
|
|
|
remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id)
|
|
assert len(remaining) == 1
|
|
assert remaining[0].id == existing.id
|
|
assert remaining[0].day_type == "sjukpenning"
|