Files
photo-live/services/api/test/embed.e2e-spec.ts
T
2026-09-20 22:22:19 +08:00

759 lines
28 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/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 也消耗预算)。
// 真实 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,
},
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);
expect(second.body.settings).toEqual({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'zh-CN',
collapsedDefault: 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);
}
}
});
});
/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */
function newwestMs(
row: { publishedAt: Date | null } | null,
): string {
return String(row?.publishedAt?.getTime() ?? 0);
}