// services/api/test/embed.e2e-spec.ts —— P4b Task 2 展示位后端 e2e。 // ONE spec 串「cn 管理 → slot-state 同步 → ov 公开读」全链路:进程内起两个 // 真实入口(cn + ov,同 public-ov.e2e-spec 模式),管理动作全走 cn API, // 发送器为生产 OutboxSender(指向 ov 入口真实 URL)——ov 读到的数据与生产 // 链路逐字节同源。 // // 覆盖(任务验收): // ① admin CRUD:创建缺省 settings/publicToken 形状/未绑定;非 admin 403; // 未认证 401;settings 部分提交/越界 400;列表倒序 + 键集游标翻页; // 创建事件同步后 ov 副本行就位(未绑定 → 公开端 404); // ② 绑定相册:albumTitle 按相册 defaultLocale 解析;ov 公开数据 photos // publishedAt 稳定倒序、thumb 现签可取回真实字节;lang 回退 // (缺省/zh-CN/en/auto/非法值不 400);ETag 四元组 200 → If-None-Match // 重复请求 304(无 body,ETag 仍携带); // ③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径); // ④ 换绑相册:configVersion+1,ov 服务新相册照片(A22 换绑不改官网—— // loader 只认 token);近期照片 20 上限与倒序; // ⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200; // ⑥ 解绑 → 404;复绑 → 200;停用(enabled=false)→ 404; // ⑦ slot-state 幂等:同 eventId 重复受理不计应用(duplicate);同版本 // 不同 eventId 重断言应用(幂等重投影);更低版本 stale 不回卷;更高 // 版本直投生效(乱序收敛); // ⑧ 限速:压小窗口(EMBED_WINDOW_REQUESTS 惰性 env)→ 超额 429 // RATE_LIMITED(304 也消耗预算)。 // ⑨ UI-01 配置字段全链路:新字段创建/校验(越界/非法 400)/旧形状归一化 // → slot-state 投影 → ov 公开 settings 全集;旧投影行读取侧默认值归一。 // 真实 MySQL×2 + MinIO(真实 JPEG 上传发布)+ HMAC 同步链路字节一致。 import { randomBytes } from 'node:crypto'; import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; import sharp from 'sharp'; import IORedis from 'ioredis'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { ErrorCodes, signSyncBody } from '@photo/contracts'; import { createStorageFromEnv, type MinioStorageAdapter, } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { AppOvModule } from '../src/app-ov.module'; import { seedAdmin } from '../src/scripts/seed-admin'; import { configureApp } from '../src/shared/configure-app'; import { hashSessionToken } from '../src/auth/session.guard'; import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; import { PublishService } from '../../worker/src/publish.service'; import { resetDb } from './reset'; import { resetOvDb } from './reset-ov'; import { OutboxSender } from '../../sync/src/sender'; jest.setTimeout(180_000); const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; /** 测试内生成真实 JPEG(同 public-ov.e2e-spec;无二进制入库)。 */ async function makeJpeg( width: number, height: number, rgb: { r: number; g: number; b: number }, ): Promise { return sharp({ create: { width, height, channels: 3, background: rgb } }) .jpeg({ quality: 85 }) .toBuffer(); } /** 签名 POST(content-batches 内部端点,⑦ 幂等直推)。 */ async function signedPost( base: string, path: string, body: unknown, ): Promise { const raw = JSON.stringify(body); return fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-sync-timestamp': String(Math.floor(Date.now() / 1000)), 'x-sync-signature': signSyncBody(SYNC_KEY, raw), }, body: raw, }); } describe('embed slots (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 redisOv: IORedis; let adminToken: string; let staffToken: string; let slotId: string; let slotToken: string; /** 相册 A:zh-CN + en 双语、真实上传发布照片。 */ let albumAId: string; let albumAToken: string; /** 相册 B:zh 单语 + 直种 photos_ov 副本行(20 上限验证)。 */ let albumBId: string; let albumBToken: string; let albumBSeedIds: string[]; /** 排空发送器:循环 runOnce 直至无未投递事件(sync 侧 flush)。 */ 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( targetAlbumId: string, clientTaskId: string, jpeg: Buffer, ): Promise { const init = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: targetAlbumId, clientTaskId, filename: 'embed-e2e.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 ${adminToken}`); expect(done.status).toBe(200); expect( await new PublishService(prismaCn, storage).processPhoto(photoId), ).toBe('published'); return photoId; } /** ov 公开端点 GET(可选 If-None-Match)。 */ async function getEmbed( token: string, query = '', ifNoneMatch?: string, ): Promise { const req = request(ovBase) .get(`/v1/public/embed-slots/${token}${query}`) .set('accept', 'application/json'); if (ifNoneMatch !== undefined) { req.set('If-None-Match', ifNoneMatch); } return req; } 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, }); redisOv = new IORedis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381', { maxRetriesPerRequest: 2, }); redisOv.on('error', () => undefined); await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD); const login = await request(cnBase).post('/v1/auth/password').send({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD, }); expect(login.status).toBe(200); adminToken = login.body.sessionToken as string; // 非 admin 对照账号(staff):直插 users + 会话(同 members-categories 模式)。 const staff = await prismaCn.users.create({ data: { account: 'embed-staff', role: 'staff', passwordHash: 'x', displayName: 'embed-staff', }, select: { id: true }, }); const staffSession = randomBytes(32).toString('hex'); await prismaCn.sessions.create({ data: { userId: staff.id, tokenHash: hashSessionToken(staffSession), expiresAt: new Date(Date.now() + 3_600_000), }, }); staffToken = staffSession; }); afterAll(async () => { await cnApp.close(); await ovApp.close(); await prismaCn.$disconnect(); await prismaOv.$disconnect(); await redisOv.quit().catch(() => undefined); }); it('① admin 创建/列表 + 权限 + 校验 + 创建事件同步(未绑定 → 公开 404)', async () => { // 缺省 settings 创建。 const created = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: '官网首页悬浮位' }); expect(created.status).toBe(201); slotId = created.body.id as string; slotToken = created.body.publicToken as string; expect(created.body).toMatchObject({ id: expect.any(String), publicToken: expect.stringMatching(/^[0-9a-z]{24}$/), name: '官网首页悬浮位', enabled: true, albumId: null, albumTitle: null, configVersion: 1, settings: { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, }, updatedAt: expect.any(String), }); // 完整 settings 创建(第二槽,验证字段落库)。 const second = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: '活动页左侧位', settings: { position: 'left', size: 'sm', intervalMs: 8000, lang: 'zh-CN', collapsedDefault: true, }, }); expect(second.status).toBe(201); // UI-01:旧形状提交(无新字段)→ 响应为归一化全集(新字段按默认值)。 expect(second.body.settings).toEqual({ position: 'left', size: 'sm', intervalMs: 8000, lang: 'zh-CN', collapsedDefault: true, imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, }); // 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。 const partial = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'x', settings: { position: 'left' } as never }); expect(partial.status).toBe(400); expect(partial.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const outOfRange = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'x', settings: { position: 'left', size: 'sm', intervalMs: 2000, lang: 'auto', collapsedDefault: false, }, }); expect(outOfRange.status).toBe(400); const emptyName = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: '' }); expect(emptyName.status).toBe(400); // 权限:staff 三端点 403(入参合法——校验管道先于服务层 admin 判定, // 带病请求会先 400 而测不到权限路径);匿名 401。 const staffAttempts = [ request(cnBase) .get('/v1/admin/embed-slots') .set('Authorization', `Bearer ${staffToken}`), request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${staffToken}`) .send({ name: '越权创建' }), request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${staffToken}`) .send({ enabled: false }), ]; for (const attempt of staffAttempts) { const res = await attempt; expect(res.status).toBe(403); expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); } const anon = await request(cnBase).get('/v1/admin/embed-slots'); expect(anon.status).toBe(401); // 列表:createdAt+id 稳定倒序 + limit=1 键集游标翻页。 const page1 = await request(cnBase) .get('/v1/admin/embed-slots?limit=1') .set('Authorization', `Bearer ${adminToken}`); expect(page1.status).toBe(200); expect(page1.body.items).toHaveLength(1); expect(page1.body.items[0].id).toBe(second.body.id); // 后创建在前 expect(page1.body.hasMore).toBe(true); const page2 = await request(cnBase) .get( `/v1/admin/embed-slots?limit=1&cursor=${page1.body.nextCursor as string}`, ) .set('Authorization', `Bearer ${adminToken}`); expect(page2.status).toBe(200); expect(page2.body.items).toHaveLength(1); expect(page2.body.items[0].id).toBe(slotId); expect(page2.body.hasMore).toBe(false); expect(page2.body.nextCursor).toBeNull(); // 创建事件同步:ov 副本行就位(未绑定 albumPublicToken null)+ 版本账本。 await drainSender(); const replica = await prismaOv.embed_slots_ov.findUnique({ where: { slotToken }, }); expect(replica).toMatchObject({ slotToken, enabled: true, albumPublicToken: null, configVersion: 1, entityVersion: 1, }); const state = await prismaOv.sync_state.findUnique({ where: { entityType_entityId: { entityType: 'slot-state', entityId: slotId }, }, }); expect(state?.appliedVersion).toBe(1); // 公开端:未绑定 → 404 NOT_FOUND(防探测同码)。 const unbound = await getEmbed(slotToken); expect(unbound.status).toBe(404); expect(unbound.body.code).toBe(ErrorCodes.NOT_FOUND); }); it('② 绑定相册 → ov 公开数据:照片倒序/标题回退/ETag 304', async () => { // 相册 A(zh-CN + en,defaultLocale zh-CN)+ 3 张真实发布照片。 const album = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${adminToken}`) .send({ translations: [ { locale: 'zh-CN', title: '周年晚会' }, { locale: 'en', title: 'Anniversary Gala' }, ], defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', }); expect(album.status).toBe(201); albumAId = album.body.id as string; albumAToken = album.body.publicToken as string; const shapes = [ { w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 } }, { w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 } }, { w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 } }, ] as const; const photoIds: string[] = []; for (let i = 0; i < shapes.length; i++) { const s = shapes[i]!; photoIds.push( await uploadAndPublish( albumAId, `embed-e2e-a-${i}`, await makeJpeg(s.w, s.h, s.rgb), ), ); } await drainSender(); // 绑定:albumTitle 按相册 defaultLocale(zh-CN)解析;configVersion+1。 const bind = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: albumAId }); expect(bind.status).toBe(200); expect(bind.body).toMatchObject({ albumId: albumAId, albumTitle: '周年晚会', configVersion: 2, }); // 绑定不存在的相册 → 404 ALBUM_NOT_FOUND。 const ghost = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: 'no-such-album' }); expect(ghost.status).toBe(404); expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); await drainSender(); // ov 公开数据:照片 publishedAt 稳定倒序 + ETag 四元组。 const first = await getEmbed(slotToken); expect(first.status).toBe(200); expect(first.body.slotToken).toBe(slotToken); expect(first.body.configVersion).toBe(2); expect(first.body.album).toEqual({ publicToken: albumAToken, title: '周年晚会', defaultLocale: 'zh-CN', }); expect(first.body.photos).toHaveLength(3); expect(first.body.photos.map((p: { photoId: string }) => p.photoId)).toEqual( [photoIds[2], photoIds[1], photoIds[0]], ); // thumb 现签:URL 可取回真实字节(走完整上传/处理/发布管线)。 const thumbUrl = first.body.photos[0].thumb.url as string; const fetched = await fetch(thumbUrl); expect(fetched.ok).toBe(true); expect((await fetched.arrayBuffer()).byteLength).toBeGreaterThan(0); expect(Date.parse(first.body.photos[0].thumb.expiresAt as string)).toBeGreaterThan( Date.now(), ); // ETag 四元组:"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"。 const newest = await prismaOv.photos_ov.findFirst({ where: { albumId: albumAId, visibility: 'published' }, orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }], select: { photoId: true, publishedAt: true }, }); const expectedEtag = `"2-3-${newwestMs(newest)}-${newest?.photoId ?? ''}"`; const etag = first.headers.etag as string; expect(etag).toBe(expectedEtag); // lang 回退:en 显式 / auto(缺省)→ defaultLocale / 非法值不 400。 const en = await getEmbed(slotToken, '?lang=en'); expect(en.status).toBe(200); expect(en.body.album.title).toBe('Anniversary Gala'); const auto = await getEmbed(slotToken, '?lang=auto'); expect(auto.body.album.title).toBe('周年晚会'); const invalid = await getEmbed(slotToken, '?lang=fr'); expect(invalid.status).toBe(200); expect(invalid.body.album.title).toBe('周年晚会'); // ETag 不含 lang:语言变化不影响校验器(客户端同槽轮询 304 语义稳定)。 expect(en.headers.etag).toBe(etag); // If-None-Match 匹配 → 304(无 body,仍携带 ETag);W/ 前缀与 * 同样命中。 const notModified = await getEmbed(slotToken, '', etag); expect(notModified.status).toBe(304); expect(notModified.text).toBe(''); expect(notModified.headers.etag).toBe(etag); expect((await getEmbed(slotToken, '', `W/${etag}`)).status).toBe(304); expect((await getEmbed(slotToken, '', '*')).status).toBe(304); // 不匹配的旧值 → 200 全量。 expect((await getEmbed(slotToken, '', '"stale-1-0-0-"')).status).toBe(200); }); it('③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径)', async () => { const before = await getEmbed(slotToken); const etagBefore = before.headers.etag as string; const newPhotoId = await uploadAndPublish( albumAId, 'embed-e2e-a-new', await makeJpeg(640, 420, { r: 200, g: 80, b: 80 }), ); await drainSender(); // 旧 If-None-Match 不再匹配 → 200 全量,新照片居首。 const refreshed = await getEmbed(slotToken, '', etagBefore); expect(refreshed.status).toBe(200); expect(refreshed.body.photos).toHaveLength(4); expect(refreshed.body.photos[0].photoId).toBe(newPhotoId); const etagAfter = refreshed.headers.etag as string; expect(etagAfter).not.toBe(etagBefore); // 新 ETag 立即命中 304。 expect((await getEmbed(slotToken, '', etagAfter)).status).toBe(304); }); it('④ 换绑相册:configVersion+1 → ov 服务新相册照片(A22)+ 20 上限', async () => { // 相册 B(zh 单语)+ 直种 25 行 photos_ov 副本(不同 publishedAt)。 const album = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${adminToken}`) .send({ translations: [{ locale: 'zh-CN', title: '品牌发布会' }], defaultLocale: 'zh-CN', startAt: '2026-11-01T10:00:00.000Z', }); expect(album.status).toBe(201); albumBId = album.body.id as string; albumBToken = album.body.publicToken as string; await drainSender(); // 相册副本先落位 albumBSeedIds = []; const base = Date.now() - 26 * 60_000; for (let i = 0; i < 25; i++) { const photoId = `embedseedB${String(i).padStart(2, '0')}`; albumBSeedIds.push(photoId); await prismaOv.photos_ov.create({ data: { photoId, albumId: albumBId, width: 320 + i, height: 240 + i, originalObjectKey: `embed-e2e/b/${photoId}/original.jpg`, thumbObjectKey: `embed-e2e/b/${photoId}/thumb.jpg`, browseObjectKey: `embed-e2e/b/${photoId}/browse.jpg`, visibility: 'published', publishedAt: new Date(base + i * 1000), entityVersion: 1, }, }); } // 换绑:configVersion+1(未提交 settings/enabled/name 不受影响)。 const rebind = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: albumBId }); expect(rebind.status).toBe(200); expect(rebind.body).toMatchObject({ albumId: albumBId, albumTitle: '品牌发布会', configVersion: expect.any(Number), name: '官网首页悬浮位', enabled: true, }); const configVersionAfterRebind = rebind.body.configVersion as number; expect(configVersionAfterRebind).toBeGreaterThanOrEqual(3); await drainSender(); // ov:新相册照片、20 上限、publishedAt 倒序(最新种子居首)。 const feed = await getEmbed(slotToken); expect(feed.status).toBe(200); expect(feed.body.album).toEqual({ publicToken: albumBToken, title: '品牌发布会', defaultLocale: 'zh-CN', }); expect(feed.body.photos).toHaveLength(20); const expectedOrder = [...albumBSeedIds].slice(-20).reverse(); expect( feed.body.photos.map((p: { photoId: string }) => p.photoId), ).toEqual(expectedOrder); // ETag 首元随相册切换(configVersion 与照片集合都变了)。 expect(feed.headers.etag).toBe( `"${configVersionAfterRebind as number}-20-${(base + 24 * 1000).toFixed(0)}-embedseedB24"`, ); // 相册 B 仅 zh:lang=en 回退 defaultLocale(不 400)。 const en = await getEmbed(slotToken, '?lang=en'); expect(en.body.album.title).toBe('品牌发布会'); }); it('⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200', async () => { const offline = await request(cnBase) .patch(`/v1/albums/${albumBId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ publicEnabled: false }); expect(offline.status).toBe(200); await drainSender(); expect((await getEmbed(slotToken)).status).toBe(404); const back = await request(cnBase) .patch(`/v1/albums/${albumBId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ publicEnabled: true }); expect(back.status).toBe(200); await drainSender(); expect((await getEmbed(slotToken)).status).toBe(200); }); it('⑥ 解绑 → 404;复绑 → 200;停用 → 404', async () => { // 解绑:albumId 显式 null。 const unbind = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: null }); expect(unbind.status).toBe(200); expect(unbind.body.albumId).toBeNull(); expect(unbind.body.albumTitle).toBeNull(); await drainSender(); expect((await getEmbed(slotToken)).status).toBe(404); // 复绑相册 A → 恢复(loader 只认 token:换绑不改官网嵌入代码)。 const rebind = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: albumAId }); expect(rebind.status).toBe(200); await drainSender(); const feed = await getEmbed(slotToken); expect(feed.status).toBe(200); expect(feed.body.album.publicToken).toBe(albumAToken); expect(feed.body.photos).toHaveLength(4); // 停用(enabled=false)→ 404(软下线,无删除端点)。 const disable = await request(cnBase) .patch(`/v1/admin/embed-slots/${slotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ enabled: false, name: '官网首页悬浮位(下线)' }); expect(disable.status).toBe(200); expect(disable.body.enabled).toBe(false); expect(disable.body.name).toBe('官网首页悬浮位(下线)'); await drainSender(); expect((await getEmbed(slotToken)).status).toBe(404); }); it('⑦ slot-state 乱序/重复幂等(直推 content-batches)', async () => { // 基准:cn 当前权威行(此刻停用态)。 const slot = await prismaCn.embed_slots.findUniqueOrThrow({ where: { id: slotId }, }); const currentVersion = slot.entityVersion; const settings = slot.settings as Record; // 更高版本「复活」事件(enabled=true,绑定相册 A):直投生效。 const revivePayload = { slotId, publicToken: slotToken, enabled: true, albumId: albumAId, albumPublicToken: albumAToken, configVersion: slot.configVersion + 1, settings, entityVersion: currentVersion + 1, }; const postBatch = async (eventId: string, entityVersion: number) => signedPost(ovBase, '/v1/internal/sync/content-batches', { batch: [ { eventId, schemaVersion: 1, entityType: 'slot-state', entityId: slotId, albumId: '', entityVersion, tombstone: false, priority: 'normal', payload: revivePayload, }, ], }); const revived = await postBatch('e2e-slot-revive-1', currentVersion + 1); expect(revived.status).toBe(200); expect(await revived.json()).toEqual({ acceptedEventIds: ['e2e-slot-revive-1'], applied: 1, }); expect((await getEmbed(slotToken)).status).toBe(200); // 同 eventId 重复投递 → duplicate(受理不计应用),公开数据不变。 const dup = await postBatch('e2e-slot-revive-1', currentVersion + 1); expect(await dup.json()).toEqual({ acceptedEventIds: ['e2e-slot-revive-1'], applied: 0, }); // 同版本不同 eventId → 重断言应用(幂等重投影,applied=1)。 const reassert = await postBatch('e2e-slot-reassert-1', currentVersion + 1); expect(await reassert.json()).toEqual({ acceptedEventIds: ['e2e-slot-reassert-1'], applied: 1, }); expect((await getEmbed(slotToken)).status).toBe(200); // 更低版本(旧快照:停用态)→ stale 不回卷:公开端仍 200。 const stale = await postBatch('e2e-slot-stale-1', currentVersion - 1); expect(await stale.json()).toEqual({ acceptedEventIds: ['e2e-slot-stale-1'], applied: 0, }); expect((await getEmbed(slotToken)).status).toBe(200); // 版本账本收敛在最高版本。 const state = await prismaOv.sync_state.findUnique({ where: { entityType_entityId: { entityType: 'slot-state', entityId: slotId }, }, }); expect(state?.appliedVersion).toBe(currentVersion + 1); }); it('⑧ 限速:slotToken+IP 固定窗超额 429(304 同样消耗预算)', async () => { // 清本 spec 造成的限速计数(窗口 60s,跨用例残留会污染压小窗口断言)。 const keys = await redisOv.keys('embed:window:*'); if (keys.length > 0) { await redisOv.del(keys); } process.env.EMBED_WINDOW_REQUESTS = '2'; try { const first = await getEmbed(slotToken); expect(first.status).toBe(200); const etag = first.headers.etag as string; // 第二次(304 命中)也计预算 → 窗口耗尽。 expect((await getEmbed(slotToken, '', etag)).status).toBe(304); const third = await getEmbed(slotToken); expect(third.status).toBe(429); expect(third.body.code).toBe(ErrorCodes.RATE_LIMITED); // 404 路径不消耗预算(校验先于限速,同 visits 排序口径)。 expect((await getEmbed('no-such-slot-token-0000000000')).status).toBe(404); } finally { delete process.env.EMBED_WINDOW_REQUESTS; const again = await redisOv.keys('embed:window:*'); if (again.length > 0) { await redisOv.del(again); } } }); it('⑨ UI-01 配置字段:cn 校验/归一化 → slot-state 投影 → ov 公开 settings', async () => { // 清限速计数(⑧ 压窗残留不影响本用例;默认窗口 120/60s 内用不顶)。 const keys = await redisOv.keys('embed:window:*'); if (keys.length > 0) { await redisOv.del(keys); } // 创建即带全量新字段 → cn 响应回显(admin 全集形状)。 const created = await request(cnBase) .post('/v1/admin/embed-slots') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'UI01 配置位', settings: { position: 'left', size: 'md', intervalMs: 6000, lang: 'auto', collapsedDefault: true, imageFit: 'cover', offsetX: 40, offsetBottom: 80, zIndex: 5000, }, }); expect(created.status).toBe(201); const ui01SlotId = created.body.id as string; const ui01Token = created.body.publicToken as string; expect(created.body.settings).toEqual({ position: 'left', size: 'md', intervalMs: 6000, lang: 'auto', collapsedDefault: true, imageFit: 'cover', offsetX: 40, offsetBottom: 80, zIndex: 5000, }); // 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。 const validBase = { position: 'left', size: 'md', intervalMs: 6000, lang: 'auto', collapsedDefault: true, imageFit: 'cover', offsetX: 40, offsetBottom: 80, zIndex: 5000, } as Record; for (const bad of [ { imageFit: 'fill' }, { offsetX: 121 }, { offsetX: -1 }, { offsetX: 1.5 }, { offsetBottom: 241 }, { zIndex: 0 }, { zIndex: 2147483001 }, ]) { const res = await request(cnBase) .patch(`/v1/admin/embed-slots/${ui01SlotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ settings: { ...validBase, ...bad } }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } // 旧形状整体替换(无新字段)→ 归一化落库(新字段回默认值)+ configVersion+1。 const legacyShape = { position: 'left', size: 'sm', intervalMs: 6000, lang: 'auto', collapsedDefault: false, }; const patched = await request(cnBase) .patch(`/v1/admin/embed-slots/${ui01SlotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ settings: legacyShape }); expect(patched.status).toBe(200); expect(patched.body.configVersion).toBe(2); const normalizedFull = { ...legacyShape, imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, }; expect(patched.body.settings).toEqual(normalizedFull); // 新字段显式 PATCH → 回显显式值。 const explicit = await request(cnBase) .patch(`/v1/admin/embed-slots/${ui01SlotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ settings: { ...legacyShape, imageFit: 'cover', offsetX: 8, offsetBottom: 120, zIndex: 999, }, }); expect(explicit.status).toBe(200); const explicitFull = { ...legacyShape, imageFit: 'cover', offsetX: 8, offsetBottom: 120, zIndex: 999, }; expect(explicit.body.settings).toEqual(explicitFull); // 绑定相册 A → 排空发送器 → ov 公开响应 settings = 投影完整全集。 const bound = await request(cnBase) .patch(`/v1/admin/embed-slots/${ui01SlotId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ albumId: albumAId }); expect(bound.status).toBe(200); await drainSender(); const feed = await getEmbed(ui01Token); expect(feed.status).toBe(200); expect(feed.body.settings).toEqual(explicitFull); // 既有字段不回归:album/photos 照常返回。 expect(feed.body.album.publicToken).toBe(albumAToken); expect(feed.body.photos.length).toBeGreaterThan(0); // 旧形状存量投影行(UI-01 前 ov 副本):直改副本 settings 模拟旧投影 → // 公开响应按契约默认值归一(读取侧归一化,无需回填事件)。 await prismaOv.embed_slots_ov.update({ where: { slotToken: ui01Token }, data: { settings: { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, }, }, }); const staleFeed = await getEmbed(ui01Token); expect(staleFeed.status).toBe(200); expect(staleFeed.body.settings).toEqual({ position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, }); }); }); /** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */ function newwestMs( row: { publishedAt: Date | null } | null, ): string { return String(row?.publishedAt?.getTime() ?? 0); }