Files
photo-live/services/api/test/batch-ops.e2e-spec.ts
T
浅念 a125fcab1a feat(api): batch hide with per-item results and owner transfer
P4a Task 2(A16/A17):

- POST /albums/{albumId}/photos/hide-batch:逐项结果(部分成功)——
  不存在/他册 → ok:false PHOTO_NOT_FOUND;非 published(unpublished/
  deleted)→ ok:false PHOTO_NOT_PUBLIC;已 hidden → ok:true 幂等 no-op
  (同值不产生第二次事件/审计/版本,P1 语义);published → 逐照片事务
  (条件更新 + outbox photo.hidden 高优先级 + audit photo.hide)。
  results 与请求同序;权限同管理照片列表(仅创建者或管理员,404 防枚举);
  单批 1..200(DTO 层 400)。
- POST /admin/albums/{albumId}/transfer:admin-only owner 移交——事务内
  条件更新 ownerId+entityVersion+1(乐观重试)+ 成员互换(目标 upsert 为
  owner,原 owner 按 keepAsCollaborator 降级 collaborator 或删行)+
  outbox album.updated(标准载荷,版本推进)+ audit album.transfer
  {from,to,kept}。目标校验同成员邀请资格谓词(active/未锁定/未停用);
  自交接 400;photos.uploaderId 不动(A17 历史上传人不变)。
- e2e(batch-ops.e2e-spec,双入口 harness):8 用例覆盖逐项结果/幂等/
  入参权限矩阵/ov 传播(真实发布链→隐藏→photos_ov=hidden+公开列表剔除)/
  keep 两分支/目标校验/交接后权限(新 owner 管理、旧 owner 保留协作者
  可上传)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 16:36:34 +08:00

699 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
});
});
});
});