2026-09-28 23:57:43 +08:00
|
|
|
|
// 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)。
|
2026-09-28 23:18:37 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 覆盖(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)。
|
2026-09-28 23:34:11 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 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(服务层)。
|
2026-09-28 23:57:43 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 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→行删(账本收敛墓碑版)。
|
2026-09-29 02:20:21 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 终审修复追加段(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 桌面浏览器不渲染)。
|
2026-09-28 23:34:11 +08:00
|
|
|
|
import { randomBytes } from 'node:crypto';
|
2026-09-28 23:18:37 +08:00
|
|
|
|
import { NestFactory } from '@nestjs/core';
|
|
|
|
|
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
2026-09-28 23:34:11 +08:00
|
|
|
|
import request from 'supertest';
|
2026-09-28 23:18:37 +08:00
|
|
|
|
import { PrismaClient } from '@photo/db-cn';
|
|
|
|
|
|
import type { Prisma } from '@photo/db-cn';
|
|
|
|
|
|
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
2026-09-28 23:34:11 +08:00
|
|
|
|
import {
|
|
|
|
|
|
AD_UPLOAD_MAX_BYTES,
|
|
|
|
|
|
type AdDeletedPayload,
|
|
|
|
|
|
type AdUpdatedPayload,
|
|
|
|
|
|
ErrorCodes,
|
|
|
|
|
|
} from '@photo/contracts';
|
2026-09-29 02:20:21 +08:00
|
|
|
|
import {
|
|
|
|
|
|
OBJECT_KEY_ROOT,
|
|
|
|
|
|
createStorageFromEnv,
|
|
|
|
|
|
type StoragePort,
|
|
|
|
|
|
} from '@photo/storage';
|
2026-09-28 23:18:37 +08:00
|
|
|
|
import { AppCnModule } from '../src/app-cn.module';
|
|
|
|
|
|
import { AppOvModule } from '../src/app-ov.module';
|
2026-09-28 23:34:11 +08:00
|
|
|
|
import { seedAdmin } from '../src/scripts/seed-admin';
|
|
|
|
|
|
import { hashSessionToken } from '../src/auth/session.guard';
|
2026-09-28 23:18:37 +08:00
|
|
|
|
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';
|
2026-09-28 23:34:11 +08:00
|
|
|
|
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
|
|
|
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
2026-09-28 23:18:37 +08:00
|
|
|
|
|
|
|
|
|
|
/** 本 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 23:57:43 +08:00
|
|
|
|
describe('ads domain (e2e)', () => {
|
2026-09-28 23:18:37 +08:00
|
|
|
|
let cnApp: NestExpressApplication;
|
|
|
|
|
|
let ovApp: NestExpressApplication;
|
2026-09-28 23:34:11 +08:00
|
|
|
|
let cnBase: string;
|
2026-09-28 23:18:37 +08:00
|
|
|
|
let ovBase: string;
|
|
|
|
|
|
let prismaCn: PrismaClient;
|
|
|
|
|
|
let prismaOv: OvPrismaClient;
|
|
|
|
|
|
let sender: OutboxSender;
|
2026-09-28 23:34:11 +08:00
|
|
|
|
let storage: StoragePort;
|
|
|
|
|
|
let adminToken: string;
|
|
|
|
|
|
let staffToken: string;
|
2026-09-28 23:18:37 +08:00
|
|
|
|
|
|
|
|
|
|
// outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt
|
|
|
|
|
|
// 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。
|
|
|
|
|
|
const occurredBase = Date.now() - 60_000;
|
|
|
|
|
|
let seq = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** ③ 落位的 v3 快照(④ stale 断言的基线)。 */
|
|
|
|
|
|
let v3Snapshot: AdUpdatedPayload;
|
|
|
|
|
|
|
2026-09-28 23:57:43 +08:00
|
|
|
|
// ── Task 7 追加段共用状态 ──
|
|
|
|
|
|
|
|
|
|
|
|
/** ⑨ 创建、⑫-⑭ 顺次推进的 CRUD 链条 ad(版本链 v1..v7 → 墓碑 v8)。 */
|
|
|
|
|
|
let crudAdId = '';
|
|
|
|
|
|
/** ⑩/⑫/⑯ 定向投放用的两个直种相册。 */
|
|
|
|
|
|
let adAlbum1 = '';
|
|
|
|
|
|
let adAlbum2 = '';
|
|
|
|
|
|
|
2026-09-28 23:18:37 +08:00
|
|
|
|
/** 排空发送器:循环 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 23:57:43 +08:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 23:18:37 +08:00
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
|
expect(SYNC_KEY).not.toBe('');
|
|
|
|
|
|
await resetDb();
|
|
|
|
|
|
await resetOvDb();
|
|
|
|
|
|
|
2026-09-28 23:34:11 +08:00
|
|
|
|
// cn 入口仅作骨架就位(①-⑤ 不走 cn HTTP;outbox 行直插 cn 库)——Task 6
|
|
|
|
|
|
// 追加段起复用同一实例走 /v1/admin/ads/uploads。
|
2026-09-28 23:18:37 +08:00
|
|
|
|
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
|
|
|
|
|
rawBody: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
configureApp(cnApp);
|
|
|
|
|
|
await cnApp.listen(0);
|
2026-09-28 23:34:11 +08:00
|
|
|
|
cnBase = await cnApp.getUrl();
|
2026-09-28 23:18:37 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-28 23:34:11 +08:00
|
|
|
|
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;
|
2026-09-28 23:18:37 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-28 23:34:11 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
2026-09-29 02:20:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-09-28 23:34:11 +08:00
|
|
|
|
});
|
2026-09-28 23:57:43 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-29 00:22:13 +08:00
|
|
|
|
// 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',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-28 23:57:43 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-29 02:20:21 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 终审修复追加段: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);
|
|
|
|
|
|
});
|
2026-09-28 23:18:37 +08:00
|
|
|
|
});
|