aee4b7ac46
Co-Authored-By: Claude Code <noreply@anthropic.com>
759 lines
28 KiB
TypeScript
759 lines
28 KiB
TypeScript
// 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);
|
||
}
|