feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user