diff --git a/.gitleaksignore b/.gitleaksignore index 85dc5e4db1..2a3ff8fff2 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -170,3 +170,6 @@ a958fdce92f53571c02fa3ea2a03c61300122763:supabase/applied-migration-hashes.json: # migrations rewrote the snapshot (CRLF to LF), so the unchanged line rescans as new. dd709e6fcc6bf8940272537e8afada52396af1a4:supabase/schema.sql:generic-api-key:13492 2323e43ae1484f18f6e245034867095af84c5360:supabase/schema.sql:generic-api-key:13492 +# Migration seal entry: the filename contains "keys" and the value is a sha256, +# which the generic-api-key rule reads as a key. Not a secret. +e270353c3f8a154971a11b9eabd2b0acef4476ec:supabase/applied-migration-hashes.json:generic-api-key:317 diff --git a/src/components/open-shifts/open-shifts-post-keys.ts b/src/components/open-shifts/open-shifts-post-keys.ts new file mode 100644 index 0000000000..f092f00d25 --- /dev/null +++ b/src/components/open-shifts/open-shifts-post-keys.ts @@ -0,0 +1,63 @@ +import { OPEN_SHIFTS_POST_KEYS_STORAGE_KEY } from "@/lib/account-scoped-browser-state"; + +/** + * The retry key for each shift being posted, kept for this tab so it survives a reload. If a post + * went live but its answer was lost, reloading the page and posting the same shift again sends the + * same key, and the server answers with the shift it already saved instead of saving a second one. + * A key is dropped once its post is confirmed, so a deliberate later post of the same shift gets a + * new key. Keys older than a day are dropped as stale. Storage can be missing or blocked, so every + * read and write is guarded and the page still works for this visit without it. + */ + +const KEY = OPEN_SHIFTS_POST_KEYS_STORAGE_KEY; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Stored = Record; + +function read(now: number): Stored { + try { + const raw = window.sessionStorage.getItem(KEY); + const value = raw ? (JSON.parse(raw) as unknown) : null; + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const kept: Stored = {}; + for (const [signature, entry] of Object.entries(value as Record)) { + if (!entry || typeof entry !== "object") continue; + const { key, at } = entry as { key?: unknown; at?: unknown }; + if (typeof key === "string" && UUID.test(key) && typeof at === "number" && now - at < MAX_AGE_MS) { + kept[signature] = { key, at }; + } + } + return kept; + } catch { + return {}; + } +} + +function write(stored: Stored): void { + try { + if (Object.keys(stored).length === 0) window.sessionStorage.removeItem(KEY); + else window.sessionStorage.setItem(KEY, JSON.stringify(stored)); + } catch { + // Storage is blocked or full: the key still holds for this visit. + } +} + +/** The key for this shift: the one kept from an earlier unanswered attempt, or a new one, kept now. */ +export function postKeyFor(signature: string, memory: Record, now = Date.now()): string { + const stored = read(now); + const key = memory[signature] ?? stored[signature]?.key ?? crypto.randomUUID(); + memory[signature] = key; + if (stored[signature]?.key !== key) write({ ...stored, [signature]: { key, at: now } }); + return key; +} + +/** The post is confirmed, so a later post of the same shift is a new one and gets a new key. */ +export function forgetPostKey(signature: string, memory: Record, now = Date.now()): void { + delete memory[signature]; + const stored = read(now); + if (signature in stored) { + delete stored[signature]; + write(stored); + } +} diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 77dbc5a62c..3f39370ae3 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -7,6 +7,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useRosterNow } from "@/components/roster/roster-format"; import { postRosterAction } from "@/components/roster/use-roster-team"; import { Button } from "@/components/ui/button"; +import { subscribeAccountTransition } from "@/lib/account-scoped-browser-state"; import { formatHours, gapTimes, gradeLabel, hoursBetween, kindLabel } from "@/lib/open-shifts/model"; import { MAX_POST_DATES, @@ -25,6 +26,7 @@ import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ROSTER_OPEN_SHIFT_KINDS, type RosterAction } from "@/lib/roster/team/model"; import { ConfirmSheet } from "./open-shifts-confirm"; +import { forgetPostKey, postKeyFor } from "./open-shifts-post-keys"; import { SignInAction } from "./open-shifts-sign-in"; import { LoadFailed, NoTeam } from "./open-shifts-states"; import { @@ -109,10 +111,30 @@ export function OpenShiftsPostPage() { const [unanswered, setUnanswered] = useState< Record >({}); - // One post key per date being posted, by everything that was sent. A retry of a date whose answer was - // lost sends the same key, so the server answers with the shift that post already saved instead of saving - // a second one. A key is dropped once its date is posted; changing anything in the shift makes a new key. + // One post key per date being posted, by everything that was sent, also kept for this tab so it survives a + // reload. A retry of a date whose answer was lost sends the same key, so the server answers with the shift + // that post already saved instead of saving a second one. A key is dropped once its date is posted; + // changing anything in the shift makes a new key. const postKeys = useRef>({}); + // Counts sign-outs on this tab, so a post in progress stops at once, before the team list catches up. + const signIns = useRef(0); + // Keep the current actor available to an in-progress post, whose render state can otherwise be stale after + // authentication changes. + const actorIdRef = useRef(state.actorId); + useEffect(() => { + actorIdRef.current = state.actorId; + }, [state.actorId]); + // Keys belong to the signed-in person, so someone else signing in on this tab never reuses one. + const keyFor = (on: string, actorId = state.actorId) => `${actorId ?? ""}|${signature(on)}`; + // Signing out drops this tab's keys, so the same person signing back in never replays an older post. + useEffect( + () => + subscribeAccountTransition(() => { + postKeys.current = {}; + signIns.current += 1; + }), + [], + ); const [copied, setCopied] = useState(false); // The last partial-failure report, kept on the page so closing the sheet never hides what went live. const [report, setReport] = useState(null); @@ -219,6 +241,8 @@ export function OpenShiftsPostPage() { * rest; the answer names every date that went live and every one that didn't. */ async function post(): Promise { + const postingActorId = actorIdRef.current; + const postingSignIn = signIns.current; if (!team || !times) return "Choose a team, date and times."; // A post that got no answer for a team that couldn't be read just now: wait, rather than let the form // fall back to another team and post there instead. A team the manager picked themselves still posts. @@ -235,7 +259,13 @@ export function OpenShiftsPostPage() { const attempts: PostAttempt[] = []; const nowDone = { ...done }; const nowUnanswered = { ...unanswered }; + let stopped = false; for (const [index, on] of toPost.entries()) { + // Do not issue another request after the account changes while a previous date is pending. + if (actorIdRef.current !== postingActorId || signIns.current !== postingSignIn) { + stopped = true; + break; + } setProgress({ current: index + 1, total: toPost.length }); const onTimes = gapTimes(on, start, end); // A date whose earlier post got no answer waits for a fresh read of the team's shifts, so it is never sent @@ -253,14 +283,15 @@ export function OpenShiftsPostPage() { if (live) { attempts.push({ date: on, ok: true, openShiftId: live.id }); nowDone[signature(on)] = live.id; + // Confirmed from the list, so a later deliberate post of the same slot gets a new key. + forgetPostKey(keyFor(on, postingActorId), postKeys.current); continue; } if (!onTimes) { attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); continue; } - const postKey = postKeys.current[signature(on)] ?? crypto.randomUUID(); - postKeys.current[signature(on)] = postKey; + const postKey = postKeyFor(keyFor(on, postingActorId), postKeys.current); const result = await postRosterAction(team.serviceId, { action: "open.post", startsAt: onTimes.startsAt, @@ -275,7 +306,7 @@ export function OpenShiftsPostPage() { if (result.ok) { attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); nowDone[signature(on)] = result.result.openShiftId ?? ""; - delete postKeys.current[signature(on)]; + forgetPostKey(keyFor(on, postingActorId), postKeys.current); } else { attempts.push({ date: on, ok: false, message: result.message }); // No answer at all: it may still have gone live, so a retry checks the team's list first. A repeat @@ -291,8 +322,14 @@ export function OpenShiftsPostPage() { } } setProgress(null); + // Keep what this run learned even when it stopped, so a date that already went live is never sent again. setDone(nowDone); setUnanswered(nowUnanswered); + if (stopped) { + const posted = attempts.filter((row) => row.ok).map((row) => formatDayShort(row.date)); + const already = posted.length > 0 ? ` Already posted: ${posted.join(", ")}.` : ""; + return `Posting stopped because the signed-in account changed.${already} Please review and try again.`; + } // Pin the team a post with no answer went to, so a re-read that misses it can't quietly switch the form. if (Object.keys(nowUnanswered).length > Object.keys(unanswered).length) setServiceId(team.serviceId); const { failed } = splitAttempts(attempts); diff --git a/src/lib/account-scoped-browser-state.ts b/src/lib/account-scoped-browser-state.ts index 4def75f271..b683ba1792 100644 --- a/src/lib/account-scoped-browser-state.ts +++ b/src/lib/account-scoped-browser-state.ts @@ -202,6 +202,13 @@ export const WORK_ACCOUNT_SYNC_MARKER_KEY = "psychsift:work:account-sync-v1"; * `psychsift:` prefix, so the sweep would miss it: it is named here. */ export const OPEN_SHIFTS_SAVED_FILTERS_STORAGE_KEY = "psychsift.open-shifts.filters.v1"; +/** + * sessionStorage — Open shifts Post: the retry key for each date whose post got no answer, by the + * shift that was sent, so a retry after a reload sends the same key and the server never saves the + * shift twice. Random keys and the shift's team, times and code, no name. Cleared on account + * transitions so the next doctor in the tab starts with no keys. + */ +export const OPEN_SHIFTS_POST_KEYS_STORAGE_KEY = "psychsift:open-shifts:post-keys-v1"; /** * The app preferences (`use-app-preferences.ts`). Display settings in it are the @@ -305,6 +312,7 @@ export function clearAccountScopedBrowserStorage(): void { removeQuietly(() => window.localStorage, WORK_TAB_PICKS_STORAGE_KEY); removeQuietly(() => window.localStorage, WORK_ACCOUNT_SYNC_MARKER_KEY); removeQuietly(() => window.localStorage, OPEN_SHIFTS_SAVED_FILTERS_STORAGE_KEY); + removeQuietly(() => window.sessionStorage, OPEN_SHIFTS_POST_KEYS_STORAGE_KEY); stripAccountScopedPreferences(); // The catch-all: every other `psychsift:` key, the clinical drafts, IndexedDB, // page caches and PsychSift's notifications (see account-device-sweep.ts). diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json index c371d7fbfb..42b8d25a50 100644 --- a/supabase/applied-migration-hashes.json +++ b/supabase/applied-migration-hashes.json @@ -1,7 +1,7 @@ { "note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.", "generator": "scripts/check-migration-immutability.mjs", - "sealed_at": "2026-10-15T17:36:28.021Z", + "sealed_at": "2026-10-15T23:25:36.210Z", "versions": { "20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c", "20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48", @@ -314,6 +314,7 @@ "20261014164220_validate_managed_ingestion_historical_reconciliation.sql": "8ffd75292620e82065300eb1e1d008c229545dde37826862820bfd6ea1314587", "20261015160000_document_child_policies_check_parent_owner.sql": "71210bbc5aecdca74bee2ae32181ba2861e65017ca1a5e87995aa8e557946094", "20261015174500_roster_open_post_once.sql": "a4edfe07497e3b833c4bedecf1b41d0d7b9fb0b73a798a71b3c17bccb9f851ff", - "20261015180000_prefilter_text_only_governed_retrieval.sql": "8ff1f445269ad6413d5f00b304fc169dd2521f0d01e215415bbc4f573d2a926a" + "20261015180000_prefilter_text_only_governed_retrieval.sql": "8ff1f445269ad6413d5f00b304fc169dd2521f0d01e215415bbc4f573d2a926a", + "20261015181500_roster_open_post_keys_posted_by_fk.sql": "57963eee880db9542b6e9a3383dcef01625a0d05bcbbc3465cd43ec613860639" } } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index c291d98311..10d23890a3 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-10-15T23:31:52.942Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "067f617ef440b12532d63d7a4e89054919da9258e403c1beaace99a7578033f2", + "schema_sha256": "6c24504e8ec1a03728867dada28a4668c56d33e819f65940db7fa45f45a88fd0", "replay_seconds": 34, "snapshot": { "views": [ @@ -23729,6 +23729,11 @@ "name": "roster_open_post_keys_pkey", "table": "roster_open_post_keys" }, + { + "def": "FOREIGN KEY (posted_by) REFERENCES auth.users(id) ON DELETE SET NULL", + "name": "roster_open_post_keys_posted_by_fkey", + "table": "roster_open_post_keys" + }, { "def": "FOREIGN KEY (service_id) REFERENCES public.on_call_services(id) ON DELETE CASCADE", "name": "roster_open_post_keys_service_id_fkey", diff --git a/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql new file mode 100644 index 0000000000..53ecee89ed --- /dev/null +++ b/supabase/migrations/20261015181500_roster_open_post_keys_posted_by_fk.sql @@ -0,0 +1,17 @@ +-- Never edit an applied migration. Merging this applies it to the live database. +-- Deleting a doctor's account also clears their id from the open-shift post keys. +-- roster_open_shifts.posted_by already clears itself when the account goes (on delete set null); +-- the post keys added in 20261015174500 kept the same id with no link to the account, so it stayed +-- until the open shift itself was purged. This gives roster_open_post_keys.posted_by the same +-- link. Any key whose poster's account is already gone is cleared first so the link can be added. +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +update public.roster_open_post_keys k + set posted_by = null + where k.posted_by is not null + and not exists (select 1 from auth.users u where u.id = k.posted_by); + +alter table public.roster_open_post_keys + add constraint roster_open_post_keys_posted_by_fkey + foreign key (posted_by) references auth.users (id) on delete set null; diff --git a/supabase/schema.sql b/supabase/schema.sql index fc13616da7..9c061fac6f 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -34213,6 +34213,10 @@ create table public.roster_open_post_keys ( primary key (service_id, post_key) ); create index roster_open_post_keys_open_shift_idx on public.roster_open_post_keys (open_shift_id); +-- Deleting an account clears its id from the post keys too (20261015181500_roster_open_post_keys_posted_by_fk.sql). +alter table public.roster_open_post_keys + add constraint roster_open_post_keys_posted_by_fkey + foreign key (posted_by) references auth.users (id) on delete set null; alter table public.roster_open_post_keys enable row level security; revoke all on table public.roster_open_post_keys from public, anon, authenticated; diff --git a/tests/open-shifts-post-retry.dom.test.tsx b/tests/open-shifts-post-retry.dom.test.tsx index d90fd734e9..c529d3486c 100644 --- a/tests/open-shifts-post-retry.dom.test.tsx +++ b/tests/open-shifts-post-retry.dom.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PostedShift, PostedShiftsState } from "@/components/open-shifts/use-posted-shifts"; +import { clearAccountScopedBrowserStorage } from "@/lib/account-scoped-browser-state"; import { gapTimes } from "@/lib/open-shifts/model"; import { sampleListings } from "@/lib/open-shifts/sample"; @@ -65,6 +66,7 @@ function liveShift(): PostedShift { } beforeEach(() => { + window.sessionStorage.clear(); mocks.post.mockReset(); mocks.push.mockReset(); mocks.state = { @@ -115,6 +117,8 @@ describe("Posting a shift", () => { await waitFor(() => expect(mocks.push).toHaveBeenCalledOnce()); expect(mocks.post).toHaveBeenCalledOnce(); expect(mocks.push.mock.calls[0]![0]).toContain(liveShift().id); + // Confirmed from the list, so the saved key is gone and a later post of the same slot is a new one. + expect(window.sessionStorage.getItem("psychsift:open-shifts:post-keys-v1")).toBeNull(); }); it("after a post gets no answer, still sends it when the matching new shift was posted by someone else", async () => { @@ -192,6 +196,124 @@ describe("Posting a shift", () => { expect(mocks.push.mock.calls[0]![0]).toContain(SAVED); }); + it("after a post gets no answer and the page is reloaded, the retry still sends the same post key", async () => { + const SAVED = "5e000000-0000-4000-8000-0000000000b6"; + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValueOnce({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + mocks.post.mockResolvedValueOnce({ + ok: true, + result: { openShiftId: SAVED, status: "open", replayed: true }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Not posted/)); + + // Reload: the page starts again from nothing but the tab's storage, and the same shift is posted. + cleanup(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.push).toHaveBeenCalledOnce()); + + const [first, retry] = mocks.post.mock.calls.map((call) => (call[1] as { postKey?: string }).postKey); + expect(first).toBeTruthy(); + expect(retry).toBe(first); + expect(mocks.push.mock.calls[0]![0]).toContain(SAVED); + // Confirmed, so the key is gone and a later post of the same shift would be a new one. + expect(window.sessionStorage.getItem("psychsift:open-shifts:post-keys-v1")).toBeNull(); + }); + + it("never reuses another person's post key after someone else signs in on the same tab", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // A different manager signs in without the page closing, and posts the same shift. + mocks.state = { + ...mocks.state, + actorId: "5e000000-0000-4000-8000-000000000002", + readAt: new Date(Date.now() + 1000), + }; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(2)); + + const [first, second] = mocks.post.mock.calls.map((call) => (call[1] as { postKey?: string }).postKey); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); + + it("never replays an older post key after the same person signs out and back in on this tab", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Review and post" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // Sign out and back in as the same manager without the page closing, then post the same shift. + clearAccountScopedBrowserStorage(); + mocks.state = { ...mocks.state, readAt: new Date(Date.now() + 1000) }; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(2)); + + const [first, second] = mocks.post.mock.calls.map((call) => (call[1] as { postKey?: string }).postKey); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); + + it("when the account changes mid-post, stops, names what went live, and never sends that date again", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + let answerFirst: (value: unknown) => void = () => undefined; + mocks.post.mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve))); + mocks.post.mockResolvedValue({ ok: false, code: "roster_unavailable", message: "Couldn't reach the server." }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Add another date" })); + fireEvent.click(screen.getByRole("button", { name: "Review and post 2 shifts" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post all 2" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // Someone else signs in while the first date is still being sent, and then it goes live. + const first = mocks.state.actorId; + mocks.state = { ...mocks.state, actorId: "5e000000-0000-4000-8000-000000000002" }; + view.rerender(); + answerFirst({ ok: true, result: { openShiftId: "5e000000-0000-4000-8000-0000000000b7", status: "open" } }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Posting stopped.*Already posted/)); + expect(mocks.post).toHaveBeenCalledOnce(); + + // The first manager signs back in and tries again: only the date that never went out is sent. + mocks.state = { ...mocks.state, actorId: first, readAt: new Date(Date.now() + 1000) }; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Yes, post it" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(2)); + const sent = mocks.post.mock.calls.map((call) => (call[1] as { startsAt: string }).startsAt); + expect(sent[1]).not.toBe(sent[0]); + }); + + it("stops before the next date as soon as the account signs out, even before the team list catches up", async () => { + mocks.state = { ...mocks.state, shifts: [] }; + let answerFirst: (value: unknown) => void = () => undefined; + mocks.post.mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve))); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add another date" })); + fireEvent.click(screen.getByRole("button", { name: "Review and post 2 shifts" })); + fireEvent.click(screen.getByRole("button", { name: "Yes, post all 2" })); + await waitFor(() => expect(mocks.post).toHaveBeenCalledOnce()); + + // The account signs out while the first date is still being sent; the team list has not re-read yet. + clearAccountScopedBrowserStorage(); + answerFirst({ ok: true, result: { openShiftId: "5e000000-0000-4000-8000-0000000000b8", status: "open" } }); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Posting stopped/)); + expect(mocks.post).toHaveBeenCalledOnce(); + }); + it("gives a deliberate new post its own key once the last one went live", async () => { mocks.post.mockResolvedValue({ ok: true, diff --git a/tests/roster-alerts.test.ts b/tests/roster-alerts.test.ts index 71e8688591..26cc5cc13c 100644 --- a/tests/roster-alerts.test.ts +++ b/tests/roster-alerts.test.ts @@ -139,6 +139,11 @@ describe("generic phone alerts", () => { result: { openShiftId: OPEN, status: "open" }, }); expect(mocks.open).toHaveBeenCalledWith(client, SERVICE, OPEN); + // The new post took the offer path: it read the week either side of the gap to find who to offer it to. + expect(mocks.read).toHaveBeenCalledWith(client, ALEX, SERVICE, "assignments", { + from: "2026-11-13", + to: "2026-11-27", + }); vi.clearAllMocks(); await dispatchRosterAlerts(client, { diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql index 10bfb826da..aca560da57 100644 --- a/tests/sql/roster-behaviour.sql +++ b/tests/sql/roster-behaviour.sql @@ -579,7 +579,14 @@ reset role; do $delete_account$ declare lee uuid := pg_temp.id('lee'); begin + -- An open-shift post key that names them, so deleting the account has to clear it. + insert into public.roster_open_post_keys (service_id, post_key, open_shift_id, posted_by) + select service_id, gen_random_uuid(), open_shift_id, lee from public.roster_open_post_keys limit 1; + if not exists (select 1 from public.roster_open_post_keys where posted_by = lee) then + raise exception 'delete: no post key fixture to check'; + end if; delete from auth.users where id = lee; + if exists (select 1 from public.roster_open_post_keys where posted_by = lee) then raise exception 'delete: post key still names them'; end if; if exists (select 1 from public.on_call_shifts where owner_id = lee) then raise exception 'delete: own shifts kept'; end if; if exists (select 1 from public.roster_publication_seen where user_id = lee) then raise exception 'delete: seen kept'; end if; if exists (select 1 from public.roster_assignments where user_id = lee) then raise exception 'delete: assignment still names them'; end if;