Files
oysqn.app/tests/integration/booking-constraints.test.ts

944 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string> {
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<string> {
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:0012: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)
}
})
})
// ── New reservation status ────────────────────────────────────────────────────
describe('new reservation status', () => {
const email = `test-status-${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('creates reservation with status confirmed', async () => {
const slot = futureSlot(70)
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(201)
expect(body.reservation.status).toBe('confirmed')
})
})
// ── Cancel reservation ────────────────────────────────────────────────────────
describe('cancel reservation', () => {
const emailA = `test-cancel-a-${Date.now()}@oysqn.test`
const emailB = `test-cancel-b-${Date.now()}@oysqn.test`
let userA: string
let userB: string
let boatId: string
let tokenA: string
let tokenB: string
const slot = futureSlot(72)
let reservationId: string
beforeAll(async () => {
boatId = await createTestBoat()
userA = await createTestUser(emailA)
userB = await createTestUser(emailB)
tokenA = await getSessionToken(emailA)
tokenB = await getSessionToken(emailB)
// User A books the slot
const { body } = await callCreateReservation(tokenA, { boat_id: boatId, ...slot })
reservationId = body.reservation.id
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userA)
await adminClient.auth.admin.deleteUser(userB)
})
it('user can cancel their own reservation', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${tokenA}` } },
})
const { error } = await client
.from('reservations')
.update({ status: 'cancelled' })
.eq('id', reservationId)
expect(error).toBeNull()
const { data } = await adminClient
.from('reservations')
.select('status')
.eq('id', reservationId)
.single()
expect(data!.status).toBe('cancelled')
})
it('cancelled reservation is excluded from reservation_slots view', async () => {
const { data } = await adminClient
.from('reservation_slots' as never)
.select('id')
.eq('id', reservationId)
expect(data).toHaveLength(0)
})
it('cancelled slot can be re-booked by another user', async () => {
const { status } = await callCreateReservation(tokenB, { boat_id: boatId, ...slot })
expect(status).toBe(201)
})
})
// ── Edit reservation ──────────────────────────────────────────────────────────
describe('edit reservation', () => {
const email = `test-edit-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
let reservationId: string
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
const { body } = await callCreateReservation(token, { boat_id: boatId, ...futureSlot(74), reason: 'Open Sail' })
reservationId = body.reservation.id
})
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('user can update reason and comment on own reservation', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${token}` } },
})
const { error } = await client
.from('reservations')
.update({ reason: 'Racing', comment: 'Crew of 4' })
.eq('id', reservationId)
expect(error).toBeNull()
const { data } = await adminClient
.from('reservations')
.select('reason, comment')
.eq('id', reservationId)
.single()
expect(data!.reason).toBe('Racing')
expect(data!.comment).toBe('Crew of 4')
})
it('editing does not change the reservation status', async () => {
const { data } = await adminClient
.from('reservations')
.select('status')
.eq('id', reservationId)
.single()
expect(data!.status).toBe('confirmed')
})
})
// ── RLS: cancel/edit permissions ──────────────────────────────────────────────
describe('RLS: cancel/edit permissions', () => {
const emailA = `test-perm-a-${Date.now()}@oysqn.test`
const emailB = `test-perm-b-${Date.now()}@oysqn.test`
let userA: string
let userB: string
let boatId: string
let tokenA: string
let tokenB: string
let reservationId: string
beforeAll(async () => {
boatId = await createTestBoat()
userA = await createTestUser(emailA)
userB = await createTestUser(emailB)
tokenA = await getSessionToken(emailA)
tokenB = await getSessionToken(emailB)
const { body } = await callCreateReservation(tokenA, { boat_id: boatId, ...futureSlot(76) })
reservationId = body.reservation.id
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userA)
await adminClient.auth.admin.deleteUser(userB)
})
it('user B cannot cancel user A reservation', async () => {
const clientB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${tokenB}` } },
})
// RLS silently ignores the update (matches 0 rows) — no error, but status unchanged
await clientB
.from('reservations')
.update({ status: 'cancelled' })
.eq('id', reservationId)
const { data } = await adminClient
.from('reservations')
.select('status')
.eq('id', reservationId)
.single()
expect(data!.status).not.toBe('cancelled')
})
it('user B cannot edit user A reservation', async () => {
const clientB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${tokenB}` } },
})
await clientB
.from('reservations')
.update({ comment: 'injected by B' })
.eq('id', reservationId)
const { data } = await adminClient
.from('reservations')
.select('comment')
.eq('id', reservationId)
.single()
expect(data!.comment).not.toBe('injected by B')
})
})
// ── 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)
})
})
// ── Cancelled reservations do not count toward weekly limit ───────────────────
describe('cancelled reservations do not count toward weekly limit', () => {
const email = `test-cancel-weekly-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
// Fixed far-future week: 2026-07-06 (Mon) — no holidays, isolated from other tests
const WEEK_BASE = new Date('2026-07-06T09:00:00Z')
function weekSlot(dayOffset: number): { start_time: string; end_time: string } {
const s = new Date(WEEK_BASE)
s.setUTCDate(WEEK_BASE.getUTCDate() + dayOffset)
const e = new Date(s)
e.setUTCHours(s.getUTCHours() + 3, 30, 0, 0)
return { start_time: s.toISOString(), end_time: e.toISOString() }
}
let firstReservationId: string
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
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: 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('first booking succeeds', async () => {
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(0) })
expect(status).toBe(201)
firstReservationId = body.reservation.id
})
it('second booking succeeds', async () => {
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(1) })
expect(status).toBe(201)
})
it('third booking is rejected (limit reached)', async () => {
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
expect(status).toBe(422)
expect(body.error.code).toBe('booking_limit_weekly')
})
it('cancelling the first booking frees up the slot count', async () => {
await adminClient.from('reservations').update({ status: 'cancelled' }).eq('id', firstReservationId)
})
it('third booking now succeeds after cancellation', async () => {
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
expect(status).toBe(201)
})
})
// ── Cancelled reservations do not count toward weekend limit ──────────────────
describe('cancelled reservations do not count toward weekend limit', () => {
const email = `test-cancel-weekend-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
// 2026-08-01 (Saturday) and 2026-08-02 (Sunday) — same 2-week period
const SAT = '2026-08-01T09:00:00Z'
const SAT_END = '2026-08-01T12:30:00Z'
const SUN = '2026-08-02T09:00:00Z'
const SUN_END = '2026-08-02T12:30:00Z'
let satReservationId: string
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('first weekend booking succeeds', async () => {
const { status, body } = await callCreateReservation(token, {
boat_id: boatId, start_time: SAT, end_time: SAT_END,
})
expect(status).toBe(201)
satReservationId = body.reservation.id
})
it('second weekend booking in same period is rejected', async () => {
const { status, body } = await callCreateReservation(token, {
boat_id: boatId, start_time: SUN, end_time: SUN_END,
})
expect(status).toBe(422)
expect(body.error.code).toBe('booking_limit_weekend')
})
it('cancelling the first weekend booking frees up the period count', async () => {
await adminClient.from('reservations').update({ status: 'cancelled' }).eq('id', satReservationId)
})
it('second weekend booking now succeeds after cancellation', async () => {
const { status } = await callCreateReservation(token, {
boat_id: boatId, start_time: SUN, end_time: SUN_END,
})
expect(status).toBe(201)
})
})
// ── Cannot modify past reservations ───────────────────────────────────────────
describe('cannot modify past reservations', () => {
const email = `test-past-mod-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
let pastReservationId: string
let futureReservationId: string
function pastSlot() {
const s = new Date()
s.setUTCDate(s.getUTCDate() - 3)
s.setUTCHours(9, 0, 0, 0)
const e = new Date(s)
e.setUTCHours(12, 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)
// Admin inserts a past reservation for the test user
const past = pastSlot()
const { data: pastRes } = await adminClient.from('reservations').insert({
boat_id: boatId, user_id: userId, start_time: past.start_time, end_time: past.end_time,
reason: 'Open Sail', status: 'confirmed',
}).select('id').single()
pastReservationId = pastRes!.id
// And a future one for comparison
const { body } = await callCreateReservation(token, { boat_id: boatId, ...futureSlot(90) })
futureReservationId = body.reservation.id
})
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('cannot cancel a past reservation', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${token}` } },
})
const { error } = await client
.from('reservations')
.update({ status: 'cancelled' })
.eq('id', pastReservationId)
expect(error).not.toBeNull()
expect(error!.message).toMatch(/past_reservation/)
})
it('cannot edit reason/comment on a past reservation', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${token}` } },
})
const { error } = await client
.from('reservations')
.update({ comment: 'should not work' })
.eq('id', pastReservationId)
expect(error).not.toBeNull()
expect(error!.message).toMatch(/past_reservation/)
})
it('can still cancel a future reservation', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${token}` } },
})
const { error } = await client
.from('reservations')
.update({ status: 'cancelled' })
.eq('id', futureReservationId)
expect(error).toBeNull()
})
})