Files
photo-live/services/worker/test/publish.e2e-spec.ts
T
浅念 0fcf18cc5c feat(ov): public read api on replicas with hot sorting
P2 Task 6(ov 公开读 API + viewer 切换):
- ov 装配 PublicOvModule(仅 app-ov.module):/public/albums/{token}、
  /photos、/photos/{id} 三端点同契约读 ov 副本——translations/categories
  自 JSON 列解析(防御性过滤),banner 经 ov 侧 STORAGE_PORT_OV 预签名
  (同本地 MinIO;只读属约定,真实 OSS 只读凭证 P5),viewCount 取
  album_stats 权威累计(无行默认 0;计数端点 Task 8 接线)
- 排序:latest=发布时间 keyset(shared/cursor.ts 同 cn);hot=likeTotal
  desc + photoId desc 决胜,顺序按 `${albumId}|${category}` 短期快照
  (内存 TTL 默认 30s,HOT_SORT_TTL_MS 惰性读取)——计数逐请求现读、
  顺序冻结窗口内一致,偏移游标(base64url 整数)在快照内严格稳定;
  非法 sort 400 VALIDATION_FAILED(cn 不感知该参数)
- 契约:viewCount 放宽为 integer|null(cn null、ov 权威值);
  listPublicPhotos 增 sort 枚举(latest|hot,缺省 latest);
  PhotoPublishedPayload 增 categoryId——补上 Tasks 3/4 管线缺口
  (payloads/publish.service/sync-inbox 投影 + 关联测试随改)
- e2e(services/api/test/public-ov.e2e-spec.ts,7 用例):双入口同进程
  起 cn+ov,cn API 种子→真实发送管线投递;断言相册/列表/单图与 cn
  深等(仅签名 URL 剔除)、banner 真字节、参数矩阵 400/404、hot 排序
  TTL 内快照(计数新/顺序旧)+TTL 过期翻转+偏移翻页、隐藏/下线/紧急
  停用逐态撤下
- viewer 切 ov:API 基址 VITE_API_BASE(缺省 http://127.0.0.1:8081,
  空串=同源网关;Vite 只读 apps/viewer/.env*,根 .env 对前端无效),
  移除 /v1 dev 代理(CORS_ORIGINS 增 127.0.0.1:5175 直连两形);
  playwright webServer 增 dev:ov;e2e 夹具 flushSync(albumId)=cn
  bootstrap 快照 + deliverPendingOnce(sender 新导出)经真实管线投影
  种子,deleteSeededPhoto 改软删让墓碑可同步;14 个 Playwright 全绿
  (journey 管理/staffB 腿仍走 cn 8080)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:05:56 +08:00

