Files
photo-live/services/api/test/batch-ops.e2e-spec.ts
T

699 lines
26 KiB
TypeScript
Raw Normal View History

// 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<Buffer> {
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<string> {
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<string, string> };
};
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<string> {
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<string[]> {
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<NestExpressApplication>(AppCnModule, {
rawBody: true,
});
configureApp(cnApp);
await cnApp.listen(0);
cnBase = await cnApp.getUrl();
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
rawBody: true,
});
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
configureApp(ovApp);
await ovApp.listen(0);
ovBase = await ovApp.getUrl();
prismaCn = new PrismaClient();
prismaOv = new OvPrismaClient();
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<string> => {
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,
});
});
});
});