Files
photo-live/services/api/test/e2e.full-chain.e2e-spec.ts
T
浅念 96057ba207 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>
2026-09-18 00:03:35 +08:00

592 lines
22 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/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);
});
});