Add lessons Phase 4: lesson sessions with hybrid calendar generation
Individual lesson occurrences generated from schedule slot patterns. Idempotent session generation with configurable rolling window. Post-lesson notes workflow with auto-set notesCompletedAt. Status tracking (scheduled/attended/missed/makeup/cancelled) and date/time filtering. 13 new tests (64 total lessons tests).
This commit is contained in:
@@ -686,4 +686,257 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.every((e: any) => e.status === 'active'))
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Generation ───
|
||||
|
||||
t.test('generates sessions for an enrollment', { tags: ['sessions', 'generate'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Session Gen Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Session', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Session Gen Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Session Gen Type', durationMinutes: 30 })
|
||||
// Use Tuesday (2) for predictable scheduling
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, startTime: '16: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 res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.generated >= 1, 'should generate at least 1 session')
|
||||
t.assert.equal(res.data.sessions.length, res.data.generated)
|
||||
// All sessions should be on Tuesday
|
||||
for (const s of res.data.sessions) {
|
||||
t.assert.equal(s.status, 'scheduled')
|
||||
t.assert.equal(s.scheduledTime, '16:00:00')
|
||||
const dayOfWeek = new Date(s.scheduledDate + 'T00:00:00').getDay()
|
||||
t.assert.equal(dayOfWeek, 2, `session date ${s.scheduledDate} should be Tuesday`)
|
||||
}
|
||||
})
|
||||
|
||||
t.test('session generation is idempotent', { tags: ['sessions', 'generate'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Idempotent Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Idemp', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Idempotent Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Idempotent Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, 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 first = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.ok(first.data.generated >= 1)
|
||||
|
||||
const second = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.equal(second.data.generated, 0, 'second call should generate 0 new sessions')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: CRUD ───
|
||||
|
||||
t.test('gets lesson session by id', { tags: ['sessions', 'read'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Get Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Get', lastName: 'Session' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Get Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Get Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '10: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
t.assert.ok(gen.data.sessions.length >= 1)
|
||||
|
||||
const res = await t.api.get(`/v1/lesson-sessions/${gen.data.sessions[0].id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.enrollmentId, enrollment.data.id)
|
||||
t.assert.equal(res.data.status, 'scheduled')
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing lesson session', { tags: ['sessions', 'read'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('lists lesson sessions with filters', { tags: ['sessions', 'read', 'filter'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'List Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'List', lastName: 'Session' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'List Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'List Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 5, 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',
|
||||
})
|
||||
await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
|
||||
const res = await t.api.get('/v1/lesson-sessions', { enrollmentId: enrollment.data.id, limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 1)
|
||||
t.assert.ok(res.data.data.every((s: any) => s.enrollmentId === enrollment.data.id))
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Status ───
|
||||
|
||||
t.test('marks a session as attended', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Attend Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Attend', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Attend Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Attend Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 1, startTime: '09: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'attended' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'attended')
|
||||
})
|
||||
|
||||
t.test('marks a session as missed', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Missed Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Missed', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Missed Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Missed Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '11: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'missed' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'missed')
|
||||
})
|
||||
|
||||
t.test('cancels a session', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Cancel Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Cancel', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Cancel Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Cancel Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 6, startTime: '13: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'cancelled' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'cancelled')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Notes ───
|
||||
|
||||
t.test('saves post-lesson notes and auto-sets notesCompletedAt', { tags: ['sessions', 'notes'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Notes Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Notes', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Notes Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Notes Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '16: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
const sessionId = gen.data.sessions[0].id
|
||||
|
||||
// Mark as attended first
|
||||
await t.api.post(`/v1/lesson-sessions/${sessionId}/status`, { status: 'attended' })
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, {
|
||||
instructorNotes: 'Emma was distracted today',
|
||||
memberNotes: 'Great focus on scales. Left hand needs work.',
|
||||
homeworkAssigned: 'Practice Fur Elise bars 1-8 hands together',
|
||||
nextLessonGoals: 'Start bars 9-16',
|
||||
topicsCovered: ['Fur Elise', 'C Major Scale'],
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.instructorNotes, 'Emma was distracted today')
|
||||
t.assert.equal(res.data.memberNotes, 'Great focus on scales. Left hand needs work.')
|
||||
t.assert.equal(res.data.homeworkAssigned, 'Practice Fur Elise bars 1-8 hands together')
|
||||
t.assert.equal(res.data.topicsCovered.length, 2)
|
||||
t.assert.ok(res.data.notesCompletedAt, 'notesCompletedAt should be auto-set')
|
||||
})
|
||||
|
||||
t.test('second notes save does not reset notesCompletedAt', { tags: ['sessions', 'notes'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Notes Idempotent Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Idemp', lastName: 'Notes' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Notes Idemp Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Notes Idemp Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '11: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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
const sessionId = gen.data.sessions[0].id
|
||||
|
||||
const first = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, { instructorNotes: 'First notes' })
|
||||
t.assert.ok(first.data.notesCompletedAt)
|
||||
const originalTimestamp = first.data.notesCompletedAt
|
||||
|
||||
const second = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, { memberNotes: 'Updated member notes' })
|
||||
t.assert.equal(second.data.notesCompletedAt, originalTimestamp, 'notesCompletedAt should not change')
|
||||
t.assert.equal(second.data.memberNotes, 'Updated member notes')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Update ───
|
||||
|
||||
t.test('updates actual start/end times', { tags: ['sessions', 'update'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Times Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Times', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Times Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Times Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, 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 gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.patch(`/v1/lesson-sessions/${gen.data.sessions[0].id}`, {
|
||||
actualStartTime: '15:05',
|
||||
actualEndTime: '15:32',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.actualStartTime, '15:05:00')
|
||||
t.assert.equal(res.data.actualEndTime, '15:32:00')
|
||||
})
|
||||
|
||||
t.test('filters sessions by date range', { tags: ['sessions', 'filter'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions', { dateFrom: '2026-01-01', dateTo: '2026-12-31', limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('filters sessions by status', { tags: ['sessions', 'filter'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions', { status: 'scheduled', limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.every((s: any) => s.status === 'scheduled'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user