feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图

Task 7(ads-carousel):/v1/admin/ads list/create/patch/delete 四端点。
- 事务三写:ads 行 + outbox(ad.updated 全量快照/ad.deleted 墓碑)+ audit
  (ad.create/update/delete);ad.* 信封 albumId 恒空串;停用迁移 true→false
  与删除均 priority='high';
- 创建/换图:staging head 三核(存在/bytes/contentType,assets complete 同
  口径)→ 事务内 copyIfAbsent 先于行写入(失败=孤儿终键对象,OSS 生命周期
  兜底)→ 版本化终键 adObjectKey(adId, v, ext);
- 更新:embed 乐观重试模式(条件 updateMany WHERE entityVersion),保证换图
  终键版本段与行版本严格对齐;换图三字段同时性校验(缺一 400);albumIds
  去重+相册存在性 count 校验([]/null 归一全局 null);
- 删除:条件 deleteMany 重跑保墓碑 entityVersion=末+1;重复删除 404;
- 载荷构建器独立 ad-payload.ts(Task 8 对账共用,逐字节同形约定);
- dto:AdCreateRequestDto.albumIds 收窄为 string[] | null(JSON null 在
  @IsOptional 下可达,embed settings 先例);
- e2e:describe 更名 ads domain (e2e),追加 ⑨-⑯ 八用例(真图直传/定向/
  非法 linkUrl/字段级 PATCH/换图/DELETE/权限/同步贯通),16/16 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 23:57:43 +08:00
parent 121afadf1e
commit 4979091d2c
6 changed files with 1079 additions and 25 deletions
+508 -7
View File
@@ -1,9 +1,9 @@
// services/api/test/ads.e2e-spec.ts —— 广告位 ov 消费链 e2e(ads-carousel
// Task 5)。聚焦「事件→投影」直断:CRUD 端点 Task 7 才有,故手工以
// AdUpdatedPayload/AdDeletedPayload 构造 cn outbox_events 行(albumId 恒空串
// ——契约信封约定,绑定关系在载荷 albumIds),经生产 OutboxSender 真实 HTTP
// 推送到测试内起的 ov 入口,断言 ads_ov 副本与 sync_state 版本账本——与生产
// 链路逐字节同源(embed.e2e-spec 模式:AppOvModule + drainSender)。
// 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)→ 副本行全字段 + 版本
@@ -23,6 +23,27 @@
// ⑦ 非 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';
@@ -70,7 +91,7 @@ interface AdsOvRow {
updatedAt: Date;
}
describe('ads sync projection (e2e)', () => {
describe('ads domain (e2e)', () => {
let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication;
let cnBase: string;
@@ -90,6 +111,14 @@ describe('ads sync projection (e2e)', () => {
/** ③ 落位的 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++) {
@@ -167,6 +196,93 @@ describe('ads sync projection (e2e)', () => {
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();
@@ -417,4 +533,389 @@ describe('ads sync projection (e2e)', () => {
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,<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',
};
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);
});
});