Add Phase 4b: instructor blocked dates, store closures, and substitute instructors
- New tables: instructor_blocked_date, store_closure (migration 0034) - substitute_instructor_id column added to lesson_session - Session generation skips blocked instructor dates and store closure periods - Substitute assignment validates sub is not blocked and has no conflicting slot - Routes: POST/GET/DELETE /instructors/:id/blocked-dates, POST/GET/DELETE /store-closures - 15 new integration tests covering blocked dates, store closures, and sub validation
This commit is contained in:
@@ -1304,4 +1304,251 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.title, 'After Update')
|
||||
})
|
||||
|
||||
// ─── Instructor Blocked Dates ───
|
||||
|
||||
t.test('creates a blocked date for an instructor', { tags: ['blocked-dates', 'create'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Blocked Date Test Instructor' })
|
||||
const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, {
|
||||
startDate: '2026-07-04',
|
||||
endDate: '2026-07-04',
|
||||
reason: 'Holiday',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.instructorId, instructor.data.id)
|
||||
t.assert.equal(res.data.startDate, '2026-07-04')
|
||||
t.assert.equal(res.data.endDate, '2026-07-04')
|
||||
t.assert.equal(res.data.reason, 'Holiday')
|
||||
t.assert.ok(res.data.id)
|
||||
})
|
||||
|
||||
t.test('creates a multi-day blocked date range', { tags: ['blocked-dates', 'create'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Vacation Test Instructor' })
|
||||
const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, {
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.startDate, '2026-08-01')
|
||||
t.assert.equal(res.data.endDate, '2026-08-07')
|
||||
t.assert.equal(res.data.reason, null)
|
||||
})
|
||||
|
||||
t.test('rejects blocked date with end before start', { tags: ['blocked-dates', 'create', 'validation'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Invalid Date Instructor' })
|
||||
const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, {
|
||||
startDate: '2026-08-07',
|
||||
endDate: '2026-08-01',
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
t.test('returns 404 for blocked date on missing instructor', { tags: ['blocked-dates', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/instructors/a0000000-0000-0000-0000-999999999999/blocked-dates', {
|
||||
startDate: '2026-07-04',
|
||||
endDate: '2026-07-04',
|
||||
})
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('lists blocked dates for an instructor', { tags: ['blocked-dates', 'read'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'List Blocked Dates Instructor' })
|
||||
await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { startDate: '2026-07-04', endDate: '2026-07-04' })
|
||||
await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { startDate: '2026-08-01', endDate: '2026-08-07' })
|
||||
|
||||
const res = await t.api.get(`/v1/instructors/${instructor.data.id}/blocked-dates`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.length, 2)
|
||||
})
|
||||
|
||||
t.test('deletes a blocked date', { tags: ['blocked-dates', 'delete'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Delete Blocked Date Instructor' })
|
||||
const blocked = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, {
|
||||
startDate: '2026-07-04',
|
||||
endDate: '2026-07-04',
|
||||
})
|
||||
const res = await t.api.del(`/v1/instructors/${instructor.data.id}/blocked-dates/${blocked.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.id, blocked.data.id)
|
||||
|
||||
const list = await t.api.get(`/v1/instructors/${instructor.data.id}/blocked-dates`)
|
||||
t.assert.equal(list.data.length, 0)
|
||||
})
|
||||
|
||||
t.test('session generation skips instructor blocked dates', { tags: ['blocked-dates', 'sessions', 'generate'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Blocked Gen Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Blocked Gen Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Blocked Gen Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Blocked', lastName: 'Gen' })
|
||||
|
||||
// Find next Sunday (dayOfWeek=0) from today
|
||||
const today = new Date()
|
||||
const daysUntilSunday = (7 - today.getDay()) % 7 || 7
|
||||
const nextSunday = new Date(today)
|
||||
nextSunday.setDate(today.getDate() + daysUntilSunday)
|
||||
const sundayStr = nextSunday.toISOString().slice(0, 10)
|
||||
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '10:00',
|
||||
})
|
||||
|
||||
// Block the first upcoming Sunday
|
||||
await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, {
|
||||
startDate: sundayStr,
|
||||
endDate: sundayStr,
|
||||
})
|
||||
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: sundayStr,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
t.assert.status(res, 200)
|
||||
|
||||
// The blocked Sunday should not appear in generated sessions
|
||||
const sessions: Array<{ scheduledDate: string }> = res.data.sessions
|
||||
const hasBlockedDate = sessions.some((s) => s.scheduledDate === sundayStr)
|
||||
t.assert.equal(hasBlockedDate, false)
|
||||
})
|
||||
|
||||
// ─── Store Closures ───
|
||||
|
||||
t.test('creates a store closure', { tags: ['store-closures', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/store-closures', {
|
||||
name: 'Christmas Break',
|
||||
startDate: '2026-12-24',
|
||||
endDate: '2026-12-26',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Christmas Break')
|
||||
t.assert.equal(res.data.startDate, '2026-12-24')
|
||||
t.assert.equal(res.data.endDate, '2026-12-26')
|
||||
t.assert.ok(res.data.id)
|
||||
})
|
||||
|
||||
t.test('rejects store closure with end before start', { tags: ['store-closures', 'create', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/store-closures', {
|
||||
name: 'Invalid',
|
||||
startDate: '2026-12-26',
|
||||
endDate: '2026-12-24',
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
t.test('lists store closures', { tags: ['store-closures', 'read'] }, async () => {
|
||||
await t.api.post('/v1/store-closures', { name: 'Closure A', startDate: '2026-11-01', endDate: '2026-11-01' })
|
||||
await t.api.post('/v1/store-closures', { name: 'Closure B', startDate: '2026-11-15', endDate: '2026-11-15' })
|
||||
|
||||
const res = await t.api.get('/v1/store-closures')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(Array.isArray(res.data))
|
||||
t.assert.ok(res.data.length >= 2)
|
||||
})
|
||||
|
||||
t.test('deletes a store closure', { tags: ['store-closures', 'delete'] }, async () => {
|
||||
const created = await t.api.post('/v1/store-closures', {
|
||||
name: 'To Delete',
|
||||
startDate: '2026-10-01',
|
||||
endDate: '2026-10-01',
|
||||
})
|
||||
const res = await t.api.del(`/v1/store-closures/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.id, created.data.id)
|
||||
})
|
||||
|
||||
t.test('session generation skips store closures', { tags: ['store-closures', 'sessions', 'generate'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Closure Gen Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Closure Gen Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Closure Gen Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Closure', lastName: 'Gen' })
|
||||
|
||||
// Find next Monday (dayOfWeek=1) from today
|
||||
const today = new Date()
|
||||
const daysUntilMonday = (8 - today.getDay()) % 7 || 7
|
||||
const nextMonday = new Date(today)
|
||||
nextMonday.setDate(today.getDate() + daysUntilMonday)
|
||||
const mondayStr = nextMonday.toISOString().slice(0, 10)
|
||||
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 1, startTime: '11:00',
|
||||
})
|
||||
|
||||
// Create a store closure covering the first upcoming Monday
|
||||
const closure = await t.api.post('/v1/store-closures', {
|
||||
name: 'Test Closure',
|
||||
startDate: mondayStr,
|
||||
endDate: mondayStr,
|
||||
})
|
||||
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: mondayStr,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const sessions: Array<{ scheduledDate: string }> = res.data.sessions
|
||||
const hasClosedDate = sessions.some((s) => s.scheduledDate === mondayStr)
|
||||
t.assert.equal(hasClosedDate, false)
|
||||
|
||||
// Clean up closure so it doesn't affect other tests
|
||||
await t.api.del(`/v1/store-closures/${closure.data.id}`)
|
||||
})
|
||||
|
||||
// ─── Substitute Instructor ───
|
||||
|
||||
t.test('assigns a substitute instructor to a session', { tags: ['sessions', 'substitute'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Primary Sub Instructor' })
|
||||
const sub = await t.api.post('/v1/instructors', { displayName: 'Substitute Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Sub Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Sub Test Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Sub', lastName: 'Student' })
|
||||
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, startTime: '14:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`)
|
||||
const session = genRes.data.sessions[0]
|
||||
|
||||
const res = await t.api.patch(`/v1/lesson-sessions/${session.id}`, {
|
||||
substituteInstructorId: sub.data.id,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.substituteInstructorId, sub.data.id)
|
||||
})
|
||||
|
||||
t.test('rejects substitute who is blocked on the session date', { tags: ['sessions', 'substitute', 'validation'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Primary Blocked Sub Test' })
|
||||
const sub = await t.api.post('/v1/instructors', { displayName: 'Blocked Sub' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Blocked Sub Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Blocked Sub Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Blocked', lastName: 'SubStudent' })
|
||||
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '15:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`)
|
||||
const session = genRes.data.sessions[0]
|
||||
|
||||
// Block the sub on the session date
|
||||
await t.api.post(`/v1/instructors/${sub.data.id}/blocked-dates`, {
|
||||
startDate: session.scheduledDate,
|
||||
endDate: session.scheduledDate,
|
||||
})
|
||||
|
||||
const res = await t.api.patch(`/v1/lesson-sessions/${session.id}`, {
|
||||
substituteInstructorId: sub.data.id,
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user