import { MemberProgressService } from '../member-progress.service' import { PrismaService } from '../../prisma/prisma.service' import { ProgressPhotoStorageService } from '../progress-photo-storage.service' import { BodyMetricDto } from '../dto/member-progress.dto' import { validate } from 'class-validator' import 'reflect-metadata' import { Reflector } from '@nestjs/core' import { ExecutionContext } from '@nestjs/common' 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(), 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([]) }) it('filters private notes on the server, strips photo keys and counts only valid supplements', async () => { const result = await service.archive('member') expect(db.memberNote.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', shared: true }) expect(db.progressPhoto.findMany.mock.calls[0][0].select.objectKey).toBeUndefined() expect(db.lessonSupplement.aggregate.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null }) expect(result.completedCount).toBe(11); expect(result.milestones).toEqual([10]) await service.archive('member', true) expect(db.memberNote.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' }) }) it('rejects missing metrics, future and invalid dates; preserves negative flexibility', async () => { await expect(service.addMetric('member', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写') await expect(service.addMetric('member', 'admin', { recordedAt: '2099-01-01', weight: 50 })).rejects.toThrow('日期') 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).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('只能批注') expect(db.booking.findFirst.mock.calls[0][0].where).toEqual({ id: 'foreign', userId: 'member', status: 'COMPLETED' }) expect(db.memberNote.create).not.toHaveBeenCalled() }) it('never signs another member photo', async () => { db.progressPhoto.findFirst.mockResolvedValue(null) await expect(service.photoUrl('other', 'photo')).rejects.toThrow('照片不存在') expect(storage.signedUrl).not.toHaveBeenCalled() }) it('lets a member preview for informed consent but blocks admin until authorized', async () => { db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'private/photo', consentedAt: null }) storage.signedUrl.mockReturnValue('short-lived') await expect(service.photoUrl('member', 'photo', true)).rejects.toThrow('尚未授权') await expect(service.photoUrl('member', 'photo')).resolves.toEqual({ url: 'short-lived', expiresIn: 60 }) }) it('consent and revocation are scoped to current member and uploaded photo', async () => { db.progressPhoto.updateMany.mockResolvedValue({ count: 1 }) await service.consent('member', 'photo', false) expect(db.progressPhoto.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'photo', userId: 'member', uploadedAt: { not: null } }, data: { consentedAt: null, revokedAt: expect.any(Date) } }) }) it('requires confirmed storage upload before exposing a photo record', async () => { db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'key' }) storage.verify.mockRejectedValue(new Error('not uploaded')) await expect(service.finishUpload('member', 'photo')).rejects.toThrow('not uploaded') expect(db.progressPhoto.update).not.toHaveBeenCalled() }) it('deletes a photo from private storage and the database, scoped to the member', async () => { db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' }) await service.remove('member', 'photos', 'photo') expect(storage.removeObject).toHaveBeenCalledWith('progress/member/a.jpg') expect(db.progressPhoto.delete).toHaveBeenCalledWith({ where: { id: 'photo' } }) }) it('does not delete the database row if storage deletion fails', async () => { db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' }) storage.removeObject.mockRejectedValue(new Error('cos down')) await expect(service.remove('member', 'photos', 'photo')).rejects.toThrow('cos down') expect(db.progressPhoto.delete).not.toHaveBeenCalled() }) it('does not delete another member metric, note or photo', async () => { db.bodyMetric.deleteMany.mockResolvedValue({ count: 0 }) db.memberNote.deleteMany.mockResolvedValue({ count: 0 }) db.progressPhoto.findFirst.mockResolvedValue(null) await expect(service.remove('other', 'metrics', 'm')).rejects.toThrow('记录不存在') await expect(service.remove('other', 'notes', 'n')).rejects.toThrow('记录不存在') await expect(service.remove('other', 'photos', 'p')).rejects.toThrow('照片不存在') expect(storage.removeObject).not.toHaveBeenCalled() }) }) 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) expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(JwtAuthGuard) expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(RolesGuard) }) it('restricts admin progress reads to admins', () => { const handler = AdminMemberProgressController.prototype.archive 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) }) 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) }) })