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

688 lines
27 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/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(),
);
}
});
});