// 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 { const svg = Buffer.from( `` + Array.from( { length: 10 }, (_, i) => ``, ).join('') + '', ); return sharp(svg).jpeg({ quality: 85 }).toBuffer(); } /** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */ async function readObject(storage: StoragePort, key: string): Promise { 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 { 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; let photoIdOfFailed: string; /** 排空发送器:循环 runOnce 直至无未投递事件(同 public-ov spec)。 */ async function drainSender(): Promise { 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 { 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 }; }; 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 => { 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(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(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 => { 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> = [ { 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(), ); } }); });