Files
photo-live/services/api/test/ui01-contracts.e2e-spec.ts
T

669 lines
24 KiB
TypeScript
Raw Normal View History

// 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);
});
});