8ba98ace63
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>
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
// 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);
|
||
});
|
||
});
|