Files
photo-live/services/worker/test/watermark.e2e-spec.ts
T
2026-09-20 17:53:44 +08:00

469 lines
19 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/watermark.e2e-spec.ts —— 水印历史重生成 e2e(P4a Task 3,
// 先写后实现)。真实 MySQL(直种 album/photos/watermark_configs/watermark_jobs,
// 形状对齐 api WatermarkService.create 的落库结果)+ 真实 MinIO(原图/变体对象)
// + 真实 Redis(BullMQ)。分层驱动同 publish.e2e-spec:核心断言直驱
// WatermarkRegenService,一组真实入队用例覆盖 Worker 包装。覆盖:
// ① 重生成(enabled):逐张产水印变体(对象 + 行 version=configVersion)、
// progress→total、job done、**有效版本推进**(watermarkEffectiveVersion/
// watermarkEnabled + entityVersion+1)+ outbox album.updated(载荷水印位);
// ② A20:原图对象键/字节零触碰;browse 变体行不动;
// ③ 失败注入:单张原图被删 → failed=1、job 仍 done(部分成功)、失败张无水印行
// (cn 保留旧图=无行回落 browse)、旧图对象拷贝到目标版本键(ov 模式键
// 存在性保障——A20 失败照片保留旧图);
// ④ 关闭版重生成:水印行逐张删除(对象保留)、有效位 enabled=false、outbox
// 载荷 enabled=false + 新版本;
// ⑤ 任务级中止:配置行缺失 → state=failed + WATERMARK_CONFIG_MISSING、照片
// 零处理、有效位不动;
// ⑥ 真实 BullMQ Worker:api 生产者同参入队 → 消费 → done;幽灵投递 skipped。
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
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 { Prisma } from '@photo/db-cn';
import { QUEUE_WATERMARK, normalizeWatermarkConfig } from '@photo/contracts';
import {
createStorageFromEnv,
watermarkVariantObjectKey,
type MinioStorageAdapter,
type StoragePort,
} from '@photo/storage';
import {
WATERMARK_REGEN_JOB_NAME,
WATERMARK_JOB_ATTEMPTS,
WATERMARK_JOB_BACKOFF,
createWatermarkRegenWorker,
type WatermarkRegenJobData,
type WatermarkRegenWorkerOptions,
} from '../src/watermark.processor';
import {
WatermarkRegenService,
WATERMARK_ERROR_CONFIG_MISSING,
type WatermarkJobOutcome,
} from '../src/watermark.service';
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));
}
}
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> {
const svg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
Array.from(
{ length: 12 },
(_, i) =>
`<rect x="${(i * 83) % width}" y="${(i * 47) % height}" width="${50 + i * 9}" height="${35 + i * 7}" fill="#${(0x50 + i * 7).toString(16).padStart(2, '0')}6030" fill-opacity="0.85"/>`,
).join('') +
'</svg>',
);
return sharp(svg).jpeg({ quality: 85 }).toBuffer();
}
const KEY_ROOT = 'photo-live/dev';
/** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */
async function readObject(storage: StoragePort, key: string): Promise<Buffer> {
const stream = (await storage.getStream(key)) as Readable;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
return Buffer.concat(chunks);
}
/** S3 协议直删单对象(StoragePort 无删除——YAGNI,仅测试隔离/注入用)。 */
async function deleteObject(key: string): Promise<void> {
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
try {
await client.send(new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }));
} finally {
await client.destroy();
}
}
describe('watermark regen (e2e)', () => {
let prisma: PrismaClient;
let storage: MinioStorageAdapter;
let service: WatermarkRegenService;
let queue: Queue;
let queueConnection: IORedis;
/** 直种 album(水印位可调,形状对齐 api create 提交后的状态)。 */
const seedAlbum = async (
alias: string,
watermark: { version: number; effective: number; enabled: boolean },
): Promise<string> => {
const suffix = randomBytes(4).toString('hex');
return (
await prisma.albums.create({
data: {
publicToken: `pt-${alias}-${suffix}`,
ownerId: `u-${alias}`,
startAt: new Date(),
defaultLocale: 'zh-CN',
watermarkVersion: watermark.version,
watermarkEffectiveVersion: watermark.effective,
watermarkEnabled: watermark.enabled,
},
select: { id: true },
})
).id;
};
/** 直种已发布照片(原图对象 + browse/thumb 变体行与对象——发布后形态)。 */
const seedPublishedPhoto = async (
albumId: string,
alias: string,
options: { withOriginalObject?: boolean } = {},
): Promise<{
photoId: string;
objectKey: string;
browseKey: string;
original: Buffer;
browse: Buffer;
}> => {
const photoId = cuidLike(alias);
const objectKey = `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`;
const browseKey = `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/browse.jpg`;
const thumbKey = `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/thumb.jpg`;
const original = await makeJpeg(900, 600);
if (options.withOriginalObject !== false) {
await storage.put(objectKey, original, 'image/jpeg');
}
const browse = await sharp(original)
.resize({ width: 1920, withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer();
const thumb = await sharp(original)
.resize({ width: 480, withoutEnlargement: true })
.jpeg({ quality: 75 })
.toBuffer();
await storage.put(browseKey, browse, 'image/jpeg');
await storage.put(thumbKey, thumb, 'image/jpeg');
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-${alias}`,
objectKey,
bytes: original.length,
processingState: 'ready',
visibility: 'published',
publishedAt: new Date(),
width: 900,
height: 600,
},
});
await prisma.photo_variants.createMany({
data: [
{ photoId, variant: 'thumb', objectKey: thumbKey, bytes: thumb.length, version: 1, width: 480, height: 320 },
{ photoId, variant: 'browse', objectKey: browseKey, bytes: browse.length, version: 1, width: 900, height: 600 },
],
});
return { photoId, objectKey, browseKey, original, browse };
};
/** 直种配置行 + 任务行(形状对齐 api WatermarkService.create 落库结果)。 */
const seedWatermarkJob = async (
albumId: string,
configVersion: number,
enabled: boolean,
total: number,
): Promise<string> => {
await prisma.watermark_configs.create({
data: {
albumId,
configVersion,
config: normalizeWatermarkConfig({
enabled,
...(enabled ? { text: { content: `WM v${configVersion}` } } : {}),
}) as unknown as Prisma.InputJsonValue,
},
});
return (
await prisma.watermark_jobs.create({
data: { albumId, configVersion, state: 'pending', total },
select: { id: true },
})
).id;
};
beforeAll(async () => {
await resetDb();
prisma = new PrismaClient();
storage = createStorageFromEnv();
service = new WatermarkRegenService(prisma, storage);
queueConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queueConnection.on('error', () => undefined);
queue = new Queue(QUEUE_WATERMARK, { connection: queueConnection });
});
afterAll(async () => {
await queue.obliterate({ force: true }).catch(() => undefined);
await queue.close().catch(() => undefined);
await queueConnection.quit().catch(() => undefined);
await prisma.$disconnect();
});
test('重生成(enabled):变体/进度/有效位推进/outbox,A20 原图与 browse 零触碰', async () => {
const albumId = await seedAlbum('wm1', { version: 1, effective: 0, enabled: false });
const photos = [
await seedPublishedPhoto(albumId, 'wm1a'),
await seedPublishedPhoto(albumId, 'wm1b'),
await seedPublishedPhoto(albumId, 'wm1c'),
];
const jobId = await seedWatermarkJob(albumId, 1, true, photos.length);
const outcome = await service.processJob(jobId);
expect(outcome).toBe('done');
const job = await prisma.watermark_jobs.findUniqueOrThrow({ where: { id: jobId } });
expect(job.state).toBe('done');
expect(job.progress).toBe(3);
expect(job.total).toBe(3);
expect(job.failed).toBe(0);
expect(job.errorCode).toBeNull();
// 逐张:水印行(version=1)+ 对象存在且为非空 jpeg;与 browse 字节不同。
for (const { photoId, objectKey, original } of photos) {
const row = await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId, variant: 'watermark' } },
});
expect(row?.version).toBe(1);
expect(row?.objectKey).toBe(watermarkVariantObjectKey(albumId, photoId, 1));
const wmObject = await readObject(storage, row!.objectKey);
expect(wmObject.length).toBeGreaterThan(0);
expect((await sharp(wmObject).metadata()).format).toBe('jpeg');
const browseRow = await prisma.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId, variant: 'browse' } },
});
expect(browseRow.version).toBe(1); // browse 变体行零触碰
expect(wmObject.equals(await readObject(storage, browseRow.objectKey))).toBe(false);
// A20:originals 键与字节零触碰。
expect(await readObject(storage, objectKey)).toEqual(original);
}
// 有效位推进 + 实体版本 +1。
const album = await prisma.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEffectiveVersion).toBe(1);
expect(album.watermarkEnabled).toBe(true);
expect(album.watermarkVersion).toBe(1);
expect(album.entityVersion).toBe(2); // 种子 1 → 推进后 2
// outbox album.updated:载荷水印位 = 推进后的有效状态。
const event = await prisma.outbox_events.findFirstOrThrow({
where: { albumId, type: 'album.updated' },
});
expect(event.entityVersion).toBe(2);
expect(event.priority).toBe('normal');
expect(event.payload).toMatchObject({
watermarkEnabled: true,
watermarkConfigVersion: 1,
});
});
test('失败注入:单张原图缺失 → failed=1 仍 done,失败张无行 + 旧图拷贝到目标键', async () => {
const albumId = await seedAlbum('wm2', { version: 1, effective: 0, enabled: false });
const ok = await seedPublishedPhoto(albumId, 'wm2a');
const broken = await seedPublishedPhoto(albumId, 'wm2b');
await deleteObject(broken.objectKey); // 模拟历史原图对象丢失(DB 行保留)
expect(await storage.head(broken.objectKey)).toBeNull();
const jobId = await seedWatermarkJob(albumId, 1, true, 2);
const outcome = await service.processJob(jobId);
expect(outcome).toBe('done');
const job = await prisma.watermark_jobs.findUniqueOrThrow({ where: { id: jobId } });
expect(job.state).toBe('done');
expect(job.failed).toBe(1);
expect(job.progress).toBe(2);
// 成功张:水印行/对象齐备。
const okRow = await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId: ok.photoId, variant: 'watermark' } },
});
expect(okRow?.version).toBe(1);
// 失败张:cn 无水印行(保留旧图=该张此前无水印,回落 browse);ov 模式键
// 保障——旧图(browse 对象)已拷贝到目标版本键,字节一致。
const brokenRow = await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId: broken.photoId, variant: 'watermark' } },
});
expect(brokenRow).toBeNull();
const fallbackKey = watermarkVariantObjectKey(albumId, broken.photoId, 1);
const fallbackObject = await readObject(storage, fallbackKey);
expect(fallbackObject.equals(broken.browse)).toBe(true);
// 部分成功仍推进有效位(done 即切换;失败张 ov 拿旧图、cn 回落 browse)。
const album = await prisma.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEffectiveVersion).toBe(1);
expect(album.watermarkEnabled).toBe(true);
});
test('关闭版重生成:水印行删除(对象保留)、有效位 enabled=false、outbox 同步', async () => {
const albumId = await seedAlbum('wm3', { version: 2, effective: 0, enabled: false });
const photo = await seedPublishedPhoto(albumId, 'wm3a');
// 前置:v1 开启版先行生效(行在 v1 键、有效位推进)。
expect(await service.processJob(await seedWatermarkJob(albumId, 1, true, 1))).toBe('done');
const v1Key = watermarkVariantObjectKey(albumId, photo.photoId, 1);
const v1Row = await prisma.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId: photo.photoId, variant: 'watermark' } },
});
expect(v1Row.objectKey).toBe(v1Key);
// 关闭版(v2 enabled=false)重生成:删行,对象保留。
const jobId = await seedWatermarkJob(albumId, 2, false, 1);
expect(await service.processJob(jobId)).toBe('done');
expect(
await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId: photo.photoId, variant: 'watermark' } },
}),
).toBeNull();
expect(await storage.head(v1Key)).not.toBeNull(); // 对象保留(历史可溯)
const album = await prisma.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEffectiveVersion).toBe(2);
expect(album.watermarkEnabled).toBe(false);
expect(album.watermarkVersion).toBe(2);
const events = await prisma.outbox_events.findMany({
where: { albumId, type: 'album.updated' },
orderBy: { entityVersion: 'asc' },
});
expect(events.at(-1)?.payload).toMatchObject({
watermarkEnabled: false,
watermarkConfigVersion: 2,
});
});
test('任务级中止:配置行缺失 → failed + WATERMARK_CONFIG_MISSING,零处理零推进', async () => {
const albumId = await seedAlbum('wm4', { version: 1, effective: 0, enabled: false });
const photo = await seedPublishedPhoto(albumId, 'wm4a');
// 不种 watermark_configs —— 任务指向的版本无配置。
const jobId = (
await prisma.watermark_jobs.create({
data: { albumId, configVersion: 1, state: 'pending', total: 1 },
select: { id: true },
})
).id;
expect(await service.processJob(jobId)).toBe('failed');
const job = await prisma.watermark_jobs.findUniqueOrThrow({ where: { id: jobId } });
expect(job.state).toBe('failed');
expect(job.errorCode).toBe(WATERMARK_ERROR_CONFIG_MISSING);
expect(job.progress).toBe(0);
expect(
await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId: photo.photoId, variant: 'watermark' } },
}),
).toBeNull();
const album = await prisma.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEffectiveVersion).toBe(0);
expect(album.watermarkEnabled).toBe(false);
expect(album.entityVersion).toBe(1); // 未推进
});
describe('真实 BullMQ Worker', () => {
let worker: Worker<WatermarkRegenJobData, WatermarkJobOutcome, string>;
let workerConnection: IORedis;
beforeAll(async () => {
workerConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
workerConnection.on('error', () => undefined);
const options: WatermarkRegenWorkerOptions = {
prisma,
storage,
connection: workerConnection,
logger: console as unknown as Logger,
};
worker = createWatermarkRegenWorker(options);
});
afterAll(async () => {
await worker.close();
await workerConnection.quit().catch(() => undefined);
});
/** 与 api 生产者同参入队(jobId=行 id 去重 + attempts:3 指数退避 1s 起)。 */
const enqueue = (jobId: string) =>
queue.add(WATERMARK_REGEN_JOB_NAME, { jobId }, {
jobId,
attempts: WATERMARK_JOB_ATTEMPTS,
backoff: WATERMARK_JOB_BACKOFF,
});
test('真实入队 → 消费 → done(有效位推进)', async () => {
const albumId = await seedAlbum('wm5', { version: 1, effective: 0, enabled: false });
const photo = await seedPublishedPhoto(albumId, 'wm5a');
const jobId = await seedWatermarkJob(albumId, 1, true, 1);
await enqueue(jobId);
await waitFor(async () =>
(await prisma.watermark_jobs.findUnique({ where: { id: jobId } }))?.state === 'done',
);
const job = await prisma.watermark_jobs.findUniqueOrThrow({ where: { id: jobId } });
expect(job.progress).toBe(1);
expect(job.failed).toBe(0);
const row = await prisma.photo_variants.findUnique({
where: { photoId_variant: { photoId: photo.photoId, variant: 'watermark' } },
});
expect(row?.version).toBe(1);
expect(
await storage.head(watermarkVariantObjectKey(albumId, photo.photoId, 1)),
).not.toBeNull();
const album = await prisma.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEnabled).toBe(true);
expect(album.watermarkEffectiveVersion).toBe(1);
});
test('行不存在的幽灵投递 → skipped,不产生任何行/对象痕迹', async () => {
const rowsBefore = await prisma.watermark_jobs.count();
const ghost = cuidLike('ghost');
await enqueue(ghost);
await waitFor(async () => {
const job = await queue.getJob(ghost);
return job?.returnvalue === 'skipped';
});
expect(await prisma.watermark_jobs.count()).toBe(rowsBefore);
});
});
});