Files
photo-live/services/api/test/photos-admin.e2e-spec.ts
T
浅念 8ba98ace63 feat(api): management photo list and account lookup
P1b Task 4: listAlbumPhotos (GET /albums/{albumId}/photos, tag
[photos]) + lookupUsersByAccount (GET /admin/users, tag [admin]).

- management list: all visibility states visible (unpublished/
  published/hidden/deleted) for owner/admin; collaborator 403,
  outsider/ghost album 404 (canView -> canManage ordering, same
  anti-enumeration as members). Items = PhotoRecord + uploader
  {userId, account, displayName} (flat-FK second query, redacted).
  Keyset (uploadedAt,id) DESC via shared/cursor; visibility filter
  validated against PhotoVisibility enum (400 otherwise), categoryId
  must belong to the album (ghost/cross-album same-code 400).
  pending_upload rows (uploadedAt NULL) excluded: the sort key is
  both the contract-required field and the keyset predicate - NULL
  can neither be shaped as PhotoRecord nor compared; in-flight
  uploads are covered by the uploads history endpoint.
- account lookup (new src/admin module): prefix search over active
  users only (credentialStatus=active, sourceStatus!=disabled,
  !localLocked - same eligibility as SessionGuard/members.add),
  account ASC, no cursor (small cap). $queryRaw LIKE prefix% with
  explicit ESCAPE '!': !/%/_/\ escaped to literal matching (Prisma
  startsWith escaping is not contractual); utf8mb4_0900_bin makes
  the prefix match case-sensitive ('AL' misses 'alice'), documented
  in contract + tests. Access policy per contract: any authenticated
  staff (deviation from plan's admin/owner), unauthenticated 401.
- e2e: photos-admin (6) + users-lookup (6) cases; api e2e 136 -> 148.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 08:54:44 +08:00

486 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(形状精确断言用)。 */
const ADMIN_PHOTO_KEYS = [
'albumId',
'bytes',
'categoryId',
'height',
'likeCount',
'photoId',
'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);
});
});