feat: Enhance reservation functionality

This commit is contained in:
2026-04-22 10:23:22 -04:00
parent 7f1e82acc2
commit 534d66c774
25 changed files with 1236 additions and 91 deletions

View File

@@ -455,6 +455,218 @@ describe('historical booking constraint', () => {
})
})
// ── 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`
@@ -521,3 +733,211 @@ describe('RBAC: reservation_slots view', () => {
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()
})
})