Add lessons Phase 6: lesson plans with curriculum tracking
Structured lesson plans with nested sections and items per enrollment. Deep create in one request, one-active-per-enrollment constraint, auto-set startedDate/masteredDate on status transitions, progress % calculation (skipped items excluded). 8 new tests (84 total).
This commit is contained in:
@@ -1068,4 +1068,240 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
const list = await t.api.get('/v1/grading-scales', { q: 'To Delete Scale', limit: 100 })
|
||||
t.assert.equal(list.data.data.length, 0)
|
||||
})
|
||||
|
||||
// ─── Lesson Plans: Deep Create ───
|
||||
|
||||
t.test('creates a lesson plan with nested sections and items', { tags: ['lesson-plans', 'create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Plan Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Emma', lastName: 'Chen' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Plan Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Plan Type', durationMinutes: 30 })
|
||||
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-15',
|
||||
})
|
||||
|
||||
const res = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id,
|
||||
enrollmentId: enrollment.data.id,
|
||||
title: 'Year 2 Piano',
|
||||
description: 'Second year curriculum',
|
||||
startedDate: '2026-01-15',
|
||||
sections: [
|
||||
{
|
||||
title: 'Scales & Arpeggios',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{ title: 'C Major 2 octaves', sortOrder: 1 },
|
||||
{ title: 'G Major 2 octaves', sortOrder: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Repertoire',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{ title: 'Minuet in G (Bach)', sortOrder: 1 },
|
||||
{ title: 'Fur Elise (Beethoven)', sortOrder: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Theory',
|
||||
sortOrder: 3,
|
||||
items: [
|
||||
{ title: 'Key signatures to 2 sharps', sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.title, 'Year 2 Piano')
|
||||
t.assert.equal(res.data.isActive, true)
|
||||
t.assert.equal(res.data.sections.length, 3)
|
||||
t.assert.equal(res.data.sections[0].title, 'Scales & Arpeggios')
|
||||
t.assert.equal(res.data.sections[0].items.length, 2)
|
||||
t.assert.equal(res.data.sections[1].items.length, 2)
|
||||
t.assert.equal(res.data.sections[2].items.length, 1)
|
||||
t.assert.equal(res.data.sections[0].items[0].status, 'not_started')
|
||||
})
|
||||
|
||||
t.test('creating new plan deactivates previous active plan', { tags: ['lesson-plans', 'create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Deactivate Plan Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Deact', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Deact Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Deact 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 plan1 = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Plan 1', sections: [],
|
||||
})
|
||||
t.assert.equal(plan1.data.isActive, true)
|
||||
|
||||
const plan2 = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Plan 2', sections: [],
|
||||
})
|
||||
t.assert.equal(plan2.data.isActive, true)
|
||||
|
||||
// Check plan1 is now inactive
|
||||
const check = await t.api.get(`/v1/lesson-plans/${plan1.data.id}`)
|
||||
t.assert.equal(check.data.isActive, false)
|
||||
})
|
||||
|
||||
// ─── Lesson Plans: Read ───
|
||||
|
||||
t.test('gets lesson plan with sections, items, and progress', { tags: ['lesson-plans', 'read'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Get Plan Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Get', lastName: 'Plan' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Get Plan Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Get Plan 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 created = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Progress Plan',
|
||||
sections: [{
|
||||
title: 'Skills', sortOrder: 1,
|
||||
items: [
|
||||
{ title: 'Skill A', sortOrder: 1 },
|
||||
{ title: 'Skill B', sortOrder: 2 },
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
const res = await t.api.get(`/v1/lesson-plans/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.title, 'Progress Plan')
|
||||
t.assert.equal(res.data.progress, 0, 'no items mastered yet')
|
||||
t.assert.equal(res.data.sections.length, 1)
|
||||
t.assert.equal(res.data.sections[0].items.length, 2)
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing lesson plan', { tags: ['lesson-plans', 'read'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-plans/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('lists lesson plans with pagination', { tags: ['lesson-plans', 'read', 'pagination'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-plans', { limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 1)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
// ─── Lesson Plan Items: Status + Auto-Dates ───
|
||||
|
||||
t.test('item status transition auto-sets startedDate and masteredDate', { tags: ['lesson-plans', 'items', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Item Status Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Item', lastName: 'Status' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Item Status Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Item Status 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',
|
||||
})
|
||||
|
||||
const plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Status Plan',
|
||||
sections: [{
|
||||
title: 'Skills', sortOrder: 1,
|
||||
items: [{ title: 'Test Item', sortOrder: 1 }],
|
||||
}],
|
||||
})
|
||||
const itemId = plan.data.sections[0].items[0].id
|
||||
|
||||
// not_started -> in_progress: sets startedDate
|
||||
const inProgress = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'in_progress' })
|
||||
t.assert.equal(inProgress.data.status, 'in_progress')
|
||||
t.assert.ok(inProgress.data.startedDate, 'startedDate should be set')
|
||||
t.assert.equal(inProgress.data.masteredDate, null)
|
||||
|
||||
// in_progress -> mastered: sets masteredDate
|
||||
const mastered = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'mastered' })
|
||||
t.assert.equal(mastered.data.status, 'mastered')
|
||||
t.assert.ok(mastered.data.masteredDate, 'masteredDate should be set')
|
||||
|
||||
// mastered -> in_progress: clears masteredDate
|
||||
const backToProgress = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'in_progress' })
|
||||
t.assert.equal(backToProgress.data.masteredDate, null, 'masteredDate should be cleared')
|
||||
t.assert.ok(backToProgress.data.startedDate, 'startedDate should remain')
|
||||
})
|
||||
|
||||
t.test('progress percentage calculation with mastered and skipped items', { tags: ['lesson-plans', 'progress'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Progress Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Prog', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Progress Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Progress Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 6, 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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Progress Plan',
|
||||
sections: [{
|
||||
title: 'Skills', sortOrder: 1,
|
||||
items: [
|
||||
{ title: 'Item A', sortOrder: 1 },
|
||||
{ title: 'Item B', sortOrder: 2 },
|
||||
{ title: 'Item C', sortOrder: 3 },
|
||||
{ title: 'Item D (skip)', sortOrder: 4 },
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
const items = plan.data.sections[0].items
|
||||
await t.api.patch(`/v1/lesson-plan-items/${items[0].id}`, { status: 'mastered' })
|
||||
await t.api.patch(`/v1/lesson-plan-items/${items[3].id}`, { status: 'skipped' })
|
||||
|
||||
// 1 mastered out of 3 non-skipped = 33%
|
||||
const res = await t.api.get(`/v1/lesson-plans/${plan.data.id}`)
|
||||
t.assert.equal(res.data.progress, 33)
|
||||
})
|
||||
|
||||
t.test('updates a lesson plan title', { tags: ['lesson-plans', 'update'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Update Plan Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Update', lastName: 'Plan' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Plan Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Update Plan Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, 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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id,
|
||||
title: 'Before Update', sections: [],
|
||||
})
|
||||
|
||||
const res = await t.api.patch(`/v1/lesson-plans/${plan.data.id}`, { title: 'After Update' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.title, 'After Update')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user