/** * Integration tests for booking constraints enforced by the create-reservation Edge Function * and the database (overlap exclusion constraint, cert check trigger). * * Requires local Supabase running with Edge Functions served: * npx supabase start * npx supabase functions serve * * Run with: * yarn test:integration * * Each describe block creates isolated test users and cleans up after itself. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { createClient, type SupabaseClient } from '@supabase/supabase-js' const SUPABASE_URL = process.env.SUPABASE_URL ?? 'http://localhost:54321' const SUPABASE_ANON_KEY = process.env.SUPABASE_KEY ?? '' const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? '' const FUNCTIONS_URL = `${SUPABASE_URL}/functions/v1` // ─── Helpers ────────────────────────────────────────────────────────────────── let adminClient: SupabaseClient function makeAnonClient() { return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }) } async function createTestUser(email: string, certifications: string[] = [], role = 'member') { const { data, error } = await adminClient.auth.admin.createUser({ email, email_confirm: true, }) if (error || !data.user) throw new Error(`Failed to create user: ${error?.message}`) const userId = data.user.id await adminClient.from('members').upsert({ user_id: userId, email, certifications, role, first_name: 'Test', last_name: 'User', }, { onConflict: 'user_id' }) return userId } async function getSessionToken(email: string): Promise { const anon = makeAnonClient() const { data: linkData } = await adminClient.auth.admin.generateLink({ type: 'magiclink', email, }) const { data: sessionData } = await anon.auth.verifyOtp({ token_hash: linkData.properties!.hashed_token!, type: 'magiclink', }) return sessionData.session!.access_token } async function callCreateReservation( token: string, body: { boat_id: string start_time: string end_time: string reason?: string }, ) { const res = await fetch(`${FUNCTIONS_URL}/create-reservation`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'apikey': SUPABASE_ANON_KEY, }, body: JSON.stringify({ reason: 'Open Sail', ...body }), }) const json = await res.json() return { status: res.status, body: json } } async function createTestBoat(requiredCerts: string[] = [], available = true): Promise { const { data, error } = await adminClient.from('boats').insert({ name: `Test Boat ${Date.now()}`, required_certs: requiredCerts, booking_available: available, max_passengers: 6, }).select('id').single() if (error) throw new Error(`Failed to create boat: ${error.message}`) return data.id } async function directInsertReservation(boatId: string, userId: string, start: string, end: string) { const { error } = await adminClient.from('reservations').insert({ boat_id: boatId, user_id: userId, start_time: start, end_time: end, reason: 'Test', status: 'confirmed', }) return error } // Future Monday 09:00–12:30 (within current ISO week, far enough ahead) function futureSlot(daysAhead: number, hour = 9, endHour = 12, endMin = 30): { start_time: string; end_time: string } { const d = new Date() d.setUTCDate(d.getUTCDate() + daysAhead) d.setUTCHours(hour, 0, 0, 0) const e = new Date(d) e.setUTCHours(endHour, endMin, 0, 0) return { start_time: d.toISOString(), end_time: e.toISOString() } } // ─── Test Suite ─────────────────────────────────────────────────────────────── beforeAll(() => { if (!SUPABASE_SERVICE_ROLE_KEY) { throw new Error('SUPABASE_SERVICE_ROLE_KEY is required. Run: npx supabase status') } adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }) }) // ── Overlap constraint (DB-level) ───────────────────────────────────────────── describe('overlap constraint', () => { const email = `test-overlap-${Date.now()}@oysqn.test` let userId: string let boatId: string let token: string beforeAll(async () => { userId = await createTestUser(email) boatId = await createTestBoat() token = await getSessionToken(email) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userId) }) it('allows first booking for a slot', async () => { const slot = futureSlot(14) const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot }) expect(status).toBe(201) }) it('rejects an overlapping booking for the same boat', async () => { // Use a different week from the first test so the weekly pre-booking limit isn't reached const slot = futureSlot(21) // Book once directly via admin await directInsertReservation(boatId, userId, slot.start_time, slot.end_time) // Second booking for same slot via Edge Function — DB exclusion constraint fires const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot }) expect(status).toBe(409) expect(body.error.code).toBe('slot_taken') }) it('allows a booking on the same boat at a different time', async () => { // Third distinct week — no pre-booking limit conflict, different time slot const slot = futureSlot(28, 13, 16, 30) const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot }) expect(status).toBe(201) }) }) // ── Certification check (DB trigger + Edge Function) ───────────────────────── describe('certification check', () => { const emailNoCert = `test-nocert-${Date.now()}@oysqn.test` const emailCert = `test-cert-${Date.now()}@oysqn.test` let userNoCert: string let userCert: string let boatId: string let tokenNoCert: string let tokenCert: string beforeAll(async () => { boatId = await createTestBoat(['j27']) userNoCert = await createTestUser(emailNoCert, []) userCert = await createTestUser(emailCert, ['j27']) tokenNoCert = await getSessionToken(emailNoCert) tokenCert = await getSessionToken(emailCert) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userNoCert) await adminClient.auth.admin.deleteUser(userCert) }) it('rejects booking when member lacks required cert', async () => { const slot = futureSlot(35) const { status, body } = await callCreateReservation(tokenNoCert, { boat_id: boatId, ...slot }) expect(status).toBe(422) expect(body.error.code).toBe('cert_required') }) it('allows booking when member has required cert', async () => { const slot = futureSlot(36) const { status } = await callCreateReservation(tokenCert, { boat_id: boatId, ...slot }) expect(status).toBe(201) }) it('allows booking on a boat with no cert requirements', async () => { const openBoat = await createTestBoat([]) const slot = futureSlot(37) const { status } = await callCreateReservation(tokenNoCert, { boat_id: openBoat, ...slot }) expect(status).toBe(201) await adminClient.from('boats').delete().eq('id', openBoat) }) }) // ── Out-of-service check ────────────────────────────────────────────────────── describe('boat out-of-service check', () => { const email = `test-oos-${Date.now()}@oysqn.test` let userId: string let boatId: string let token: string beforeAll(async () => { boatId = await createTestBoat([], false) // booking_available = false userId = await createTestUser(email) token = await getSessionToken(email) }) afterAll(async () => { await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userId) }) it('rejects booking when boat is out of service', async () => { const slot = futureSlot(40) const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot }) expect(status).toBe(422) expect(body.error.code).toBe('boat_unavailable') }) }) // ── Weekly session limit ────────────────────────────────────────────────────── describe('weekly session limit', () => { const email = `test-weekly-${Date.now()}@oysqn.test` let userId: string let boatId: string let token: string // Use a fixed far-future week so existing data doesn't interfere // Monday 2026-06-08 (week with no holidays) const WEEK_BASE = new Date('2026-06-08T09:00:00Z') function weekSlot(dayOffset: number, hour = 9): { start_time: string; end_time: string } { const s = new Date(WEEK_BASE) s.setUTCDate(WEEK_BASE.getUTCDate() + dayOffset) s.setUTCHours(hour, 0, 0, 0) const e = new Date(s) e.setUTCHours(hour + 3, 30, 0, 0) return { start_time: s.toISOString(), end_time: e.toISOString() } } beforeAll(async () => { userId = await createTestUser(email) boatId = await createTestBoat() token = await getSessionToken(email) // Set max_sessions_per_week to 2 (should already be the default) await adminClient.from('booking_config') .upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' }) // Set advance hours to 1 so our far-future bookings count as pre-bookings await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' }) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userId) // Restore default await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' }) }) it('allows first pre-booking of the week', async () => { const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(0) }) expect(status).toBe(201) }) it('allows second pre-booking of the week', async () => { const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(1) }) expect(status).toBe(201) }) it('rejects third pre-booking of the week', async () => { const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) }) expect(status).toBe(422) expect(body.error.code).toBe('booking_limit_weekly') }) }) // ── Weekend session limit ───────────────────────────────────────────────────── describe('weekend session limit', () => { const email = `test-weekend-${Date.now()}@oysqn.test` let userId: string let boatId: string let token: string // 2026-06-13 (Saturday) and 2026-06-14 (Sunday) — same 2-week period // 2026-06-20 (Saturday) — same 2-week period as June 13 (period weeks = 2) const SAT1 = '2026-06-13T09:00:00Z' const SAT1_END = '2026-06-13T12:30:00Z' const SUN1 = '2026-06-14T09:00:00Z' const SUN1_END = '2026-06-14T12:30:00Z' beforeAll(async () => { userId = await createTestUser(email) boatId = await createTestBoat() token = await getSessionToken(email) await adminClient.from('booking_config') .upsert({ key: 'max_weekend_sessions_per_period', value: 1 }, { onConflict: 'key' }) await adminClient.from('booking_config') .upsert({ key: 'weekend_period_weeks', value: 2 }, { onConflict: 'key' }) await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' }) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userId) await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' }) }) it('allows first weekend booking in the period', async () => { const { status } = await callCreateReservation(token, { boat_id: boatId, start_time: SAT1, end_time: SAT1_END, }) expect(status).toBe(201) }) it('rejects second weekend booking in the same period', async () => { const { status, body } = await callCreateReservation(token, { boat_id: boatId, start_time: SUN1, end_time: SUN1_END, }) expect(status).toBe(422) expect(body.error.code).toBe('booking_limit_weekend') }) }) // ── Open-session window bypass ──────────────────────────────────────────────── describe('open-session window bypasses pre-booking limits', () => { const email = `test-opensession-${Date.now()}@oysqn.test` let userId: string let boatId: string let token: string beforeAll(async () => { userId = await createTestUser(email) boatId = await createTestBoat() token = await getSessionToken(email) // Set limit to 0 pre-bookings/week so only open sessions can go through await adminClient.from('booking_config') .upsert({ key: 'max_sessions_per_week', value: 0 }, { onConflict: 'key' }) // Open session window: 9999 hours (everything is an open session) await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 9999 }, { onConflict: 'key' }) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(userId) await adminClient.from('booking_config') .upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' }) await adminClient.from('booking_config') .upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' }) }) it('allows booking even when pre-booking limit is 0, because slot is within open-session window', async () => { const slot = futureSlot(1) const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot }) expect(status).toBe(201) }) }) // ── Historical booking constraint ──────────────────────────────────────────── describe('historical booking constraint', () => { const emailMember = `test-hist-member-${Date.now()}@oysqn.test` const emailAdmin = `test-hist-admin-${Date.now()}@oysqn.test` let memberUserId: string let adminUserId: string let boatId: string let memberToken: string let adminToken: string function pastSlot(): { start_time: string; end_time: string } { const d = new Date() d.setUTCDate(d.getUTCDate() - 7) d.setUTCHours(9, 0, 0, 0) const e = new Date(d) e.setUTCHours(12, 30, 0, 0) return { start_time: d.toISOString(), end_time: e.toISOString() } } beforeAll(async () => { boatId = await createTestBoat() memberUserId = await createTestUser(emailMember, [], 'member') adminUserId = await createTestUser(emailAdmin, [], 'admin') memberToken = await getSessionToken(emailMember) adminToken = await getSessionToken(emailAdmin) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(memberUserId) await adminClient.auth.admin.deleteUser(adminUserId) }) it('rejects a historical booking by a regular member', async () => { const slot = pastSlot() const { status, body } = await callCreateReservation(memberToken, { boat_id: boatId, ...slot }) expect(status).toBe(422) expect(body.error.code).toBe('historical_booking_not_allowed') }) it('allows a historical booking by an admin', async () => { const slot = pastSlot() const { status } = await callCreateReservation(adminToken, { boat_id: boatId, ...slot }) expect(status).toBe(201) }) it('rejects a historical booking by a skipper (non-admin role)', async () => { const emailSkipper = `test-hist-skipper-${Date.now()}@oysqn.test` const skipperId = await createTestUser(emailSkipper, [], 'skipper') const skipperToken = await getSessionToken(emailSkipper) try { const slot = pastSlot() const { status, body } = await callCreateReservation(skipperToken, { boat_id: boatId, ...slot }) expect(status).toBe(422) expect(body.error.code).toBe('historical_booking_not_allowed') } finally { await adminClient.auth.admin.deleteUser(skipperId) } }) }) // ── RBAC visibility: reservation_slots view ─────────────────────────────────── describe('RBAC: reservation_slots view', () => { const emailOwner = `test-rbac-owner-${Date.now()}@oysqn.test` const emailOther = `test-rbac-other-${Date.now()}@oysqn.test` let ownerUserId: string let otherUserId: string let boatId: string let ownerToken: string let otherToken: string beforeAll(async () => { boatId = await createTestBoat() ownerUserId = await createTestUser(emailOwner) otherUserId = await createTestUser(emailOther) ownerToken = await getSessionToken(emailOwner) otherToken = await getSessionToken(emailOther) // Create a reservation for ownerUser directly via admin const slot = futureSlot(50) await directInsertReservation(boatId, ownerUserId, slot.start_time, slot.end_time) }) afterAll(async () => { await adminClient.from('reservations').delete().eq('boat_id', boatId) await adminClient.from('boats').delete().eq('id', boatId) await adminClient.auth.admin.deleteUser(ownerUserId) await adminClient.auth.admin.deleteUser(otherUserId) }) it('owner can see full reservation details', async () => { const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { autoRefreshToken: false, persistSession: false }, global: { headers: { Authorization: `Bearer ${ownerToken}` } }, }) const { data, error } = await client.from('reservations').select('*').eq('boat_id', boatId) expect(error).toBeNull() expect(data).toHaveLength(1) expect(data![0].user_id).toBeDefined() expect(data![0].reason).toBeDefined() }) it('other member sees the slot via reservation_slots (boat/time/status only)', async () => { const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { autoRefreshToken: false, persistSession: false }, global: { headers: { Authorization: `Bearer ${otherToken}` } }, }) // reservation_slots view — should return the row const { data: slots, error: slotsErr } = await client .from('reservation_slots' as never) .select('id, boat_id, start_time, end_time, status') .eq('boat_id', boatId) expect(slotsErr).toBeNull() expect(slots).toHaveLength(1) }) it('other member cannot see personal details of owner reservation via direct table query', async () => { const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { autoRefreshToken: false, persistSession: false }, global: { headers: { Authorization: `Bearer ${otherToken}` } }, }) // Should return 0 rows — RLS "Users can read own reservations" blocks other members const { data } = await client.from('reservations').select('*').eq('boat_id', boatId) const ownerRows = (data ?? []).filter((r: { user_id: string }) => r.user_id === ownerUserId) expect(ownerRows).toHaveLength(0) }) })