Files
photo-live/services/api/test/ads.e2e-spec.ts
T
浅念 bfba76b5e6 fix(ads): 终审修复——staging 前缀+运行时上限核验/失效绑定编辑/白名单去 heic/杂项
- verifyStaging 硬化(services/api/src/ads/ads.service.ts):新增键前缀核验
  (仅 OBJECT_KEY_ROOT/ads/staging/ 布局,防照片域任意键被提升为公开广告)
  与运行时 2MB 上限(head.bytes > AD_UPLOAD_MAX_BYTES → 400 报文含限额值
  ——init 申明管不住实际 PUT 体,create/update 是唯一核验点);e2e ⑰ 以
  测试 storage 直接 presignPut 真实对象覆盖两分支
- 失效绑定编辑 400(admin AdsFormDialog):PATCH 仅在 albumIds 实际变化时
  携带(albumIdsChanged 序敏感深比较,纯函数落 adsLogic + vitest 用例);
  preloadOptions 解析失败的绑定 ID 加「已失效,可移除」后缀(zh/en 新增
  adsManage.form.staleAlbum)——软删绑定不再挡住其它字段保存
- 白名单去 heic(原图直出无变体兜底,Chromium/Firefox 不渲染=破图):
  contracts 三处 enum 收窄 [image/jpeg, image/png] 并重生成类型;dto 定义
  广告域独立 AD_IMAGE_CONTENT_TYPES(与照片域 UPLOAD_CONTENT_TYPES 分离),
  AD_IMAGE_EXTENSIONS 由其派生;init 对 .heic 文件名以广告域集合二次核验
  拒绝(共享 extOfFilename 仍认 heic,杜绝 init 放行→create 中段断裂);
  e2e heic 矩阵 400;admin accept/文案/类型同步收窄
- searchAlbums 补 catch(http 层已弹 toast,本地静默保持旧选项)
- ActivityPage 关闭记忆测试补位(activity.spec):dismissed 不拉取不渲染、
  close 写 sessionStorage 并清空轮播两用例(getPublicAds 入替身防真实 fetch)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 02:20:21 +08:00

