8e674a902e
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
outbox album.updated priority=high + audit album.delete);列表/详情/更新/
成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
(status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
669 lines
24 KiB
TypeScript
669 lines
24 KiB
TypeScript
// services/api/test/ui01-contracts.e2e-spec.ts —— UI-01 Task 2 契约扩展 e2e:
|
||
// cover 封面摘要 / preview 受权限预览 / scope=own 数据范围(真实 app + MySQL +
|
||
// MinIO:签名 URL 实取回字节,N+1 用 Prisma 中间件计真实查询数)。覆盖:
|
||
// ① listAlbums cover:有封面 → url 短期签名且可取回真实字节;同 kind 多行
|
||
// 取最新 effective(createdAt/id DESC 首行);pending 行不计;无封面 →
|
||
// null;getAlbum 详情同形;批量组装无 N+1(6 相册单页,相册域 Prisma 查
|
||
// 询数恒定:admin=3 albums+translations+album_assets / 非 admin=4 另加成
|
||
// 员行查询,与封面相册数无关);
|
||
// ② preview 权限矩阵(scope=all,owner/admin):published 签发(thumb+browse
|
||
// 可取回字节);hidden 对 manager 签发;deleted / 无变体 → null(记录
|
||
// 本身仍如实返回);
|
||
// ③ scope=own:collab 仅见自己上传(服务端过滤,uploaderId 全等于本人);
|
||
// 自己的 hidden 行可见但 preview=null(隐藏签发仅 admin/owner);owner
|
||
// scope=own 过滤为自己上传;旧请求(无 scope)owner 全量行为不变;
|
||
// ④ 权限边界:collab scope=all(缺省与显式)→ 403;被移除成员 scope=own →
|
||
// 404(成员资格逐请求);非成员跨相册 → 404;scope 非法值 → 400。
|
||
import { randomBytes } from 'node:crypto';
|
||
import { type INestApplication } from '@nestjs/common';
|
||
import { Test } from '@nestjs/testing';
|
||
import request from 'supertest';
|
||
import {
|
||
PutObjectCommand,
|
||
S3Client,
|
||
} from '@aws-sdk/client-s3';
|
||
import { PrismaClient } from '@photo/db-cn';
|
||
import { ErrorCodes } from '@photo/contracts';
|
||
import { AppCnModule } from '../src/app-cn.module';
|
||
import { hashSessionToken } from '../src/auth/session.guard';
|
||
import { PrismaService } from '../src/prisma/prisma.service';
|
||
import { API_PREFIX } from '../src/shared/constants';
|
||
import { resetDb } from './reset';
|
||
|
||
jest.setTimeout(120_000);
|
||
|
||
/** 契约 pattern ^[0-9a-z]{20,32}$(同 albums.service 生成式口径)。 */
|
||
function makePublicToken(): string {
|
||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||
const bytes = randomBytes(24);
|
||
let token = '';
|
||
for (let i = 0; i < 24; i++) {
|
||
token += alphabet[bytes[i] % alphabet.length];
|
||
}
|
||
return token;
|
||
}
|
||
|
||
/** 固定时间基(分钟偏移构造确定性 uploadedAt/createdAt 序列)。 */
|
||
const T0 = Date.UTC(2026, 8, 20, 8, 0, 0);
|
||
const atMinute = (m: number): Date => new Date(T0 + m * 60_000);
|
||
|
||
describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => {
|
||
let app: INestApplication;
|
||
let prisma: PrismaClient;
|
||
let s3: S3Client;
|
||
|
||
let ownerId: string;
|
||
let collabId: string;
|
||
let adminId: string;
|
||
let outsiderId: string;
|
||
let ownerToken: string;
|
||
let collabToken: string;
|
||
let adminToken: string;
|
||
let outsiderToken: string;
|
||
|
||
/** 主相册 A(owner + collab):封面 + 全状态照片矩阵。 */
|
||
let albumAId: string;
|
||
/** 相册 B(owner 独有,无封面无照片):无封面对照 + 跨册 404。 */
|
||
let albumBId: string;
|
||
/** 其余 4 个带封面相册(N+1 批量断言用,owner 名下)。 */
|
||
const fillerIds: string[] = [];
|
||
|
||
let coverOldAssetId: string;
|
||
let coverNewAssetId: string;
|
||
const coverOldBytes = Buffer.from([0x11, 0x12, 0x13, 0x14]);
|
||
const coverNewBytes = Buffer.from([0x21, 0x22, 0x23, 0x24, 0x25]);
|
||
|
||
let phPubC: string; // published / collab / 有变体
|
||
let phPubO: string; // published / owner / 有变体(字节取回断言用)
|
||
let phHidC: string; // hidden / collab / 有变体
|
||
let phHidO: string; // hidden / owner / 有变体
|
||
let phDelO: string; // deleted / owner / 有变体
|
||
let phNovarC: string; // unpublished / collab / 无变体
|
||
|
||
const pubOBrowseBytes = Buffer.from([0x31, 0x32, 0x33, 0x34, 0x35, 0x36]);
|
||
|
||
/** MinIO 实写对象(封面/预览可取回性)。 */
|
||
async function putObject(key: string, body: Buffer): Promise<void> {
|
||
await s3.send(
|
||
new PutObjectCommand({
|
||
Bucket: process.env.S3_BUCKET,
|
||
Key: key,
|
||
Body: body,
|
||
ContentType: 'image/jpeg',
|
||
}),
|
||
);
|
||
}
|
||
|
||
async function fetchBody(url: string): Promise<Buffer> {
|
||
const res = await fetch(url);
|
||
expect(res.status).toBe(200);
|
||
return Buffer.from(await res.arrayBuffer());
|
||
}
|
||
|
||
function listAlbums(token: string, query?: Record<string, string | number>) {
|
||
return request(app.getHttpServer())
|
||
.get(`/${API_PREFIX}/albums`)
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.query(query ?? {});
|
||
}
|
||
|
||
function listPhotos(
|
||
token: string,
|
||
albumId: string,
|
||
query?: Record<string, string | number>,
|
||
) {
|
||
return request(app.getHttpServer())
|
||
.get(`/${API_PREFIX}/albums/${albumId}/photos`)
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.query(query ?? {});
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
await resetDb();
|
||
const moduleRef = await Test.createTestingModule({
|
||
imports: [AppCnModule],
|
||
}).compile();
|
||
app = moduleRef.createNestApplication();
|
||
app.setGlobalPrefix(API_PREFIX);
|
||
await app.init();
|
||
|
||
prisma = new PrismaClient();
|
||
s3 = new S3Client({
|
||
endpoint: process.env.S3_ENDPOINT,
|
||
region: process.env.S3_REGION,
|
||
credentials: {
|
||
accessKeyId: process.env.S3_ACCESS_KEY as string,
|
||
secretAccessKey: process.env.S3_SECRET_KEY as string,
|
||
},
|
||
forcePathStyle: true,
|
||
});
|
||
|
||
const mkUser = (account: string, displayName: string, role = 'staff') =>
|
||
prisma.users.create({
|
||
data: { account, role, passwordHash: 'x', displayName },
|
||
select: { id: true },
|
||
});
|
||
ownerId = (await mkUser('u01-owner', '欧阳雯')).id;
|
||
collabId = (await mkUser('u01-collab', '程澜')).id;
|
||
adminId = (await mkUser('u01-admin', '艾管', 'admin')).id;
|
||
outsiderId = (await mkUser('u01-outsider', '韦外')).id;
|
||
|
||
const mkToken = async (userId: string): Promise<string> => {
|
||
const token = randomBytes(32).toString('hex');
|
||
await prisma.sessions.create({
|
||
data: {
|
||
userId,
|
||
tokenHash: hashSessionToken(token),
|
||
expiresAt: new Date(Date.now() + 3_600_000),
|
||
},
|
||
});
|
||
return token;
|
||
};
|
||
ownerToken = await mkToken(ownerId);
|
||
collabToken = await mkToken(collabId);
|
||
adminToken = await mkToken(adminId);
|
||
outsiderToken = await mkToken(outsiderId);
|
||
|
||
// 相册 A:owner + collab;zh-CN 翻译。
|
||
const albumA = await prisma.albums.create({
|
||
data: {
|
||
publicToken: makePublicToken(),
|
||
ownerId,
|
||
startAt: new Date(),
|
||
defaultLocale: 'zh-CN',
|
||
},
|
||
select: { id: true },
|
||
});
|
||
albumAId = albumA.id;
|
||
await prisma.album_translations.create({
|
||
data: { albumId: albumAId, locale: 'zh-CN', title: 'UI01 契约相册' },
|
||
});
|
||
await prisma.album_members.createMany({
|
||
data: [
|
||
{ albumId: albumAId, userId: ownerId, role: 'owner', status: 'active' },
|
||
{
|
||
albumId: albumAId,
|
||
userId: collabId,
|
||
role: 'collaborator',
|
||
status: 'active',
|
||
},
|
||
],
|
||
});
|
||
|
||
// 相册 B:owner 独有(无 collab)——无封面对照 + 跨册 404。
|
||
const albumB = await prisma.albums.create({
|
||
data: {
|
||
publicToken: makePublicToken(),
|
||
ownerId,
|
||
startAt: new Date(),
|
||
defaultLocale: 'zh-CN',
|
||
},
|
||
select: { id: true },
|
||
});
|
||
albumBId = albumB.id;
|
||
await prisma.album_translations.create({
|
||
data: { albumId: albumBId, locale: 'zh-CN', title: 'UI01 无封面相册' },
|
||
});
|
||
await prisma.album_members.create({
|
||
data: { albumId: albumBId, userId: ownerId, role: 'owner', status: 'active' },
|
||
});
|
||
|
||
// 4 个补充相册(带封面):N+1 批量断言(单页 6 相册,5 有封面)。
|
||
for (let i = 0; i < 4; i++) {
|
||
const filler = await prisma.albums.create({
|
||
data: {
|
||
publicToken: makePublicToken(),
|
||
ownerId,
|
||
startAt: new Date(),
|
||
defaultLocale: 'zh-CN',
|
||
},
|
||
select: { id: true },
|
||
});
|
||
fillerIds.push(filler.id);
|
||
await prisma.album_translations.create({
|
||
data: { albumId: filler.id, locale: 'zh-CN', title: `UI01 补充 ${i}` },
|
||
});
|
||
await prisma.album_members.create({
|
||
data: {
|
||
albumId: filler.id,
|
||
userId: ownerId,
|
||
role: 'owner',
|
||
status: 'active',
|
||
},
|
||
});
|
||
const assetId = `u01assetfill${i}0000000000000`;
|
||
const key = `photo-live/dev/albums/${filler.id}/assets/${assetId}.jpg`;
|
||
await prisma.album_assets.create({
|
||
data: {
|
||
id: assetId,
|
||
albumId: filler.id,
|
||
kind: 'cover',
|
||
locale: null,
|
||
objectKey: key,
|
||
state: 'effective',
|
||
expectedBytes: 4,
|
||
contentType: 'image/jpeg',
|
||
},
|
||
});
|
||
await putObject(key, Buffer.from([0x41, 0x42, 0x43]));
|
||
}
|
||
|
||
// 相册 A 封面矩阵:pending 行(不计)→ 旧 effective 行 → 新 effective 行
|
||
//(最新生效胜出)。
|
||
coverOldAssetId = 'u01assetold00000000000000000';
|
||
coverNewAssetId = 'u01assetnew00000000000000000';
|
||
const coverOldKey = `photo-live/dev/albums/${albumAId}/assets/${coverOldAssetId}.jpg`;
|
||
const coverNewKey = `photo-live/dev/albums/${albumAId}/assets/${coverNewAssetId}.jpg`;
|
||
await prisma.album_assets.createMany({
|
||
data: [
|
||
{
|
||
id: 'u01assetpending0000000000000',
|
||
albumId: albumAId,
|
||
kind: 'cover',
|
||
locale: null,
|
||
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetpending0000000000000.jpg`,
|
||
state: 'pending',
|
||
expectedBytes: 4,
|
||
contentType: 'image/jpeg',
|
||
createdAt: atMinute(50), // 最新但 pending → 不计
|
||
},
|
||
{
|
||
id: coverOldAssetId,
|
||
albumId: albumAId,
|
||
kind: 'cover',
|
||
locale: null,
|
||
objectKey: coverOldKey,
|
||
state: 'effective',
|
||
expectedBytes: 4,
|
||
contentType: 'image/jpeg',
|
||
createdAt: atMinute(10),
|
||
},
|
||
{
|
||
id: coverNewAssetId,
|
||
albumId: albumAId,
|
||
kind: 'cover',
|
||
locale: null,
|
||
objectKey: coverNewKey,
|
||
state: 'effective',
|
||
expectedBytes: 5,
|
||
contentType: 'image/jpeg',
|
||
createdAt: atMinute(20), // 最新 effective → 胜出
|
||
},
|
||
{
|
||
id: 'u01assetbanner000000000000',
|
||
albumId: albumAId,
|
||
kind: 'banner', // 非 cover kind → 不计
|
||
locale: null,
|
||
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetbanner000000000000.jpg`,
|
||
state: 'effective',
|
||
expectedBytes: 4,
|
||
contentType: 'image/jpeg',
|
||
createdAt: atMinute(30),
|
||
},
|
||
],
|
||
});
|
||
await putObject(coverOldKey, coverOldBytes);
|
||
await putObject(coverNewKey, coverNewBytes);
|
||
|
||
// 照片矩阵(album A;uploadedAt 递减稳定序)。
|
||
const seedPhoto = async (spec: {
|
||
id: string;
|
||
uploaderId: string;
|
||
visibility: string;
|
||
processingState: string;
|
||
uploadedAt: Date;
|
||
withVariants: boolean;
|
||
}): Promise<void> => {
|
||
await prisma.photos.create({
|
||
data: {
|
||
id: spec.id,
|
||
albumId: albumAId,
|
||
uploaderId: spec.uploaderId,
|
||
objectKey: `photo-live/dev/albums/${albumAId}/originals/${spec.id}.jpg`,
|
||
bytes: 9999,
|
||
width: 1600,
|
||
height: 1000,
|
||
uploadedAt: spec.uploadedAt,
|
||
publishedAt:
|
||
spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt),
|
||
processingState: spec.processingState,
|
||
visibility: spec.visibility,
|
||
entityVersion: 2,
|
||
},
|
||
});
|
||
if (!spec.withVariants) {
|
||
return;
|
||
}
|
||
await prisma.photo_variants.createMany({
|
||
data: (['thumb', 'browse'] as const).map((variant, i) => ({
|
||
photoId: spec.id,
|
||
variant,
|
||
objectKey: `photo-live/dev/albums/${albumAId}/variants/${spec.id}/1/${variant}.jpg`,
|
||
bytes: 100 + i,
|
||
version: 1,
|
||
width: variant === 'thumb' ? 320 : 1600,
|
||
height: variant === 'thumb' ? 200 : 1000,
|
||
})),
|
||
});
|
||
};
|
||
|
||
phPubC = 'u01-photo-pub-c';
|
||
phPubO = 'u01-photo-pub-o';
|
||
phHidC = 'u01-photo-hid-c';
|
||
phHidO = 'u01-photo-hid-o';
|
||
phDelO = 'u01-photo-del-o';
|
||
phNovarC = 'u01-photo-novar-c';
|
||
await seedPhoto({
|
||
id: phPubC,
|
||
uploaderId: collabId,
|
||
visibility: 'published',
|
||
processingState: 'ready',
|
||
uploadedAt: atMinute(60),
|
||
withVariants: true,
|
||
});
|
||
await seedPhoto({
|
||
id: phPubO,
|
||
uploaderId: ownerId,
|
||
visibility: 'published',
|
||
processingState: 'ready',
|
||
uploadedAt: atMinute(50),
|
||
withVariants: true,
|
||
});
|
||
await seedPhoto({
|
||
id: phHidC,
|
||
uploaderId: collabId,
|
||
visibility: 'hidden',
|
||
processingState: 'ready',
|
||
uploadedAt: atMinute(40),
|
||
withVariants: true,
|
||
});
|
||
await seedPhoto({
|
||
id: phHidO,
|
||
uploaderId: ownerId,
|
||
visibility: 'hidden',
|
||
processingState: 'ready',
|
||
uploadedAt: atMinute(30),
|
||
withVariants: true,
|
||
});
|
||
await seedPhoto({
|
||
id: phDelO,
|
||
uploaderId: ownerId,
|
||
visibility: 'deleted',
|
||
processingState: 'ready',
|
||
uploadedAt: atMinute(20),
|
||
withVariants: true,
|
||
});
|
||
await seedPhoto({
|
||
id: phNovarC,
|
||
uploaderId: collabId,
|
||
visibility: 'unpublished',
|
||
processingState: 'processing_failed',
|
||
uploadedAt: atMinute(10),
|
||
withVariants: false,
|
||
});
|
||
|
||
// 预览字节取回断言用:phPubO 的 browse 对象实写 MinIO。
|
||
await putObject(
|
||
`photo-live/dev/albums/${albumAId}/variants/${phPubO}/1/browse.jpg`,
|
||
pubOBrowseBytes,
|
||
);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
await prisma.$disconnect();
|
||
await s3.destroy();
|
||
});
|
||
|
||
it('① listAlbums cover:最新 effective 胜出、pending/非 cover 不计、URL 可取回字节', async () => {
|
||
const res = await listAlbums(ownerToken);
|
||
expect(res.status).toBe(200);
|
||
const items = res.body.items as {
|
||
id: string;
|
||
cover: { url: string; expiresAt: string } | null;
|
||
}[];
|
||
expect(items).toHaveLength(6); // A + B + 4 filler(owner 全部可见)
|
||
|
||
const byId = new Map(items.map((item) => [item.id, item]));
|
||
// A:cover 非 null,签名 URL 实取回新 effective 行字节(非旧行/pending)。
|
||
const coverA = byId.get(albumAId)?.cover;
|
||
expect(coverA).not.toBeNull();
|
||
expect(coverA?.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||
expect(await fetchBody(coverA!.url)).toEqual(coverNewBytes);
|
||
// B:无封面 → null(键在,值为 null;旧字段不受影响)。
|
||
expect(byId.get(albumBId)?.cover).toBeNull();
|
||
// filler:全部非 null。
|
||
for (const id of fillerIds) {
|
||
expect(byId.get(id)?.cover).not.toBeNull();
|
||
}
|
||
});
|
||
|
||
it('① getAlbum 详情:cover 同形(批量路径与单查路径一致)', async () => {
|
||
const res = await request(app.getHttpServer())
|
||
.get(`/${API_PREFIX}/albums/${albumAId}`)
|
||
.set('Authorization', `Bearer ${ownerToken}`);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.cover).not.toBeNull();
|
||
expect(await fetchBody(res.body.cover.url as string)).toEqual(coverNewBytes);
|
||
|
||
const resB = await request(app.getHttpServer())
|
||
.get(`/${API_PREFIX}/albums/${albumBId}`)
|
||
.set('Authorization', `Bearer ${ownerToken}`);
|
||
expect(resB.status).toBe(200);
|
||
expect(resB.body.cover).toBeNull();
|
||
});
|
||
|
||
it('① 批量无 N+1:6 相册(5 封面)单页列表 = 恒定 Prisma 查询数', async () => {
|
||
// Prisma 5 $use 无卸载钩子:只挂一次(纯计数透传,不改变行为),以
|
||
// 计数器清零分隔两次测量。只计相册域模型——SessionGuard 的 sessions
|
||
// 查询等基础设施流量不入断言(防守卫演进误伤本断言)。
|
||
const prismaService = app.get(PrismaService);
|
||
const ALBUM_DOMAIN_MODELS = new Set([
|
||
'albums',
|
||
'album_translations',
|
||
'album_assets',
|
||
'album_members',
|
||
]);
|
||
let ops = 0;
|
||
prismaService.$use((params, next) => {
|
||
if (ALBUM_DOMAIN_MODELS.has(params.model as string)) {
|
||
ops += 1;
|
||
}
|
||
return next(params);
|
||
});
|
||
|
||
try {
|
||
const res = await listAlbums(ownerToken);
|
||
expect(res.status).toBe(200);
|
||
expect((res.body.items as unknown[]).length).toBe(6);
|
||
// owner:成员行 1 + albums 1 + translations 1 + album_assets 1 = 4。
|
||
// 封面相册数(5)不进入查询数——若退化为逐相册查封面(N+1)至少 +5。
|
||
expect(ops).toBe(4);
|
||
|
||
// admin 对照:无成员行查询,恒 3(与封面相册数无关)。
|
||
ops = 0;
|
||
const adminRes = await listAlbums(adminToken);
|
||
expect(adminRes.status).toBe(200);
|
||
expect((adminRes.body.items as unknown[]).length).toBe(6);
|
||
expect(ops).toBe(3);
|
||
} finally {
|
||
// 中间件无法卸载(Prisma 5),保留纯计数透传不影响后续用例;置回 0
|
||
// 仅为语义整洁。
|
||
ops = 0;
|
||
}
|
||
});
|
||
|
||
it('② owner scope=all(缺省):preview 矩阵 + browse 字节取回', async () => {
|
||
const res = await listPhotos(ownerToken, albumAId);
|
||
expect(res.status).toBe(200);
|
||
const byId = new Map(
|
||
(res.body.items as { photoId: string; preview: unknown }[]).map(
|
||
(item) => [item.photoId, item.preview],
|
||
),
|
||
);
|
||
expect(res.body.items).toHaveLength(6);
|
||
// published/hidden(含他人上传)对 manager 签发;deleted/无变体 null。
|
||
expect(byId.get(phPubC)).not.toBeNull();
|
||
expect(byId.get(phPubO)).not.toBeNull();
|
||
expect(byId.get(phHidC)).not.toBeNull();
|
||
expect(byId.get(phHidO)).not.toBeNull();
|
||
expect(byId.get(phDelO)).toBeNull();
|
||
expect(byId.get(phNovarC)).toBeNull();
|
||
|
||
// 签名 URL 实取回字节(thumb/browse 双通道,preview.browse 为浏览图)。
|
||
const previewO = byId.get(phPubO) as {
|
||
thumb: { url: string };
|
||
browse: { url: string };
|
||
};
|
||
expect(await fetchBody(previewO.browse.url)).toEqual(pubOBrowseBytes);
|
||
});
|
||
|
||
it('② admin scope=all:全部签发(他人 hidden 亦签)+ preview 形状', async () => {
|
||
const res = await listPhotos(adminToken, albumAId, { scope: 'all' });
|
||
expect(res.status).toBe(200);
|
||
const items = res.body.items as {
|
||
photoId: string;
|
||
preview: { thumb: { url: string; expiresAt: string }; browse: { url: string } } | null;
|
||
}[];
|
||
const byId = new Map(items.map((item) => [item.photoId, item.preview]));
|
||
expect(byId.get(phPubC)).not.toBeNull();
|
||
expect(byId.get(phHidC)).not.toBeNull(); // 他人 hidden 对 admin 签发
|
||
expect(byId.get(phHidO)).not.toBeNull();
|
||
expect(byId.get(phDelO)).toBeNull();
|
||
expect(byId.get(phNovarC)).toBeNull();
|
||
for (const item of items) {
|
||
if (item.preview !== null) {
|
||
expect(Object.keys(item.preview).sort()).toEqual(['browse', 'thumb']);
|
||
expect(item.preview.thumb.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('③ collab scope=own:仅自己上传(服务端过滤)+ 自己 hidden 行 preview=null', async () => {
|
||
const res = await listPhotos(collabToken, albumAId, { scope: 'own' });
|
||
expect(res.status).toBe(200);
|
||
const items = res.body.items as {
|
||
photoId: string;
|
||
uploaderId: string;
|
||
preview: unknown;
|
||
}[];
|
||
expect(items.map((i) => i.photoId)).toEqual([phPubC, phHidC, phNovarC]);
|
||
for (const item of items) {
|
||
expect(item.uploaderId).toBe(collabId); // 服务端过滤,无 owner 照片
|
||
}
|
||
// 自己的 published/未完成处理照常签发;hidden 仅 admin/owner 签发 → null
|
||
//(记录仍可见,图片不可见)。
|
||
const byId = new Map(items.map((i) => [i.photoId, i.preview]));
|
||
expect(byId.get(phPubC)).not.toBeNull();
|
||
expect(byId.get(phHidC)).toBeNull();
|
||
expect(byId.get(phNovarC)).toBeNull();
|
||
});
|
||
|
||
it('③ owner scope=own:过滤为自己上传(collab 照片不入列表)', async () => {
|
||
const res = await listPhotos(ownerToken, albumAId, { scope: 'own' });
|
||
expect(res.status).toBe(200);
|
||
const ids = (res.body.items as { photoId: string }[]).map((i) => i.photoId);
|
||
expect(ids).toEqual([phPubO, phHidO, phDelO]);
|
||
// 组合过滤:scope=own + visibility=published。
|
||
const combined = await listPhotos(ownerToken, albumAId, {
|
||
scope: 'own',
|
||
visibility: 'published',
|
||
});
|
||
expect(combined.status).toBe(200);
|
||
expect(
|
||
(combined.body.items as { photoId: string }[]).map((i) => i.photoId),
|
||
).toEqual([phPubO]);
|
||
});
|
||
|
||
it('④ collab scope=all(缺省与显式)→ 403;非成员任何 scope → 404;非法 scope → 400', async () => {
|
||
const implicit = await listPhotos(collabToken, albumAId);
|
||
expect(implicit.status).toBe(403);
|
||
expect(implicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||
|
||
const explicit = await listPhotos(collabToken, albumAId, { scope: 'all' });
|
||
expect(explicit.status).toBe(403);
|
||
expect(explicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||
|
||
// 非成员(outsider 对 A)任何 scope → 404(防枚举)。
|
||
for (const scope of ['own', 'all', undefined]) {
|
||
const res = await listPhotos(outsiderToken, albumAId, scope === undefined ? {} : { scope });
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||
}
|
||
// collab 对非成员相册 B → 404。
|
||
const cross = await listPhotos(collabToken, albumBId, { scope: 'own' });
|
||
expect(cross.status).toBe(404);
|
||
expect(cross.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||
|
||
// scope 非法值 → 400;空串视为缺省(collab → 403,同缺省口径)。
|
||
const garbage = await listPhotos(ownerToken, albumAId, { scope: 'mine' });
|
||
expect(garbage.status).toBe(400);
|
||
expect(garbage.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||
const emptyScope = await listPhotos(collabToken, albumAId, { scope: '' });
|
||
expect(emptyScope.status).toBe(403);
|
||
});
|
||
|
||
it('④ 被移除成员 scope=own → 404(成员资格逐请求检查)', async () => {
|
||
// 基线:collab scope=own 可用(③ 已证,此处再验一次后移除)。
|
||
expect(
|
||
(await listPhotos(collabToken, albumAId, { scope: 'own' })).status,
|
||
).toBe(200);
|
||
await prisma.album_members.deleteMany({
|
||
where: { albumId: albumAId, userId: collabId },
|
||
});
|
||
const removed = await listPhotos(collabToken, albumAId, { scope: 'own' });
|
||
expect(removed.status).toBe(404);
|
||
expect(removed.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||
// 恢复成员(后续若有 spec 复用;本 spec 至此为止)。
|
||
await prisma.album_members.create({
|
||
data: {
|
||
albumId: albumAId,
|
||
userId: collabId,
|
||
role: 'collaborator',
|
||
status: 'active',
|
||
},
|
||
});
|
||
});
|
||
|
||
it('cover 回退 banner(宣传图/封面共用:仅 banner 相册 → cover=banner 对象)', async () => {
|
||
// 直种仅 banner 相册(复用本文件资产直种模式)。
|
||
const album = await prisma.albums.create({
|
||
data: {
|
||
publicToken: makePublicToken(),
|
||
ownerId: 'u-ui01-coverfb',
|
||
startAt: new Date('2026-10-03T02:00:00.000Z'),
|
||
endAt: new Date('2026-10-03T12:00:00.000Z'),
|
||
defaultLocale: 'zh-CN',
|
||
},
|
||
});
|
||
await prisma.album_translations.create({
|
||
data: { albumId: album.id, locale: 'zh-CN', title: '仅宣传图相册' },
|
||
});
|
||
const key = `photo-live/dev/albums/${album.id}/assets/u01assetbannerfb00000000.jpg`;
|
||
await putObject(key, Buffer.from([0x51, 0x52, 0x53]));
|
||
await prisma.album_assets.create({
|
||
data: {
|
||
id: 'u01assetbannerfb00000000',
|
||
albumId: album.id,
|
||
kind: 'banner',
|
||
locale: null,
|
||
objectKey: key,
|
||
state: 'effective',
|
||
expectedBytes: 3,
|
||
contentType: 'image/jpeg',
|
||
},
|
||
});
|
||
|
||
const res = await listAlbums(adminToken);
|
||
expect(res.status).toBe(200);
|
||
const item = (res.body.items as Array<{ id: string; cover: { url: string } | null }>).find(
|
||
(row) => row.id === album.id,
|
||
);
|
||
expect(item).toBeDefined();
|
||
expect(item?.cover).not.toBeNull();
|
||
const fetched = await fetch(item!.cover!.url);
|
||
expect(fetched.status).toBe(200);
|
||
expect(Buffer.from(await fetched.arrayBuffer()).length).toBe(3);
|
||
});
|
||
});
|