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:
浅念
2026-09-18 00:03:35 +08:00
parent 6d61d117d5
commit 96057ba207
@@ -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);
});
});