Files
mp-pilates/packages/server/src/studio/__tests__/teaching-case.service.spec.ts
2026-09-18 18:26:47 +08:00

111 lines
7.4 KiB
TypeScript

import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../../prisma/prisma.service'
import { TeachingCaseService } from '../teaching-case.service'
import { TeachingCaseStorageService } from '../teaching-case-storage.service'
import { SaveTeachingCaseDto } from '../dto/teaching-case.dto'
const input = (): SaveTeachingCaseDto => ({ title: '舒展的变化', displayName: '小林', lessonCount: 24, background: '久坐', achievement: '站姿更舒展', frames: [{ kind: 'pair', beforeId: 'before', afterId: 'after', caption: '侧面' }], sortOrder: 5, published: true, consentConfirmed: true, consentNote: '2026-09-18 书面授权', version: 1 })
const record = () => ({ ...input(), id: 'case-1', createdAt: new Date(), updatedAt: new Date(), consentedAt: new Date() })
describe('TeachingCaseService publication and storage boundaries', () => {
const prisma = {
teachingCase: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), updateMany: jest.fn() },
teachingCaseAsset: { count: jest.fn(), findMany: jest.fn(), create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
$transaction: jest.fn(),
}
const storage = { signedUrl: jest.fn(), createCaseCredential: jest.fn(), verify: jest.fn() }
let service: TeachingCaseService
beforeEach(() => {
jest.resetAllMocks()
prisma.$transaction.mockImplementation((queries: Promise<unknown>[]) => Promise.all(queries))
prisma.teachingCase.findMany.mockResolvedValue([record()])
prisma.teachingCase.count.mockResolvedValue(1)
prisma.teachingCase.findFirst.mockResolvedValue(record())
prisma.teachingCase.findUnique.mockResolvedValue(record())
prisma.teachingCase.updateMany.mockResolvedValue({ count: 1 })
prisma.teachingCaseAsset.count.mockResolvedValue(2)
prisma.teachingCaseAsset.findMany.mockResolvedValue([{ id: 'before', objectKey: 'teaching-cases/before.jpg' }, { id: 'after', objectKey: 'teaching-cases/after.jpg' }])
storage.signedUrl.mockImplementation((key: string) => `signed:${key}`)
service = new TeachingCaseService(prisma as unknown as PrismaService, storage as unknown as TeachingCaseStorageService)
})
it('only lists published consented cases in deterministic order and excludes private consent fields', async () => {
const result = await service.list({ page: 2, limit: 3 })
expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { published: true, consentConfirmed: true }, skip: 3, take: 3, orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }] }))
expect(prisma.teachingCase.count).toHaveBeenCalledWith({ where: { published: true, consentConfirmed: true } })
expect(result.items[0]).not.toHaveProperty('consentNote')
expect(result.items[0]).not.toHaveProperty('consentedAt')
expect(result.items[0]).not.toHaveProperty('version')
expect(result.items[0].frames[0]).toMatchObject({ beforeUrl: 'signed:teaching-cases/before.jpg', afterUrl: 'signed:teaching-cases/after.jpg' })
expect(storage.signedUrl).toHaveBeenCalledWith('teaching-cases/before.jpg', 'get', 900)
})
it('does not reveal a draft or withdrawn case through a direct public URL', async () => {
prisma.teachingCase.findFirst.mockResolvedValue(null)
await expect(service.detail('draft-id')).rejects.toBeInstanceOf(NotFoundException)
expect(prisma.teachingCase.findFirst).toHaveBeenCalledWith({ where: { id: 'draft-id', published: true, consentConfirmed: true } })
expect(storage.signedUrl).not.toHaveBeenCalled()
})
it('returns private consent details only in the admin view', async () => {
const result = await service.list({ page: 1, limit: 10 }, true)
expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} }))
expect(result.items[0]).toHaveProperty('consentNote', input().consentNote)
})
it.each([{ consentConfirmed: false }, { consentNote: ' ' }])('requires separate documented consent before publishing: %j', async patch => {
await expect(service.save({ ...input(), ...patch })).rejects.toBeInstanceOf(BadRequestException)
expect(prisma.teachingCase.create).not.toHaveBeenCalled()
})
it.each([
[{ kind: 'pair' as const, beforeId: 'before', caption: '' }],
[{ kind: 'pair' as const, beforeId: 'before', afterId: 'before', caption: '' }],
[{ kind: 'composite' as const, beforeId: 'before', afterId: 'after', caption: '' }],
])('rejects incomplete or ambiguous comparison frames', async frame => {
await expect(service.save({ ...input(), frames: [frame] })).rejects.toBeInstanceOf(BadRequestException)
})
it('rejects fabricated asset IDs, private archive photos and incomplete uploads', async () => {
prisma.teachingCaseAsset.count.mockResolvedValue(1)
await expect(service.save(input())).rejects.toThrow('未上传完成')
expect(prisma.teachingCaseAsset.count).toHaveBeenCalledWith({ where: { id: { in: ['before', 'after'] }, ready: true } })
expect(prisma.teachingCase.create).not.toHaveBeenCalled()
})
it('allows unpublished drafts without consent and strips presentation URLs from persisted frames', async () => {
prisma.teachingCase.create.mockResolvedValue(record())
const dto = { ...input(), published: false, consentConfirmed: false, consentNote: '', frames: [{ ...input().frames[0], beforeUrl: 'https://untrusted.test/photo.jpg' }] }
await service.save(dto)
expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ published: false, consentedAt: null, frames: input().frames }) })
})
it('preserves frame ordering and supports multiple composite photos', async () => {
prisma.teachingCase.create.mockResolvedValue(record())
const frames = [{ kind: 'composite' as const, beforeId: 'after', caption: '封面' }, { kind: 'composite' as const, beforeId: 'before', caption: '第二组' }]
await service.save({ ...input(), frames })
expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ frames }) })
})
it('rejects stale updates instead of overwriting another administrator or withdrawal', async () => {
prisma.teachingCase.updateMany.mockResolvedValue({ count: 0 })
await expect(service.save(input(), 'case-1')).rejects.toBeInstanceOf(ConflictException)
expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1', version: 1 }, data: expect.objectContaining({ version: { increment: 1 } }) })
})
it('withdraws consent and publication together and invalidates older editors', async () => {
await service.withdraw('case-1')
expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1' }, data: { published: false, consentConfirmed: false, consentedAt: null, version: { increment: 1 } } })
})
it('does not complete an upload before COS confirms the object', async () => {
prisma.teachingCaseAsset.findUnique.mockResolvedValue({ id: 'asset', objectKey: 'teaching-cases/a.jpg' })
storage.verify.mockRejectedValue(new Error('not uploaded'))
await expect(service.completeUpload('asset')).rejects.toThrow('not uploaded')
expect(prisma.teachingCaseAsset.update).not.toHaveBeenCalled()
storage.verify.mockResolvedValue(undefined)
await service.completeUpload('asset')
expect(prisma.teachingCaseAsset.update).toHaveBeenCalledWith({ where: { id: 'asset' }, data: { ready: true } })
})
})