2026-09-17 23:21:57 +08:00
|
|
|
|
// 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,
|
2026-09-18 17:05:56 +08:00
|
|
|
|
categoryId: null, // 本 spec 种子未分类(categoryId 随载荷同步到 ov 副本)
|
2026-09-17 23:21:57 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|