2026-09-21 14:09:35 +08:00
|
|
|
|
// 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',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-23 10:13:08 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-21 14:09:35 +08:00
|
|
|
|
});
|