426 lines
17 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/worker/test/publish.e2e-spec.ts —— Task 21 发布事务/补偿扫描 e2e(先写后实现)。
// 真实 MySQL(Prisma 直种 album/photos/photo_jobs)+ 真实 MinIO(storage.put 真
// JPEG 原图,变体 head 复核)+ 真实 Redis(BullMQ)。
// 分层驱动以规避 BullMQ 时序抖动:核心断言直接调 PublishService/sweepOnce;
// Worker 包装经一组真实入队用例覆盖(happy / 终败 / 跳过)。
// 覆盖:
// ① 发布事务:变体对象 + photo_variants(2) + photos(ready/published/尺寸/
// entityVersion+1) + outbox(photo.published,payload=契约字段全集) 单事务一致;
// ② 重复处理幂等:无二次变体/事件,entityVersion 不再自增;
// ③ 状态不可处理(pending_upload)→ 跳过;
// ④ sweepOnce:到期 pending(nextRetryAt 过期 / NULL)认领入队,done 不再认领,
// 僵尸 active(updatedAt>5min)重入队;
// ⑤ 真实 Worker:happy 路径 done;坏文件 3 次指数退避后 photo processing_failed
// (visibility 仍 unpublished)+ job failed(attempt=3);pending_upload 跳过置 done。
import { randomBytes } from 'node:crypto';
import { Queue, type Worker } from 'bullmq';
import IORedis from 'ioredis';
import type { Logger } from 'pino';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import type { PhotoPublishedPayload } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import {
PROCESS_JOB_ATTEMPTS,
PROCESS_JOB_BACKOFF,
PHOTO_PROCESS_JOB_NAME,
createPhotoProcessWorker,
photoProcessJobId,
type PhotoProcessJobData,
type ProcessOutcome,
} from '../src/process.processor';
import { PublishService } from '../src/publish.service';
import { sweepOnce } from '../src/sweep';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 轮询等待:e2e 对 BullMQ 异步路径的断言前置。 */
async function waitFor(
check: () => Promise<boolean>,
timeoutMs = 30_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));
}
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 测试内生成真实 JPEG(无二进制入库)。 */
async function makeJpeg(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 180, g: 120, b: 60 } },
})
.jpeg({ quality: 85 })
.toBuffer();
}
describe('publish (e2e)', () => {
let prisma: PrismaClient;
let storage: MinioStorageAdapter;
let publish: PublishService;
let queue: Queue;
let queueConnection: IORedis;
const KEY_ROOT = 'photo-live/dev';
interface SeedResult {
albumId: string;
photoId: string;
objectKey: string;
}
/** 直种 album + photo(+可选原图对象) + photo_jobs(pending)。 */
async function seedPhoto(
alias: string,
options: {
state?: string;
jobState?: string;
nextRetryAt?: Date | null;
updatedAt?: Date;
original?: Buffer | null; // null = 不写对象(或调用方自写)
} = {},
): Promise<SeedResult> {
const suffix = randomBytes(4).toString('hex');
const albumId = (
await prisma.albums.create({
data: {
publicToken: `pt-${alias}-${suffix}`,
ownerId: `u-${alias}`,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
})
).id;
const photoId = cuidLike(alias);
const objectKey = `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`;
const original =
options.original === undefined
? await makeJpeg(800, 500)
: options.original;
if (original !== null) {
await storage.put(objectKey, original, 'image/jpeg');
}
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-${alias}`,
objectKey,
bytes: original?.length ?? 0,
processingState: options.state ?? 'uploaded',
},
});
await prisma.photo_jobs.create({
data: {
photoId,
type: PHOTO_PROCESS_JOB_NAME,
state: options.jobState ?? 'pending',
nextRetryAt: options.nextRetryAt ?? null,
...(options.updatedAt !== undefined ? { updatedAt: options.updatedAt } : {}),
},
});
return { albumId, photoId, objectKey };
}
/** 变体对象键(与 src/publish.service.ts 的 variantsObjectKey 同布局的断言字面量)。 */
const variantKey = (albumId: string, photoId: string, variant: string) =>
`${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
beforeAll(async () => {
await resetDb();
prisma = new PrismaClient();
storage = createStorageFromEnv();
publish = new PublishService(prisma, storage);
queueConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queueConnection.on('error', () => undefined); // 测试环境不崩进程
queue = new Queue('photo-process', { connection: queueConnection });
});
afterAll(async () => {
await queue.obliterate({ force: true }).catch(() => undefined);
await queue.close().catch(() => undefined);
await queueConnection.quit().catch(() => undefined);
await prisma.$disconnect();
});
describe('PublishService.processPhoto(直接驱动)', () => {
test('发布事务:变体上传 + 三表单事务一致(photos/photo_variants/outbox)', async () => {
const { albumId, photoId, objectKey } = await seedPhoto('pub1');
const outcome = await publish.processPhoto(photoId);
expect(outcome).toBe('published');
// MinIO:thumb/browse 真实存在(head 复核,jpeg + 非零字节)。
const thumbHead = await storage.head(variantKey(albumId, photoId, 'thumb'));
const browseHead = await storage.head(variantKey(albumId, photoId, 'browse'));
expect(thumbHead).not.toBeNull();
expect(browseHead).not.toBeNull();
expect(thumbHead?.contentType).toBe('image/jpeg');
expect(thumbHead?.bytes).toBeGreaterThan(0);
expect(browseHead?.bytes).toBeGreaterThan(0);
// photos:ready/published/publishedAt/尺寸/entityVersion 1→2。
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('ready');
expect(photo.visibility).toBe('published');
expect(photo.publishedAt).not.toBeNull();
expect(photo.width).toBe(800);
expect(photo.height).toBe(500);
expect(photo.entityVersion).toBe(2);
expect(photo.errorCode).toBeNull();
// photo_variants:两行,键/尺寸/版本一致(800x500 → thumb 480x300,
// browse 不放大 800x500)。
const variants = await prisma.photo_variants.findMany({
where: { photoId },
orderBy: { variant: 'asc' }, // browse < thumb(字典序)
});
expect(variants).toHaveLength(2);
const [browseRow, thumbRow] = variants;
expect(thumbRow).toMatchObject({
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
version: 1,
width: 480,
height: 300,
});
expect(thumbRow.bytes).toBe(thumbHead?.bytes);
expect(browseRow).toMatchObject({
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
version: 1,
width: 800,
height: 500,
});
expect(browseRow.bytes).toBe(browseHead?.bytes);
// outbox:类型/entityVersion 对齐 photo,payload = 契约 PhotoPublishedPayload 全集。
const event = await prisma.outbox_events.findFirstOrThrow({
where: { photoId },
});
expect(event.type).toBe('photo.published');
expect(event.albumId).toBe(albumId);
expect(event.entityVersion).toBe(2);
expect(event.occurredAt.getTime()).toBe(photo.publishedAt?.getTime());
const expectedPayload: PhotoPublishedPayload = {
photoId,
albumId,
categoryId: null, // 本 spec 种子未分类(categoryId 随载荷同步到 ov 副本)
objectKey,
thumbKey: variantKey(albumId, photoId, 'thumb'),
browseKey: variantKey(albumId, photoId, 'browse'),
width: 800,
height: 500,
publishedAt: (photo.publishedAt as Date).toISOString(),
entityVersion: 2,
};
expect(event.payload).toEqual(expectedPayload);
// 全库仅此一条事件(无重复/孤儿)。
expect(await prisma.outbox_events.count()).toBe(1);
});
test('重复处理幂等:无二次变体/事件,entityVersion 不再自增', async () => {
const { photoId } = await seedPhoto('pub2');
expect(await publish.processPhoto(photoId)).toBe('published');
const before = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
const again = await publish.processPhoto(photoId);
expect(again).toBe('already');
const after = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(after.entityVersion).toBe(before.entityVersion);
expect(after.publishedAt?.getTime()).toBe(before.publishedAt?.getTime());
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(2);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(1);
});
test('状态不可处理(pending_upload)→ 跳过,不留任何处理痕迹', async () => {
const { photoId } = await seedPhoto('pub3', { state: 'pending_upload' });
expect(await publish.processPhoto(photoId)).toBe('skipped');
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('pending_upload');
expect(photo.visibility).toBe('unpublished');
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
});
describe('sweepOnce(直接驱动,无 Worker 在跑)', () => {
test('认领到期 pending(过期/NULL)并重排,done 不认领,僵尸 active 重排', async () => {
// 前置:直驱用例留下的 pending job 行(照片其实已处理)先置 done——
// 既贴近真实(api 侧 job 随处理闭环),也让本用例的认领计数可精确断言。
await prisma.photo_jobs.updateMany({
where: { state: 'pending' },
data: { state: 'done' },
});
const past = await seedPhoto('sw-a', {
state: 'pending_upload',
nextRetryAt: new Date(Date.now() - 60_000),
}); // ① nextRetryAt 过期
const nullRetry = await seedPhoto('sw-b', { state: 'pending_upload' }); // ② NULL
const done = await seedPhoto('sw-c', {
state: 'pending_upload',
jobState: 'done',
}); // done:不再认领
const stale = await seedPhoto('sw-d', {
state: 'pending_upload',
jobState: 'active',
updatedAt: new Date(Date.now() - 6 * 60_000),
}); // 僵尸 active(>5min)
const result = await sweepOnce({ prisma, queue });
expect(result.claimed).toBe(2);
expect(result.enqueueFailures).toBe(0);
expect(result.staleReEnqueued).toBe(1);
// DB:pending→active(过期与 NULL 都认领);done 不动;active 保持。
const jobs = await prisma.photo_jobs.findMany({
where: { photoId: { in: [past.photoId, nullRetry.photoId, done.photoId, stale.photoId] } },
});
const stateOf = (photoId: string) =>
jobs.find((j) => j.photoId === photoId)?.state;
expect(stateOf(past.photoId)).toBe('active');
expect(stateOf(nullRetry.photoId)).toBe('active');
expect(stateOf(done.photoId)).toBe('done');
expect(stateOf(stale.photoId)).toBe('active');
// BullMQ:认领/重排的 jobId 已入队;done 的 jobId 不存在。
expect(await queue.getJob(photoProcessJobId(past.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(nullRetry.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(stale.photoId))).toBeDefined();
expect(await queue.getJob(photoProcessJobId(done.photoId))).toBeUndefined();
// 清空队列,后续真实 Worker 用例从干净状态开始。
await queue.obliterate({ force: true });
});
});
describe('真实 BullMQ Worker', () => {
let worker: Worker<PhotoProcessJobData, ProcessOutcome, string>;
let workerConnection: IORedis;
const logger = console as unknown as Logger; // e2e 直观日志
beforeAll(async () => {
workerConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
workerConnection.on('error', () => undefined);
worker = createPhotoProcessWorker({
prisma,
storage,
connection: workerConnection,
logger,
});
});
afterAll(async () => {
await worker.close();
await workerConnection.quit().catch(() => undefined);
});
/** 与 api 生产者同参入队(jobId 去重 + attempts:3 指数退避 1s 起)。 */
const enqueue = (photoId: string) =>
queue.add(
PHOTO_PROCESS_JOB_NAME,
{ photoId },
{
jobId: photoProcessJobId(photoId),
attempts: PROCESS_JOB_ATTEMPTS,
backoff: PROCESS_JOB_BACKOFF,
},
);
test('happy:真实入队 → 处理 → 发布事务 + photo_jobs done', async () => {
const { albumId, photoId } = await seedPhoto('wk-ok');
await enqueue(photoId);
await waitFor(async () =>
(await prisma.photos.findUnique({ where: { id: photoId } }))?.processingState === 'ready',
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.visibility).toBe('published');
expect(photo.entityVersion).toBe(2);
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(2);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(1);
expect(
await storage.head(variantKey(albumId, photoId, 'thumb')),
).not.toBeNull();
const job = await prisma.photo_jobs.findUniqueOrThrow({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
});
expect(job.state).toBe('done');
expect(job.attempt).toBe(1);
});
test('坏文件:3 次退避重试后终败,photo processing_failed 且不公开', async () => {
const corrupt = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
randomBytes(8192),
]);
const { photoId } = await seedPhoto('wk-bad', { original: corrupt });
await enqueue(photoId);
await waitFor(
async () =>
(await prisma.photo_jobs.findUnique({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
}))?.state === 'failed',
60_000,
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('processing_failed');
expect(photo.errorCode).toBe('DECODE_FAILED');
expect(photo.visibility).toBe('unpublished'); // 失败不公开
expect(photo.publishedAt).toBeNull();
const job = await prisma.photo_jobs.findUniqueOrThrow({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
});
expect(job.state).toBe('failed');
expect(job.errorCode).toBe('DECODE_FAILED');
expect(job.attempt).toBe(3);
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
test('pending_upload 跳过:job 置 done,照片零处理', async () => {
const { photoId } = await seedPhoto('wk-skip', { state: 'pending_upload' });
await enqueue(photoId);
await waitFor(
async () =>
(await prisma.photo_jobs.findUnique({
where: { photoId_type: { photoId, type: PHOTO_PROCESS_JOB_NAME } },
}))?.state === 'done',
);
const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.processingState).toBe('pending_upload');
expect(photo.visibility).toBe('unpublished');
expect(await prisma.photo_variants.count({ where: { photoId } })).toBe(0);
expect(await prisma.outbox_events.count({ where: { photoId } })).toBe(0);
});
});
});