feat: 支持课后评价与成长档案

完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-09 15:55:48 +08:00
parent c3e46f7ffa
commit 139882d7a1
50 changed files with 1307 additions and 15 deletions

View File

@@ -0,0 +1,102 @@
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'
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 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).toBeUndefined()
})
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('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('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)
})
})