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[]) => 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 } }) }) })