feat: 支持新版会员档案
This commit is contained in:
@@ -10,8 +10,9 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../../auth/roles.guard'
|
||||
import { emptyPostureAssessment, POSTURE_SECTIONS, togglePostureMark, isPostureAssessment } from '@mp-pilates/shared'
|
||||
describe('Member progress permissions and measurements', () => {
|
||||
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
|
||||
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
|
||||
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
|
||||
let service: MemberProgressService
|
||||
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
|
||||
@@ -30,11 +31,71 @@ describe('Member progress permissions and measurements', () => {
|
||||
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
|
||||
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeNull()
|
||||
})
|
||||
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
|
||||
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
|
||||
})
|
||||
it('continues to display historical measurements with no posture assessment', async () => {
|
||||
const historical = { id: 'legacy', recordedAt: '2025-01-01', weight: 50, bodyFat: null, flexibility: -3, remark: '首次测量', height: null, assessment: null }
|
||||
db.bodyMetric.findMany.mockResolvedValue([historical])
|
||||
db.bodyMetric.findFirst.mockResolvedValue(historical)
|
||||
expect((await service.archive('member')).metrics).toEqual([historical])
|
||||
expect(await service.metric('member', 'legacy')).toEqual({ nickname: '学员', record: historical })
|
||||
expect(db.bodyMetric.findFirst).toHaveBeenCalledWith({ where: { id: 'legacy', userId: 'member' } })
|
||||
})
|
||||
it('accepts legacy clients without new fields, posture-only records and notes-only records', async () => {
|
||||
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', weight: 50 })
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data).toMatchObject({ weight: 50, height: null, assessment: emptyPostureAssessment() })
|
||||
const assessment = emptyPostureAssessment()
|
||||
assessment.plumb.alignment = 'no'
|
||||
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', assessment })
|
||||
expect(db.bodyMetric.create.mock.calls[1][0].data).toMatchObject({ weight: null, assessment })
|
||||
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', remark: '观察肩部排列' })
|
||||
expect(db.bodyMetric.create.mock.calls[2][0].data.remark).toBe('观察肩部排列')
|
||||
})
|
||||
it('allows empty optional measurements, including explicit null when clearing an existing value', async () => {
|
||||
expect(await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height: null, weight: null }))).toHaveLength(0)
|
||||
for (const height of [0, 251, Infinity, '170']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height }))).length).toBeGreaterThan(0)
|
||||
})
|
||||
it('updates the existing member record in place and clears removed measurements and findings', async () => {
|
||||
const assessment = emptyPostureAssessment()
|
||||
assessment.spine.flat = 'yes'; assessment.spine.flatLocation = '下胸椎'
|
||||
db.bodyMetric.updateMany.mockResolvedValue({ count: 1 })
|
||||
db.bodyMetric.findFirst.mockResolvedValue({ id: 'existing', assessment })
|
||||
await service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-02', weight: null, assessment })
|
||||
expect(db.bodyMetric.updateMany).toHaveBeenCalledWith({ where: { id: 'existing', userId: 'member' }, data: expect.objectContaining({ weight: null, height: null, assessment, operatorId: 'admin', recordedAt: new Date('2025-01-02T00:00:00.000Z') }) })
|
||||
expect(db.bodyMetric.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it('cannot read or edit another member record and rejects invalid edits before writing', async () => {
|
||||
db.bodyMetric.findFirst.mockResolvedValue(null)
|
||||
db.bodyMetric.updateMany.mockResolvedValue({ count: 0 })
|
||||
await expect(service.metric('other', 'existing')).rejects.toThrow('不存在')
|
||||
await expect(service.updateMetric('other', 'existing', 'admin', { recordedAt: '2025-01-01', weight: 50 })).rejects.toThrow('不存在')
|
||||
expect(db.bodyMetric.updateMany.mock.calls[0][0].where).toEqual({ id: 'existing', userId: 'other' })
|
||||
db.bodyMetric.updateMany.mockClear()
|
||||
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
|
||||
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
|
||||
expect(db.bodyMetric.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects malformed, unknown, wrong-side and contradictory posture payloads', async () => {
|
||||
const badPayloads = [
|
||||
[], { ...emptyPostureAssessment(), version: 2 },
|
||||
{ ...emptyPostureAssessment(), answers: { unknown: ['right'] } },
|
||||
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['checked'] } },
|
||||
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right', 'right'] } },
|
||||
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right'], 'side.ankle.plantar': ['right'] } },
|
||||
{ ...emptyPostureAssessment(), plumb: { alignment: true, stacking: null } },
|
||||
{ ...emptyPostureAssessment(), spine: { flat: 'no', flatLocation: 'stale location', imbalance: null, imbalanceLocation: '' } },
|
||||
{ ...emptyPostureAssessment(), spine: { flat: 'yes', flatLocation: 'x'.repeat(201), imbalance: null, imbalanceLocation: '' } },
|
||||
]
|
||||
for (const assessment of badPayloads) {
|
||||
const dto = Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', assessment })
|
||||
expect((await validate(dto)).length).toBeGreaterThan(0)
|
||||
await expect(service.addMetric('member', 'admin', dto)).rejects.toThrow('体态检查内容无效')
|
||||
}
|
||||
expect(db.bodyMetric.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it('only annotates the target member completed booking', async () => {
|
||||
db.booking.findFirst.mockResolvedValue(null)
|
||||
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
|
||||
@@ -86,6 +147,42 @@ describe('Member progress permissions and measurements', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Posture checklist selection behavior', () => {
|
||||
const allGroups = POSTURE_SECTIONS.flatMap(section => section.groups)
|
||||
it('keeps left and right independent while replacing contradictory findings on the same side', () => {
|
||||
const group = allGroups.find(group => group.id === 'side.ankle')!
|
||||
const [neutral, plantar, dorsal] = group.options
|
||||
let answers = togglePostureMark({}, group, neutral, 'left')
|
||||
answers = togglePostureMark(answers, group, plantar, 'right')
|
||||
expect(answers).toEqual({ [neutral.id]: ['left'], [plantar.id]: ['right'] })
|
||||
answers = togglePostureMark(answers, group, dorsal, 'right')
|
||||
expect(answers).toEqual({ [neutral.id]: ['left'], [dorsal.id]: ['right'] })
|
||||
answers = togglePostureMark(answers, group, dorsal, 'right')
|
||||
expect(answers).toEqual({ [neutral.id]: ['left'] })
|
||||
})
|
||||
it('allows scapula findings in different planes and excludes neutral only on the affected side', () => {
|
||||
const group = allGroups.find(group => group.id === 'back.scapula')!
|
||||
const option = (id: string) => group.options.find(option => option.id === group.id + '.' + id)!
|
||||
let answers = togglePostureMark({}, group, option('neutral'), 'left')
|
||||
answers = togglePostureMark(answers, group, option('elevated'), 'right')
|
||||
answers = togglePostureMark(answers, group, option('upward'), 'right')
|
||||
answers = togglePostureMark(answers, group, option('winging'), 'right')
|
||||
expect(Object.keys(answers)).toHaveLength(4)
|
||||
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
|
||||
answers = togglePostureMark(answers, group, option('neutral'), 'right')
|
||||
expect(answers).toEqual({ [option('neutral').id]: ['left', 'right'] })
|
||||
})
|
||||
it('clears bilateral abnormalities when whole-body neutral is selected', () => {
|
||||
const group = allGroups.find(group => group.id === 'front.head')!
|
||||
const [clockwise, , neutral, tilt] = group.options
|
||||
let answers = togglePostureMark({}, group, clockwise, 'checked')
|
||||
answers = togglePostureMark(answers, group, tilt, 'right')
|
||||
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
|
||||
answers = togglePostureMark(answers, group, neutral, 'checked')
|
||||
expect(answers).toEqual({ [neutral.id]: ['checked'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Member progress authorization', () => {
|
||||
it('requires authentication on member and admin progress controllers', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
|
||||
@@ -99,4 +196,12 @@ describe('Member progress authorization', () => {
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
})
|
||||
it('restricts record editing to admins and exposes no member write route', () => {
|
||||
const handler = AdminMemberProgressController.prototype.updateMetric
|
||||
const guard = new RolesGuard(new Reflector())
|
||||
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
expect('updateMetric' in MemberProgressController.prototype).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user