feat: 支持教学案例展示功能
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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 } })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user