fix(m6): post-review pass — cache prefix, snapshot lock, perm-before-parse, LIKE escape

Addresses spec-reviewer + code-reviewer feedback on the M6 bundle:

Critical:
- frontend/src/lib/missions.ts: add `listPrefix()` so TanStack invalidation
  catches every filtered list variant; the previous `list()` returned
  `['missions','list',{}]` and only matched the exact empty-filter cache,
  leaving filtered tables stale after create/transition/delete.
- backend/app/services/missions.py: acquire the same per-scenario
  `pg_advisory_xact_lock` key used by `set_scenario_tests` before
  snapshotting; without it a concurrent M5 reorder could freeze a torn
  snapshot under READ COMMITTED. Sorted by key to avoid deadlocks with
  another snapshotter.

Important:
- backend/app/api/missions.py: `@require_perm("mission.update",
  "mission.archive")` on the transition endpoint so users without either
  perm get 403 before the body is parsed (no shape leak via 400).
- backend/app/services/missions.py: escape `%` / `_` / `\` in user-typed
  `q` / `client` LIKE search; users can no longer trigger wildcard
  semantics by typing literal `%`. Added `escape='\\'` arg on every .like().
- backend/app/services/missions.py: filter `MissionTest.deleted_at` and
  `MissionScenario.deleted_at` in the list-item and detail counts so M7+
  soft-deletes don't drift the totals silently.

Nits:
- backend/app/api/users.py: order `/users/roster` by email for stable
  rendering + deterministic e2e selectors.
- frontend/src/pages/MissionDetailPage.tsx: distinct accent per
  transition target (cyan/orange/green/teal) matching the status legend.
- e2e/tests/m6-missions.spec.ts: switch fragile `getByRole(name=/In
  Progress/i)` to the stable `mission-transition-in_progress` data-testid.

New tests:
- test_create_mission_rejects_soft_deleted_scenario
- test_transition_perm_gate_runs_before_payload_parse
- test_search_treats_wildcards_as_literals

Suite: 106 pytest passing (was 103), 43 Playwright passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-13 15:14:57 +02:00
parent 00b7557e30
commit e1b51db25f
9 changed files with 149 additions and 18 deletions

View File

@@ -133,6 +133,11 @@ export interface TransitionPayload {
}
export const missionKeys = {
/** Prefix-only key — pass this to `invalidateQueries` to refresh every
* filtered variant. Matching is prefix-based: `['missions','list',{q:'x'}]`
* also gets invalidated.
*/
listPrefix: () => ['missions', 'list'] as const,
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
detail: (id: string) => ['missions', 'detail', id] as const,
};

View File

@@ -27,6 +27,13 @@ const ALLOWED_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = {
archived: [],
};
const TRANSITION_BUTTON_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
function useMission(id: string) {
return useQuery({
queryKey: missionKeys.detail(id),
@@ -55,14 +62,14 @@ export function MissionDetailPage() {
apiPost<Mission>(`/missions/${missionId}/transition`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
qc.invalidateQueries({ queryKey: missionKeys.list() });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
},
});
const remove = useMutation({
mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.list() });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate('/missions');
},
});
@@ -94,7 +101,7 @@ export function MissionDetailPage() {
{allowedNext.map((target) => (
<Button
key={target}
accent={target === 'archived' ? 'teal' : 'cyan'}
accent={TRANSITION_BUTTON_ACCENT[target]}
onClick={() => transition.mutate({ status: target })}
data-testid={`mission-transition-${target}`}
disabled={transition.isPending}

View File

@@ -100,7 +100,7 @@ export function MissionsCreatePage() {
const createMutation = useMutation({
mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body),
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: missionKeys.list() });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate(`/missions/${created.id}`);
},
});