test(api): full-chain e2e covering A01 A02 A03 A06
One spec chaining the whole business flow over public APIs only: seed-admin + real password login, album/category/member invite, same-filename double upload via presign PUT (idempotent complete), real BullMQ worker consumption publishing both photos (outbox has exactly two photo.published events), public feed with locale fallback, cursor pagination and signed-URL bytes, hide/remove/ soft-delete lifecycle with immediate permission loss, and full audit-trail assertions. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
// services/api/test/e2e.full-chain.e2e-spec.ts —— Task 25 全链路 API E2E
|
||||
// (A01/A02/A03/A06 验收证据)。ONE spec 串完整业务流,且流程只走公开 API:
|
||||
// seed-admin → admin 密码登录 → 建相册(zh+en)+分类 → 邀请 staffB →
|
||||
// staffB 登录 → 同名文件两次上传(不同 clientTaskId,A03)→ 双 presign PUT
|
||||
// 实写 MinIO → complete(其一调两次,幂等)→ 真实 BullMQ Worker 消费
|
||||
// (services/worker src 的 createPhotoProcessWorker,同 worker publish e2e
|
||||
// bootstrap)→ 双照片 ready+published、outbox 恰 2 条 photo.published(无第三
|
||||
// 条)→ 公开 API(en 精确命中/回退、列表、limit=1 游标翻页无重复、签名 URL
|
||||
// 取回真实字节、单图)→ staffB 隐藏 photo1 → owner 移除 staffB(A02:被移除
|
||||
// 成员立即失去上传/管理链)→ admin 软删除 photo2 → 空列表 → audit 全程可查。
|
||||
// Prisma 仅用于种子(staffB 账号)与断言;不用于流程本身。真实 MySQL + Redis
|
||||
// (BullMQ)+ MinIO(staging/originals/variants 对象)。
|
||||
import { type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import sharp from 'sharp';
|
||||
import IORedis from 'ioredis';
|
||||
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 { hashPassword } from '../src/auth/password';
|
||||
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||
import { API_PREFIX } from '../src/shared/constants';
|
||||
import {
|
||||
createPhotoProcessWorker,
|
||||
} from '../../worker/src/process.processor';
|
||||
import {
|
||||
PublishService,
|
||||
variantsObjectKey,
|
||||
} from '../../worker/src/publish.service';
|
||||
import { resetDb } from './reset';
|
||||
|
||||
jest.setTimeout(180_000);
|
||||
|
||||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||||
const STAFF_ACCOUNT = 'chain-staffb';
|
||||
const STAFF_PASSWORD = 'staffb-secret-1';
|
||||
|
||||
/** 轮询等待:真实 Worker 异步消费的前置(同 worker publish e2e)。 */
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean>,
|
||||
timeoutMs = 60_000,
|
||||
stepMs = 100,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (await check()) return;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`waitFor 超时(${timeoutMs}ms)`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试内生成真实 JPEG(900px,同任务要求;无二进制入库)。 */
|
||||
async function makeJpeg(
|
||||
width: number,
|
||||
height: number,
|
||||
rgb: { r: number; g: number; b: number },
|
||||
): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: rgb },
|
||||
})
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe('full-chain (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let storage: MinioStorageAdapter;
|
||||
|
||||
let adminToken: string;
|
||||
let staffBId: string;
|
||||
let staffBToken: string;
|
||||
let albumId: string;
|
||||
let publicToken: string;
|
||||
let categoryId: string;
|
||||
|
||||
/** 同名两次上传的两张照片(uploadA=complete 调两次的那张)。 */
|
||||
let uploadA: { uploadId: string; photoId: string; objectKey: string };
|
||||
let uploadB: { uploadId: string; photoId: string; objectKey: string };
|
||||
let jpegA: Buffer;
|
||||
let jpegB: Buffer;
|
||||
|
||||
const login = (account: string, password: string) =>
|
||||
request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/auth/password`)
|
||||
.send({ account, password });
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(ADMIN_ACCOUNT).not.toBe('');
|
||||
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();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('seed-admin → admin 真实密码登录(A01)', async () => {
|
||||
const seeded = await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||
expect(seeded.created).toBe(true);
|
||||
// 幂等:重跑跳过且不改写密码(seed.spec 已单测,此处仅顺带证据)。
|
||||
expect(await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD)).toEqual({
|
||||
created: false,
|
||||
});
|
||||
|
||||
const res = await login(ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sessionToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
adminToken = res.body.sessionToken as string;
|
||||
|
||||
const admin = await prisma.users.findUniqueOrThrow({
|
||||
where: { account: ADMIN_ACCOUNT },
|
||||
});
|
||||
expect(admin.role).toBe('admin');
|
||||
// 登录审计(A01 证据之一)。
|
||||
expect(
|
||||
await prisma.audit_logs.count({
|
||||
where: { action: 'auth.login', actorId: admin.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('admin 建相册(zh-CN+en)+ 分类 + 经成员 API 邀请 staffB', async () => {
|
||||
// staffB 账号:Prisma 直种(邀请端点按 userId 关联,账号查询接口属 P1b)。
|
||||
staffBId = (
|
||||
await prisma.users.create({
|
||||
data: {
|
||||
account: STAFF_ACCOUNT,
|
||||
passwordHash: await hashPassword(STAFF_PASSWORD),
|
||||
displayName: 'staffB',
|
||||
role: 'staff',
|
||||
credentialStatus: 'active',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).id;
|
||||
|
||||
const album = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/albums`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '全链路晚会', description: '端到端验收' },
|
||||
{ locale: 'en', title: 'Full Chain Gala' },
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(album.status).toBe(201);
|
||||
albumId = album.body.id as string;
|
||||
publicToken = album.body.publicToken as string;
|
||||
expect(publicToken).toMatch(/^[0-9a-z]{20,32}$/);
|
||||
|
||||
const category = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/albums/${albumId}/categories`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
sortOrder: 0,
|
||||
translations: [
|
||||
{ locale: 'zh-CN', name: '主舞台' },
|
||||
{ locale: 'en', name: 'Main Stage' },
|
||||
],
|
||||
});
|
||||
expect(category.status).toBe(201);
|
||||
categoryId = category.body.id as string;
|
||||
|
||||
const invite = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/albums/${albumId}/members`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ userId: staffBId, role: 'collaborator' });
|
||||
expect(invite.status).toBe(201);
|
||||
expect(invite.body).toMatchObject({
|
||||
userId: staffBId,
|
||||
role: 'collaborator',
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('staffB 登录 → 同名文件两次上传 → 双 presign PUT 实写 → complete(其一调两次,A03)', async () => {
|
||||
const res = await login(STAFF_ACCOUNT, STAFF_PASSWORD);
|
||||
expect(res.status).toBe(200);
|
||||
staffBToken = res.body.sessionToken as string;
|
||||
|
||||
jpegA = await makeJpeg(900, 600, { r: 180, g: 120, b: 60 });
|
||||
jpegB = await makeJpeg(900, 600, { r: 60, g: 130, b: 180 });
|
||||
|
||||
const initAs = async (clientTaskId: string, bytes: number) => {
|
||||
const init = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/uploads`)
|
||||
.set('Authorization', `Bearer ${staffBToken}`)
|
||||
.send({
|
||||
albumId,
|
||||
clientTaskId,
|
||||
filename: 'show.jpg', // 同名文件(A03:同名不覆盖)
|
||||
bytes,
|
||||
contentType: 'image/jpeg',
|
||||
categoryId,
|
||||
});
|
||||
expect(init.status).toBe(201);
|
||||
return init.body as {
|
||||
uploadId: string;
|
||||
photoId: string;
|
||||
objectKey: string;
|
||||
credential: { url: string; method: string; headers: Record<string, string> };
|
||||
};
|
||||
};
|
||||
|
||||
const one = await initAs('chain-a', jpegA.length);
|
||||
const two = await initAs('chain-b', jpegB.length);
|
||||
// 同名不同 clientTaskId:两会话两照片,正式键互异(同名不覆盖)。
|
||||
expect(one.uploadId).not.toBe(two.uploadId);
|
||||
expect(one.photoId).not.toBe(two.photoId);
|
||||
expect(one.objectKey).not.toBe(two.objectKey);
|
||||
uploadA = one;
|
||||
uploadB = two;
|
||||
expect(await prisma.photos.count({ where: { albumId } })).toBe(2);
|
||||
expect(
|
||||
await prisma.upload_sessions.count({ where: { userId: staffBId } }),
|
||||
).toBe(2);
|
||||
|
||||
// 两个 presign PUT:真实 JPEG 字节写入 MinIO staging。
|
||||
for (const [init, jpeg] of [
|
||||
[one, jpegA],
|
||||
[two, jpegB],
|
||||
] as const) {
|
||||
const put = await fetch(init.credential.url, {
|
||||
method: init.credential.method,
|
||||
headers: init.credential.headers,
|
||||
body: new Uint8Array(jpeg),
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
}
|
||||
|
||||
const complete = (uploadId: string) =>
|
||||
request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/uploads/${uploadId}/complete`)
|
||||
.set('Authorization', `Bearer ${staffBToken}`);
|
||||
|
||||
const done = await complete(one.uploadId);
|
||||
expect(done.status).toBe(200);
|
||||
expect(done.body).toEqual({ photoId: one.photoId, processingState: 'uploaded' });
|
||||
|
||||
// 幂等:同一会话 complete 调两次 → 同结果,不产生第二个 job/audit(A03)。
|
||||
const again = await complete(one.uploadId);
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body).toEqual(done.body);
|
||||
|
||||
const doneB = await complete(two.uploadId);
|
||||
expect(doneB.status).toBe(200);
|
||||
expect(doneB.body).toEqual({ photoId: two.photoId, processingState: 'uploaded' });
|
||||
|
||||
// DB:受控复制后 originals 已在,job 各恰一条 pending。
|
||||
for (const { photoId } of [one, two]) {
|
||||
expect(
|
||||
await prisma.photo_jobs.count({ where: { photoId, type: 'process' } }),
|
||||
).toBe(1);
|
||||
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
|
||||
expect(photo.processingState).toBe('uploaded');
|
||||
expect(photo.visibility).toBe('unpublished');
|
||||
expect(
|
||||
await storage.head(photo.objectKey),
|
||||
).toMatchObject({ bytes: photoId === one.photoId ? jpegA.length : jpegB.length });
|
||||
}
|
||||
});
|
||||
|
||||
it('真实 Worker 消费 → 双照片 ready+published;outbox 恰 2 条 photo.published(无第三条,A03/A06);MinIO 变体在', async () => {
|
||||
// API complete 已把两张照片的 job 入 photo-process 队列;起真实 BullMQ
|
||||
// Worker(worker src,同 publish.e2e bootstrap)消费之——这是「上传即公开」
|
||||
// 的 A06 全链路证据。
|
||||
const workerConnection = new IORedis(
|
||||
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
|
||||
{ maxRetriesPerRequest: null },
|
||||
);
|
||||
workerConnection.on('error', () => undefined);
|
||||
const worker = createPhotoProcessWorker({
|
||||
prisma,
|
||||
storage,
|
||||
connection: workerConnection,
|
||||
});
|
||||
try {
|
||||
await waitFor(
|
||||
async () =>
|
||||
(
|
||||
await prisma.photos.findMany({
|
||||
where: { id: { in: [uploadA.photoId, uploadB.photoId] } },
|
||||
select: { processingState: true },
|
||||
})
|
||||
).every((p) => p.processingState === 'ready'),
|
||||
);
|
||||
|
||||
for (const { photoId } of [uploadA, uploadB]) {
|
||||
const photo = await prisma.photos.findUniqueOrThrow({
|
||||
where: { id: photoId },
|
||||
});
|
||||
expect(photo).toMatchObject({
|
||||
processingState: 'ready',
|
||||
visibility: 'published',
|
||||
entityVersion: 2,
|
||||
width: 900,
|
||||
height: 600,
|
||||
});
|
||||
expect(photo.publishedAt).not.toBeNull();
|
||||
// job 闭环 done(attempt=1)。
|
||||
const job = await prisma.photo_jobs.findUniqueOrThrow({
|
||||
where: { photoId_type: { photoId, type: 'process' } },
|
||||
});
|
||||
expect(job).toMatchObject({ state: 'done', attempt: 1 });
|
||||
// 变体行 + MinIO 真实变体对象(键布局取自 worker 生产实现)。
|
||||
expect(
|
||||
await prisma.photo_variants.count({ where: { photoId } }),
|
||||
).toBe(2);
|
||||
for (const variant of ['thumb', 'browse'] as const) {
|
||||
const head = await storage.head(
|
||||
variantsObjectKey(albumId, photoId, 1, variant),
|
||||
);
|
||||
expect(head).not.toBeNull();
|
||||
expect(head?.contentType).toBe('image/jpeg');
|
||||
expect(head?.bytes).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// A03 心脏断言:全库恰 2 条 photo.published(每照片一条,双 complete
|
||||
// 不多、同名两传不丢)。
|
||||
expect(
|
||||
await prisma.outbox_events.count({ where: { type: 'photo.published' } }),
|
||||
).toBe(2);
|
||||
expect(
|
||||
await prisma.outbox_events.count({ where: { photoId: uploadA.photoId } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.outbox_events.count({ where: { photoId: uploadB.photoId } }),
|
||||
).toBe(1);
|
||||
} finally {
|
||||
await worker.close();
|
||||
await workerConnection.quit().catch(() => undefined);
|
||||
}
|
||||
|
||||
// 重复处理幂等(发布事务即幂等边界):再驱动一次 → already,无第三条事件。
|
||||
const publish = new PublishService(prisma, storage);
|
||||
expect(await publish.processPhoto(uploadA.photoId)).toBe('already');
|
||||
expect(
|
||||
await prisma.outbox_events.count({ where: { type: 'photo.published' } }),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('公开 API:en 命中/回退、列表 2 张、limit=1 翻页无重复、thumb 真实字节、单图 200', async () => {
|
||||
const albumInfo = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}`)
|
||||
.query({ locale: 'en' });
|
||||
expect(albumInfo.status).toBe(200);
|
||||
expect(albumInfo.body.resolvedLocale).toBe('en');
|
||||
expect(albumInfo.body.translation).toMatchObject({
|
||||
locale: 'en',
|
||||
title: 'Full Chain Gala',
|
||||
});
|
||||
// 分类同语言解析。
|
||||
expect(albumInfo.body.categories[0]).toMatchObject({
|
||||
id: categoryId,
|
||||
name: 'Main Stage',
|
||||
});
|
||||
// 缺省 locale → defaultLocale(zh-CN)。
|
||||
const zhInfo = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}`);
|
||||
expect(zhInfo.body.resolvedLocale).toBe('zh-CN');
|
||||
expect(zhInfo.body.translation).toMatchObject({ title: '全链路晚会' });
|
||||
|
||||
// 列表 2 张(均为 published)。
|
||||
const list = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`);
|
||||
expect(list.status).toBe(200);
|
||||
const both = new Set(
|
||||
list.body.items.map((p: { photoId: string }) => p.photoId),
|
||||
);
|
||||
expect(both).toEqual(new Set([uploadA.photoId, uploadB.photoId]));
|
||||
|
||||
// limit=1 游标翻页走完两页:并集恰两张、无重复。
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const page = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`)
|
||||
.query({ limit: 1, ...(cursor ? { cursor } : {}) });
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.body.items).toHaveLength(1);
|
||||
seen.push(page.body.items[0].photoId);
|
||||
pages += 1;
|
||||
if (!page.body.hasMore) {
|
||||
expect(page.body.nextCursor).toBeNull();
|
||||
break;
|
||||
}
|
||||
expect(typeof page.body.nextCursor).toBe('string');
|
||||
cursor = page.body.nextCursor as string;
|
||||
}
|
||||
expect(pages).toBe(2);
|
||||
expect(new Set(seen).size).toBe(2);
|
||||
expect(new Set(seen)).toEqual(both);
|
||||
|
||||
// 条目 thumb 签名 URL 取回真实 JPEG 字节。
|
||||
const thumb = list.body.items[0].thumb as { url: string; expiresAt: string };
|
||||
expect(Date.parse(thumb.expiresAt)).toBeGreaterThan(Date.now());
|
||||
const fetched = await fetch(thumb.url);
|
||||
expect(fetched.status).toBe(200);
|
||||
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
|
||||
expect(Buffer.from(await fetched.arrayBuffer()).length).toBeGreaterThan(0);
|
||||
|
||||
// 单图深链 200。
|
||||
const single = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadB.photoId}`);
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body.photoId).toBe(uploadB.photoId);
|
||||
expect(single.body.categoryId).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('staffB 隐藏 photo1 → 公开列表剩 1、单图 404;outbox photo.hidden', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch(`/${API_PREFIX}/photos/${uploadA.photoId}`)
|
||||
.set('Authorization', `Bearer ${staffBToken}`)
|
||||
.send({ visibility: 'hidden' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
photoId: uploadA.photoId,
|
||||
visibility: 'hidden',
|
||||
});
|
||||
expect(
|
||||
(await prisma.photos.findUniqueOrThrow({ where: { id: uploadA.photoId } }))
|
||||
.entityVersion,
|
||||
).toBe(3); // published(2) → hidden(3)
|
||||
|
||||
const list = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`);
|
||||
expect(list.body.items).toHaveLength(1);
|
||||
expect(list.body.items[0].photoId).toBe(uploadB.photoId);
|
||||
|
||||
const single = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadA.photoId}`);
|
||||
expect(single.status).toBe(404);
|
||||
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||
|
||||
const event = await prisma.outbox_events.findFirstOrThrow({
|
||||
where: { photoId: uploadA.photoId, type: 'photo.hidden' },
|
||||
});
|
||||
expect(event.entityVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('owner 移除 staffB → 其上传/管理链即刻失效(A02 逐次校验)', async () => {
|
||||
const remove = await request(app.getHttpServer())
|
||||
.delete(`/${API_PREFIX}/albums/${albumId}/members/${staffBId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(remove.status).toBe(204);
|
||||
|
||||
// 会话仍有效,但成员行已删:init → 404 ALBUM_NOT_FOUND(非成员同码防枚举)。
|
||||
const init = await request(app.getHttpServer())
|
||||
.post(`/${API_PREFIX}/uploads`)
|
||||
.set('Authorization', `Bearer ${staffBToken}`)
|
||||
.send({
|
||||
albumId,
|
||||
clientTaskId: 'chain-after-removal',
|
||||
filename: 'show.jpg',
|
||||
bytes: 1000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(init.status).toBe(404);
|
||||
expect(init.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||
expect(
|
||||
await prisma.upload_sessions.count({
|
||||
where: { clientTaskId: 'chain-after-removal' },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// 历史上传亦不可再管理(上传者本人仍须是当时的有效成员)→ 404 防枚举。
|
||||
const manage = await request(app.getHttpServer())
|
||||
.patch(`/${API_PREFIX}/photos/${uploadA.photoId}`)
|
||||
.set('Authorization', `Bearer ${staffBToken}`)
|
||||
.send({ visibility: 'hidden' });
|
||||
expect(manage.status).toBe(404);
|
||||
expect(manage.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('admin 软删除 photo2 → 公开列表空、单图 404;outbox photo.deleted;行/对象保留', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.delete(`/${API_PREFIX}/photos/${uploadB.photoId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
const photo = await prisma.photos.findUniqueOrThrow({
|
||||
where: { id: uploadB.photoId },
|
||||
});
|
||||
expect(photo.visibility).toBe('deleted');
|
||||
expect(photo.entityVersion).toBe(3); // published(2) → deleted(3)
|
||||
// 软删除:行/对象保留(物理清除属 P4a)。
|
||||
expect(await storage.head(photo.objectKey)).not.toBeNull();
|
||||
|
||||
const list = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
expect(list.body.hasMore).toBe(false);
|
||||
|
||||
const single = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadB.photoId}`);
|
||||
expect(single.status).toBe(404);
|
||||
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||
|
||||
const event = await prisma.outbox_events.findFirstOrThrow({
|
||||
where: { photoId: uploadB.photoId, type: 'photo.deleted' },
|
||||
});
|
||||
expect(event.entityVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('audit 全程可查:动作齐全且 actor 正确(A01/A02 证据)', async () => {
|
||||
const rows = (action: string) =>
|
||||
prisma.audit_logs.findMany({ where: { action } });
|
||||
|
||||
const created = await rows('album.create');
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toMatchObject({
|
||||
actorId: (await prisma.users.findUniqueOrThrow({ where: { account: ADMIN_ACCOUNT } })).id,
|
||||
resourceId: albumId,
|
||||
});
|
||||
|
||||
const added = await rows('album.member.add');
|
||||
expect(added).toHaveLength(1);
|
||||
expect(added[0].actorId).toBe(created[0].actorId);
|
||||
expect(added[0].resourceId).toBe(albumId);
|
||||
|
||||
const removed = await rows('album.member.remove');
|
||||
expect(removed).toHaveLength(1);
|
||||
expect(removed[0]).toMatchObject({
|
||||
actorId: created[0].actorId,
|
||||
resourceId: albumId,
|
||||
});
|
||||
|
||||
const completes = await rows('upload.complete');
|
||||
expect(completes).toHaveLength(2); // 双 complete 幂等不落第二条
|
||||
for (const row of completes) {
|
||||
expect(row.actorId).toBe(staffBId);
|
||||
}
|
||||
expect(new Set(completes.map((r) => r.resourceId))).toEqual(
|
||||
new Set([uploadA.photoId, uploadB.photoId]),
|
||||
);
|
||||
|
||||
const hide = await rows('photo.hide');
|
||||
expect(hide).toHaveLength(1);
|
||||
expect(hide[0]).toMatchObject({
|
||||
actorId: staffBId,
|
||||
resourceId: uploadA.photoId,
|
||||
});
|
||||
|
||||
const del = await rows('photo.delete');
|
||||
expect(del).toHaveLength(1);
|
||||
expect(del[0]).toMatchObject({
|
||||
actorId: created[0].actorId,
|
||||
resourceId: uploadB.photoId,
|
||||
});
|
||||
});
|
||||
|
||||
it('admin 收尾操作正常:幂等重复删除 204 不再发事件;成员列表已无 staffB', async () => {
|
||||
const again = await request(app.getHttpServer())
|
||||
.delete(`/${API_PREFIX}/photos/${uploadB.photoId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(again.status).toBe(204);
|
||||
expect(
|
||||
await prisma.outbox_events.count({ where: { type: 'photo.deleted' } }),
|
||||
).toBe(1);
|
||||
|
||||
const members = await request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/albums/${albumId}/members`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(members.status).toBe(200);
|
||||
const ids = members.body.items.map((m: { userId: string }) => m.userId);
|
||||
expect(ids).not.toContain(staffBId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user