1050 lines
39 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/ads.e2e-spec.ts —— 广告位域 e2e(ads-carousel)。
// Task 5 段聚焦「事件→投影」直断:手工以 AdUpdatedPayload/AdDeletedPayload
// 构造 cn outbox_events 行(albumId 恒空串——契约信封约定,绑定关系在载荷
// albumIds),经生产 OutboxSender 真实 HTTP 推送到测试内起的 ov 入口,断言
// ads_ov 副本与 sync_state 版本账本——与生产链路逐字节同源(embed.e2e-spec
// 模式:AppOvModule + drainSender)。
//
// 覆盖(5 用例,链条递进共享同一 adId):
// ① ad.updated 快照(enabled=true, albumIds=null)→ 副本行全字段 + 版本
// 账本就位;
// ② v2(enabled=false)→ 删行(disabled=公开不可见=无投影,设计 §2.2/§4);
// ③ v3(enabled=true, albumIds 数组)→ 同 adId upsert 复活(albumIds 整体
// 替换);
// ④ 旧版本重放(v2 迟到于 v3 后,新 eventId)→ 行保持 v3(版本单调不回滚
// ——迟到的 disabled 快照不能删掉现行行);
// ⑤ ad.deleted 墓碑 v4(high 优先级)→ 删行,账本收敛 v4。
// ④ 在 ⑤ 之前执行:stale 重放的断言基线是「行处于 v3 态」,墓碑(v4)之后
// 无法回到该基线(此后 v3 亦为 stale)。
//
// Task 6 追加段(cn HTTP):/v1/admin/ads/uploads 上传初始化——
// ⑥ admin 201:staging 键形态 + 凭证真实可用(PUT 实写 MinIO 后 head 可核,
// 对照 uploads.e2e 的 presign 断言写法;无会话概念=不落任何 DB 行);
// ⑦ 非 admin 403;匿名 401(入参合法);
// ⑧ 超过 AD_UPLOAD_MAX_BYTES(2MB)400(服务层,报文含限额值);
// contentType 非白名单 400(DTO @IsIn);扩展名不接受 400(服务层)。
//
// Task 7 追加段(cn HTTP CRUD + outbox + audit + 同步贯通):
// ⑨ 创建:真图直传 → POST /admin/ads → 201:行就位(albumIds 未提交归一
// null)、outbox {type:'ad.updated', albumId:'', entityVersion:1}(载荷=
// 全量快照)、audit ad.create、imageUrl 为现签 URL(GET 回真实字节)、
// 终键 v1 对象 head 可见;
// ⑩ albumIds 定向:绑定两相册(含重复元素去重)→ 载荷回传数组;传 []/
// null 归一 null;绑定不存在的相册 400 VALIDATION_FAILED;
// ⑪ linkUrl 非法(javascript:/data:、超长 2048+)400(DTO pattern/
// maxLength);
// ⑫ PATCH:title/enabled/sortOrder/albumIds 各自 bump entityVersion+1;
// 停用(true→false)outbox priority='high',常规更新 normal;空请求体
// 400;albumIds 缺省不碰(null=转全局、数组=整体替换);不存在的 ad 404;
// ⑬ PATCH 换图:三字段缺一 400(服务层同时性校验);齐备(新 staging 真
// 图)→ 终键 v{N+1} 新对象(head 可见新字节/类型);旧版本对象不回收;
// ⑭ DELETE:行删 + outbox {type:'ad.deleted', priority:'high',
// entityVersion=末+1}(载荷={adId, entityVersion})+ audit ad.delete;
// 再删 404(NOT_FOUND);
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
// 就位;停用→drainSender→行删;删除→drainSender→行删(账本收敛墓碑版)。
//
// 终审修复追加段(verifyStaging 硬化 + heic 白名单收窄):
// ⑰ 非 ads/staging 前缀键(照片域路径冒充 staging,真实 PUT 对象后创建)→
// 400 VALIDATION_FAILED——防任意域对象被提升为公开广告;
// 合法前缀 + 实际 2MB+1 字节(init 申明管不住 PUT 体)→ 400
// VALIDATION_FAILED(报文含限额值——create 是运行时上限唯一核验点);
// ⑧ 追加 heic 矩阵:contentType=image/heic 与 .heic 文件名(类型合法)均
// 400(白名单收窄为 jpeg/png,heic 桌面浏览器不渲染)。
import { randomBytes } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import type { Prisma } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import {
AD_UPLOAD_MAX_BYTES,
type AdDeletedPayload,
type AdUpdatedPayload,
ErrorCodes,
} from '@photo/contracts';
import {
OBJECT_KEY_ROOT,
createStorageFromEnv,
type StoragePort,
} 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 { hashSessionToken } from '../src/auth/session.guard';
import { configureApp } from '../src/shared/configure-app';
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
import { resetDb } from './reset';
import { resetOvDb } from './reset-ov';
import { OutboxSender } from '../../sync/src/sender';
jest.setTimeout(120_000);
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
/** 本 spec 全程操作的单个广告位 ID(链条用例共享;VarChar(32) 内)。 */
const AD_ID = 'ads-e2e-0001';
/** cn 原值创建时刻(ISO UTC)——排序 tie-break 位,断言原值回传。 */
const AD_CREATED_AT = '2026-09-28T08:00:00.000Z';
/** 副本行断言所需的最小结构(prismaOv.ads_ov 行的形状子集)。 */
interface AdsOvRow {
adId: string;
imageObjectKey: string;
linkUrl: string;
sortOrder: number;
albumIds: unknown;
entityVersion: number;
createdAt: Date;
updatedAt: Date;
}
describe('ads domain (e2e)', () => {
let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication;
let cnBase: string;
let ovBase: string;
let prismaCn: PrismaClient;
let prismaOv: OvPrismaClient;
let sender: OutboxSender;
let storage: StoragePort;
let adminToken: string;
let staffToken: string;
// outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt
// 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。
const occurredBase = Date.now() - 60_000;
let seq = 0;
/** ③ 落位的 v3 快照(④ stale 断言的基线)。 */
let v3Snapshot: AdUpdatedPayload;
// ── Task 7 追加段共用状态 ──
/** ⑨ 创建、⑫-⑭ 顺次推进的 CRUD 链条 ad(版本链 v1..v7 → 墓碑 v8)。 */
let crudAdId = '';
/** ⑩/⑫/⑯ 定向投放用的两个直种相册。 */
let adAlbum1 = '';
let adAlbum2 = '';
/** 排空发送器:循环 runOnce 直至无未投递事件(embed.e2e 同款实现)。 */
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 仍有未投递事件');
}
/**
* 手工构造 outbox 行(绕过 CRUD——Task 7 才有):albumId 恒空串(ad.* 信封
* 约定);occurredAt 按序号递增保证批内顺序。返回 eventId(outbox 主键,
* ④ 断言 stale 行 appliedAt 用)。
*/
async function enqueueAdEvent(
type: 'ad.updated' | 'ad.deleted',
entityVersion: number,
payload: AdUpdatedPayload | AdDeletedPayload,
priority: 'normal' | 'high' = 'normal',
): Promise<string> {
const row = await prismaCn.outbox_events.create({
data: {
type,
albumId: '',
entityVersion,
priority,
payload: payload as unknown as Prisma.InputJsonValue,
occurredAt: new Date(occurredBase + seq * 1000),
},
select: { id: true },
});
seq += 1;
return row.id;
}
/** ad.updated 全量快照载荷(版本相关字段随 v 演进,可覆盖)。 */
function adSnapshot(
v: number,
overrides: Partial<AdUpdatedPayload> = {},
): AdUpdatedPayload {
return {
adId: AD_ID,
imageObjectKey: `photo-live/dev/ads/${AD_ID}/v${v}.jpg`,
linkUrl: `https://example.com/ads?v=${v}`,
enabled: true,
sortOrder: v * 10,
albumIds: null,
createdAt: AD_CREATED_AT,
entityVersion: v,
...overrides,
};
}
/** 断言副本行 === 快照全字段(updatedAt 为 ov 应用时刻,仅断言存在)。 */
function expectReplica(row: AdsOvRow | null, p: AdUpdatedPayload): void {
expect(row).toMatchObject({
adId: AD_ID,
imageObjectKey: p.imageObjectKey,
linkUrl: p.linkUrl,
sortOrder: p.sortOrder,
albumIds: p.albumIds,
entityVersion: p.entityVersion,
createdAt: new Date(AD_CREATED_AT),
updatedAt: expect.any(Date),
});
}
/** 断言版本账本收敛在指定版本。 */
async function expectAppliedVersion(v: number): Promise<void> {
const state = await prismaOv.sync_state.findUnique({
where: { entityType_entityId: { entityType: 'ad', entityId: AD_ID } },
});
expect(state?.appliedVersion).toBe(v);
}
// ── Task 7 追加段共用工具 ──
/**
* 真实直传广告图到 staging(init → PUT MinIO,Task 6 的 ⑥ 同款链路抽出的
* helper):返回创建/换图请求所需的申明三元组。
*/
async function uploadAdImage(
bytes: number,
contentType: 'image/jpeg' | 'image/png' = 'image/jpeg',
filename = 'ad-banner.jpg',
): Promise<{
imageStagingKey: string;
imageBytes: number;
imageContentType: string;
}> {
const init = await request(cnBase)
.post('/v1/admin/ads/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send({ filename, bytes, contentType });
expect(init.status).toBe(201);
const put = await fetch(init.body.credential.url, {
method: init.body.credential.method,
headers: init.body.credential.headers,
body: new Uint8Array(Buffer.alloc(bytes, 9)),
});
expect(put.ok).toBe(true);
return {
imageStagingKey: init.body.objectKey,
imageBytes: bytes,
imageContentType: contentType,
};
}
/** 组装合法创建请求体(真实 staging 图,字段可覆盖)。 */
async function adCreateBody(
overrides: Record<string, unknown> = {},
): Promise<Record<string, unknown>> {
return {
linkUrl: 'https://sponsor.example/landing',
...(await uploadAdImage(200)),
...overrides,
};
}
/** 该 ad 的 outbox 事件行(payload.adId 过滤,occurredAt 升序)。 */
async function adOutboxEvents(
adId: string,
type: 'ad.updated' | 'ad.deleted',
) {
const rows = await prismaCn.outbox_events.findMany({
where: { type },
orderBy: { occurredAt: 'asc' },
});
return rows.filter((r) => (r.payload as { adId?: string }).adId === adId);
}
/** 断言 cn ads 行匹配(部分字段 toMatchObject)。 */
async function expectAdRow(
adId: string,
expected: Record<string, unknown>,
): Promise<void> {
const row = await prismaCn.ads.findUniqueOrThrow({ where: { id: adId } });
expect(row).toMatchObject(expected);
}
/** 直种相册行(albumIds 存在性校验标的;ads 域只读 id,无需翻译行)。 */
async function seedAdAlbum(alias: string): Promise<string> {
const album = await prismaCn.albums.create({
data: {
publicToken: `adalb-${alias}-${randomBytes(6).toString('hex')}`,
ownerId: 'ads-e2e-owner',
startAt: new Date('2026-10-01T00:00:00.000Z'),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
return album.id;
}
/** 断言 ov 版本账本收敛在指定版本(任意 adId)。 */
async function expectOvAppliedVersion(adId: string, v: number): Promise<void> {
const state = await prismaOv.sync_state.findUnique({
where: { entityType_entityId: { entityType: 'ad', entityId: adId } },
});
expect(state?.appliedVersion).toBe(v);
}
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
await resetOvDb();
// cn 入口仅作骨架就位(①-⑤ 不走 cn HTTP;outbox 行直插 cn 库)——Task 6
// 追加段起复用同一实例走 /v1/admin/ads/uploads。
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();
sender = new OutboxSender({
prisma: prismaCn,
ovApiUrl: ovBase,
internalKey: SYNC_KEY,
});
storage = createStorageFromEnv();
// Task 6 追加段身份:admin(seedAdmin + 真实登录)与 staff 对照账号
// (直插 users + sessions,embed.e2e 同款);匿名 = 不带 Authorization。
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;
const staff = await prismaCn.users.create({
data: {
account: 'ads-staff',
role: 'staff',
passwordHash: 'x',
displayName: 'ads-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();
});
it('① ad.updated 快照(enabled=true, albumIds=null)→ 副本行 + 版本账本就位', async () => {
const v1 = adSnapshot(1);
await enqueueAdEvent('ad.updated', 1, v1);
await drainSender();
const replica = await prismaOv.ads_ov.findUnique({
where: { adId: AD_ID },
});
expectReplica(replica, v1);
// albumIds null(全局投放)→ SQL NULL,非 JSON null。
expect(replica?.albumIds).toBeNull();
const state = await prismaOv.sync_state.findUnique({
where: { entityType_entityId: { entityType: 'ad', entityId: AD_ID } },
});
expect(state).toMatchObject({
entityType: 'ad',
entityId: AD_ID,
appliedVersion: 1,
watermark: false,
});
});
it('② ad.updated v2(enabled=false)→ 行删除(disabled=无投影)', async () => {
await enqueueAdEvent('ad.updated', 2, adSnapshot(2, { enabled: false }));
await drainSender();
expect(
await prismaOv.ads_ov.findUnique({ where: { adId: AD_ID } }),
).toBeNull();
await expectAppliedVersion(2);
});
it('③ ad.updated v3(enabled=true, albumIds 数组)→ 行复活(同 adId upsert,albumIds 整体替换)', async () => {
v3Snapshot = adSnapshot(3, {
albumIds: ['ads-e2e-alb-1', 'ads-e2e-alb-2'],
sortOrder: 35,
linkUrl: 'https://example.com/ads?promo=v3',
});
await enqueueAdEvent('ad.updated', 3, v3Snapshot);
await drainSender();
const replica = await prismaOv.ads_ov.findUnique({
where: { adId: AD_ID },
});
expectReplica(replica, v3Snapshot);
// 定向投放数组逐元素回传(JSON 列整体替换)。
expect(replica?.albumIds).toEqual(['ads-e2e-alb-1', 'ads-e2e-alb-2']);
await expectAppliedVersion(3);
});
it('④ 旧版本重放(v2 迟到于 v3 后,新 eventId)→ 行保持 v3(版本单调不回滚)', async () => {
const staleEventId = await enqueueAdEvent(
'ad.updated',
2,
adSnapshot(2, { enabled: false }),
);
await drainSender();
// 行保持 v3 态:迟到的 disabled 快照(若被应用会删行)未生效。
const replica = await prismaOv.ads_ov.findUnique({
where: { adId: AD_ID },
});
expectReplica(replica, v3Snapshot);
expect(replica?.albumIds).toEqual(['ads-e2e-alb-1', 'ads-e2e-alb-2']);
await expectAppliedVersion(3);
// stale 事件裁决即终(appliedAt 置位、不入积压)。
const staleRow = await prismaOv.sync_inbox.findUnique({
where: { eventId: staleEventId },
});
expect(staleRow?.appliedAt).not.toBeNull();
});
it('⑤ ad.deleted 墓碑 v4(high)→ 行删除,账本收敛 v4', async () => {
await enqueueAdEvent(
'ad.deleted',
4,
{ adId: AD_ID, entityVersion: 4 },
'high',
);
await drainSender();
expect(
await prismaOv.ads_ov.findUnique({ where: { adId: AD_ID } }),
).toBeNull();
await expectAppliedVersion(4);
});
// ── Task 6 追加段:/v1/admin/ads/uploads 上传初始化(cn HTTP,admin-only)──
it('⑥ admin 初始化 → 201 staging 键 + 凭证(真实 PUT 实写 MinIO,head 可核)', async () => {
const res = await request(cnBase)
.post('/v1/admin/ads/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send({ filename: 'ad-banner.jpeg', bytes: 256, contentType: 'image/jpeg' });
expect(res.status).toBe(201);
// 响应形状精确断言(契约 AdUploadInitResponse;无会话概念=无其他字段)。
expect(Object.keys(res.body).sort()).toEqual(['credential', 'objectKey']);
expect(Object.keys(res.body.credential).sort()).toEqual(
['expiresAt', 'headers', 'method', 'mode', 'url'],
);
expect(res.body.credential).toMatchObject({
mode: 'presigned',
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
});
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
// staging 键形态:photo-live/dev/ads/staging/{cuid 25 字符}.{ext};jpeg 归一
// 为 jpg(扩展名白名单映射,同上传域口径)。
expect(res.body.objectKey).toMatch(
/^photo-live\/dev\/ads\/staging\/c[0-9a-z]{24}\.jpg$/,
);
// 凭证真实可用:按凭证 PUT 后 head 可见精确字节数与类型(fetch,Node 22)。
const put = await fetch(res.body.credential.url, {
method: res.body.credential.method,
headers: res.body.credential.headers,
body: new Uint8Array(Buffer.alloc(256, 7)),
});
expect(put.ok).toBe(true);
expect(await storage.head(res.body.objectKey)).toMatchObject({
bytes: 256,
contentType: 'image/jpeg',
});
});
it('⑦ 非 admin 403;匿名 401(入参合法)', async () => {
const body = { filename: 'ad.png', bytes: 100, contentType: 'image/png' };
const staff = await request(cnBase)
.post('/v1/admin/ads/uploads')
.set('Authorization', `Bearer ${staffToken}`)
.send(body);
expect(staff.status).toBe(403);
expect(staff.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const anon = await request(cnBase).post('/v1/admin/ads/uploads').send(body);
expect(anon.status).toBe(401);
});
it('⑧ 超过 2MB / contentType 非白名单 / 扩展名不接受 → 400 VALIDATION_FAILED', async () => {
const post = (payload: object) =>
request(cnBase)
.post('/v1/admin/ads/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send(payload);
const over = await post({
filename: 'big.jpg',
bytes: AD_UPLOAD_MAX_BYTES + 1,
contentType: 'image/jpeg',
});
expect(over.status).toBe(400);
expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 服务层限额报文含限额值(DTO 不设 @Max,限额唯一裁决点在服务层)。
expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES));
const badType = await post({
filename: 'a.gif',
bytes: 100,
contentType: 'image/gif',
});
expect(badType.status).toBe(400);
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const badExt = await post({
filename: 'a.gif',
bytes: 100,
contentType: 'image/jpeg',
});
expect(badExt.status).toBe(400);
expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const noExt = await post({
filename: 'noext',
bytes: 100,
contentType: 'image/jpeg',
});
expect(noExt.status).toBe(400);
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// heic 收窄(终审 Fix 3):contentType 非广告白名单 → DTO @IsIn 拒绝。
const heicType = await post({
filename: 'a.heic',
bytes: 100,
contentType: 'image/heic',
});
expect(heicType.status).toBe(400);
expect(heicType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// heic 文件名 + 合法 contentType:共享 extOfFilename 仍认 heic(照片域
// 白名单),init 以广告域 AD_IMAGE_EXTENSIONS 二次核验拒绝(杜绝 init
// 放行 .heic staging 键 → create 中段断裂)。
const heicExt = await post({
filename: 'a.heic',
bytes: 100,
contentType: 'image/jpeg',
});
expect(heicExt.status).toBe(400);
expect(heicExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
// ── Task 7 追加段:/v1/admin/ads CRUD(cn HTTP + outbox + audit)──
it('⑨ 创建:真图直传 → POST /admin/ads → 201 行/outbox v1/audit/imageUrl 现签', async () => {
const image = await uploadAdImage(200);
const res = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: '首页轮播',
linkUrl: 'https://sponsor.example/launch',
sortOrder: 3,
...image,
});
expect(res.status).toBe(201);
// 响应形状精确(契约 AdminAdItem 全字段,无多余键)。
expect(Object.keys(res.body).sort()).toEqual([
'albumIds',
'createdAt',
'enabled',
'id',
'imageObjectKey',
'imageUrl',
'linkUrl',
'sortOrder',
'title',
'updatedAt',
]);
crudAdId = res.body.id;
expect(res.body).toMatchObject({
id: crudAdId,
title: '首页轮播',
linkUrl: 'https://sponsor.example/launch',
enabled: true,
sortOrder: 3,
// albumIds 未提交 → 全局投放(落库归一 null)。
albumIds: null,
});
// cn 行就位:版本化终键 v1、albumIds SQL NULL、entityVersion=1。
const row = await prismaCn.ads.findUnique({ where: { id: crudAdId } });
expect(row).not.toBeNull();
expect(row).toMatchObject({
title: '首页轮播',
imageObjectKey: `photo-live/dev/ads/${crudAdId}/v1.jpg`,
linkUrl: 'https://sponsor.example/launch',
enabled: true,
sortOrder: 3,
entityVersion: 1,
});
expect(row?.albumIds).toBeNull();
expect(res.body.imageObjectKey).toBe(row?.imageObjectKey);
// imageUrl 为现签 URL:GET 回真实对象(200 字节)。
const img = await fetch(res.body.imageUrl);
expect(img.ok).toBe(true);
expect(Number(img.headers.get('content-length'))).toBe(200);
// 终键对象可见(staging → v1 受控转存)。
expect(await storage.head(row!.imageObjectKey)).toMatchObject({
bytes: 200,
contentType: 'image/jpeg',
});
// outbox:ad.updated v1(albumId 恒空串信封约定),载荷=全量快照。
const events = await adOutboxEvents(crudAdId, 'ad.updated');
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
albumId: '',
entityVersion: 1,
priority: 'normal',
});
expect(events[0].payload).toEqual({
adId: crudAdId,
imageObjectKey: row!.imageObjectKey,
linkUrl: 'https://sponsor.example/launch',
enabled: true,
sortOrder: 3,
albumIds: null,
createdAt: row!.createdAt.toISOString(),
entityVersion: 1,
});
// audit:ad.create。
expect(
await prismaCn.audit_logs.findFirst({
where: { action: 'ad.create', resourceId: crudAdId },
}),
).not.toBeNull();
});
it('⑩ albumIds 定向:两相册去重回传;[]/null 归一 null;不存在相册 400', async () => {
adAlbum1 = await seedAdAlbum('one');
adAlbum2 = await seedAdAlbum('two');
const targeted = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send(await adCreateBody({ albumIds: [adAlbum1, adAlbum2, adAlbum1] }));
expect(targeted.status).toBe(201);
// 重复元素去重后回传。
expect(targeted.body.albumIds).toEqual([adAlbum1, adAlbum2]);
const targetedEvents = await adOutboxEvents(targeted.body.id, 'ad.updated');
expect(targetedEvents).toHaveLength(1);
expect(
(targetedEvents[0].payload as { albumIds: unknown }).albumIds,
).toEqual([adAlbum1, adAlbum2]);
const emptyArr = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send(await adCreateBody({ albumIds: [] }));
expect(emptyArr.status).toBe(201);
expect(emptyArr.body.albumIds).toBeNull();
expect(
(await prismaCn.ads.findUnique({ where: { id: emptyArr.body.id } }))
?.albumIds,
).toBeNull();
// JSON null 在 @IsOptional 下可达(embed settings 先例):视同缺省=全局。
const nullIds = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send(await adCreateBody({ albumIds: null }));
expect(nullIds.status).toBe(201);
expect(nullIds.body.albumIds).toBeNull();
const missing = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send(await adCreateBody({ albumIds: ['no-such-album'] }));
expect(missing.status).toBe(400);
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('⑪ linkUrl 非法(javascript:/data:、超长)→ 400 VALIDATION_FAILED', async () => {
const image = await uploadAdImage(64);
const post = (linkUrl: string) =>
request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send({ linkUrl, ...image });
const js = await post('javascript:alert(1)');
expect(js.status).toBe(400);
expect(js.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const dataUri = await post('data:text/html,<script></script>');
expect(dataUri.status).toBe(400);
expect(dataUri.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const long = await post(`https://example.com/${'x'.repeat(2100)}`);
expect(long.status).toBe(400);
expect(long.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('⑫ PATCH:title/enabled/sortOrder/albumIds 各自 bump+1;停用 high;空体 400;缺省不碰', async () => {
const patch = (body: object) =>
request(cnBase)
.patch(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(body);
// 空请求体(无任何待更新字段)→ 400(对照 embed 更新口径)。
const empty = await patch({});
expect(empty.status).toBe(400);
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// title:v1→2;albumIds 缺省不碰(仍 null)。
const renamed = await patch({ title: '改名' });
expect(renamed.status).toBe(200);
expect(renamed.body.title).toBe('改名');
expect(renamed.body.albumIds).toBeNull();
await expectAdRow(crudAdId, { title: '改名', albumIds: null, entityVersion: 2 });
// 停用(true→false):v2→3,outbox priority='high'(ov 侧尽快删投影)。
const disabled = await patch({ enabled: false });
expect(disabled.status).toBe(200);
expect(disabled.body.enabled).toBe(false);
await expectAdRow(crudAdId, { enabled: false, entityVersion: 3 });
let events = await adOutboxEvents(crudAdId, 'ad.updated');
expect(events.at(-1)).toMatchObject({ entityVersion: 3, priority: 'high' });
// sortOrder(非停用迁移 → normal):v3→4。
const sorted = await patch({ sortOrder: 7 });
expect(sorted.status).toBe(200);
expect(sorted.body.sortOrder).toBe(7);
await expectAdRow(crudAdId, { sortOrder: 7, entityVersion: 4 });
events = await adOutboxEvents(crudAdId, 'ad.updated');
expect(events.at(-1)).toMatchObject({ entityVersion: 4, priority: 'normal' });
// albumIds 数组=整体替换:v4→5。
const bound = await patch({ albumIds: [adAlbum1] });
expect(bound.status).toBe(200);
expect(bound.body.albumIds).toEqual([adAlbum1]);
await expectAdRow(crudAdId, {
albumIds: [adAlbum1],
entityVersion: 5,
});
// albumIds null=转全局:v5→6。
const global = await patch({ albumIds: null });
expect(global.status).toBe(200);
expect(global.body.albumIds).toBeNull();
await expectAdRow(crudAdId, { albumIds: null, entityVersion: 6 });
// 不存在的 ad → 404 NOT_FOUND。
const absent = await request(cnBase)
.patch('/v1/admin/ads/no-such-ad')
.set('Authorization', `Bearer ${adminToken}`)
.send({ title: 'x' });
expect(absent.status).toBe(404);
expect(absent.body.code).toBe(ErrorCodes.NOT_FOUND);
});
it('⑬ PATCH 换图:三字段缺一 400;齐备 → 终键 v{N+1} 新对象(旧对象不回收)', async () => {
const patch = (body: object) =>
request(cnBase)
.patch(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(body);
// 换图三字段必须同时提供(服务层裁决):仅 staging 键 / 缺 bytes / 缺类型。
const onlyKey = await patch({
imageStagingKey: 'photo-live/dev/ads/staging/cpartialstub0000000.jpg',
});
expect(onlyKey.status).toBe(400);
expect(onlyKey.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const image2 = await uploadAdImage(320, 'image/png', 'ad-banner-2.png');
const noBytes = await patch({
imageStagingKey: image2.imageStagingKey,
imageContentType: 'image/png',
});
expect(noBytes.status).toBe(400);
expect(noBytes.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const noType = await patch({
imageStagingKey: image2.imageStagingKey,
imageBytes: 320,
});
expect(noType.status).toBe(400);
expect(noType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 换图成功:v6→7,终键 v7.png(扩展名随 staging 键),head 可见新字节/类型。
const swap = await patch(image2);
expect(swap.status).toBe(200);
expect(swap.body.imageObjectKey).toBe(
`photo-live/dev/ads/${crudAdId}/v7.png`,
);
await expectAdRow(crudAdId, {
imageObjectKey: `photo-live/dev/ads/${crudAdId}/v7.png`,
entityVersion: 7,
});
expect(
await storage.head(`photo-live/dev/ads/${crudAdId}/v7.png`),
).toMatchObject({ bytes: 320, contentType: 'image/png' });
// 旧版本对象不回收(OSS 生命周期兜底,契约语义)。
expect(
await storage.head(`photo-live/dev/ads/${crudAdId}/v1.jpg`),
).toMatchObject({ bytes: 200 });
});
it('⑭ DELETE:行删 + 墓碑(末版本+1, high)+ audit;再删 404', async () => {
const del = await request(cnBase)
.delete(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(del.status).toBe(204);
expect(
await prismaCn.ads.findUnique({ where: { id: crudAdId } }),
).toBeNull();
const tombs = await adOutboxEvents(crudAdId, 'ad.deleted');
expect(tombs).toHaveLength(1);
expect(tombs[0]).toMatchObject({
albumId: '',
entityVersion: 8,
priority: 'high',
});
expect(tombs[0].payload).toEqual({ adId: crudAdId, entityVersion: 8 });
expect(
await prismaCn.audit_logs.findFirst({
where: { action: 'ad.delete', resourceId: crudAdId },
}),
).not.toBeNull();
// 重复删除 → 404(硬删除语义,无恢复端点)。
const again = await request(cnBase)
.delete(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(again.status).toBe(404);
expect(again.body.code).toBe(ErrorCodes.NOT_FOUND);
});
it('⑮ 权限:非 admin 全端点 403;匿名 401(list/create/patch/delete)', async () => {
const createBody = {
linkUrl: 'https://x.example/',
imageStagingKey: 'photo-live/dev/ads/staging/cstub.jpg',
imageBytes: 1,
imageContentType: 'image/jpeg',
};
// admin list 200(list() 复用 toItem 的映射出口,⑨-⑭ 之后存留 ⑩ 的三行:
// targeted/emptyArr/nullIds,均 sortOrder=0、createdAt 升序):形状精确到
// 契约 AdminAdItem 全字段键集,albumIds null/数组双形态经 toItem 归一。
const adminList = await request(cnBase)
.get('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`);
expect(adminList.status).toBe(200);
expect(adminList.body.items).toHaveLength(3);
expect(adminList.body.items[0]).toMatchObject({
imageObjectKey: expect.stringMatching(
/^photo-live\/dev\/ads\/c[0-9a-z]+\/v1\.(jpg|png)$/,
),
linkUrl: 'https://sponsor.example/landing',
enabled: true,
imageUrl: expect.any(String),
});
expect(Object.keys(adminList.body.items[0]).sort()).toEqual([
'albumIds',
'createdAt',
'enabled',
'id',
'imageObjectKey',
'imageUrl',
'linkUrl',
'sortOrder',
'title',
'updatedAt',
]);
const staffList = await request(cnBase)
.get('/v1/admin/ads')
.set('Authorization', `Bearer ${staffToken}`);
expect(staffList.status).toBe(403);
expect(staffList.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const staffCreate = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${staffToken}`)
.send(createBody);
expect(staffCreate.status).toBe(403);
expect(staffCreate.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const staffPatch = await request(cnBase)
.patch(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ title: 'x' });
expect(staffPatch.status).toBe(403);
expect(staffPatch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const staffDelete = await request(cnBase)
.delete(`/v1/admin/ads/${crudAdId}`)
.set('Authorization', `Bearer ${staffToken}`);
expect(staffDelete.status).toBe(403);
expect(staffDelete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const anonList = await request(cnBase).get('/v1/admin/ads');
expect(anonList.status).toBe(401);
const anonCreate = await request(cnBase)
.post('/v1/admin/ads')
.send(createBody);
expect(anonCreate.status).toBe(401);
const anonPatch = await request(cnBase)
.patch(`/v1/admin/ads/${crudAdId}`)
.send({ title: 'x' });
expect(anonPatch.status).toBe(401);
const anonDelete = await request(cnBase).delete(`/v1/admin/ads/${crudAdId}`);
expect(anonDelete.status).toBe(401);
});
it('⑯ 同步贯通:创建→drain→ads_ov 行就位;停用→drain→行删;删除→drain→行删', async () => {
const created = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send(await adCreateBody({ albumIds: [adAlbum1], sortOrder: 12 }));
expect(created.status).toBe(201);
const adId = created.body.id as string;
await drainSender();
const replica = await prismaOv.ads_ov.findUnique({ where: { adId } });
expect(replica).toMatchObject({
adId,
imageObjectKey: created.body.imageObjectKey,
linkUrl: 'https://sponsor.example/landing',
sortOrder: 12,
albumIds: [adAlbum1],
entityVersion: 1,
updatedAt: expect.any(Date),
});
// 停用 → ov 删投影行(disabled=无投影,设计 §2.2/§4),账本收敛 v2。
const off = await request(cnBase)
.patch(`/v1/admin/ads/${adId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: false });
expect(off.status).toBe(200);
await drainSender();
expect(
await prismaOv.ads_ov.findUnique({ where: { adId } }),
).toBeNull();
await expectOvAppliedVersion(adId, 2);
// 删除 → 墓碑 v3:行保持删除(不被旧事件复活),账本收敛墓碑版。
const del = await request(cnBase)
.delete(`/v1/admin/ads/${adId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(del.status).toBe(204);
await drainSender();
expect(
await prismaOv.ads_ov.findUnique({ where: { adId } }),
).toBeNull();
await expectOvAppliedVersion(adId, 3);
});
// ── 终审修复追加段:verifyStaging 硬化(键前缀 + 运行时字节上限)──
it('⑰ 非 ads/staging 前缀键 / 超 2MB 实际字节 → 400 VALIDATION_FAILED', async () => {
const rowsBefore = await prismaCn.ads.count();
// (i) 照片域路径冒充 staging:扩展名/字节/类型三核全会过,仅键前缀不符
//(防照片域 staging/原图/banner 等任意键被提升为公开广告)。绕过 init
// 端点、以测试 storage 直接 presignPut 后 PUT 真实对象,再走创建。
const foreignKey = `${OBJECT_KEY_ROOT}/albums/fake/staging/x.jpg`;
const foreignCred = await storage.presignPut(
foreignKey,
100,
'image/jpeg',
300,
);
const putForeign = await fetch(foreignCred.url, {
method: foreignCred.method,
headers: foreignCred.headers,
body: new Uint8Array(Buffer.alloc(100, 9)),
});
expect(putForeign.ok).toBe(true);
const foreign = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
linkUrl: 'https://sponsor.example/landing',
imageStagingKey: foreignKey,
imageBytes: 100,
imageContentType: 'image/jpeg',
});
expect(foreign.status).toBe(400);
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// (ii) 合法前缀 + 实际 2MB+1 字节:init 的申明字节管不住 PUT 体(预签名
// PUT 无服务端强制),create 是运行时上限唯一核验点;申明如实(DTO 无
// @Max)仍须 400,报文含限额值。
const overKey = `${OBJECT_KEY_ROOT}/ads/staging/e2e-oversize-${randomBytes(4).toString('hex')}.jpg`;
const overCred = await storage.presignPut(
overKey,
AD_UPLOAD_MAX_BYTES + 1,
'image/jpeg',
300,
);
const putOver = await fetch(overCred.url, {
method: overCred.method,
headers: overCred.headers,
body: new Uint8Array(Buffer.alloc(AD_UPLOAD_MAX_BYTES + 1, 9)),
});
expect(putOver.ok).toBe(true);
const over = await request(cnBase)
.post('/v1/admin/ads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
linkUrl: 'https://sponsor.example/landing',
imageStagingKey: overKey,
imageBytes: AD_UPLOAD_MAX_BYTES + 1,
imageContentType: 'image/jpeg',
});
expect(over.status).toBe(400);
expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 服务层限额报文含限额值(运行时上限与 init 同一口径)。
expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES));
// 两笔拒绝均不落行(失败是请求级错误,DB 存量不受污染)。
expect(await prismaCn.ads.count()).toBe(rowsBefore);
});
});