Files
photo-live/services/api/test/photos.e2e-spec.ts
T
浅念 6d61d117d5 feat(api): photo visibility lifecycle with audit and outbox events
Serializes turbo tasks (concurrency=1): api and worker e2e suites share
one MySQL/MinIO and resetDb TRUNCATEs raced when run in parallel.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 23:51:29 +08:00

747 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/test/photos.e2e-spec.ts —— Task 24 照片可见性生命周期 e2e
// (先写后实现)。真实 app + MySQL + 真实 MinIO(变体对象)。覆盖:
// ① 公开单图 getPublicPhoto:published → 200 PublicPhotoDetail(+categoryId,
// 签名 URL 现签可取回);换册 token / hidden / unpublished / 幽灵 id →
// 404 PHOTO_NOT_PUBLIC(旧深链不泄露状态,A16);
// ② PATCH /photos/{id} 隐藏:上传协作者(自己照片)→ 200 PhotoRecord;
// 相册 owner(他人上传)→ 200;其他协作者 → 403;外人员工 → 404
// (PHOTO_NOT_FOUND,防枚举);隐藏后公开列表即刻排除、单图 404;
// ③ outbox photo.hidden(entityVersion+1)+ audit photo.hide;
// ④ 恢复发布:hidden→published 仅 owner/admin → 200(上传协作者 403,
// P1a 从严默认,待定需求决策);列表重新出现;outbox 再发
// photo.published(新行、entityVersion 再+1);publishedAt 不变(时间轴
// 不因隐藏/恢复漂移);
// ⑤ DELETE 软删除 → 204;DB visibility=deleted 且行/对象键保留;公开面
// 即刻消失;outbox photo.deleted + audit photo.delete;deleted 后恢复
// 发布 → 400(员工侧终态,物理清除/恢复属 P4a 管理流程);协作者删自己
// ✓、删他人 ✗ 403;
// ⑥ 状态机 400:非法 visibility 值;unpublished→published(发布属 worker
// 处理链路);unpublished→hidden;幂等同值 PATCH → 200 且无新事件。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
let token = '';
const bytes = randomBytes(24);
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 测试内生成真实 JPEG(无二进制入库,同 worker publish e2e)。 */
async function makeJpeg(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
})
.jpeg({ quality: 85 })
.toBuffer();
}
const KEY_ROOT = 'photo-live/dev';
const variantKey = (
albumId: string,
photoId: string,
variant: 'thumb' | 'browse',
) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
/** 契约 PhotoRecord 的必返字段全集(形状精确断言用)。 */
const PHOTO_RECORD_KEYS = [
'albumId',
'bytes',
'categoryId',
'height',
'likeCount',
'photoId',
'processingState',
'publishedAt',
'shotAt',
'uploadedAt',
'uploaderId',
'visibility',
'width',
].sort();
describe('photos visibility (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: MinioStorageAdapter;
let albumId: string;
let publicToken: string;
let catAId: string;
let ownerId: string;
let collabId: string;
let otherCollabId: string;
let adminId: string;
let ownerToken: string;
let collabToken: string;
let otherCollabToken: string;
let outsiderToken: string;
let adminToken: string;
// 生命周期照片(依序:隐藏→恢复→软删除)。
let lifecycleId: string;
let lifecyclePublishedAt: Date;
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
storage = createStorageFromEnv();
// 账号:owner(相册创建者)/ collab(上传协作者)/ otherCollab / admin /
// outsider(外人员工,非成员)。
const mkUser = (account: string, role = 'staff') =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName: account },
select: { id: true },
});
ownerId = (await mkUser('ph-owner')).id;
collabId = (await mkUser('ph-collab')).id;
otherCollabId = (await mkUser('ph-other')).id;
adminId = (await mkUser('ph-admin', 'admin')).id;
const outsiderId = (await mkUser('ph-outsider')).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
ownerToken = await mkToken(ownerId);
collabToken = await mkToken(collabId);
otherCollabToken = await mkToken(otherCollabId);
outsiderToken = await mkToken(outsiderId);
adminToken = await mkToken(adminId);
// 主相册:owner + 两协作者成员行;分类 catA。
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true, publicToken: true },
});
albumId = album.id;
publicToken = album.publicToken;
await prisma.album_translations.create({
data: { albumId, locale: 'zh-CN', title: '生命周期相册' },
});
await prisma.album_members.createMany({
data: [
{ albumId, userId: ownerId, role: 'owner', status: 'active' },
{ albumId, userId: collabId, role: 'collaborator', status: 'active' },
{
albumId,
userId: otherCollabId,
role: 'collaborator',
status: 'active',
},
],
});
const cat = await prisma.categories.create({
data: { albumId, sortOrder: 1 },
select: { id: true },
});
catAId = cat.id;
await prisma.category_translations.create({
data: { categoryId: catAId, locale: 'zh-CN', name: '主分类' },
});
// 另一册(token 不匹配用例):独立相册 + 一张 published 照片。
const otherAlbum = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId: 'u-ph-other-album',
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
await prisma.album_translations.create({
data: { albumId: otherAlbum.id, locale: 'zh-CN', title: '另一册' },
});
await seedPhoto('phOtherAlbum', otherAlbum.id, 'u-ph-other-album', {
visibility: 'published',
});
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
/**
* 直种照片:photos 行 + 两条 photo_variants + MinIO 真实变体对象(默认)。
* entityVersion=2 对齐 worker 发布事务落库形状(unpublished 种 1 亦可,此处
* 统一 2,版本断言以「迁移前实测值」为基线,不依赖种子常量)。
*/
async function seedPhoto(
alias: string,
targetAlbumId: string,
uploaderId: string,
options: {
visibility?: string;
categoryId?: string | null;
withVariants?: boolean;
processingState?: string;
} = {},
): Promise<string> {
const photoId = cuidLike(alias);
const [thumb, browse] = await Promise.all([
makeJpeg(480, 300),
makeJpeg(800, 500),
]);
if (options.withVariants !== false) {
await storage.put(variantKey(targetAlbumId, photoId, 'thumb'), thumb, 'image/jpeg');
await storage.put(variantKey(targetAlbumId, photoId, 'browse'), browse, 'image/jpeg');
}
await prisma.photos.create({
data: {
id: photoId,
albumId: targetAlbumId,
uploaderId,
categoryId: options.categoryId ?? null,
objectKey: `${KEY_ROOT}/albums/${targetAlbumId}/originals/${photoId}.jpg`,
bytes: browse.length,
width: 800,
height: 500,
uploadedAt: new Date(),
publishedAt: options.visibility === 'unpublished' ? null : new Date(),
processingState: options.processingState ?? 'ready',
visibility: options.visibility ?? 'published',
entityVersion: 2,
},
});
if (options.withVariants !== false) {
await prisma.photo_variants.createMany({
data: [
{
photoId,
variant: 'thumb',
objectKey: variantKey(targetAlbumId, photoId, 'thumb'),
bytes: thumb.length,
version: 1,
width: 480,
height: 300,
},
{
photoId,
variant: 'browse',
objectKey: variantKey(targetAlbumId, photoId, 'browse'),
bytes: browse.length,
version: 1,
width: 800,
height: 500,
},
],
});
}
return photoId;
}
function patchPhoto(token: string, photoId: string, body: object) {
return request(app.getHttpServer())
.patch(`/${API_PREFIX}/photos/${photoId}`)
.set('Authorization', `Bearer ${token}`)
.send(body);
}
function deletePhoto(token: string, photoId: string) {
return request(app.getHttpServer())
.delete(`/${API_PREFIX}/photos/${photoId}`)
.set('Authorization', `Bearer ${token}`);
}
function getPublicPhoto(token: string, photoId: string) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${token}/photos/${photoId}`);
}
function listPublicPhotos(query?: Record<string, string | number>) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`)
.query(query ?? {});
}
/** 全量公开列表的 id 集(hasMore 分页下循环取尽,断言「出现/消失」用)。 */
async function publicPhotoIds(): Promise<string[]> {
const ids: string[] = [];
let cursor: string | undefined;
for (;;) {
const res = await listPublicPhotos(
cursor === undefined ? { limit: 60 } : { limit: 60, cursor },
);
expect(res.status).toBe(200);
ids.push(...res.body.items.map((p: { photoId: string }) => p.photoId));
if (res.body.nextCursor === null) {
return ids;
}
cursor = res.body.nextCursor as string;
}
}
describe('公开单图 getPublicPhoto', () => {
let singleId: string;
let hiddenId: string;
let unpubId: string;
let otherAlbumPhotoId: string;
beforeAll(async () => {
singleId = await seedPhoto('phSingle', albumId, collabId, {
categoryId: catAId,
});
hiddenId = await seedPhoto('phHidden', albumId, collabId, {
visibility: 'hidden',
});
unpubId = await seedPhoto('phUnpub', albumId, collabId, {
visibility: 'unpublished',
processingState: 'uploaded',
withVariants: false,
});
const other = await prisma.photos.findFirstOrThrow({
where: { albumId: { not: albumId }, visibility: 'published' },
select: { id: true },
});
otherAlbumPhotoId = other.id;
});
it('published → 200 PublicPhotoDetail:categoryId + 现签 thumb/browse 可取回', async () => {
const res = await getPublicPhoto(publicToken, singleId);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual([
'browse',
'categoryId',
'height',
'likeCount',
'photoId',
'publishedAt',
'thumb',
'width',
]);
expect(res.body.photoId).toBe(singleId);
expect(res.body.categoryId).toBe(catAId);
expect(res.body.width).toBe(800);
expect(res.body.height).toBe(500);
expect(res.body.likeCount).toBe(0);
for (const key of ['thumb', 'browse'] as const) {
const signed = res.body[key] as { url: string; expiresAt: string };
expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now());
const fetched = await fetch(signed.url);
expect(fetched.status).toBe(200);
expect(
Buffer.from(await fetched.arrayBuffer()).length,
).toBeGreaterThan(0);
}
});
it('换册照片 / hidden / unpublished / 幽灵 id → 404 PHOTO_NOT_PUBLIC', async () => {
// 换册:照片真实存在且 published,但不属于本 token 相册。
const mismatch = await getPublicPhoto(publicToken, otherAlbumPhotoId);
expect(mismatch.status).toBe(404);
expect(mismatch.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
for (const photoId of [hiddenId, unpubId, 'no-such-photo']) {
const res = await getPublicPhoto(publicToken, photoId);
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
expect(typeof res.body.requestId).toBe('string');
}
});
});
describe('PATCH /photos/{id} 隐藏与权限矩阵', () => {
let ownId: string; // collab 上传
let ownerPhotoId: string; // owner 上传
let otherTargetId: string; // collab 上传(otherCollab 尝试 → 403)
beforeAll(async () => {
ownId = await seedPhoto('phOwn', albumId, collabId);
ownerPhotoId = await seedPhoto('phOwnerUp', albumId, ownerId);
otherTargetId = await seedPhoto('phOtherTarget', albumId, collabId);
lifecycleId = await seedPhoto('phLife', albumId, collabId);
lifecyclePublishedAt = (
await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
select: { publishedAt: true },
})
).publishedAt as Date;
});
it('上传协作者隐藏自己的照片 → 200 PhotoRecord;公开列表即刻排除、单图 404', async () => {
const before = await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
select: { entityVersion: true },
});
const ids = await publicPhotoIds();
expect(ids).toContain(lifecycleId);
const res = await patchPhoto(collabToken, lifecycleId, {
visibility: 'hidden',
});
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(PHOTO_RECORD_KEYS);
expect(res.body).toMatchObject({
photoId: lifecycleId,
albumId,
uploaderId: collabId,
visibility: 'hidden',
processingState: 'ready',
likeCount: 0,
});
expect(res.body.publishedAt).not.toBeNull();
// A16 核心:公开面即刻不可见。
expect(await publicPhotoIds()).not.toContain(lifecycleId);
const single = await getPublicPhoto(publicToken, lifecycleId);
expect(single.status).toBe(404);
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
// 版本推进:2 → 3。
const after = await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
select: { entityVersion: true, visibility: true },
});
expect(after.entityVersion).toBe(before.entityVersion + 1);
expect(after.visibility).toBe('hidden');
});
it('outbox photo.hidden + audit photo.hide(entityVersion 对齐)', async () => {
const event = await prisma.outbox_events.findFirstOrThrow({
where: { photoId: lifecycleId, type: 'photo.hidden' },
});
expect(event.albumId).toBe(albumId);
expect(event.entityVersion).toBe(3);
expect(event.deliveredAt).toBeNull();
expect(event.payload).toMatchObject({
photoId: lifecycleId,
albumId,
visibility: 'hidden',
entityVersion: 3,
});
const audit = await prisma.audit_logs.findFirstOrThrow({
where: {
action: 'photo.hide',
actorId: collabId,
resourceId: lifecycleId,
},
});
expect(audit.metadata).toMatchObject({ from: 'published', to: 'hidden' });
});
it('相册 owner 隐藏他人上传 → 200;其他协作者 → 403;外人员工 → 404(防枚举)', async () => {
const ownerRes = await patchPhoto(ownerToken, ownId, {
visibility: 'hidden',
});
expect(ownerRes.status).toBe(200);
expect(ownerRes.body.visibility).toBe('hidden');
const otherRes = await patchPhoto(otherCollabToken, otherTargetId, {
visibility: 'hidden',
});
expect(otherRes.status).toBe(403);
expect(otherRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const outsiderRes = await patchPhoto(outsiderToken, otherTargetId, {
visibility: 'hidden',
});
expect(outsiderRes.status).toBe(404);
expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
// owner 自己上传的照片隐藏(同一人两径均 200)。
const ownerOwn = await patchPhoto(ownerToken, ownerPhotoId, {
visibility: 'hidden',
});
expect(ownerOwn.status).toBe(200);
// 幽灵照片 id:任何角色 404。
const ghost = await patchPhoto(ownerToken, 'no-such-photo', {
visibility: 'hidden',
});
expect(ghost.status).toBe(404);
expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
});
it('恢复发布:上传协作者(自己 hidden 照片)→ 403;owner → 200,列表重现,outbox 再发 photo.published', async () => {
// 协作者不可恢复(P1a 从严默认)。
const collabRes = await patchPhoto(collabToken, lifecycleId, {
visibility: 'published',
});
expect(collabRes.status).toBe(403);
expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const ownerRes = await patchPhoto(ownerToken, lifecycleId, {
visibility: 'published',
});
expect(ownerRes.status).toBe(200);
expect(ownerRes.body.visibility).toBe('published');
// 公开面恢复。
expect(await publicPhotoIds()).toContain(lifecycleId);
const single = await getPublicPhoto(publicToken, lifecycleId);
expect(single.status).toBe(200);
// publishedAt 不变:隐藏/恢复不重排时间轴。
const row = await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
select: { publishedAt: true, entityVersion: true },
});
expect((row.publishedAt as Date).getTime()).toBe(
lifecyclePublishedAt.getTime(),
);
expect(row.entityVersion).toBe(4);
// outbox:photo.published 新行(entityVersion=4),载荷为契约全集。
const events = await prisma.outbox_events.findMany({
where: { photoId: lifecycleId },
orderBy: { entityVersion: 'asc' },
});
expect(events.map((e) => [e.type, e.entityVersion])).toEqual([
['photo.hidden', 3],
['photo.published', 4],
]);
expect(events[1].payload).toEqual({
photoId: lifecycleId,
albumId,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
thumbKey: variantKey(albumId, lifecycleId, 'thumb'),
browseKey: variantKey(albumId, lifecycleId, 'browse'),
width: 800,
height: 500,
publishedAt: lifecyclePublishedAt.toISOString(),
entityVersion: 4,
});
const audit = await prisma.audit_logs.findFirstOrThrow({
where: {
action: 'photo.publish',
actorId: ownerId,
resourceId: lifecycleId,
},
});
expect(audit.metadata).toMatchObject({ from: 'hidden', to: 'published' });
});
it('admin 恢复发布 → 200(公司管理员全量)', async () => {
const adminHiddenId = await seedPhoto('phAdminRepublish', albumId, collabId, {
visibility: 'hidden',
});
const res = await patchPhoto(adminToken, adminHiddenId, {
visibility: 'published',
});
expect(res.status).toBe(200);
expect(res.body.visibility).toBe('published');
});
it('状态机 400:unpublished→published(发布属处理链路)/ unpublished→hidden;非法值 → 400', async () => {
const unpubId = await seedPhoto('phSmUnpub', albumId, collabId, {
visibility: 'unpublished',
processingState: 'uploaded',
withVariants: false,
});
const toPublished = await patchPhoto(collabToken, unpubId, {
visibility: 'published',
});
expect(toPublished.status).toBe(400);
expect(toPublished.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const toHidden = await patchPhoto(collabToken, unpubId, {
visibility: 'hidden',
});
expect(toHidden.status).toBe(400);
expect(toHidden.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 契约 enum 之外的任何值(含 deleted——软删除只走 DELETE)。
for (const garbage of ['deleted', 'public', 42]) {
const res = await patchPhoto(collabToken, lifecycleId, {
visibility: garbage,
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
// visibility 缺失 → 400。
const missing = await request(app.getHttpServer())
.patch(`/${API_PREFIX}/photos/${lifecycleId}`)
.set('Authorization', `Bearer ${collabToken}`)
.send({});
expect(missing.status).toBe(400);
// 状态机拒绝不产生事件/审计。
expect(
await prisma.outbox_events.count({ where: { photoId: unpubId } }),
).toBe(0);
expect(
await prisma.audit_logs.count({ where: { resourceId: unpubId } }),
).toBe(0);
});
it('幂等同值 PATCH → 200 当前记录且无新事件/审计(entityVersion 不动)', async () => {
const eventsBefore = await prisma.outbox_events.count({
where: { photoId: lifecycleId },
});
const res = await patchPhoto(collabToken, lifecycleId, {
visibility: 'published',
});
expect(res.status).toBe(200);
expect(res.body.visibility).toBe('published');
expect(await prisma.outbox_events.count({ where: { photoId: lifecycleId } })).toBe(
eventsBefore,
);
const row = await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
select: { entityVersion: true },
});
expect(row.entityVersion).toBe(4);
});
});
describe('DELETE /photos/{id} 软删除', () => {
let otherOwnedId: string;
beforeAll(async () => {
otherOwnedId = await seedPhoto('phDelOther', albumId, ownerId);
});
it('其他协作者删他人上传 → 403;协作者删自己 → 204;行/对象键保留', async () => {
const forbidden = await deletePhoto(otherCollabToken, otherOwnedId);
expect(forbidden.status).toBe(403);
expect(forbidden.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const res = await deletePhoto(collabToken, lifecycleId);
expect(res.status).toBe(204);
expect(res.body).toEqual({});
// DB:软删除——行保留、visibility=deleted、对象键原样、版本推进。
const row = await prisma.photos.findUniqueOrThrow({
where: { id: lifecycleId },
});
expect(row.visibility).toBe('deleted');
expect(row.entityVersion).toBe(5);
expect(row.objectKey).toBe(
`${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
);
// 公开面即刻消失(列表 + 旧深链单图)。
expect(await publicPhotoIds()).not.toContain(lifecycleId);
const single = await getPublicPhoto(publicToken, lifecycleId);
expect(single.status).toBe(404);
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
});
it('outbox photo.deleted + audit photo.delete', async () => {
const event = await prisma.outbox_events.findFirstOrThrow({
where: { photoId: lifecycleId, type: 'photo.deleted' },
});
expect(event.entityVersion).toBe(5);
expect(event.payload).toMatchObject({
photoId: lifecycleId,
albumId,
visibility: 'deleted',
entityVersion: 5,
});
const audit = await prisma.audit_logs.findFirstOrThrow({
where: {
action: 'photo.delete',
actorId: collabId,
resourceId: lifecycleId,
},
});
expect(audit.metadata).toMatchObject({ from: 'published', to: 'deleted' });
// 生命周期全事件序:hidden(3) → published(4) → deleted(5)。
const events = await prisma.outbox_events.findMany({
where: { photoId: lifecycleId },
orderBy: { entityVersion: 'asc' },
});
expect(events.map((e) => [e.type, e.entityVersion])).toEqual([
['photo.hidden', 3],
['photo.published', 4],
['photo.deleted', 5],
]);
});
it('deleted 为员工侧终态:恢复发布 → 400;重复 DELETE → 幂等 204 无新事件', async () => {
const res = await patchPhoto(ownerToken, lifecycleId, {
visibility: 'published',
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const eventsBefore = await prisma.outbox_events.count({
where: { photoId: lifecycleId },
});
const again = await deletePhoto(collabToken, lifecycleId);
expect(again.status).toBe(204);
expect(
await prisma.outbox_events.count({ where: { photoId: lifecycleId } }),
).toBe(eventsBefore);
});
it('owner 删除他人上传 → 204;外人员工 → 404;幽灵 id → 404', async () => {
const ownerRes = await deletePhoto(ownerToken, otherOwnedId);
expect(ownerRes.status).toBe(204);
const outsiderRes = await deletePhoto(outsiderToken, otherOwnedId);
expect(outsiderRes.status).toBe(404);
expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
const ghost = await deletePhoto(ownerToken, 'no-such-photo');
expect(ghost.status).toBe(404);
expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
});
it('未认证 PATCH/DELETE → 401', async () => {
const patch = await request(app.getHttpServer())
.patch(`/${API_PREFIX}/photos/${otherOwnedId}`)
.send({ visibility: 'hidden' });
expect(patch.status).toBe(401);
expect(patch.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
const del = await request(app.getHttpServer())
.delete(`/${API_PREFIX}/photos/${otherOwnedId}`);
expect(del.status).toBe(401);
});
});
});