// services/api/test/batch-ops.e2e-spec.ts —— P4a Task 2 批量隐藏 + 负责人交接 // e2e(先写后实现)。真实 app ×2(cn=AppCnModule + ov=AppOvModule,同 // public-ov.e2e-spec 模式)+ MySQL×2 + MinIO + HMAC 同步链路。覆盖: // ① 批量隐藏主链路:owner POST hide-batch [p1,p2,p3,ghost] → 200 逐项结果 // (与请求同序;ghost ok:false code PHOTO_NOT_FOUND);DB p1-3 hidden + // entityVersion+1;outbox photo.hidden×3(priority high,A16 即刻撤下语义) // + audit photo.hide×3(逐成功项一条); // ② 部分失败语义:[已隐藏(p4), 已删除] → 已隐藏 ok:true 幂等无操作(同值 // = no-op 成功,P1 语义:无新事件/审计/版本,entityVersion 不动); // deleted ok:false code PHOTO_NOT_PUBLIC(不可隐藏态); // ③ 权限与入参:协作者(非 owner)→ 403;外人员工 → 404(防枚举);幽灵 // 相册 → 404;空数组 / >200 / 含空串 → 400 VALIDATION_FAILED; // ④ ov 传播:真实发布链(上传→complete→worker 发布事务)→ 排空后 ov 副本 // published/公开列表含 p1-3;隐藏 → 再排空(deliverPendingOnce)→ // photos_ov.visibility=hidden + ov 公开列表剔除(A16 观众侧); // ⑤ 交接 keep=true:admin transfer → 200 Album(ownerId=staffB);成员角色 // 互换(oldOwner owner→collaborator、staffB collaborator→owner,唯一 // owner 行);photos.uploaderId 不动(A17 历史上传人不变);albums // entityVersion+1 + outbox album.updated;audit album.transfer // {from,to,kept}; // ⑥ 交接 keep=false:oldOwner 成员行删除;目标原非成员 → owner 行 upsert // 产生; // ⑦ 权限与目标校验:非 admin(含现 owner)→ 403;幽灵用户/停用(锁定/ // 上游 disabled/待激活)→ 400;目标=现任 owner → 400;幽灵相册 → 404; // 交接后 oldOwner(保留协作者)管理相册 → 403、上传 → 201;新 owner // 管理 → 200。 import { randomBytes } from 'node:crypto'; import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; import sharp from 'sharp'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { ErrorCodes } from '@photo/contracts'; import { createStorageFromEnv, type MinioStorageAdapter, } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { AppOvModule } from '../src/app-ov.module'; import { hashSessionToken } from '../src/auth/session.guard'; import { configureApp } from '../src/shared/configure-app'; // ov 入口含 realtime 网关:进程内装配须与生产 main-ov 一致安装契约 WS 适配器。 import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; import { PublishService } from '../../worker/src/publish.service'; import { resetDb } from './reset'; import { resetOvDb } from './reset-ov'; import { deliverPendingOnce } from '../../sync/src/sender'; jest.setTimeout(180_000); const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; /** 测试内生成真实 JPEG(同 public-ov.e2e-spec;无二进制入库)。 */ async function makeJpeg( width: number, height: number, rgb: { r: number; g: number; b: number }, ): Promise { return sharp({ create: { width, height, channels: 3, background: rgb } }) .jpeg({ quality: 85 }) .toBuffer(); } /** cuid 形状 ID(直种照片用,同 photos.e2e-spec 约定)。 */ function cuidLike(alias: string): string { return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; } describe('batch hide + owner transfer (e2e)', () => { let cnApp: NestExpressApplication; let ovApp: NestExpressApplication; let cnBase: string; let ovBase: string; let prismaCn: PrismaClient; let prismaOv: OvPrismaClient; let storage: MinioStorageAdapter; let ownerAId: string; let staffBId: string; let collabId: string; let adminId: string; /** 停用系目标账号(⑥ 用例:锁定 / 上游停用 / 待激活)。 */ let lockedUserId: string; let disabledUserId: string; let pendingUserId: string; let ownerAToken: string; let staffBToken: string; let collabToken: string; let outsiderToken: string; let adminToken: string; let albumAId: string; let albumBId: string; let publicTokenA: string; /** 真实发布链照片(uploader=ownerA;隐藏主链路 + ov 传播用)。 */ let p1: string; let p2: string; let p3: string; /** 直种:预隐藏(幂等用例)/ 已删除(不可隐藏态用例)。 */ let preHiddenId: string; let deletedId: string; function hideBatch(token: string, albumId: string, photoIds: string[]) { return request(cnBase) .post(`/v1/albums/${albumId}/photos/hide-batch`) .set('Authorization', `Bearer ${token}`) .send({ photoIds }); } function transferAlbum(token: string, albumId: string, body: object) { return request(cnBase) .post(`/v1/admin/albums/${albumId}/transfer`) .set('Authorization', `Bearer ${token}`) .send(body); } /** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */ async function uploadAndPublish( token: string, clientTaskId: string, jpeg: Buffer, ): Promise { const init = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${token}`) .send({ albumId: albumAId, clientTaskId, filename: 'batch-ops.jpg', bytes: jpeg.length, contentType: 'image/jpeg', }); expect(init.status).toBe(201); const { uploadId, credential } = init.body as { uploadId: string; credential: { url: string; method: string; headers: Record }; }; const put = await fetch(credential.url, { method: credential.method, headers: credential.headers, body: new Uint8Array(jpeg), }); expect(put.ok).toBe(true); const done = await request(cnBase) .post(`/v1/uploads/${uploadId}/complete`) .set('Authorization', `Bearer ${token}`); expect(done.status).toBe(200); expect( await new PublishService(prismaCn, storage).processPhoto( init.body.photoId as string, ), ).toBe('published'); return init.body.photoId as string; } /** 直种照片(无变体对象:本 spec 对直种行只断言可见性/版本/事件,不读图)。 */ async function seedPhoto( alias: string, visibility: 'hidden' | 'deleted', ): Promise { const photoId = cuidLike(alias); await prismaCn.photos.create({ data: { id: photoId, albumId: albumAId, uploaderId: ownerAId, objectKey: `photo-live/dev/albums/${albumAId}/originals/${photoId}.jpg`, bytes: 1000, width: 800, height: 500, uploadedAt: new Date(), publishedAt: visibility === 'hidden' ? new Date() : null, processingState: 'ready', visibility, entityVersion: 2, }, }); return photoId; } /** ov 公开列表全量 id 集(hasMore 分页下循环取尽)。 */ async function ovPublicPhotoIds(): Promise { const ids: string[] = []; let cursor: string | undefined; for (;;) { const res = await request(ovBase) .get(`/v1/public/albums/${publicTokenA}/photos`) .query(cursor === undefined ? { limit: 60 } : { limit: 60, cursor }); expect(res.status).toBe(200); ids.push(...res.body.items.map((p: { photoId: string }) => p.photoId)); if (res.body.nextCursor === null) { return ids; } cursor = res.body.nextCursor as string; } } beforeAll(async () => { expect(SYNC_KEY).not.toBe(''); await resetDb(); await resetOvDb(); 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(); storage = createStorageFromEnv(); // 账号:ownerA(相册 A/B 创建者)/ staffB(交接目标)/ collab(协作者)/ // admin / outsider(外人员工)/ 三个不可交接目标(锁定、上游停用、待激活)。 const mkUser = ( account: string, extra: { role?: string; localLocked?: boolean; sourceStatus?: string; credentialStatus?: string } = {}, ) => prismaCn.users.create({ data: { account, role: extra.role ?? 'staff', passwordHash: 'x', displayName: account, ...(extra.localLocked !== undefined ? { localLocked: extra.localLocked } : {}), ...(extra.sourceStatus !== undefined ? { sourceStatus: extra.sourceStatus } : {}), ...(extra.credentialStatus !== undefined ? { credentialStatus: extra.credentialStatus } : {}), }, select: { id: true }, }); ownerAId = (await mkUser('bo-owner')).id; staffBId = (await mkUser('bo-staff-b')).id; collabId = (await mkUser('bo-collab')).id; adminId = (await mkUser('bo-admin', { role: 'admin' })).id; const outsiderId = (await mkUser('bo-outsider')).id; lockedUserId = (await mkUser('bo-locked', { localLocked: true })).id; disabledUserId = ( await mkUser('bo-src-disabled', { sourceStatus: 'disabled' }) ).id; pendingUserId = ( await mkUser('bo-pending-cred', { credentialStatus: 'pending' }) ).id; const mkToken = async (userId: string): Promise => { const token = randomBytes(32).toString('hex'); await prismaCn.sessions.create({ data: { userId, tokenHash: hashSessionToken(token), expiresAt: new Date(Date.now() + 3_600_000), }, }); return token; }; ownerAToken = await mkToken(ownerAId); staffBToken = await mkToken(staffBId); collabToken = await mkToken(collabId); outsiderToken = await mkToken(outsiderId); adminToken = await mkToken(adminId); // 相册 A(API 创建:album.updated v1 事件入 outbox,ov 副本链路用)+ // 成员 staffB/collab(协作者)。 const albumA = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${ownerAToken}`) .send({ translations: [{ locale: 'zh-CN', title: '批量操作相册' }], defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', }); expect(albumA.status).toBe(201); albumAId = albumA.body.id as string; publicTokenA = albumA.body.publicToken as string; for (const userId of [staffBId, collabId]) { const added = await request(cnBase) .post(`/v1/albums/${albumAId}/members`) .set('Authorization', `Bearer ${ownerAToken}`) .send({ userId }); expect(added.status).toBe(201); } // 相册 B(keep=false 用例;staffB 非成员 → 交接 upsert 建行路径)。 const albumB = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${ownerAToken}`) .send({ translations: [{ locale: 'zh-CN', title: '交接不保留相册' }], defaultLocale: 'zh-CN', startAt: '2026-10-02T10:00:00.000Z', }); expect(albumB.status).toBe(201); albumBId = albumB.body.id as string; // p1-3:真实上传+发布链(uploader=ownerA)→ 排空,ov 副本就位(公开可见)。 const shapes = [ { w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 } }, { w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 } }, { w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 } }, ] as const; const published: string[] = []; for (let i = 0; i < shapes.length; i++) { const s = shapes[i]!; published.push( await uploadAndPublish( ownerAToken, `bo-p${i + 1}`, await makeJpeg(s.w, s.h, s.rgb), ), ); } [p1, p2, p3] = published; await deliverPendingOnce({ prisma: prismaCn, ovApiUrl: ovBase, internalKey: SYNC_KEY, }); // 直种:预隐藏 + 已删除(隐藏状态机边界用例)。 preHiddenId = await seedPhoto('boPreHidden', 'hidden'); deletedId = await seedPhoto('boDeleted', 'deleted'); }); afterAll(async () => { await cnApp.close(); await ovApp.close(); await prismaCn.$disconnect(); await prismaOv.$disconnect(); }); describe('批量隐藏 hidePhotosBatch', () => { it('① owner 批量 [p1,p2,p3,ghost] → 200 逐项结果(同序);DB hidden+版本+1;outbox high×3;audit×3', async () => { const before = await prismaCn.photos.findMany({ where: { id: { in: [p1, p2, p3] } }, select: { id: true, entityVersion: true }, }); const beforeVersion = new Map(before.map((r) => [r.id, r.entityVersion])); const res = await hideBatch(ownerAToken, albumAId, [ p1, p2, p3, 'no-such-photo', ]); expect(res.status).toBe(200); expect(Object.keys(res.body)).toEqual(['results']); // 与请求同序逐项对应;成功项无 code 字段。 expect(res.body.results).toEqual([ { photoId: p1, ok: true }, { photoId: p2, ok: true }, { photoId: p3, ok: true }, { photoId: 'no-such-photo', ok: false, code: ErrorCodes.PHOTO_NOT_FOUND, }, ]); const after = await prismaCn.photos.findMany({ where: { id: { in: [p1, p2, p3] } }, select: { id: true, visibility: true, entityVersion: true }, }); for (const row of after) { expect(row.visibility).toBe('hidden'); expect(row.entityVersion).toBe( (beforeVersion.get(row.id) as number) + 1, ); } // outbox:photo.hidden×3,高优先级(A16 即刻撤下),载荷最小事实。 const events = await prismaCn.outbox_events.findMany({ where: { type: 'photo.hidden', photoId: { in: [p1, p2, p3] } }, }); expect(events).toHaveLength(3); for (const event of events) { expect(event.priority).toBe('high'); expect(event.albumId).toBe(albumAId); expect(event.entityVersion).toBe( after.find((r) => r.id === event.photoId)?.entityVersion, ); expect(event.payload).toMatchObject({ photoId: event.photoId, albumId: albumAId, visibility: 'hidden', entityVersion: event.entityVersion, }); } // audit:每个成功项恰一条 photo.hide。 const audits = await prismaCn.audit_logs.findMany({ where: { action: 'photo.hide', resourceId: { in: [p1, p2, p3] }, }, }); expect(audits).toHaveLength(3); for (const audit of audits) { expect(audit.actorId).toBe(ownerAId); expect(audit.metadata).toMatchObject({ from: 'published', to: 'hidden' }); } }); it('② [已隐藏, 已删除] → 幂等 ok:true(无新事件/审计/版本)+ ok:false PHOTO_NOT_PUBLIC', async () => { const preHiddenBefore = await prismaCn.photos.findUniqueOrThrow({ where: { id: preHiddenId }, select: { entityVersion: true }, }); const deletedBefore = await prismaCn.photos.findUniqueOrThrow({ where: { id: deletedId }, select: { entityVersion: true }, }); const res = await hideBatch(ownerAToken, albumAId, [ preHiddenId, deletedId, ]); expect(res.status).toBe(200); expect(res.body.results).toEqual([ { photoId: preHiddenId, ok: true }, { photoId: deletedId, ok: false, code: ErrorCodes.PHOTO_NOT_PUBLIC }, ]); // 同值幂等:不产生第二次事件/审计,entityVersion 不动(P1 语义)。 expect( await prismaCn.outbox_events.count({ where: { photoId: { in: [preHiddenId, deletedId] } }, }), ).toBe(0); expect( await prismaCn.audit_logs.count({ where: { action: 'photo.hide', resourceId: { in: [preHiddenId, deletedId] }, }, }), ).toBe(0); expect( await prismaCn.photos.findUniqueOrThrow({ where: { id: preHiddenId }, select: { entityVersion: true, visibility: true }, }), ).toEqual({ entityVersion: preHiddenBefore.entityVersion, visibility: 'hidden' }); expect( ( await prismaCn.photos.findUniqueOrThrow({ where: { id: deletedId }, select: { entityVersion: true }, }) ).entityVersion, ).toBe(deletedBefore.entityVersion); }); it('③ 权限与入参:协作者 403;外人员工 404;幽灵相册 404;空/超 200/空串 → 400', async () => { const forbidden = await hideBatch(collabToken, albumAId, [p1]); expect(forbidden.status).toBe(403); expect(forbidden.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsider = await hideBatch(outsiderToken, albumAId, [p1]); expect(outsider.status).toBe(404); expect(outsider.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const ghostAlbum = await hideBatch(ownerAToken, 'no-such-album', [p1]); expect(ghostAlbum.status).toBe(404); expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const empty = await hideBatch(ownerAToken, albumAId, []); expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const tooMany = await hideBatch( ownerAToken, albumAId, Array.from({ length: 201 }, (_, i) => `photo-${i}`), ); expect(tooMany.status).toBe(400); expect(tooMany.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const blankItem = await hideBatch(ownerAToken, albumAId, [p1, '']); expect(blankItem.status).toBe(400); expect(blankItem.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('④ ov 传播:隐藏事件排空后 photos_ov=hidden,ov 公开列表剔除', async () => { // 排空隐藏事件前:ov 副本仍为 published(beforeAll 已排空发布事件)。 expect(await ovPublicPhotoIds()).toEqual( expect.arrayContaining([p1, p2, p3]), ); await deliverPendingOnce({ prisma: prismaCn, ovApiUrl: ovBase, internalKey: SYNC_KEY, }); const replicas = await prismaOv.photos_ov.findMany({ where: { photoId: { in: [p1, p2, p3] } }, select: { photoId: true, visibility: true }, }); expect(replicas).toHaveLength(3); for (const replica of replicas) { expect(replica.visibility).toBe('hidden'); } const ids = await ovPublicPhotoIds(); expect(ids).not.toContain(p1); expect(ids).not.toContain(p2); expect(ids).not.toContain(p3); }); }); describe('负责人交接 transferAlbum', () => { it('⑤ admin 转移 A → staffB(keep=true):owner 变更、角色互换、上传人不变、版本+outbox、audit', async () => { const versionBefore = ( await prismaCn.albums.findUniqueOrThrow({ where: { id: albumAId }, select: { entityVersion: true }, }) ).entityVersion; const uploaderIdsBefore = await prismaCn.photos.findMany({ where: { albumId: albumAId }, select: { id: true, uploaderId: true }, orderBy: { id: 'asc' }, }); const res = await transferAlbum(adminToken, albumAId, { targetUserId: staffBId, keepAsCollaborator: true, }); expect(res.status).toBe(200); expect(res.body.id).toBe(albumAId); expect(res.body.ownerId).toBe(staffBId); // 成员角色互换:oldOwner→collaborator、staffB→owner;唯一 owner 行。 const oldOwnerRow = await prismaCn.album_members.findUnique({ where: { albumId_userId: { albumId: albumAId, userId: ownerAId } }, }); expect(oldOwnerRow?.role).toBe('collaborator'); expect(oldOwnerRow?.status).toBe('active'); const newOwnerRow = await prismaCn.album_members.findUnique({ where: { albumId_userId: { albumId: albumAId, userId: staffBId } }, }); expect(newOwnerRow?.role).toBe('owner'); const ownerRows = await prismaCn.album_members.findMany({ where: { albumId: albumAId, role: 'owner' }, }); expect(ownerRows).toHaveLength(1); expect(ownerRows[0]?.userId).toBe(staffBId); // A17:历史上传人不变(照片行(含已隐藏)uploaderId 全部原样)。 const uploaderIdsAfter = await prismaCn.photos.findMany({ where: { albumId: albumAId }, select: { id: true, uploaderId: true }, orderBy: { id: 'asc' }, }); expect(uploaderIdsAfter).toEqual(uploaderIdsBefore); // 版本推进 + outbox album.updated(标准载荷,ov 无需 owner 字段)。 const albumAfter = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumAId }, select: { ownerId: true, entityVersion: true }, }); expect(albumAfter.ownerId).toBe(staffBId); expect(albumAfter.entityVersion).toBe(versionBefore + 1); const event = await prismaCn.outbox_events.findFirstOrThrow({ where: { type: 'album.updated', albumId: albumAId }, orderBy: { entityVersion: 'desc' }, }); expect(event.entityVersion).toBe(albumAfter.entityVersion); // audit album.transfer {from,to,kept}。 const audit = await prismaCn.audit_logs.findFirstOrThrow({ where: { action: 'album.transfer', resourceId: albumAId }, }); expect(audit.actorId).toBe(adminId); expect(audit.metadata).toEqual({ from: ownerAId, to: staffBId, kept: true, }); }); it('⑥ 权限与目标校验:非 admin 403;幽灵/停用/待激活目标 400;自交接 400;幽灵相册 404', async () => { // 非 admin:现任 owner(staffB,staff 角色)同样不可交接(组织级治理动作)。 const byOwner = await transferAlbum(staffBToken, albumAId, { targetUserId: ownerAId, keepAsCollaborator: true, }); expect(byOwner.status).toBe(403); expect(byOwner.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const ghostTarget = await transferAlbum(adminToken, albumAId, { targetUserId: 'u-bo-ghost-user', keepAsCollaborator: true, }); expect(ghostTarget.status).toBe(400); expect(ghostTarget.body.code).toBe(ErrorCodes.VALIDATION_FAILED); for (const invalidTarget of [ lockedUserId, disabledUserId, pendingUserId, ]) { const res = await transferAlbum(adminToken, albumAId, { targetUserId: invalidTarget, keepAsCollaborator: true, }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } // 目标=现任 owner:无迁移语义,400。 const selfTransfer = await transferAlbum(adminToken, albumBId, { targetUserId: ownerAId, keepAsCollaborator: true, }); expect(selfTransfer.status).toBe(400); expect(selfTransfer.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const ghostAlbum = await transferAlbum(adminToken, 'no-such-album', { targetUserId: staffBId, keepAsCollaborator: true, }); expect(ghostAlbum.status).toBe(404); expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); it('⑦ 交接后:新 owner 管理 200;oldOwner(保留协作者)管理 403、上传 201', async () => { const newOwnerManage = await request(cnBase) .patch(`/v1/albums/${albumAId}`) .set('Authorization', `Bearer ${staffBToken}`) .send({ downloadEnabled: false }); expect(newOwnerManage.status).toBe(200); expect(newOwnerManage.body.ownerId).toBe(staffBId); expect(newOwnerManage.body.downloadEnabled).toBe(false); const oldOwnerManage = await request(cnBase) .patch(`/v1/albums/${albumAId}`) .set('Authorization', `Bearer ${ownerAToken}`) .send({ downloadEnabled: true }); expect(oldOwnerManage.status).toBe(403); expect(oldOwnerManage.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); // 保留协作者仍可上传(成员行有效即「已加入的相册」)。 const upload = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${ownerAToken}`) .send({ albumId: albumAId, clientTaskId: 'bo-upload-after-transfer', filename: 'after-transfer.jpg', bytes: 1000, contentType: 'image/jpeg', }); expect(upload.status).toBe(201); }); it('⑧ keep=false:oldOwner 成员行删除;非成员目标 upsert 为 owner', async () => { const res = await transferAlbum(adminToken, albumBId, { targetUserId: staffBId, keepAsCollaborator: false, }); expect(res.status).toBe(200); expect(res.body.ownerId).toBe(staffBId); expect( await prismaCn.album_members.findUnique({ where: { albumId_userId: { albumId: albumBId, userId: ownerAId } }, }), ).toBeNull(); const newOwnerRow = await prismaCn.album_members.findUniqueOrThrow({ where: { albumId_userId: { albumId: albumBId, userId: staffBId } }, }); expect(newOwnerRow.role).toBe('owner'); expect(newOwnerRow.status).toBe('active'); const audit = await prismaCn.audit_logs.findFirstOrThrow({ where: { action: 'album.transfer', resourceId: albumBId }, }); expect(audit.metadata).toEqual({ from: ownerAId, to: staffBId, kept: false, }); }); }); });