feat: Enhance reservation functionality
This commit is contained in:
@@ -35,7 +35,7 @@ test.describe('Auth flow', () => {
|
||||
|
||||
// Auth callback redirects to home (authenticated state)
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByText('Upcoming Reservations')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Upcoming Reservations' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('unauthenticated access to protected route redirects to splash', async ({ page }) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user