Files
photo-live/services/api/test/photos-admin.e2e-spec.ts
T
浅念 8f4ad11bd5 feat(contracts,api): UI-01 cover, preview, scope=own and embed config extensions
契约扩展(全部可选/可空,旧客户端零感知)+ cn/ov 实现 + e2e 权限矩阵:
- AlbumSummary += cover(kind=cover state=effective 最新行的短期签名 URL,
  列表/详情按页批量组装非 N+1;未配置为 null)
- AdminPhoto += preview{thumb,browse}(变体齐备且未删除才签发;隐藏仅
  admin/owner;协作者仅自己上传,scope=own 服务端过滤 uploaderId,
  成员资格逐请求裁决)
- listAlbumPhotos += scope=all|own(缺省 all 行为不变:仅 owner/admin,
  协作者 403;own 任何有效成员)
- EmbedSlotSettings += imageFit/offsetX/offsetBottom/zIndex(cn PATCH 校验
  → slot-state 投影 → ov 公开回读;settings 为 Json 列免迁移,读侧
  normalizeStoredSettings 兜旧形状默认值)
- Flutter 模型同步(SignedUrl/PhotoPreview/AlbumPhotoScope,可空容错)

e2e:ui01-contracts 9 例(封面选取/字节取回/N+1 计数断言、preview 权限
矩阵、scope 过滤与 403/404/400)+ embed ⑨ 配置链路;全量 e2e 257 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:09:35 +08:00

