// 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→行删(账本收敛墓碑版)。 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 { 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 { 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 { 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 { 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 { 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 = {}, ): Promise> { 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, ): Promise { const row = await prismaCn.ads.findUniqueOrThrow({ where: { id: adId } }); expect(row).toMatchObject(expected); } /** 直种相册行(albumIds 存在性校验标的;ads 域只读 id,无需翻译行)。 */ async function seedAdAlbum(alias: string): Promise { 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 { 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(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(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); }); // ── 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,'); 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); }); });