2026-09-18 17:05:56 +08:00
|
|
|
|
// services/api/test/public-ov.e2e-spec.ts —— P2 Task 6 ov 公开读端点 e2e。
|
|
|
|
|
|
// ONE spec 串「cn 种 → 同步 → ov 读」全链路:进程内起两个真实入口(cn + ov,
|
|
|
|
|
|
// 同 sync.e2e-spec 模式),业务动作全走 cn API(建相册/分类/banner 资产/上传
|
|
|
|
|
|
// complete + 直驱 worker 发布事务),发送器为生产 OutboxSender(指向 ov 入口
|
|
|
|
|
|
// 真实 URL)——ov 读到的数据与生产链路逐字节同源。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 覆盖(任务验收):
|
|
|
|
|
|
// ① 契约同形(本 spec 的核心证明):ov getPublicAlbum/listPublicPhotos/
|
|
|
|
|
|
// getPublicPhoto 与 cn 同端点响应 deep-equal(剥离现签 URL 后逐字段相等;
|
|
|
|
|
|
// album 另差 viewCount——cn 占位 null / ov 真实值);游标字符串相等
|
|
|
|
|
|
// (同一 shared/cursor 编码);分类过滤/单图 categoryId 同形;
|
|
|
|
|
|
// banner 签名 URL 可取回真实字节;locale 回退(en/zh)同形;
|
|
|
|
|
|
// ② viewCount 真实读:无 album_stats 行 → 0;有行 → 行值(计数端点 Task 8);
|
|
|
|
|
|
// ③ 参数校验同形:sort 非法 → 400(ov 新增参数);cursor 非法 → 400;
|
|
|
|
|
|
// category 幽灵 → 400(与 cn 同码同因);幽灵 token 三端点 → 404
|
|
|
|
|
|
// ALBUM_NOT_PUBLIC;
|
|
|
|
|
|
// ④ hot 排序快照:likeTotal 全 0 → photoId 倒序决胜;TTL 窗口内顺序冻结
|
|
|
|
|
|
// (DB 改 likeTotal 后响应顺序不变,计数现读);翻页对同一快照切片;
|
|
|
|
|
|
// TTL 过期后重算 → 顺序翻转;
|
|
|
|
|
|
// ⑤ 隐藏照片:cn 隐藏 → 高优先级同步 → ov 列表剔除 + 单图 404
|
|
|
|
|
|
// PHOTO_NOT_PUBLIC;
|
|
|
|
|
|
// ⑥ 相册下线:cn publicEnabled=false → ov 404(与 cn 同码);
|
|
|
|
|
|
// ⑦ 紧急停用(Task 5 通道 × 公开读):ov disable → ov 404 而 cn 仍 200
|
|
|
|
|
|
// (ov 本地独立位);enable → 恢复。
|
|
|
|
|
|
// 真实 MySQL×2 + MinIO(banner/变体真实 JPEG)+ HMAC 同步链路字节一致。
|
|
|
|
|
|
import { NestFactory } from '@nestjs/core';
|
|
|
|
|
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
|
|
|
|
|
import request from 'supertest';
|
|
|
|
|
|
import sharp from 'sharp';
|
|
|
|
|
|
import { PrismaClient } from '@photo/db-cn';
|
|
|
|
|
|
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
|
|
|
|
|
import { ErrorCodes, 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';
|
2026-09-18 17:51:55 +08:00
|
|
|
|
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
|
|
|
|
|
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
|
|
|
|
|
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
2026-09-18 17:05:56 +08:00
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
|
|
/** hot 快照 TTL(默认 30s 对 e2e 太长:缩短到 2s,「窗口内冻结」断言留足余量)。 */
|
|
|
|
|
|
const HOT_TTL_TEST_MS = 2000;
|
|
|
|
|
|
|
|
|
|
|
|
/** 测试内生成真实 JPEG(同 sync.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(emergency 内部端点)。 */
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** SignedUrl 字段剥离(现签 URL 必然不同:两端各自签名;形状仍断言)。 */
|
|
|
|
|
|
interface ItemNoUrl {
|
|
|
|
|
|
photoId: string;
|
|
|
|
|
|
width: number;
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
likeCount: number;
|
|
|
|
|
|
publishedAt: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripItem(item: {
|
|
|
|
|
|
photoId: string;
|
|
|
|
|
|
width: number;
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
thumb: unknown;
|
|
|
|
|
|
browse: unknown;
|
|
|
|
|
|
likeCount: number;
|
|
|
|
|
|
publishedAt: string;
|
|
|
|
|
|
}): ItemNoUrl {
|
|
|
|
|
|
const { thumb: _thumb, browse: _browse, ...rest } = item;
|
|
|
|
|
|
void _thumb;
|
|
|
|
|
|
void _browse;
|
|
|
|
|
|
return rest;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('public-ov (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 adminToken: string;
|
|
|
|
|
|
|
|
|
|
|
|
let albumId: string;
|
|
|
|
|
|
let publicToken: string;
|
|
|
|
|
|
let categoryId: string;
|
|
|
|
|
|
/** 按上传顺序(publishedAt 升序)的三张照片。 */
|
|
|
|
|
|
let photoIds: string[];
|
|
|
|
|
|
|
|
|
|
|
|
/** 排空发送器:循环 runOnce 直至无未投递事件。 */
|
|
|
|
|
|
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(
|
|
|
|
|
|
clientTaskId: string,
|
|
|
|
|
|
jpeg: Buffer,
|
|
|
|
|
|
withCategory: boolean,
|
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
|
const init = await request(cnBase)
|
|
|
|
|
|
.post('/v1/uploads')
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
albumId,
|
|
|
|
|
|
clientTaskId,
|
|
|
|
|
|
filename: 'pub-ov.jpg',
|
|
|
|
|
|
bytes: jpeg.length,
|
|
|
|
|
|
contentType: 'image/jpeg',
|
|
|
|
|
|
...(withCategory ? { categoryId } : {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** GET(匿名公开端点;supertest Response.body 已是宽松 JSON 类型)。 */
|
|
|
|
|
|
async function getJson(base: string, path: string): Promise<request.Response> {
|
|
|
|
|
|
return request(base).get(path).set('accept', 'application/json');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
|
expect(SYNC_KEY).not.toBe('');
|
|
|
|
|
|
await resetDb();
|
|
|
|
|
|
await resetOvDb();
|
|
|
|
|
|
// hot 快照 TTL 缩短(服务端逐请求惰性读 env,见 public-ov.service)。
|
|
|
|
|
|
process.env.HOT_SORT_TTL_MS = String(HOT_TTL_TEST_MS);
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-18 17:51:55 +08:00
|
|
|
|
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
2026-09-18 17:05:56 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
|
delete process.env.HOT_SORT_TTL_MS;
|
|
|
|
|
|
await cnApp.close();
|
|
|
|
|
|
await ovApp.close();
|
|
|
|
|
|
await prismaCn.$disconnect();
|
|
|
|
|
|
await prismaOv.$disconnect();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('① ov 三端点与 cn 契约同形(deep-equal 剥离现签 URL;游标逐字节相等)', async () => {
|
|
|
|
|
|
// —— cn 侧种全套:相册(zh+en)+ 分类 + banner(全局)+ 3 张发布照片 ——
|
|
|
|
|
|
const album = await request(cnBase)
|
|
|
|
|
|
.post('/v1/albums')
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
translations: [
|
|
|
|
|
|
{ locale: 'zh-CN', title: '海外晚会', description: 'ov 公开读验收', venue: '主会场' },
|
|
|
|
|
|
{ locale: 'en', title: 'Overseas Gala' },
|
|
|
|
|
|
],
|
|
|
|
|
|
defaultLocale: 'zh-CN',
|
|
|
|
|
|
startAt: '2026-10-01T10:00:00.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(album.status).toBe(201);
|
|
|
|
|
|
albumId = album.body.id as string;
|
|
|
|
|
|
publicToken = album.body.publicToken as string;
|
|
|
|
|
|
|
|
|
|
|
|
const category = await request(cnBase)
|
|
|
|
|
|
.post(`/v1/albums/${albumId}/categories`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
sortOrder: 0,
|
|
|
|
|
|
translations: [
|
|
|
|
|
|
{ locale: 'zh-CN', name: '红毯' },
|
|
|
|
|
|
{ locale: 'en', name: 'Red Carpet' },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(category.status).toBe(201);
|
|
|
|
|
|
categoryId = category.body.id as string;
|
|
|
|
|
|
|
|
|
|
|
|
// banner:资产 API 全链路(init → PUT → complete → asset.updated 事件)。
|
|
|
|
|
|
const bannerJpeg = await makeJpeg(1200, 300, { r: 150, g: 100, b: 60 });
|
|
|
|
|
|
const bannerInit = await request(cnBase)
|
|
|
|
|
|
.post(`/v1/albums/${albumId}/assets`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
kind: 'banner',
|
|
|
|
|
|
filename: 'banner.jpg',
|
|
|
|
|
|
bytes: bannerJpeg.length,
|
|
|
|
|
|
contentType: 'image/jpeg',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(bannerInit.status).toBe(201);
|
|
|
|
|
|
const bannerCredential = bannerInit.body.credential as {
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
method: string;
|
|
|
|
|
|
headers: Record<string, string>;
|
|
|
|
|
|
};
|
|
|
|
|
|
expect(
|
|
|
|
|
|
await fetch(bannerCredential.url, {
|
|
|
|
|
|
method: bannerCredential.method,
|
|
|
|
|
|
headers: bannerCredential.headers,
|
|
|
|
|
|
body: new Uint8Array(bannerJpeg),
|
|
|
|
|
|
}).then((r) => r.ok),
|
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
const bannerDone = await request(cnBase)
|
|
|
|
|
|
.post(`/v1/albums/${albumId}/assets/${bannerInit.body.assetId as string}/complete`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
|
|
expect(bannerDone.status).toBe(200);
|
|
|
|
|
|
|
|
|
|
|
|
photoIds = [];
|
|
|
|
|
|
const shapes = [
|
|
|
|
|
|
{ w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 }, withCategory: true },
|
|
|
|
|
|
{ w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 }, withCategory: false },
|
|
|
|
|
|
{ w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 }, withCategory: false },
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
for (let i = 0; i < shapes.length; i++) {
|
|
|
|
|
|
const s = shapes[i]!;
|
|
|
|
|
|
photoIds.push(
|
|
|
|
|
|
await uploadAndPublish(
|
|
|
|
|
|
`pub-ov-${i}`,
|
|
|
|
|
|
await makeJpeg(s.w, s.h, s.rgb),
|
|
|
|
|
|
s.withCategory,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
await drainSender();
|
|
|
|
|
|
|
|
|
|
|
|
// —— getPublicAlbum 同形(zh 缺省 + en 显式两个 locale)——
|
|
|
|
|
|
for (const localeQuery of ['', '?locale=en']) {
|
|
|
|
|
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
|
|
|
|
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
|
|
|
|
|
expect(ov.status).toBe(200);
|
|
|
|
|
|
// 剥离 banner(现签 URL 必不同)与 viewCount(cn 占位 null / ov 真实 0)
|
|
|
|
|
|
// 后逐字段相等——契约同形的直接证明。
|
|
|
|
|
|
const { banner: _cb, viewCount: _cv, ...cnRest } = cn.body;
|
|
|
|
|
|
const { banner: ovBanner, viewCount: ovViewCount, ...ovRest } = ov.body;
|
|
|
|
|
|
void _cb;
|
|
|
|
|
|
void _cv;
|
|
|
|
|
|
expect(ovRest).toEqual(cnRest);
|
|
|
|
|
|
expect(ovViewCount).toBe(0); // 无 album_stats 行 → 默认 0(真实值语义)
|
|
|
|
|
|
// banner:两端都有、形状合法、ov URL 取回真实字节(签名对副本键有效)。
|
|
|
|
|
|
expect(ovBanner).not.toBeNull();
|
|
|
|
|
|
expect(typeof ovBanner.url).toBe('string');
|
|
|
|
|
|
expect(Date.parse(ovBanner.expiresAt)).toBeGreaterThan(Date.now());
|
|
|
|
|
|
const fetched = await fetch(ovBanner.url as string);
|
|
|
|
|
|
expect(fetched.ok).toBe(true);
|
|
|
|
|
|
expect((await fetched.arrayBuffer()).byteLength).toBe(bannerJpeg.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// —— listPublicPhotos 同形:默认页 / limit=2(hasMore+游标值)/ 分类过滤 ——
|
|
|
|
|
|
const listCases = [
|
|
|
|
|
|
'',
|
|
|
|
|
|
'?limit=2',
|
|
|
|
|
|
`?limit=2&category=${categoryId}`,
|
|
|
|
|
|
'?sort=latest',
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const query of listCases) {
|
|
|
|
|
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
|
|
|
|
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
|
|
|
|
|
expect(ov.status).toBe(200);
|
|
|
|
|
|
expect(ov.body.items.map(stripItem)).toEqual(cn.body.items.map(stripItem));
|
|
|
|
|
|
expect(ov.body.nextCursor).toBe(cn.body.nextCursor); // 同一 shared/cursor 编码 → 逐字节相等
|
|
|
|
|
|
expect(ov.body.hasMore).toBe(cn.body.hasMore);
|
|
|
|
|
|
// 现签 URL 形状仍校验(每条可取回真实字节)。
|
|
|
|
|
|
for (const item of ov.body.items) {
|
|
|
|
|
|
expect(Date.parse(item.thumb.expiresAt)).toBeGreaterThan(Date.now());
|
|
|
|
|
|
expect(await fetch(item.thumb.url).then((r) => r.ok)).toBe(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// limit=2 分页确实产生了 hasMore(上面游标相等性才有意义)。
|
|
|
|
|
|
const firstPage = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?limit=2`);
|
|
|
|
|
|
expect(firstPage.body.hasMore).toBe(true);
|
|
|
|
|
|
expect(firstPage.body.items).toHaveLength(2);
|
|
|
|
|
|
|
|
|
|
|
|
// —— getPublicPhoto 同形(含 categoryId;刷新入口语义=每次现签)——
|
|
|
|
|
|
for (const photoId of photoIds) {
|
|
|
|
|
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
|
|
|
|
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
|
|
|
|
|
expect(ov.status).toBe(200);
|
|
|
|
|
|
expect(stripItem(ov.body)).toEqual(stripItem(cn.body));
|
|
|
|
|
|
expect(ov.body.categoryId).toBe(cn.body.categoryId);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('② viewCount 真实读:album_stats 行值(计数端点 Task 8 接线)', async () => {
|
|
|
|
|
|
const before = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
|
|
|
|
|
expect(before.body.viewCount).toBe(0);
|
|
|
|
|
|
await prismaOv.album_stats.create({
|
|
|
|
|
|
data: { albumId, viewCount: 5, statVersion: 3 },
|
|
|
|
|
|
});
|
|
|
|
|
|
const after = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
|
|
|
|
|
expect(after.body.viewCount).toBe(5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('③ 参数校验同形:sort/cursor/category 400;幽灵 token 三端点 404', async () => {
|
|
|
|
|
|
const badSort = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=bogus`);
|
|
|
|
|
|
expect(badSort.status).toBe(400);
|
|
|
|
|
|
expect(badSort.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
|
|
|
|
|
|
|
|
|
|
|
const badCursor = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?cursor=zzz`);
|
|
|
|
|
|
expect(badCursor.status).toBe(400);
|
|
|
|
|
|
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
|
|
|
|
|
|
|
|
|
|
|
// 幽灵分类:与 cn 同码同因(防探测,不区分不存在/跨册)。
|
|
|
|
|
|
for (const base of [cnBase, ovBase]) {
|
|
|
|
|
|
const ghostCategory = await getJson(base, `/v1/public/albums/${publicToken}/photos?category=cat-ghost`);
|
|
|
|
|
|
expect(ghostCategory.status).toBe(400);
|
|
|
|
|
|
expect(ghostCategory.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ghostToken = 'g' + '0'.repeat(23);
|
|
|
|
|
|
for (const path of [
|
|
|
|
|
|
`/v1/public/albums/${ghostToken}`,
|
|
|
|
|
|
`/v1/public/albums/${ghostToken}/photos`,
|
|
|
|
|
|
`/v1/public/albums/${ghostToken}/photos/p-x`,
|
|
|
|
|
|
]) {
|
|
|
|
|
|
const res = await getJson(ovBase, path);
|
|
|
|
|
|
expect(res.status).toBe(404);
|
|
|
|
|
|
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('④ hot 排序:photoId 决胜 → TTL 窗口冻结(计数现读)→ 过期翻转', async () => {
|
|
|
|
|
|
// likeTotal 全 0:hot = likeTotal DESC, photoId DESC(id 决胜)。
|
|
|
|
|
|
const idDesc = [...photoIds].sort((a, b) => (a < b ? 1 : -1));
|
|
|
|
|
|
const first = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot&limit=2`);
|
|
|
|
|
|
expect(first.status).toBe(200);
|
|
|
|
|
|
expect(first.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc.slice(0, 2));
|
|
|
|
|
|
expect(first.body.hasMore).toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
// 快照语义:DB 改 likeTotal 后,TTL 窗口内顺序冻结(仍 id 序)——但条目
|
|
|
|
|
|
// 计数现读(尾张已带新值)。
|
|
|
|
|
|
const tail = idDesc[2]!;
|
|
|
|
|
|
await prismaOv.photos_ov.update({
|
|
|
|
|
|
where: { photoId: tail },
|
|
|
|
|
|
data: { likeTotal: 10 },
|
|
|
|
|
|
});
|
|
|
|
|
|
const stale = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
|
|
|
|
|
expect(stale.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc);
|
|
|
|
|
|
expect(stale.body.items.find((i: ItemNoUrl) => i.photoId === tail).likeCount).toBe(10);
|
|
|
|
|
|
|
|
|
|
|
|
// 翻页对同一快照切片:第 2 页从冻结清单 offset 2 取——尾张仍在末位。
|
|
|
|
|
|
const page2 = await getJson(
|
|
|
|
|
|
ovBase,
|
|
|
|
|
|
`/v1/public/albums/${publicToken}/photos?sort=hot&limit=2&cursor=${first.body.nextCursor as string}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(page2.status).toBe(200);
|
|
|
|
|
|
expect(page2.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([tail]);
|
|
|
|
|
|
expect(page2.body.hasMore).toBe(false);
|
|
|
|
|
|
|
|
|
|
|
|
// TTL 过期 → 快照重算 → 顺序翻转(尾张 +10 居首)。
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, HOT_TTL_TEST_MS + 200));
|
|
|
|
|
|
const flipped = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
|
|
|
|
|
expect(flipped.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([
|
|
|
|
|
|
tail,
|
|
|
|
|
|
...idDesc.slice(0, 2),
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('⑤ 隐藏照片:cn 隐藏 → 同步 → ov 列表剔除 + 单图 404 PHOTO_NOT_PUBLIC', async () => {
|
|
|
|
|
|
const hidden = photoIds[1]!;
|
|
|
|
|
|
const hide = await request(cnBase)
|
|
|
|
|
|
.patch(`/v1/photos/${hidden}`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({ visibility: 'hidden' });
|
|
|
|
|
|
expect(hide.status).toBe(200);
|
|
|
|
|
|
await drainSender();
|
|
|
|
|
|
|
|
|
|
|
|
const cnList = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos`);
|
|
|
|
|
|
const ovList = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos`);
|
|
|
|
|
|
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(
|
|
|
|
|
|
cnList.body.items.map((i: ItemNoUrl) => i.photoId),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).not.toContain(hidden);
|
|
|
|
|
|
|
|
|
|
|
|
const single = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${hidden}`);
|
|
|
|
|
|
expect(single.status).toBe(404);
|
|
|
|
|
|
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('⑥ 相册下线:cn publicEnabled=false → ov 与 cn 同码 404', async () => {
|
|
|
|
|
|
const offline = await request(cnBase)
|
|
|
|
|
|
.patch(`/v1/albums/${albumId}`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({ publicEnabled: false });
|
|
|
|
|
|
expect(offline.status).toBe(200);
|
|
|
|
|
|
await drainSender();
|
|
|
|
|
|
|
|
|
|
|
|
for (const base of [cnBase, ovBase]) {
|
|
|
|
|
|
const album = await getJson(base, `/v1/public/albums/${publicToken}`);
|
|
|
|
|
|
expect(album.status).toBe(404);
|
|
|
|
|
|
expect(album.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
|
|
|
|
|
const list = await getJson(base, `/v1/public/albums/${publicToken}/photos`);
|
|
|
|
|
|
expect(list.status).toBe(404);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('⑦ 紧急停用:ov disable → ov 404 而 cn 无感;enable 恢复', async () => {
|
|
|
|
|
|
// 独立相册(不影响前序场景):cn 建 + 同步 → ov 可读。
|
|
|
|
|
|
const album = await request(cnBase)
|
|
|
|
|
|
.post('/v1/albums')
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
translations: [{ locale: 'zh-CN', title: '紧急停用验证' }],
|
|
|
|
|
|
defaultLocale: 'zh-CN',
|
|
|
|
|
|
startAt: '2026-10-01T10:00:00.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(album.status).toBe(201);
|
|
|
|
|
|
const token = album.body.publicToken as string;
|
|
|
|
|
|
await drainSender();
|
|
|
|
|
|
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
|
|
|
|
|
|
|
|
|
|
|
// ov 本地停用(独立于同步版本链)。
|
|
|
|
|
|
const disable = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
|
|
|
|
|
albumToken: token,
|
|
|
|
|
|
reason: 'e2e: public-ov visibility',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(disable.status).toBe(200);
|
|
|
|
|
|
const ovRes = await getJson(ovBase, `/v1/public/albums/${token}`);
|
|
|
|
|
|
expect(ovRes.status).toBe(404);
|
|
|
|
|
|
expect(ovRes.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
|
|
|
|
|
// cn 无感:仍 200(紧急停用是 ov 观众域动作,cn 权威数据不变)。
|
|
|
|
|
|
expect((await getJson(cnBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
|
|
|
|
|
|
|
|
|
|
|
// 显式 enable → 恢复。
|
|
|
|
|
|
const enable = await signedPost(ovBase, '/v1/internal/emergency/enable', {
|
|
|
|
|
|
albumToken: token,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(enable.status).toBe(200);
|
|
|
|
|
|
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
|
|
|
|
|
});
|
2026-09-23 10:13:08 +08:00
|
|
|
|
|
|
|
|
|
|
it('⑥ banner 回退 cover(asset.updated kind=cover 投影 coverAssetKey + ov 读侧回退)', async () => {
|
|
|
|
|
|
// cn 真实链路:建相册 → cover 资产 init/PUT/complete(真实 asset.updated 事件)。
|
|
|
|
|
|
const album = await request(cnBase)
|
|
|
|
|
|
.post('/v1/albums')
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
translations: [{ locale: 'zh-CN', title: '仅封面 ov 相册' }],
|
|
|
|
|
|
defaultLocale: 'zh-CN',
|
|
|
|
|
|
startAt: '2026-10-02T10:00:00.000Z',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(album.status).toBe(201);
|
|
|
|
|
|
const token = album.body.publicToken as string;
|
|
|
|
|
|
|
|
|
|
|
|
const coverJpeg = await makeJpeg(960, 320, { r: 90, g: 130, b: 180 });
|
|
|
|
|
|
const init = await request(cnBase)
|
|
|
|
|
|
.post(`/v1/albums/${album.body.id as string}/assets`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
|
|
|
|
.send({
|
|
|
|
|
|
kind: 'cover',
|
|
|
|
|
|
filename: 'cover.jpg',
|
|
|
|
|
|
bytes: coverJpeg.length,
|
|
|
|
|
|
contentType: 'image/jpeg',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(init.status).toBe(201);
|
|
|
|
|
|
const credential = init.body.credential as { url: string; method: string; headers: Record<string, string> };
|
|
|
|
|
|
await fetch(credential.url, {
|
|
|
|
|
|
method: credential.method,
|
|
|
|
|
|
headers: credential.headers,
|
|
|
|
|
|
body: new Uint8Array(coverJpeg),
|
|
|
|
|
|
});
|
|
|
|
|
|
const done = await request(cnBase)
|
|
|
|
|
|
.post(`/v1/albums/${album.body.id as string}/assets/${init.body.assetId as string}/complete`)
|
|
|
|
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
|
|
expect(done.status).toBe(200);
|
|
|
|
|
|
|
|
|
|
|
|
// 真实发送管线:asset.updated(cover) → ov coverAssetKey 投影。
|
|
|
|
|
|
await drainSender();
|
|
|
|
|
|
|
|
|
|
|
|
// ov 读侧回退:bannerAssetKey 空 → coverAssetKey 签发(字节=cover 对象)。
|
|
|
|
|
|
const before = await prismaOv.albums_ov.findUnique({ where: { publicToken: token } });
|
|
|
|
|
|
expect(before?.bannerAssetKey).toBeNull();
|
|
|
|
|
|
expect(before?.coverAssetKey).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
|
|
const ov = await getJson(ovBase, `/v1/public/albums/${token}`);
|
|
|
|
|
|
expect(ov.status).toBe(200);
|
|
|
|
|
|
const banner = ov.body.banner as { url: string };
|
|
|
|
|
|
expect(banner).not.toBeNull();
|
|
|
|
|
|
const fetched = await fetch(banner.url);
|
|
|
|
|
|
expect(fetched.status).toBe(200);
|
|
|
|
|
|
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
|
|
|
|
|
|
});
|
2026-09-18 17:05:56 +08:00
|
|
|
|
});
|