Fixes 3 Important issues from re-review of the previous fast-follow-up commit: 1. booked_slots desync on reschedule: the cleanup trigger was `after update of status`, so changing date/time on a still-confirmed booking never fired it -- the OLD slot stayed blocked and the NEW slot stayed shown as free. Changed to an unqualified `after update`, and the UPDATE branch now compares the full (date, time, status) triple instead of just status, so it frees the old slot on any change away from it and (re)mirrors the new one when the row is still/now confirmed. Added pgTAP coverage: reschedule removes the old slot's mirror row and creates the new one. 2. The Edge Functions' comments/commit message claimed a non-200 response would make pg_net "retry" -- that's not how pg_net works, it's fire-and-forget and never inspects the response status. Corrected the comments to state the real reasoning (nothing consumes the status code, so a 500 wouldn't help and would misrepresent an already-successful booking/inquiry as failed). Closed the actual gap that created -- a failed Resend send was only visible in Edge Function logs nobody reads -- by adding a `notification_failures` table (new migration, admin-only select via RLS, no anon/authenticated write access, only service_role writes) and having both handlers insert a row (booking/inquiry id + error message) whenever the Resend call fails, the API key is missing, or an unexpected error occurs. Insert failures are swallowed (best-effort -- already in a failure path, must not throw a louder one). Added pgTAP tests for the table's RLS/grant shape. 3. `20260812000002_bookings_slot_uniqueness.sql` could have failed outright on `supabase db push` against live data if any existing confirmed booking already shared a (date, time) with another. Added `dedupe_confirmed_booking_slots()`, called before the index is created: it collapses each duplicate group down to the earliest-created row (kept 'confirmed', others cancelled) and logs what it did via `raise notice`, so index creation is guaranteed to succeed either way. Extracted as a standalone, service_role-only-executable function (rather than inlining the SQL) specifically so the pgTAP test can exercise the exact same logic the migration runs. Added a test that seeds two duplicate confirmed bookings (having first dropped the index to simulate the pre-migration state), runs the dedupe function, and asserts exactly one confirmed booking remains -- the earliest one -- before recreating the index. pgTAP suite grew from 23 to 37 tests. Verified locally: supabase db reset (all 7 migrations apply cleanly), supabase test db (37/37 pass), vitest run (51/51 pass), npm run build, npm run lint, and npm run test:functions (6/6 pass) all succeed. Still nothing here touches the live Supabase project. Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
283 lines
11 KiB
PL/PgSQL
283 lines
11 KiB
PL/PgSQL
begin;
|
|
select plan(37);
|
|
|
|
-- Seed reference data as service_role (bypasses RLS)
|
|
set local role service_role;
|
|
insert into public.services (id, name, duration_minutes, price_from, description, image)
|
|
values ('knotless', 'Knotless Braids', 240, 150, 'Tension-free braids.', 'styles/knotless.jpg');
|
|
insert into public.products (id, name, category, price, image, description, in_stock)
|
|
values ('edge-control', 'Silk Edge Control', 'hair-care', 12, 'products/edge-control.jpg', 'Hold with shine.', true);
|
|
reset role;
|
|
|
|
-- Anyone can read services
|
|
set local role anon;
|
|
select isnt_empty(
|
|
$$ select * from public.services $$,
|
|
'anon can read services'
|
|
);
|
|
|
|
-- Anyone can read products
|
|
select isnt_empty(
|
|
$$ select * from public.products $$,
|
|
'anon can read products'
|
|
);
|
|
|
|
-- Anon cannot write to services
|
|
select throws_ok(
|
|
$$ insert into public.services (id, name, duration_minutes, price_from, description, image)
|
|
values ('hack', 'Hack', 1, 1, 'x', 'x') $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot insert into services'
|
|
);
|
|
|
|
-- Anyone can create a booking
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
|
|
values ('knotless', '2026-09-15', '10:00 AM', 'Jane Doe', '555-0100', 'jane@example.com', '') $$,
|
|
'anon can create a booking'
|
|
);
|
|
|
|
-- Anon cannot read bookings (PII protection). No SELECT privilege is granted
|
|
-- to anon on this table at all now, so this is blocked at the grant layer
|
|
-- (permission denied) rather than merely filtered to empty rows by RLS.
|
|
select throws_ok(
|
|
$$ select * from public.bookings $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot read bookings'
|
|
);
|
|
|
|
-- Anon cannot update bookings (no UPDATE privilege granted, regardless of RLS)
|
|
select throws_ok(
|
|
$$ update public.bookings set contact_notes = 'hacked' $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot update bookings'
|
|
);
|
|
|
|
-- Anyone can create an inquiry
|
|
select lives_ok(
|
|
$$ insert into public.inquiries (product_id, contact_name, contact_email, contact_notes)
|
|
values ('edge-control', 'Jane Doe', 'jane@example.com', 'Is this in stock?') $$,
|
|
'anon can create an inquiry'
|
|
);
|
|
|
|
-- Anon cannot read inquiries (PII protection). Same as bookings above: no
|
|
-- SELECT privilege is granted, so this fails at the grant layer.
|
|
select throws_ok(
|
|
$$ select * from public.inquiries $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot read inquiries'
|
|
);
|
|
|
|
-- Anyone can read booked_slots (no PII there)
|
|
select isnt_empty(
|
|
$$ select * from public.booked_slots $$,
|
|
'anon can read booked_slots (populated by the trigger from the booking above)'
|
|
);
|
|
|
|
-- Anon cannot write booked_slots directly
|
|
select throws_ok(
|
|
$$ insert into public.booked_slots (date, time) values ('2026-09-16', '1:00 PM') $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot insert into booked_slots directly'
|
|
);
|
|
|
|
-- The admin (matching UID) can read bookings
|
|
set local role authenticated;
|
|
set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';
|
|
select isnt_empty(
|
|
$$ select * from public.bookings $$,
|
|
'admin can read bookings'
|
|
);
|
|
|
|
-- The admin can write to services
|
|
select lives_ok(
|
|
$$ insert into public.services (id, name, duration_minutes, price_from, description, image)
|
|
values ('cornrows', 'Cornrows', 120, 90, 'Classic style.', 'styles/cornrows.jpg') $$,
|
|
'admin can insert into services'
|
|
);
|
|
|
|
-- Double-booking prevention: only one *confirmed* booking may hold a given
|
|
-- (date, time) slot. Uses service_role so grant-layer checks don't interfere
|
|
-- with the uniqueness check under test.
|
|
set local role service_role;
|
|
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
|
values ('knotless', '2026-10-01', '2:00 PM', 'A Client', '555-0101', 'a@example.com', '', 'confirmed') $$,
|
|
'first confirmed booking for a slot succeeds'
|
|
);
|
|
|
|
select throws_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
|
values ('knotless', '2026-10-01', '2:00 PM', 'B Client', '555-0102', 'b@example.com', '', 'confirmed') $$,
|
|
'23505',
|
|
null,
|
|
'a second confirmed booking for an already-booked slot fails with a uniqueness violation'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
|
values ('knotless', '2026-10-01', '3:00 PM', 'C Client', '555-0103', 'c@example.com', '', 'confirmed') $$,
|
|
'a confirmed booking for a different slot on the same date succeeds'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
|
values ('knotless', '2026-10-01', '2:00 PM', 'D Client', '555-0104', 'd@example.com', '', 'cancelled') $$,
|
|
'a cancelled booking for an already-booked slot still succeeds (partial index only covers confirmed)'
|
|
);
|
|
|
|
-- booked_slots cleanup: mirror row is removed once no confirmed booking
|
|
-- remains for a (date, time), whether via status update or deletion.
|
|
select isnt_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-01' and time = '2:00 PM' $$,
|
|
'booked_slots mirror has an entry for the confirmed slot'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ update public.bookings set status = 'cancelled'
|
|
where date = '2026-10-01' and time = '2:00 PM' and status = 'confirmed' $$,
|
|
'cancelling the confirmed booking succeeds'
|
|
);
|
|
|
|
select is_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-01' and time = '2:00 PM' $$,
|
|
'booked_slots mirror row is removed after the booking is cancelled'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
|
|
values ('knotless', '2026-10-02', '9:00 AM', 'E Client', '555-0105', 'e@example.com', '') $$,
|
|
'confirmed booking for the deletion test succeeds'
|
|
);
|
|
|
|
select isnt_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-02' and time = '9:00 AM' $$,
|
|
'booked_slots mirror has an entry before deletion'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ delete from public.bookings where date = '2026-10-02' and time = '9:00 AM' $$,
|
|
'deleting the confirmed booking succeeds'
|
|
);
|
|
|
|
select is_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-02' and time = '9:00 AM' $$,
|
|
'booked_slots mirror row is removed after the booking is deleted'
|
|
);
|
|
|
|
-- Rescheduling: an update that changes date/time while status stays
|
|
-- 'confirmed' must free the OLD slot's mirror row and create one for the
|
|
-- NEW slot. A trigger scoped to `after update of status` would never fire
|
|
-- for this (status doesn't change), which was the bug caught in review.
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
|
|
values ('knotless', '2026-10-03', '11:00 AM', 'F Client', '555-0106', 'f@example.com', '') $$,
|
|
'confirmed booking for the reschedule test succeeds'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ update public.bookings set date = '2026-10-04', time = '1:00 PM'
|
|
where date = '2026-10-03' and time = '11:00 AM' and status = 'confirmed' $$,
|
|
'rescheduling the confirmed booking to a new date/time succeeds'
|
|
);
|
|
|
|
select is_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-03' and time = '11:00 AM' $$,
|
|
'booked_slots mirror row for the OLD slot is removed after a reschedule'
|
|
);
|
|
|
|
select isnt_empty(
|
|
$$ select * from public.booked_slots where date = '2026-10-04' and time = '1:00 PM' $$,
|
|
'booked_slots mirror row for the NEW slot is created after a reschedule'
|
|
);
|
|
|
|
-- notification_failures: admin-only select; only service_role writes.
|
|
select lives_ok(
|
|
$$ insert into public.notification_failures (kind, record_id, error_message)
|
|
values ('booking', gen_random_uuid(), 'Resend responded 401: unauthorized') $$,
|
|
'service_role can insert into notification_failures'
|
|
);
|
|
|
|
set local role anon;
|
|
select throws_ok(
|
|
$$ select * from public.notification_failures $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot read notification_failures'
|
|
);
|
|
|
|
select throws_ok(
|
|
$$ insert into public.notification_failures (kind, record_id, error_message)
|
|
values ('booking', gen_random_uuid(), 'hack') $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot insert into notification_failures'
|
|
);
|
|
|
|
set local role authenticated;
|
|
set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';
|
|
select isnt_empty(
|
|
$$ select * from public.notification_failures $$,
|
|
'admin can read notification_failures'
|
|
);
|
|
|
|
-- Self-healing unique index migration: replay the exact dedupe logic
|
|
-- 20260812000002 runs before creating bookings_active_slot_idx, proving
|
|
-- that pre-existing duplicate confirmed bookings for a slot get collapsed
|
|
-- to a single confirmed row (the earliest-created one) rather than making
|
|
-- the index creation fail outright.
|
|
-- DDL (drop/create index) requires table ownership, which service_role
|
|
-- doesn't have -- reset to the connecting (owner) role for this block. The
|
|
-- dedupe_confirmed_booking_slots() call further down still exercises the
|
|
-- service_role grant path via an explicit role switch.
|
|
reset role;
|
|
|
|
select lives_ok(
|
|
$$ drop index if exists public.bookings_active_slot_idx $$,
|
|
'temporarily dropping the unique index to simulate pre-migration data succeeds'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, created_at)
|
|
values
|
|
('knotless', '2026-11-01', '4:00 PM', 'Dup Earliest', '555-0201', 'dup1@example.com', '', 'confirmed', now() - interval '1 hour'),
|
|
('knotless', '2026-11-01', '4:00 PM', 'Dup Later', '555-0202', 'dup2@example.com', '', 'confirmed', now()) $$,
|
|
'seeding two duplicate confirmed bookings for the same slot succeeds without the unique index'
|
|
);
|
|
|
|
-- Exercises the same execute grant the migration relies on: only
|
|
-- service_role can call this function (revoked from anon/authenticated).
|
|
set local role service_role;
|
|
select lives_ok(
|
|
$$ select public.dedupe_confirmed_booking_slots() $$,
|
|
'dedupe_confirmed_booking_slots() runs without error'
|
|
);
|
|
|
|
select is(
|
|
(select count(*)::int from public.bookings where date = '2026-11-01' and time = '4:00 PM' and status = 'confirmed'),
|
|
1,
|
|
'the self-heal dedupe leaves exactly one confirmed booking per duplicated slot'
|
|
);
|
|
|
|
select results_eq(
|
|
$$ select contact_name from public.bookings where date = '2026-11-01' and time = '4:00 PM' and status = 'confirmed' $$,
|
|
$$ values ('Dup Earliest'::text) $$,
|
|
'the self-heal dedupe keeps the earliest-created booking as confirmed'
|
|
);
|
|
|
|
-- Back to the owner role for the final DDL step.
|
|
reset role;
|
|
select lives_ok(
|
|
$$ create unique index bookings_active_slot_idx on public.bookings (date, time) where status = 'confirmed' $$,
|
|
'the unique index can be created after the self-heal dedupe removes the duplicate'
|
|
);
|
|
|
|
select * from finish();
|
|
rollback;
|