Files
photo-live/services/api/test/embed.e2e-spec.ts
T

947 lines
34 KiB
TypeScript
Raw Normal View History

// 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<Buffer> {
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<Response> {
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<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(
targetAlbumId: string,
clientTaskId: string,
jpeg: Buffer,
): Promise<string> {
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<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 ${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<request.Response> {
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<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,
});
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,
// UI-04 新字段(旧形状创建 → 归一化默认:carousel/16/true)。
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
},
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,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
});
// 校验失败: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<string, unknown>;
// 更高版本「复活」事件(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,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
});
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
const validBase = {
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
} as Record<string, unknown>;
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,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
};
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,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
};
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,
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
});
});
});
/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */
function newwestMs(
row: { publishedAt: Date | null } | null,
): string {
return String(row?.publishedAt?.getTime() ?? 0);
}