688 lines
27 KiB
TypeScript
688 lines
27 KiB
TypeScript
|
|
// services/api/test/watermark.e2e-spec.ts —— P4a Task 3 水印版本化 + 历史重生成
|
|||
|
|
// 全链路 e2e。ONE spec 串完整业务流(cn API → worker 重生成(直驱,BullMQ 消费
|
|||
|
|
// 由 worker/test/watermark.e2e-spec.ts 覆盖)→ cn/ov 公开侧服务 → ov 副本传播)。
|
|||
|
|
// 进程内起两个真实入口(cn + ov,同 public-ov.e2e-spec 模式:OutboxSender 指向
|
|||
|
|
// ov 真实 URL,ov 读到的数据与生产链路同源)。覆盖(任务验收):
|
|||
|
|
// ① POST watermark-jobs(enabled):202 {jobId, configVersion};GET 状态形状
|
|||
|
|
// (current=最新配置缺省填齐 / jobs 创建时间倒序 / pending+total);
|
|||
|
|
// ② 新照片发布即套用最新版(POST 后、任务 done 前发布 → watermark 变体对象
|
|||
|
|
// 已产且与 browse 字节不同——契约 WatermarkJobCreated 语义);
|
|||
|
|
// ③ 历史重生成(3 张):直驱 processJob → done(progress=total=3, failed=0)
|
|||
|
|
// → cn 公开列表 browse 现签 URL 取回**水印字节**(≠ browse 变体对象);
|
|||
|
|
// ④ A20:原图 objectKey 与字节全程零触碰;
|
|||
|
|
// ⑤ ov 传播:drainSender 后 albums_ov.watermarkEnabled/ConfigVersion 就位;
|
|||
|
|
// ov 公开列表 browse URL = 模式键 variants/{photoId}/{v}/watermark.jpg,
|
|||
|
|
// 取回字节与 cn 水印对象一致;
|
|||
|
|
// ⑥ 失败注入:删 1 张原图 → 新版本任务 done 且 failed=1;失败张 cn 保留旧版
|
|||
|
|
// 水印(行未覆盖)、ov 模式键 = 旧图拷贝(同字节——失败照片保留旧图);
|
|||
|
|
// 恢复原图后 re-POST → failed=0 全量收敛;
|
|||
|
|
// ⑦ 关闭版重生成:done 后 cn/ov 公开列表回落 browse 键;水印行删除、对象
|
|||
|
|
// 保留(历史可溯);
|
|||
|
|
// ⑧ 空相册:POST 即建即 done(无照片可重生成),有效位当场推进并同步 ov;
|
|||
|
|
// ⑨ 权限:协作者 POST/GET → 403;圈外人 → 404;admin → 202/200;
|
|||
|
|
// ⑩ 校验:enabled=true 缺 text.content → 400;opacity/scalePercent/fontSize
|
|||
|
|
// 越界、position 非法 → 400。
|
|||
|
|
import { randomBytes } from 'node:crypto';
|
|||
|
|
import { Readable } from 'node:stream';
|
|||
|
|
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|||
|
|
import { NestFactory } from '@nestjs/core';
|
|||
|
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
|||
|
|
import request from 'supertest';
|
|||
|
|
import sharp from 'sharp';
|
|||
|
|
import { PrismaClient } from '@photo/db-cn';
|
|||
|
|
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
|||
|
|
import { ErrorCodes } from '@photo/contracts';
|
|||
|
|
import {
|
|||
|
|
createStorageFromEnv,
|
|||
|
|
watermarkVariantObjectKey,
|
|||
|
|
type MinioStorageAdapter,
|
|||
|
|
type StoragePort,
|
|||
|
|
} from '@photo/storage';
|
|||
|
|
import { AppCnModule } from '../src/app-cn.module';
|
|||
|
|
import { AppOvModule } from '../src/app-ov.module';
|
|||
|
|
import { hashSessionToken } from '../src/auth/session.guard';
|
|||
|
|
import { configureApp } from '../src/shared/configure-app';
|
|||
|
|
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
|||
|
|
import { PublishService } from '../../worker/src/publish.service';
|
|||
|
|
import {
|
|||
|
|
WatermarkRegenService,
|
|||
|
|
} from '../../worker/src/watermark.service';
|
|||
|
|
import { OutboxSender } from '../../sync/src/sender';
|
|||
|
|
import { resetDb } from './reset';
|
|||
|
|
import { resetOvDb } from './reset-ov';
|
|||
|
|
|
|||
|
|
jest.setTimeout(180_000);
|
|||
|
|
|
|||
|
|
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
|||
|
|
|
|||
|
|
/** 测试内生成真实 JPEG(带纹理,水印差异可观测;无二进制入库)。 */
|
|||
|
|
async function makeJpeg(
|
|||
|
|
width: number,
|
|||
|
|
height: number,
|
|||
|
|
tint: number,
|
|||
|
|
): Promise<Buffer> {
|
|||
|
|
const svg = Buffer.from(
|
|||
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
|
|||
|
|
Array.from(
|
|||
|
|
{ length: 10 },
|
|||
|
|
(_, i) =>
|
|||
|
|
`<rect x="${(i * 79) % width}" y="${(i * 43) % height}" width="${45 + i * 8}" height="${32 + i * 6}" fill="#${(tint + i * 11).toString(16).padStart(2, '0')}7040" fill-opacity="0.85"/>`,
|
|||
|
|
).join('') +
|
|||
|
|
'</svg>',
|
|||
|
|
);
|
|||
|
|
return sharp(svg).jpeg({ quality: 85 }).toBuffer();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 流式读取对象为单缓冲(与 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 (e2e)', () => {
|
|||
|
|
let cnApp: NestExpressApplication;
|
|||
|
|
let ovApp: NestExpressApplication;
|
|||
|
|
let cnBase: string;
|
|||
|
|
let ovBase: string;
|
|||
|
|
let prismaCn: PrismaClient;
|
|||
|
|
let prismaOv: OvPrismaClient;
|
|||
|
|
let storage: MinioStorageAdapter;
|
|||
|
|
let sender: OutboxSender;
|
|||
|
|
let regen: WatermarkRegenService;
|
|||
|
|
let publish: PublishService;
|
|||
|
|
|
|||
|
|
let ownerId: string;
|
|||
|
|
let ownerToken: string;
|
|||
|
|
let collabToken: string;
|
|||
|
|
let outsiderToken: string;
|
|||
|
|
let adminToken: string;
|
|||
|
|
|
|||
|
|
let albumId: string;
|
|||
|
|
let publicToken: string;
|
|||
|
|
/** 上传顺序的三张照片(v1 重生成目标)。 */
|
|||
|
|
let photoIds: string[];
|
|||
|
|
let originals: Map<string, Buffer>;
|
|||
|
|
let photoIdOfFailed: string;
|
|||
|
|
|
|||
|
|
/** 排空发送器:循环 runOnce 直至无未投递事件(同 public-ov spec)。 */
|
|||
|
|
async function drainSender(): Promise<void> {
|
|||
|
|
for (let round = 0; round < 20; round++) {
|
|||
|
|
const stats = await sender.runOnce();
|
|||
|
|
if (stats.picked === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
|||
|
|
async function uploadAndPublish(clientTaskId: string, jpeg: Buffer): Promise<string> {
|
|||
|
|
const init = await request(cnBase)
|
|||
|
|
.post('/v1/uploads')
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|||
|
|
.send({
|
|||
|
|
albumId,
|
|||
|
|
clientTaskId,
|
|||
|
|
filename: 'wm.jpg',
|
|||
|
|
bytes: jpeg.length,
|
|||
|
|
contentType: 'image/jpeg',
|
|||
|
|
});
|
|||
|
|
expect(init.status).toBe(201);
|
|||
|
|
const { uploadId, photoId, credential } = init.body as {
|
|||
|
|
uploadId: string;
|
|||
|
|
photoId: string;
|
|||
|
|
credential: { url: string; method: string; headers: Record<string, string> };
|
|||
|
|
};
|
|||
|
|
const put = await fetch(credential.url, {
|
|||
|
|
method: credential.method,
|
|||
|
|
headers: credential.headers,
|
|||
|
|
body: new Uint8Array(jpeg),
|
|||
|
|
});
|
|||
|
|
expect(put.ok).toBe(true);
|
|||
|
|
const done = await request(cnBase)
|
|||
|
|
.post(`/v1/uploads/${uploadId}/complete`)
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`);
|
|||
|
|
expect(done.status).toBe(200);
|
|||
|
|
expect(await publish.processPhoto(photoId)).toBe('published');
|
|||
|
|
originals.set(photoId, jpeg);
|
|||
|
|
return photoId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const postWatermark = (token: string, body: object) =>
|
|||
|
|
request(cnBase)
|
|||
|
|
.post(`/v1/albums/${albumId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${token}`)
|
|||
|
|
.send(body);
|
|||
|
|
|
|||
|
|
const getWatermark = (token: string) =>
|
|||
|
|
request(cnBase)
|
|||
|
|
.get(`/v1/albums/${albumId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${token}`);
|
|||
|
|
|
|||
|
|
/** 取回照片的 browse 变体对象字节(对照基准)。 */
|
|||
|
|
const browseObjectOf = async (photoId: string): Promise<Buffer> => {
|
|||
|
|
const row = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId, variant: 'browse' } },
|
|||
|
|
});
|
|||
|
|
return readObject(storage, row.objectKey);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
beforeAll(async () => {
|
|||
|
|
expect(SYNC_KEY).not.toBe('');
|
|||
|
|
await resetDb();
|
|||
|
|
await resetOvDb();
|
|||
|
|
|
|||
|
|
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
|||
|
|
rawBody: true,
|
|||
|
|
});
|
|||
|
|
configureApp(cnApp);
|
|||
|
|
await cnApp.listen(0);
|
|||
|
|
cnBase = await cnApp.getUrl();
|
|||
|
|
|
|||
|
|
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
|||
|
|
rawBody: true,
|
|||
|
|
});
|
|||
|
|
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
|||
|
|
configureApp(ovApp);
|
|||
|
|
await ovApp.listen(0);
|
|||
|
|
ovBase = await ovApp.getUrl();
|
|||
|
|
|
|||
|
|
prismaCn = new PrismaClient();
|
|||
|
|
prismaOv = new OvPrismaClient();
|
|||
|
|
storage = createStorageFromEnv();
|
|||
|
|
sender = new OutboxSender({ prisma: prismaCn, ovApiUrl: ovBase, internalKey: SYNC_KEY });
|
|||
|
|
regen = new WatermarkRegenService(prismaCn, storage);
|
|||
|
|
publish = new PublishService(prismaCn, storage);
|
|||
|
|
|
|||
|
|
const mkUser = (account: string, extra: { role?: string } = {}) =>
|
|||
|
|
prismaCn.users.create({
|
|||
|
|
data: {
|
|||
|
|
account,
|
|||
|
|
role: extra.role ?? 'staff',
|
|||
|
|
passwordHash: 'x',
|
|||
|
|
displayName: account,
|
|||
|
|
},
|
|||
|
|
select: { id: true },
|
|||
|
|
});
|
|||
|
|
ownerId = (await mkUser('wm-owner')).id;
|
|||
|
|
const collabId = (await mkUser('wm-collab')).id;
|
|||
|
|
const outsiderId = (await mkUser('wm-outsider')).id;
|
|||
|
|
const adminId = (await mkUser('wm-admin', { role: 'admin' })).id;
|
|||
|
|
|
|||
|
|
const mkToken = async (userId: string): Promise<string> => {
|
|||
|
|
const token = randomBytes(32).toString('hex');
|
|||
|
|
await prismaCn.sessions.create({
|
|||
|
|
data: {
|
|||
|
|
userId,
|
|||
|
|
tokenHash: hashSessionToken(token),
|
|||
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return token;
|
|||
|
|
};
|
|||
|
|
ownerToken = await mkToken(ownerId);
|
|||
|
|
collabToken = await mkToken(collabId);
|
|||
|
|
outsiderToken = await mkToken(outsiderId);
|
|||
|
|
adminToken = await mkToken(adminId);
|
|||
|
|
|
|||
|
|
const album = await request(cnBase)
|
|||
|
|
.post('/v1/albums')
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|||
|
|
.send({
|
|||
|
|
translations: [
|
|||
|
|
{ locale: 'zh-CN', title: '水印验收相册' },
|
|||
|
|
{ locale: 'en', title: 'Watermark Gala' },
|
|||
|
|
],
|
|||
|
|
defaultLocale: 'zh-CN',
|
|||
|
|
startAt: '2026-11-01T10:00:00.000Z',
|
|||
|
|
});
|
|||
|
|
expect(album.status).toBe(201);
|
|||
|
|
albumId = album.body.id as string;
|
|||
|
|
publicToken = album.body.publicToken as string;
|
|||
|
|
|
|||
|
|
// 协作者(可见不可管)与三张已发布照片。
|
|||
|
|
const member = await request(cnBase)
|
|||
|
|
.post(`/v1/albums/${albumId}/members`)
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|||
|
|
.send({ userId: collabId, role: 'collaborator' });
|
|||
|
|
expect(member.status).toBe(201);
|
|||
|
|
|
|||
|
|
originals = new Map();
|
|||
|
|
photoIds = [];
|
|||
|
|
for (let i = 0; i < 3; i++) {
|
|||
|
|
photoIds.push(
|
|||
|
|
await uploadAndPublish(
|
|||
|
|
`wm-seed-${i}`,
|
|||
|
|
await makeJpeg(900 - i * 60, 600 - i * 40, 0x70 + i * 0x18),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
await drainSender();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterAll(async () => {
|
|||
|
|
await cnApp.close();
|
|||
|
|
await ovApp.close();
|
|||
|
|
await prismaCn.$disconnect();
|
|||
|
|
await prismaOv.$disconnect();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('① POST(enabled)→ 202 {jobId, configVersion};GET 状态形状(缺省填齐/pending)', async () => {
|
|||
|
|
const res = await postWatermark(ownerToken, {
|
|||
|
|
enabled: true,
|
|||
|
|
text: { content: '© Photo Live' },
|
|||
|
|
});
|
|||
|
|
expect(res.status).toBe(202);
|
|||
|
|
expect(res.body).toEqual({ jobId: expect.any(String), configVersion: 1 });
|
|||
|
|
expect((res.body.jobId as string).length).toBeGreaterThan(0);
|
|||
|
|
|
|||
|
|
// albums.watermarkVersion(最新创建版本)即刻推进;有效位未动(任务未 done)。
|
|||
|
|
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
|
|||
|
|
expect(album.watermarkVersion).toBe(1);
|
|||
|
|
expect(album.watermarkEffectiveVersion).toBe(0);
|
|||
|
|
expect(album.watermarkEnabled).toBe(false);
|
|||
|
|
|
|||
|
|
// 审计行。
|
|||
|
|
const audit = await prismaCn.audit_logs.findFirstOrThrow({
|
|||
|
|
where: { action: 'album.watermark', resourceId: albumId },
|
|||
|
|
});
|
|||
|
|
expect(audit.metadata).toMatchObject({ configVersion: 1, enabled: true });
|
|||
|
|
|
|||
|
|
const status = await getWatermark(ownerToken);
|
|||
|
|
expect(status.status).toBe(200);
|
|||
|
|
expect(status.body.current).toEqual({
|
|||
|
|
configVersion: 1,
|
|||
|
|
// 缺省填齐(契约 WatermarkConfig 可选字段的服务端默认值)。
|
|||
|
|
config: {
|
|||
|
|
enabled: true,
|
|||
|
|
position: 'bottomRight',
|
|||
|
|
opacity: 50,
|
|||
|
|
scalePercent: 20,
|
|||
|
|
text: { content: '© Photo Live', fontSize: 24 },
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
expect(status.body.jobs).toHaveLength(1);
|
|||
|
|
expect(status.body.jobs[0]).toMatchObject({
|
|||
|
|
configVersion: 1,
|
|||
|
|
state: 'pending',
|
|||
|
|
progress: 0,
|
|||
|
|
total: 3,
|
|||
|
|
failed: 0,
|
|||
|
|
});
|
|||
|
|
expect(status.body.jobs[0].jobId).toBe(res.body.jobId);
|
|||
|
|
expect(typeof status.body.jobs[0].createdAt).toBe('string');
|
|||
|
|
expect(status.body.jobs[0].errorCode).toBeUndefined();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('② 新照片发布即套用最新版:POST 后发布 → watermark 变体对象已产且 ≠ browse', async () => {
|
|||
|
|
const photoId = await uploadAndPublish('wm-after-post', await makeJpeg(760, 500, 0x99));
|
|||
|
|
photoIds.push(photoId);
|
|||
|
|
|
|||
|
|
// 发布路径写入 watermark 行(version=1=最新创建版本)+ 对象存在且非空 jpeg,
|
|||
|
|
// 与 browse 变体字节不同(水印本身即差异——A18「新照片即刻套用」)。
|
|||
|
|
const row = await prismaCn.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');
|
|||
|
|
expect(wmObject.equals(await browseObjectOf(photoId))).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('③ 历史重生成 → done;cn 公开列表服务水印字节;④ A20 原图零触碰;⑤ ov 模式键', async () => {
|
|||
|
|
const pending = await getWatermark(ownerToken);
|
|||
|
|
const jobId = pending.body.jobs[0].jobId as string;
|
|||
|
|
expect(await regen.processJob(jobId)).toBe('done');
|
|||
|
|
|
|||
|
|
const status = await getWatermark(ownerToken);
|
|||
|
|
expect(status.body.jobs[0]).toMatchObject({
|
|||
|
|
state: 'done',
|
|||
|
|
// total = POST 时点的已发布照片快照(3);progress 含 ② 追加的 1 张
|
|||
|
|
// (发布中途的新照片自然纳入处理——progress 计「已处理」,可超过 total)。
|
|||
|
|
progress: 4,
|
|||
|
|
total: 3,
|
|||
|
|
failed: 0,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 有效位推进(仅 done 后——ov 切换的前提)。
|
|||
|
|
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
|
|||
|
|
expect(album.watermarkEffectiveVersion).toBe(1);
|
|||
|
|
expect(album.watermarkEnabled).toBe(true);
|
|||
|
|
|
|||
|
|
// cn 公开列表:每条 browse 现签 URL 取回的是**水印字节**(≠ browse 变体)。
|
|||
|
|
const list = await request(cnBase)
|
|||
|
|
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
|
|||
|
|
expect(list.status).toBe(200);
|
|||
|
|
expect(list.body.items.map((i: { photoId: string }) => i.photoId).sort()).toEqual(
|
|||
|
|
[...photoIds].sort(),
|
|||
|
|
);
|
|||
|
|
for (const item of list.body.items as Array<{
|
|||
|
|
photoId: string;
|
|||
|
|
browse: { url: string };
|
|||
|
|
}>) {
|
|||
|
|
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
|
|||
|
|
expect(fetched.length).toBeGreaterThan(0);
|
|||
|
|
expect(fetched.equals(await browseObjectOf(item.photoId))).toBe(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A20:原图对象键与字节零触碰。
|
|||
|
|
for (const [photoId, original] of originals) {
|
|||
|
|
const photo = await prismaCn.photos.findUniqueOrThrow({ where: { id: photoId } });
|
|||
|
|
expect(photo.objectKey).toBe(
|
|||
|
|
`photo-live/dev/albums/${albumId}/originals/${photoId}.jpg`,
|
|||
|
|
);
|
|||
|
|
expect(await readObject(storage, photo.objectKey)).toEqual(original);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ov:副本位 + 模式键。发送器排空后 album.updated(有效位推进)到达 ov。
|
|||
|
|
await drainSender();
|
|||
|
|
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({
|
|||
|
|
where: { albumId },
|
|||
|
|
});
|
|||
|
|
expect(ovAlbum.watermarkEnabled).toBe(true);
|
|||
|
|
expect(ovAlbum.watermarkConfigVersion).toBe(1);
|
|||
|
|
|
|||
|
|
const ovList = await request(ovBase)
|
|||
|
|
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
|
|||
|
|
expect(ovList.status).toBe(200);
|
|||
|
|
expect(ovList.body.items).toHaveLength(photoIds.length);
|
|||
|
|
for (const item of ovList.body.items as Array<{
|
|||
|
|
photoId: string;
|
|||
|
|
browse: { url: string };
|
|||
|
|
}>) {
|
|||
|
|
// 模式键(确定性问题:URL 路径含版本段 watermark 对象键)。
|
|||
|
|
expect(item.browse.url).toContain(
|
|||
|
|
`/albums/${albumId}/variants/${item.photoId}/1/watermark.jpg`,
|
|||
|
|
);
|
|||
|
|
// 取回字节与 cn 水印对象一致(同一对象、两域各自现签)。
|
|||
|
|
const cnRow = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId: item.photoId, variant: 'watermark' } },
|
|||
|
|
});
|
|||
|
|
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
|
|||
|
|
expect(fetched.equals(await readObject(storage, cnRow.objectKey))).toBe(true);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑥ 失败注入:删 1 张原图 → 新版本 done 且 failed=1;失败张两侧保留旧图;恢复后 re-POST 收敛', async () => {
|
|||
|
|
photoIdOfFailed = photoIds[1]!;
|
|||
|
|
const failedPhoto = await prismaCn.photos.findUniqueOrThrow({
|
|||
|
|
where: { id: photoIdOfFailed },
|
|||
|
|
});
|
|||
|
|
const failedOriginal = originals.get(photoIdOfFailed)!;
|
|||
|
|
await deleteObject(failedPhoto.objectKey);
|
|||
|
|
|
|||
|
|
// v1 水印对象字节(失败张的「旧图」基准)。
|
|||
|
|
const v1Key = watermarkVariantObjectKey(albumId, photoIdOfFailed, 1);
|
|||
|
|
const v1Object = await readObject(storage, v1Key);
|
|||
|
|
|
|||
|
|
const res = await postWatermark(ownerToken, {
|
|||
|
|
enabled: true,
|
|||
|
|
text: { content: 'v2 水印' },
|
|||
|
|
position: 'topRight',
|
|||
|
|
});
|
|||
|
|
expect(res.status).toBe(202);
|
|||
|
|
expect(res.body.configVersion).toBe(2);
|
|||
|
|
expect(await regen.processJob(res.body.jobId)).toBe('done');
|
|||
|
|
|
|||
|
|
const status = await getWatermark(ownerToken);
|
|||
|
|
expect(status.body.jobs[0]).toMatchObject({
|
|||
|
|
configVersion: 2,
|
|||
|
|
state: 'done',
|
|||
|
|
progress: 4,
|
|||
|
|
total: 4,
|
|||
|
|
failed: 1,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 成功张:行推进到 v2。
|
|||
|
|
for (const photoId of photoIds.filter((id) => id !== photoIdOfFailed)) {
|
|||
|
|
const row = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId, variant: 'watermark' } },
|
|||
|
|
});
|
|||
|
|
expect(row.version).toBe(2);
|
|||
|
|
}
|
|||
|
|
// 失败张:cn 行保留 v1(未覆盖——保留旧图),ov 模式键 v2 = v1 旧图拷贝。
|
|||
|
|
const failedRow = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId: photoIdOfFailed, variant: 'watermark' } },
|
|||
|
|
});
|
|||
|
|
expect(failedRow.version).toBe(1);
|
|||
|
|
expect(
|
|||
|
|
await readObject(storage, watermarkVariantObjectKey(albumId, photoIdOfFailed, 2)),
|
|||
|
|
).toEqual(v1Object);
|
|||
|
|
|
|||
|
|
await drainSender();
|
|||
|
|
// cn:失败张 browse URL 取回 v1 旧图字节;成功张取回 v2。
|
|||
|
|
const cnList = await request(cnBase)
|
|||
|
|
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
|
|||
|
|
const byPhoto = new Map(
|
|||
|
|
(cnList.body.items as Array<{ photoId: string; browse: { url: string } }>).map(
|
|||
|
|
(i) => [i.photoId, i.browse.url],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
expect(
|
|||
|
|
Buffer.from(await (await fetch(byPhoto.get(photoIdOfFailed)!)).arrayBuffer()),
|
|||
|
|
).toEqual(v1Object);
|
|||
|
|
const okPhoto = photoIds[0]!;
|
|||
|
|
const okV2 = await readObject(
|
|||
|
|
storage,
|
|||
|
|
watermarkVariantObjectKey(albumId, okPhoto, 2),
|
|||
|
|
);
|
|||
|
|
expect(
|
|||
|
|
Buffer.from(await (await fetch(byPhoto.get(okPhoto)!)).arrayBuffer()),
|
|||
|
|
).toEqual(okV2);
|
|||
|
|
|
|||
|
|
// ov:失败张模式键 v2 同样是旧图字节(与 cn 一致——A20 双域同语义)。
|
|||
|
|
const ovList = await request(ovBase)
|
|||
|
|
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
|
|||
|
|
const ovByPhoto = new Map(
|
|||
|
|
(ovList.body.items as Array<{ photoId: string; browse: { url: string } }>).map(
|
|||
|
|
(i) => [i.photoId, i.browse.url],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
expect(ovByPhoto.get(photoIdOfFailed)).toContain(
|
|||
|
|
`/variants/${photoIdOfFailed}/2/watermark.jpg`,
|
|||
|
|
);
|
|||
|
|
expect(
|
|||
|
|
Buffer.from(await (await fetch(ovByPhoto.get(photoIdOfFailed)!)).arrayBuffer()),
|
|||
|
|
).toEqual(v1Object);
|
|||
|
|
|
|||
|
|
// —— 恢复原图 → re-POST v3 → failed=0 全量收敛(重新发起语义)——
|
|||
|
|
await storage.put(failedPhoto.objectKey, failedOriginal, 'image/jpeg');
|
|||
|
|
const retry = await postWatermark(ownerToken, {
|
|||
|
|
enabled: true,
|
|||
|
|
text: { content: 'v3 retry' },
|
|||
|
|
});
|
|||
|
|
expect(retry.status).toBe(202);
|
|||
|
|
expect(retry.body.configVersion).toBe(3);
|
|||
|
|
expect(await regen.processJob(retry.body.jobId)).toBe('done');
|
|||
|
|
|
|||
|
|
const after = await getWatermark(ownerToken);
|
|||
|
|
expect(after.body.jobs[0]).toMatchObject({
|
|||
|
|
configVersion: 3,
|
|||
|
|
state: 'done',
|
|||
|
|
failed: 0,
|
|||
|
|
progress: 4,
|
|||
|
|
});
|
|||
|
|
const recovered = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId: photoIdOfFailed, variant: 'watermark' } },
|
|||
|
|
});
|
|||
|
|
expect(recovered.version).toBe(3);
|
|||
|
|
expect(
|
|||
|
|
(await readObject(storage, watermarkVariantObjectKey(albumId, photoIdOfFailed, 3))).equals(
|
|||
|
|
v1Object,
|
|||
|
|
),
|
|||
|
|
).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑦ 关闭版重生成:两侧回落 browse 键;水印行删除、对象保留', async () => {
|
|||
|
|
const res = await postWatermark(ownerToken, { enabled: false });
|
|||
|
|
expect(res.status).toBe(202);
|
|||
|
|
expect(res.body.configVersion).toBe(4);
|
|||
|
|
expect(await regen.processJob(res.body.jobId)).toBe('done');
|
|||
|
|
|
|||
|
|
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
|
|||
|
|
expect(album.watermarkEnabled).toBe(false);
|
|||
|
|
expect(album.watermarkEffectiveVersion).toBe(4);
|
|||
|
|
|
|||
|
|
// 行全删(对象保留——历史可溯)。
|
|||
|
|
expect(
|
|||
|
|
await prismaCn.photo_variants.count({ where: { photoId: { in: photoIds }, variant: 'watermark' } }),
|
|||
|
|
).toBe(0);
|
|||
|
|
for (const photoId of photoIds) {
|
|||
|
|
expect(
|
|||
|
|
await storage.head(watermarkVariantObjectKey(albumId, photoId, 3)),
|
|||
|
|
).not.toBeNull();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await drainSender();
|
|||
|
|
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId } });
|
|||
|
|
expect(ovAlbum.watermarkEnabled).toBe(false);
|
|||
|
|
expect(ovAlbum.watermarkConfigVersion).toBe(4);
|
|||
|
|
|
|||
|
|
// cn/ov 公开列表:browse URL 全部回落 browse 变体键,取回字节=browse 对象。
|
|||
|
|
for (const base of [cnBase, ovBase]) {
|
|||
|
|
const list = await request(base)
|
|||
|
|
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
|
|||
|
|
expect(list.status).toBe(200);
|
|||
|
|
for (const item of list.body.items as Array<{
|
|||
|
|
photoId: string;
|
|||
|
|
browse: { url: string };
|
|||
|
|
}>) {
|
|||
|
|
expect(item.browse.url).toContain(`/variants/${item.photoId}/1/browse.jpg`);
|
|||
|
|
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
|
|||
|
|
expect(fetched.equals(await browseObjectOf(item.photoId))).toBe(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑧ 空相册:POST 即建即 done,有效位当场推进并同步 ov', async () => {
|
|||
|
|
const emptyAlbum = await request(cnBase)
|
|||
|
|
.post('/v1/albums')
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|||
|
|
.send({
|
|||
|
|
translations: [{ locale: 'zh-CN', title: '空相册水印' }],
|
|||
|
|
defaultLocale: 'zh-CN',
|
|||
|
|
startAt: '2026-11-20T10:00:00.000Z',
|
|||
|
|
});
|
|||
|
|
expect(emptyAlbum.status).toBe(201);
|
|||
|
|
const emptyId = emptyAlbum.body.id as string;
|
|||
|
|
|
|||
|
|
const res = await request(cnBase)
|
|||
|
|
.post(`/v1/albums/${emptyId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|||
|
|
.send({ enabled: true, text: { content: '空相册' } });
|
|||
|
|
expect(res.status).toBe(202);
|
|||
|
|
|
|||
|
|
const status = await request(cnBase)
|
|||
|
|
.get(`/v1/albums/${emptyId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${ownerToken}`);
|
|||
|
|
expect(status.body.jobs[0]).toMatchObject({ state: 'done', total: 0, progress: 0 });
|
|||
|
|
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: emptyId } });
|
|||
|
|
expect(album.watermarkEnabled).toBe(true);
|
|||
|
|
expect(album.watermarkEffectiveVersion).toBe(1);
|
|||
|
|
|
|||
|
|
await drainSender();
|
|||
|
|
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId: emptyId } });
|
|||
|
|
expect(ovAlbum.watermarkEnabled).toBe(true);
|
|||
|
|
expect(ovAlbum.watermarkConfigVersion).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑨ 权限:协作者 403 / 圈外人 404 / admin 可管;未认证 401', async () => {
|
|||
|
|
const collabPost = await postWatermark(collabToken, {
|
|||
|
|
enabled: true,
|
|||
|
|
text: { content: 'x' },
|
|||
|
|
});
|
|||
|
|
expect(collabPost.status).toBe(403);
|
|||
|
|
expect(collabPost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
|||
|
|
|
|||
|
|
const collabGet = await getWatermark(collabToken);
|
|||
|
|
expect(collabGet.status).toBe(403);
|
|||
|
|
expect(collabGet.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
|||
|
|
|
|||
|
|
const outsider = await request(cnBase)
|
|||
|
|
.post(`/v1/albums/${albumId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${outsiderToken}`)
|
|||
|
|
.send({ enabled: true, text: { content: 'x' } });
|
|||
|
|
expect(outsider.status).toBe(404);
|
|||
|
|
expect(outsider.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
|||
|
|
|
|||
|
|
const anon = await request(cnBase)
|
|||
|
|
.get(`/v1/albums/${albumId}/watermark-jobs`);
|
|||
|
|
expect(anon.status).toBe(401);
|
|||
|
|
|
|||
|
|
const adminStatus = await getWatermark(adminToken);
|
|||
|
|
expect(adminStatus.status).toBe(200);
|
|||
|
|
const adminPost = await postWatermark(adminToken, {
|
|||
|
|
enabled: true,
|
|||
|
|
text: { content: 'admin v5' },
|
|||
|
|
});
|
|||
|
|
expect(adminPost.status).toBe(202);
|
|||
|
|
expect(adminPost.body.configVersion).toBe(5);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑩ 校验:enabled=true 缺 text.content / 越界值 / 非法 position → 400', async () => {
|
|||
|
|
const cases: Array<Record<string, unknown>> = [
|
|||
|
|
{ enabled: true }, // 缺 text.content
|
|||
|
|
{ enabled: true, text: { content: '' } }, // 空串
|
|||
|
|
{ enabled: true, text: { content: 'x' }, opacity: 101 },
|
|||
|
|
{ enabled: true, text: { content: 'x' }, opacity: -1 },
|
|||
|
|
{ enabled: true, text: { content: 'x' }, scalePercent: 4 },
|
|||
|
|
{ enabled: true, text: { content: 'x' }, scalePercent: 51 },
|
|||
|
|
{ enabled: true, text: { content: 'x' }, position: 'middle' },
|
|||
|
|
{ enabled: true, text: { content: 'x', fontSize: 7 } },
|
|||
|
|
{ enabled: true, text: { content: 'x', fontSize: 73 } },
|
|||
|
|
{ enabled: 'yes', text: { content: 'x' } },
|
|||
|
|
];
|
|||
|
|
for (const body of cases) {
|
|||
|
|
const res = await postWatermark(ownerToken, body);
|
|||
|
|
expect(res.status).toBe(400);
|
|||
|
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('GET jobs 全史:创建时间倒序(configVersion 降序)且条目字段完整', async () => {
|
|||
|
|
const status = await getWatermark(ownerToken);
|
|||
|
|
expect(status.status).toBe(200);
|
|||
|
|
const jobs = status.body.jobs as Array<{
|
|||
|
|
jobId: string;
|
|||
|
|
configVersion: number;
|
|||
|
|
state: string;
|
|||
|
|
progress: number;
|
|||
|
|
total: number;
|
|||
|
|
failed: number;
|
|||
|
|
createdAt: string;
|
|||
|
|
}>;
|
|||
|
|
expect(jobs.length).toBeGreaterThanOrEqual(5);
|
|||
|
|
const versions = jobs.map((j) => j.configVersion);
|
|||
|
|
expect([...versions].sort((a, b) => b - a)).toEqual(versions);
|
|||
|
|
for (const job of jobs) {
|
|||
|
|
expect(Object.keys(job).sort()).toEqual(
|
|||
|
|
['configVersion', 'createdAt', 'failed', 'jobId', 'progress', 'state', 'total'].sort(),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|