begin; select plan(64); -- 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 = '2b88676b-f901-459a-9147-b914190c4f81'; 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' ); -- salon-images bucket: anyone can read, only admin can write set local role anon; set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000000'; select lives_ok( $$ select * from storage.objects where bucket_id = 'salon-images' $$, 'anon can read salon-images objects' ); select throws_ok( $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'test.jpg') $$, '42501', null, 'anon cannot upload to salon-images' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'admin-test.jpg') $$, 'admin can upload to salon-images' ); -- Scoped to a single booking (not a blanket service_id filter): several -- other knotless bookings above share a slot across statuses (e.g. two -- cancelled bookings for 2026-10-01 2:00 PM), and a blanket update would -- try to move both of them to 'completed' at once, colliding on the -- occupied-slot unique index below -- that's a test-setup hazard, not the -- behavior under test here. select lives_ok( $$ update public.bookings set status = 'completed' where date = '2026-10-04' and time = '1:00 PM' and status = 'confirmed' $$, 'admin can mark a booking completed' ); select throws_ok( $$ update public.bookings set status = 'bogus' where service_id = 'knotless' $$, '23514', null, 'booking status is still constrained to known values' ); -- A completed appointment actually happened and must keep occupying its -- slot: marking a confirmed booking completed must NOT free booked_slots -- (regression test for the confirmed -> completed double-booking bug). select lives_ok( $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status) values ('knotless', '2026-10-05', '10:00 AM', 'Complete Client', '555-0301', 'complete@example.com', '', 'confirmed') $$, 'confirmed booking for the completed-slot test succeeds' ); select isnt_empty( $$ select * from public.booked_slots where date = '2026-10-05' and time = '10:00 AM' $$, 'booked_slots mirror has an entry for the confirmed slot before completion' ); select lives_ok( $$ update public.bookings set status = 'completed' where date = '2026-10-05' and time = '10:00 AM' and status = 'confirmed' $$, 'marking the booking completed succeeds' ); select isnt_empty( $$ select * from public.booked_slots where date = '2026-10-05' and time = '10:00 AM' $$, 'booked_slots mirror row STILL holds the slot after confirmed -> completed' ); select throws_ok( $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status) values ('knotless', '2026-10-05', '10:00 AM', 'Double Book Client', '555-0302', 'double@example.com', '', 'confirmed') $$, '23505', null, 'a second confirmed booking for a now-completed slot still fails with a uniqueness violation' ); 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' ); -- Duration-aware booked_slots: the mirror row now carries the booked -- service's duration, so client-side availability computation can detect -- overlap (not just an exact time match). knotless has duration_minutes = 240 -- (seeded at the top of this file). set local role service_role; select lives_ok( $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes) values ('knotless', '2026-12-01', '9:00 AM', 'Duration Client', '555-0301', 'duration@example.com', '') $$, 'confirmed booking for the duration test succeeds' ); select results_eq( $$ select duration_minutes from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, $$ values (240) $$, 'booked_slots mirror row carries the booked services duration_minutes' ); select lives_ok( $$ update public.bookings set status = 'completed' where date = '2026-12-01' and time = '9:00 AM' and status = 'confirmed' $$, 'marking the duration-test booking completed succeeds' ); select results_eq( $$ select duration_minutes from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, $$ values (240) $$, 'booked_slots keeps the correct duration_minutes after confirmed -> completed' ); select lives_ok( $$ update public.bookings set status = 'cancelled' where date = '2026-12-01' and time = '9:00 AM' and status = 'completed' $$, 'cancelling the duration-test booking succeeds' ); select is_empty( $$ select * from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, 'booked_slots mirror row is removed after the duration-test booking is cancelled, regardless of duration' ); -- site_content: public read, admin-only write. Singleton row seeded by the -- migration, so no insert test is needed -- only read and update. set local role anon; select isnt_empty( $$ select * from public.site_content where id = 'default' $$, 'anon can read site_content' ); select throws_ok( $$ update public.site_content set hero_headline = 'hack' where id = 'default' $$, '42501', null, 'anon cannot update site_content' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ update public.site_content set hero_headline = 'Updated Headline' where id = 'default' $$, 'admin can update site_content' ); -- testimonials: public read, admin-only write. set local role anon; select isnt_empty( $$ select * from public.testimonials $$, 'anon can read testimonials' ); select throws_ok( $$ insert into public.testimonials (quote, author, avatar_image, sort_order) values ('hack', 'Hacker', 'x.jpg', 99) $$, '42501', null, 'anon cannot insert into testimonials' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ insert into public.testimonials (quote, author, avatar_image, sort_order) values ('Great service!', 'Test Client', 'test.jpg', 99) $$, 'admin can insert into testimonials' ); -- rules: public read, admin-only write. set local role anon; select isnt_empty( $$ select * from public.rules $$, 'anon can read rules' ); select throws_ok( $$ insert into public.rules (title, body, icon_key, sort_order) values ('hack', 'hack', 'deposit', 99) $$, '42501', null, 'anon cannot insert into rules' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ insert into public.rules (title, body, icon_key, sort_order) values ('Test Rule', 'Test body.', 'payment', 99) $$, 'admin can insert into rules' ); -- products.featured / featured_order: no new RLS policy (existing -- products_admin_write already covers all columns), but confirm anon still -- can't set them and admin can. set local role anon; select throws_ok( $$ update public.products set featured = true where id = 'edge-control' $$, '42501', null, 'anon cannot set products.featured' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ update public.products set featured = true, featured_order = 0 where id = 'edge-control' $$, 'admin can set products.featured and featured_order' ); select * from finish(); rollback;