Files
mp-pilates/packages/server/src/user/__tests__/member-progress.service.spec.ts
2026-10-18 14:59:48 +08:00

208 lines
15 KiB
TypeScript

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)
})
})