488 lines
17 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/photos-admin.e2e-spec.ts —— P1b Task 4 管理照片列表 e2e
// (先写后实现)。真实 app + MySQL。覆盖:
// ① owner GET /albums/{albumId}/photos:全部可见性状态可见(unpublished/
// published/hidden/deleted,含 uploaded/processing/processing_failed 处理
// 态),(uploadedAt,id) DESC 稳定倒序(同 uploadedAt 以 id DESC 决胜),
// 条目 = PhotoRecord + uploader{userId,account,displayName} 脱敏内嵌
// (无邮箱/密码/状态字段;契约 PhotoRecord 不含 errorCode——管理侧对失败
// 的可见性经 processingState=processing_failed 表达);未完成上传
// (pending_upload、uploadedAt NULL)不入列表(排序键未就绪,见服务注释);
// 他册照片不入列表;
// ② visibility 过滤(四态逐一 + 非法值 400);categoryId 过滤(含与
// visibility 组合;幽灵/跨册分类 400);
// ③ 游标分页:limit=3 三页跨页无重复、尾页 hasMore=false;非法 cursor /
// limit 越界 → 400;
// ④ 权限:协作者(非 owner 成员)403 AUTH_FORBIDDEN;外人员工 404
// ALBUM_NOT_FOUND;公司 admin 200;幽灵相册 404;未认证 401。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
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 { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
let token = '';
const bytes = randomBytes(24);
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
/** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用;
* UI-01 增 preview——服务恒发(无权/无变体为 null),键恒在)。 */
const ADMIN_PHOTO_KEYS = [
'albumId',
'bytes',
'categoryId',
'height',
'likeCount',
'photoId',
'preview',
'processingState',
'publishedAt',
'shotAt',
'uploadedAt',
'uploader',
'uploaderId',
'visibility',
'width',
].sort();
/** 固定时间基(分钟偏移构造确定性 uploadedAt 序列)。 */
const T0 = Date.UTC(2026, 8, 12, 8, 0, 0);
const atMinute = (m: number): Date => new Date(T0 + m * 60_000);
describe('管理照片列表 GET /albums/{albumId}/photos (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let albumId: string;
let catAId: string;
let catBId: string;
let catOtherId: string;
let ownerId: string;
let collabId: string;
let ownerToken: string;
let collabToken: string;
let adminToken: string;
let outsiderToken: string;
// 种子照片(显式 ID + 显式 uploadedAt,期望序可静态推出)。
let p1: string; // published / collab / catA / t+50
let p2: string; // published / owner / 无分类 / t+40
let p3: string; // hidden / collab / catA / t+30
let p4: string; // deleted / owner / t+20
let p5: string; // unpublished processing_failed / collab / catB / t+10
let tieA: string; // unpublished processing / owner / t+0(与 tieB 同刻)
let tieB: string; // unpublished uploaded / collab / catB / t+0(同刻 id 决胜)
/** 全量无过滤期望序(uploadedAt DESC, id DESC;tie-b > tie-a)。 */
const FULL_ORDER = () => [p1, p2, p3, p4, p5, tieB, tieA];
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();
// 账号:owner / collab(上传协作者)/ admin / outsider(外人员工)。
// displayName 与 account 不同值,验证 uploader 映射取的是资料字段。
const mkUser = (
account: string,
displayName: string,
role = 'staff',
) =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName },
select: { id: true },
});
ownerId = (await mkUser('pa-owner', '潘欧文')).id;
collabId = (await mkUser('pa-collab', '程澜')).id;
const adminId = (await mkUser('pa-admin', '艾管', 'admin')).id;
const outsiderId = (await mkUser('pa-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);
// 主相册:owner + collab 成员行;分类 catA/catB。
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumId = album.id;
await prisma.album_translations.create({
data: { albumId, locale: 'zh-CN', title: '管理列表相册' },
});
await prisma.album_members.createMany({
data: [
{ albumId, userId: ownerId, role: 'owner', status: 'active' },
{ albumId, userId: collabId, role: 'collaborator', status: 'active' },
],
});
const catA = await prisma.categories.create({
data: { albumId, sortOrder: 1 },
select: { id: true },
});
const catB = await prisma.categories.create({
data: { albumId, sortOrder: 2 },
select: { id: true },
});
catAId = catA.id;
catBId = catB.id;
await prisma.category_translations.createMany({
data: [
{ categoryId: catAId, locale: 'zh-CN', name: '晚宴' },
{ categoryId: catBId, locale: 'zh-CN', name: '后台' },
],
});
// 他册(跨册分类/照片排除用):owner 名下独立相册 + 分类 + 一张 published。
const otherAlbum = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
const catOther = await prisma.categories.create({
data: { albumId: otherAlbum.id, sortOrder: 1 },
select: { id: true },
});
catOtherId = catOther.id;
await seedPhoto({
id: 'pa-photo-other',
targetAlbumId: otherAlbum.id,
uploaderId: ownerId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(45),
});
// 主册种子:两上传人 × 全可见性状态 × 处理态变体;tie 对同 uploadedAt。
p1 = await seedPhoto({
id: 'pa-photo-p1',
targetAlbumId: albumId,
uploaderId: collabId,
visibility: 'published',
processingState: 'ready',
categoryId: catAId,
uploadedAt: atMinute(50),
});
p2 = await seedPhoto({
id: 'pa-photo-p2',
targetAlbumId: albumId,
uploaderId: ownerId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(40),
});
p3 = await seedPhoto({
id: 'pa-photo-p3',
targetAlbumId: albumId,
uploaderId: collabId,
visibility: 'hidden',
processingState: 'ready',
categoryId: catAId,
uploadedAt: atMinute(30),
});
p4 = await seedPhoto({
id: 'pa-photo-p4',
targetAlbumId: albumId,
uploaderId: ownerId,
visibility: 'deleted',
processingState: 'ready',
uploadedAt: atMinute(20),
});
p5 = await seedPhoto({
id: 'pa-photo-p5',
targetAlbumId: albumId,
uploaderId: collabId,
visibility: 'unpublished',
processingState: 'processing_failed',
categoryId: catBId,
uploadedAt: atMinute(10),
});
tieA = await seedPhoto({
id: 'pa-photo-tie-a',
targetAlbumId: albumId,
uploaderId: ownerId,
visibility: 'unpublished',
processingState: 'processing',
uploadedAt: atMinute(0),
});
tieB = await seedPhoto({
id: 'pa-photo-tie-b',
targetAlbumId: albumId,
uploaderId: collabId,
visibility: 'unpublished',
processingState: 'uploaded',
categoryId: catBId,
uploadedAt: atMinute(0),
});
// 未完成上传(init 后未 complete 的会话占位行):uploadedAt NULL → 不入列表。
await prisma.photos.create({
data: {
id: 'pa-photo-pending',
albumId,
uploaderId: collabId,
objectKey: `photo-live/dev/albums/${albumId}/originals/pa-photo-pending.jpg`,
visibility: 'unpublished',
processingState: 'pending_upload',
},
});
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
/**
* 直种照片行(管理列表不触及对象存储/变体——契约 PhotoRecord 无 URL 字段,
* 故无需 MinIO 对象与 photo_variants 行)。
*/
async function seedPhoto(spec: {
id: string;
targetAlbumId: string;
uploaderId: string;
visibility: string;
processingState: string;
categoryId?: string;
uploadedAt: Date;
}): Promise<string> {
await prisma.photos.create({
data: {
id: spec.id,
albumId: spec.targetAlbumId,
uploaderId: spec.uploaderId,
categoryId: spec.categoryId ?? null,
objectKey: `photo-live/dev/albums/${spec.targetAlbumId}/originals/${spec.id}.jpg`,
bytes: 12345,
width: 800,
height: 500,
uploadedAt: spec.uploadedAt,
publishedAt:
spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt),
processingState: spec.processingState,
visibility: spec.visibility,
entityVersion: 2,
},
});
return spec.id;
}
function listMgmt(
token: string,
query?: Record<string, string | number>,
) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumId}/photos`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
function idsOf(body: { items: { photoId: string }[] }): string[] {
return body.items.map((item) => item.photoId);
}
it('owner → 200:全状态可见、(uploadedAt,id) DESC、uploader 脱敏内嵌正确', async () => {
const res = await listMgmt(ownerToken);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual([
'hasMore',
'items',
'nextCursor',
]);
expect(res.body.hasMore).toBe(false);
expect(res.body.nextCursor).toBeNull();
// 全状态 + 处理态变体全部可见;未完成上传(NULL uploadedAt)与他册照片排除。
expect(idsOf(res.body)).toEqual(FULL_ORDER());
expect(idsOf(res.body)).not.toContain('pa-photo-pending');
expect(idsOf(res.body)).not.toContain('pa-photo-other');
// 条目形状 = PhotoRecord 全字段 + uploader(无任何凭据/状态冗余字段)。
expect(Object.keys(res.body.items[0]).sort()).toEqual(ADMIN_PHOTO_KEYS);
// uploader 内嵌:两上传人各自映射到 account/displayName(脱敏资料字段)。
const byId = new Map(
(res.body.items as {
photoId: string;
uploader: { userId: string; account: string; displayName: string };
processingState: string;
publishedAt: string | null;
categoryId: string | null;
}[]).map((item) => [item.photoId, item]),
);
for (const item of byId.values()) {
expect(item.uploader).toEqual(
item.uploader.userId === ownerId
? { userId: ownerId, account: 'pa-owner', displayName: '潘欧文' }
: { userId: collabId, account: 'pa-collab', displayName: '程澜' },
);
}
// 处理态可见性(管理侧失败洞察):processing_failed / processing / uploaded。
expect(byId.get(p5)?.processingState).toBe('processing_failed');
expect(byId.get(tieA)?.processingState).toBe('processing');
expect(byId.get(tieB)?.processingState).toBe('uploaded');
// 未发布 publishedAt=null(与 published 行对照);分类映射。
expect(byId.get(p5)?.publishedAt).toBeNull();
expect(byId.get(p1)?.publishedAt).not.toBeNull();
expect(byId.get(p1)?.categoryId).toBe(catAId);
expect(byId.get(p2)?.categoryId).toBeNull();
});
it('visibility 过滤:published/unpublished/hidden/deleted 逐一命中;非法值 → 400', async () => {
const published = await listMgmt(ownerToken, { visibility: 'published' });
expect(published.status).toBe(200);
expect(idsOf(published.body)).toEqual([p1, p2]);
const unpublished = await listMgmt(ownerToken, {
visibility: 'unpublished',
});
expect(unpublished.status).toBe(200);
expect(idsOf(unpublished.body)).toEqual([p5, tieB, tieA]);
const hidden = await listMgmt(ownerToken, { visibility: 'hidden' });
expect(hidden.status).toBe(200);
expect(idsOf(hidden.body)).toEqual([p3]);
const deleted = await listMgmt(ownerToken, { visibility: 'deleted' });
expect(deleted.status).toBe(200);
expect(idsOf(deleted.body)).toEqual([p4]);
for (const garbage of ['public', 'PUBLISHED', '42']) {
const res = await listMgmt(ownerToken, { visibility: garbage });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('categoryId 过滤与组合;幽灵/跨册 categoryId → 400', async () => {
const catA = await listMgmt(ownerToken, { categoryId: catAId });
expect(catA.status).toBe(200);
expect(idsOf(catA.body)).toEqual([p1, p3]);
const catB = await listMgmt(ownerToken, { categoryId: catBId });
expect(catB.status).toBe(200);
expect(idsOf(catB.body)).toEqual([p5, tieB]);
const combined = await listMgmt(ownerToken, {
visibility: 'published',
categoryId: catAId,
});
expect(combined.status).toBe(200);
expect(idsOf(combined.body)).toEqual([p1]);
const ghost = await listMgmt(ownerToken, { categoryId: 'no-such-cat' });
expect(ghost.status).toBe(400);
expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 跨册分类:真实存在但不属于本相册——同码 400(不区分两种失败,防探测)。
const cross = await listMgmt(ownerToken, { categoryId: catOtherId });
expect(cross.status).toBe(400);
expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('游标分页:limit=3 三页跨页无重复,尾页 hasMore=false / nextCursor=null', async () => {
const seen: string[] = [];
let cursor: string | undefined;
let pages = 0;
for (;;) {
const res = await listMgmt(
ownerToken,
cursor === undefined ? { limit: 3 } : { limit: 3, cursor },
);
expect(res.status).toBe(200);
pages += 1;
seen.push(...idsOf(res.body));
if (res.body.hasMore === false) {
expect(res.body.nextCursor).toBeNull();
expect(pages).toBe(3);
break;
}
expect(res.body.nextCursor).toEqual(expect.any(String));
cursor = res.body.nextCursor as string;
}
expect(seen).toEqual(FULL_ORDER());
expect(new Set(seen).size).toBe(seen.length); // 无重复
});
it('非法 cursor / limit 越界 → 400 VALIDATION_FAILED', async () => {
const badCursor = await listMgmt(ownerToken, { cursor: 'not-a-cursor!' });
expect(badCursor.status).toBe(400);
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
for (const limit of [0, 101, 'abc']) {
const res = await listMgmt(ownerToken, { limit });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
// 合法界内:1 与 100 均 200。
expect((await listMgmt(ownerToken, { limit: 1 })).status).toBe(200);
expect((await listMgmt(ownerToken, { limit: 100 })).status).toBe(200);
});
it('权限:collab 403 / outsider 404 / admin 200 / 幽灵相册 404 / 未认证 401', async () => {
const collabRes = await listMgmt(collabToken);
expect(collabRes.status).toBe(403);
expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const outsiderRes = await listMgmt(outsiderToken);
expect(outsiderRes.status).toBe(404);
expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
const adminRes = await listMgmt(adminToken, { visibility: 'deleted' });
expect(adminRes.status).toBe(200);
expect(idsOf(adminRes.body)).toEqual([p4]);
const ghost = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/no-such-album/photos`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(ghost.status).toBe(404);
expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
const anon = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumId}/photos`);
expect(anon.status).toBe(401);
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});