Files
photo-live/services/api/test/assets.e2e-spec.ts
T
浅念 361a72f300 feat(api): album asset upload chain
P1b Task 3: initAlbumAssetUpload / completeAlbumAssetUpload (tags
[assets]) over album_assets with in-row one-shot sessions.

- migration asset_state: album_assets + state (pending|effective),
  expectedBytes, contentType (utf8mb4_0900_bin); pre-existing rows
  backfilled to effective. Complete head-verifies bytes AND
  contentType vs the init declaration (P1a Task 11 server-side
  fallback), then conditionally flips pending->effective with the
  winner writing the album.asset.add audit (resourceId=albumId,
  metadata assetId/kind/locale). Effective rows re-complete
  idempotently (re-signed URL, no re-verify, survives object
  cleanup).
- object key is the final key (albums/{albumId}/assets/{assetId}.{ext});
  the pending row is the logical staging - no copy step, keys embed
  assetId so re-init can never overwrite another row's object.
- permissions: owner/admin only (collaborator 403, outsider 404);
  uploadEnabled does NOT gate assets (switch governs photo uploads
  per 4.9 - both endpoints carry no ALBUM_UPLOAD_DISABLED).
- public banner now locale-aware (req 4.7): effective rows resolved
  as display-locale row -> global (locale NULL) row -> null, latest
  wins per tier (same-kind re-upload replaces, old rows retained).
- e2e: test/assets.e2e-spec.ts (14 cases, real jpegs via sharp,
  MinIO round-trips); public spec seed marked effective.

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

599 lines
22 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/assets.e2e-spec.ts —— P1b Task 3 相册资产上传链路 e2e
// (先写后实现)。认证同 uploads spec:Prisma 直种 sessions 行换 Bearer 令牌。
// 真实 MinIO:presign PUT 实写 head 可核验;真实 JPEG 经 sharp 生成(无二进制
// 入库)。覆盖:
// init/complete 主链路:owner init banner(zh-CN) → 201 {assetId, objectKey,
// credential}(键形 photo-live/dev/albums/{albumId}/assets/{assetId}.jpg,
// DB 行 state=pending + 申明值 expectedBytes/contentType 行内持久化);
// PUT 真图 → complete 200 {assetId, url, expiresAt},签名 URL 可取回
// 真实字节;行置 effective;audit album.asset.add(actor=操作者、
// resourceId=albumId、metadata assetId/kind/locale)。
// 公开消费 + 语言回退(需求 4.7):GET /public/albums/{token}?locale=zh-CN
// banner=该资产签名 URL;en 请求无 en 行且无全局行 → null;补全局行
// (locale 缺省)后 en 取全局、zh-CN 取 zh-CN;仅全局行的相册 zh-CN
// 请求 → 全局(回退默认图)。
// 替换语义:同 kind(zh-CN banner)二次上传 → 公开取新资产行,旧行保留。
// 权限:协作者 init/complete → 403 AUTH_FORBIDDEN(资产是相册配置,owner/
// admin 专属);局外人 → 404 ALBUM_NOT_FOUND(不可见同码防枚举);
// uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)。
// 校验:bytes 超 UPLOAD_MAX_BYTES / contentType 白名单外 → 400;complete 前
// 未 PUT → 400 UPLOAD_VERIFY_FAILED(行保持 pending);字节数不符 → 400;
// contentType 不符(presign 不签名 content-type,服务端兜底)→ 400。
// 幂等:重复 complete → 200 同 assetId(URL 现签),audit 不重复;对象已
// 删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)。
// cover/logo 建链就绪:logo 冒烟一例。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import sharp from 'sharp';
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);
/** 契约 AssetInitResponse / AssetCompleteResponse 字段全集(形状精确断言用)。 */
const INIT_KEYS = ['assetId', 'objectKey', 'credential'].sort();
const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort();
const COMPLETE_KEYS = ['assetId', 'url', 'expiresAt'].sort();
const KEY_ROOT = 'photo-live/dev';
/** 测试内生成真实 JPEG(尺寸/配色区分内容:新旧资产字节各不相同)。 */
const makeJpeg = (
width: number,
height: number,
rgb: { r: number; g: number; b: number },
) =>
sharp({
create: { width, height, channels: 3, background: rgb },
})
.jpeg({ quality: 85 })
.toBuffer();
type Credential = {
url: string;
method: string;
headers: Record<string, string>;
};
/** 按凭证实写对象(contentType 可覆盖以构造核验失败用例)。 */
async function putByCredential(
credential: Credential,
body: Buffer,
contentType?: string,
): Promise<boolean> {
const res = await fetch(credential.url, {
method: credential.method,
headers:
contentType === undefined
? credential.headers
: { 'Content-Type': contentType },
body: new Uint8Array(body),
});
return res.ok;
}
describe('album assets upload (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
let collaboratorId: string;
let outsiderId: string;
let ownerToken: string;
let collaboratorToken: string;
let outsiderToken: string;
let albumId: string;
let albumToken: string;
/** 仅全局 banner 的第二相册(zh-CN 请求回退全局用例)。 */
let globalOnlyAlbumId: string;
let globalOnlyToken: string;
/** T1 主链路资产锚点(幂等/对象删除用例复用)。 */
let t1Asset: { assetId: string; objectKey: string; jpeg: Buffer };
const initAsset = (token: string, album: string, body: object) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/albums/${album}/assets`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const completeAsset = (token: string, album: string, assetId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/albums/${album}/assets/${assetId}/complete`)
.set('Authorization', `Bearer ${token}`);
const getPublic = (token: string, query?: Record<string, string>) =>
request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${token}`)
.query(query ?? {});
/** 完整链路 helper:init → PUT 真图 → complete,返回锚点与响应。 */
const uploadAsset = async (
jpeg: Buffer,
overrides: Record<string, unknown> = {},
album: string = albumId,
) => {
const initRes = await initAsset(ownerToken, album, {
kind: 'banner',
locale: 'zh-CN',
filename: 'b.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
...overrides,
});
expect(initRes.status).toBe(201);
const { assetId, objectKey, credential } = initRes.body as {
assetId: string;
objectKey: string;
credential: Credential;
};
expect(await putByCredential(credential, jpeg)).toBe(true);
const completeRes = await completeAsset(ownerToken, album, assetId);
expect(completeRes.status).toBe(200);
return {
assetId,
objectKey,
jpeg,
complete: completeRes.body as { assetId: string; url: string },
};
};
/** 取公开 banner 并断言字节与预期一致(签名 URL 可取回真实对象)。 */
const expectPublicBannerBytes = async (
token: string,
locale: string,
jpeg: Buffer,
) => {
const res = await getPublic(token, { locale });
expect(res.status).toBe(200);
const banner = res.body.banner as { url: string; expiresAt: string };
expect(banner).not.toBeNull();
expect(Date.parse(banner.expiresAt)).toBeGreaterThan(Date.now());
const fetched = await fetch(banner.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg);
};
/** 经 API 建册(owner),返回 {id, publicToken}(publicEnabled 默认 true)。 */
const createAlbum = async (title: string): Promise<{
id: string;
publicToken: string;
}> => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title },
{ locale: 'en', title: `${title} (en)` },
],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T02:00:00.000Z',
});
expect(res.status).toBe(201);
const album = await prisma.albums.findUnique({
where: { id: res.body.id as string },
select: { publicToken: true },
});
expect(album).not.toBeNull();
return { id: res.body.id as string, publicToken: album!.publicToken };
};
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();
const mkUser = (account: string) =>
prisma.users.create({
data: { account, passwordHash: 'x', displayName: account },
select: { id: true },
});
ownerId = (await mkUser('as-owner')).id;
collaboratorId = (await mkUser('as-collab')).id;
outsiderId = (await mkUser('as-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);
collaboratorToken = await mkToken(collaboratorId);
outsiderToken = await mkToken(outsiderId);
const main = await createAlbum('资产相册');
albumId = main.id;
albumToken = main.publicToken;
await prisma.album_members.create({
data: { albumId, userId: collaboratorId, role: 'collaborator' },
});
const globalOnly = await createAlbum('仅全局资产相册');
globalOnlyAlbumId = globalOnly.id;
globalOnlyToken = globalOnly.publicToken;
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
it('owner 主链路:init → 201 行内会话;PUT 真图;complete → 200 可取回;DB effective + audit', async () => {
const jpeg = await makeJpeg(64, 40, { r: 96, g: 130, b: 182 });
const initRes = await initAsset(ownerToken, albumId, {
kind: 'banner',
locale: 'zh-CN',
filename: 'b.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
expect(initRes.status).toBe(201);
expect(Object.keys(initRes.body).sort()).toEqual(INIT_KEYS);
expect(Object.keys(initRes.body.credential).sort()).toEqual(CREDENTIAL_KEYS);
expect(initRes.body.credential).toMatchObject({
mode: 'presigned',
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
});
expect(
new Date(initRes.body.credential.expiresAt).getTime(),
).toBeGreaterThan(Date.now());
const { assetId, objectKey, credential } = initRes.body as {
assetId: string;
objectKey: string;
credential: Credential;
};
t1Asset = { assetId, objectKey, jpeg };
// 对象键:photo-live/dev/albums/{albumId}/assets/{assetId}.jpg(最终键即键)。
expect(objectKey).toBe(`${KEY_ROOT}/albums/${albumId}/assets/${assetId}.jpg`);
// DB:init 即建行,state=pending,申明值行内持久化(complete 核验依据)。
const pendingRow = await prisma.album_assets.findUnique({
where: { id: assetId },
});
expect(pendingRow).toMatchObject({
albumId,
kind: 'banner',
locale: 'zh-CN',
objectKey,
state: 'pending',
expectedBytes: jpeg.length,
contentType: 'image/jpeg',
});
expect(await putByCredential(credential, jpeg)).toBe(true);
const completeRes = await completeAsset(ownerToken, albumId, assetId);
expect(completeRes.status).toBe(200);
expect(Object.keys(completeRes.body).sort()).toEqual(COMPLETE_KEYS);
expect(completeRes.body.assetId).toBe(assetId);
expect(Date.parse(completeRes.body.expiresAt)).toBeGreaterThan(Date.now());
// 签名 URL 直取 MinIO:真实字节 + JPEG content-type。
const fetched = await fetch(completeRes.body.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg);
// DB:行置 effective(挂载生效)。
const effectiveRow = await prisma.album_assets.findUnique({
where: { id: assetId },
});
expect(effectiveRow?.state).toBe('effective');
// audit album.asset.add:actor/resourceId=albumId/metadata 三元组。
const audits = await prisma.audit_logs.findMany({
where: { action: 'album.asset.add', resourceId: albumId },
});
expect(audits).toHaveLength(1);
expect(audits[0]).toMatchObject({
actorId: ownerId,
metadata: { assetId, kind: 'banner', locale: 'zh-CN' },
});
// 公开消费:GET /public/albums/{token}?locale=zh-CN banner=该资产。
await expectPublicBannerBytes(albumToken, 'zh-CN', jpeg);
});
it('需求 4.7 语言回退:en 请求无 en 行且无全局行 → banner null', async () => {
const res = await getPublic(albumToken, { locale: 'en' });
expect(res.status).toBe(200);
expect(res.body.resolvedLocale).toBe('en');
expect(res.body.banner).toBeNull();
});
it('全局行(locale 缺省):en 取全局、zh-CN 仍取 zh-CN 行', async () => {
const globalJpeg = await makeJpeg(80, 50, { r: 182, g: 96, b: 130 });
const uploaded = await uploadAsset(globalJpeg, { locale: undefined });
// DB:全局行 locale=NULL。
const row = await prisma.album_assets.findUnique({
where: { id: uploaded.assetId },
});
expect(row?.locale).toBeNull();
await expectPublicBannerBytes(albumToken, 'en', globalJpeg);
await expectPublicBannerBytes(albumToken, 'zh-CN', t1Asset.jpeg);
});
it('仅全局行的相册:zh-CN 请求 → 全局(回退默认图,需求 4.7)', async () => {
const jpeg = await makeJpeg(48, 30, { r: 130, g: 182, b: 96 });
await uploadAsset(jpeg, { locale: undefined }, globalOnlyAlbumId);
await expectPublicBannerBytes(globalOnlyToken, 'zh-CN', jpeg);
});
it('替换语义:同 kind 二次上传 → 公开取新资产;旧行保留', async () => {
const newer = await uploadAsset(await makeJpeg(96, 60, { r: 200, g: 80, b: 80 }));
// 公开 zh-CN 取新资产行(最新 effective)。
await expectPublicBannerBytes(albumToken, 'zh-CN', newer.jpeg);
// 旧行保留(P1b 无删除端点;zh-CN 行数=2:T1 + 本次)。
const rows = await prisma.album_assets.findMany({
where: { albumId, kind: 'banner', locale: 'zh-CN' },
});
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.state)).toEqual(['effective', 'effective']);
});
it('协作者 init/complete → 403 AUTH_FORBIDDEN;局外人 → 404 ALBUM_NOT_FOUND', async () => {
const collabInit = await initAsset(collaboratorToken, albumId, {
kind: 'banner',
locale: 'zh-CN',
filename: 'c.jpg',
bytes: 1000,
contentType: 'image/jpeg',
});
expect(collabInit.status).toBe(403);
expect(collabInit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
// 协作者对既有资产 complete 亦 403(可见但无管理权)。
const collabComplete = await completeAsset(
collaboratorToken,
albumId,
t1Asset.assetId,
);
expect(collabComplete.status).toBe(403);
expect(collabComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const outsiderInit = await initAsset(outsiderToken, albumId, {
kind: 'banner',
filename: 'o.jpg',
bytes: 1000,
contentType: 'image/jpeg',
});
expect(outsiderInit.status).toBe(404);
expect(outsiderInit.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
const outsiderComplete = await completeAsset(
outsiderToken,
albumId,
t1Asset.assetId,
);
expect(outsiderComplete.status).toBe(404);
expect(outsiderComplete.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// 幽灵资产 id:可见册内不存在 → 403 AUTH_FORBIDDEN(契约:不区分防枚举)。
const ghost = await completeAsset(ownerToken, albumId, 'no-such-asset');
expect(ghost.status).toBe(403);
expect(ghost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
});
it('uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)', async () => {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: false },
});
try {
const jpeg = await makeJpeg(32, 32, { r: 10, g: 10, b: 10 });
const uploaded = await uploadAsset(jpeg, { kind: 'cover' });
expect(uploaded.complete.assetId).toBe(uploaded.assetId);
const row = await prisma.album_assets.findUnique({
where: { id: uploaded.assetId },
});
expect(row).toMatchObject({ kind: 'cover', state: 'effective' });
} finally {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: true },
});
}
});
it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调)→ 400;contentType 白名单外 → 400', async () => {
const previous = process.env.UPLOAD_MAX_BYTES;
process.env.UPLOAD_MAX_BYTES = '500';
try {
const tooBig = await initAsset(ownerToken, albumId, {
kind: 'banner',
filename: 'big.jpg',
bytes: 501,
contentType: 'image/jpeg',
});
expect(tooBig.status).toBe(400);
expect(tooBig.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
} finally {
if (previous === undefined) {
delete process.env.UPLOAD_MAX_BYTES;
} else {
process.env.UPLOAD_MAX_BYTES = previous;
}
}
const badType = await initAsset(ownerToken, albumId, {
kind: 'banner',
filename: 't.txt',
bytes: 100,
contentType: 'text/plain',
});
expect(badType.status).toBe(400);
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 扩展名白名单与 kind 枚举(DTO 层)。
const badExt = await initAsset(ownerToken, albumId, {
kind: 'banner',
filename: 'virus.exe',
bytes: 100,
contentType: 'image/jpeg',
});
expect(badExt.status).toBe(400);
const badKind = await initAsset(ownerToken, albumId, {
kind: 'mascot',
filename: 'm.jpg',
bytes: 100,
contentType: 'image/jpeg',
});
expect(badKind.status).toBe(400);
});
it('complete 前未 PUT → 400 UPLOAD_VERIFY_FAILED,行保持 pending', async () => {
const initRes = await initAsset(ownerToken, albumId, {
kind: 'logo',
filename: 'l.png',
bytes: 800,
contentType: 'image/png',
});
expect(initRes.status).toBe(201);
const { assetId } = initRes.body as { assetId: string };
const res = await completeAsset(ownerToken, albumId, assetId);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const row = await prisma.album_assets.findUnique({
where: { id: assetId },
});
expect(row?.state).toBe('pending');
});
it('核验兜底:字节数不符 → 400;contentType 不符 → 400(行均保持 pending)', async () => {
const jpeg = await makeJpeg(40, 30, { r: 1, g: 2, b: 3 });
// ① 申明 bytes 与实传不符。
const wrongBytes = await initAsset(ownerToken, albumId, {
kind: 'banner',
filename: 'wb.jpg',
bytes: jpeg.length + 1000,
contentType: 'image/jpeg',
});
expect(wrongBytes.status).toBe(201);
const wb = wrongBytes.body as { assetId: string; credential: Credential };
expect(await putByCredential(wb.credential, jpeg)).toBe(true);
const bytesRes = await completeAsset(ownerToken, albumId, wb.assetId);
expect(bytesRes.status).toBe(400);
expect(bytesRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
// ② 申明 image/jpeg 实传 application/octet-stream(presign 不签名 content-type)。
const wrongType = await initAsset(ownerToken, albumId, {
kind: 'banner',
filename: 'wt.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
const wt = wrongType.body as { assetId: string; credential: Credential };
expect(
await putByCredential(wt.credential, jpeg, 'application/octet-stream'),
).toBe(true);
const typeRes = await completeAsset(ownerToken, albumId, wt.assetId);
expect(typeRes.status).toBe(400);
expect(typeRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
for (const id of [wb.assetId, wt.assetId]) {
const row = await prisma.album_assets.findUnique({
where: { id },
});
expect(row?.state).toBe('pending');
}
});
it('重复 complete 幂等 → 200 同 assetId 且 URL 可取回;audit 不重复', async () => {
const again = await completeAsset(ownerToken, albumId, t1Asset.assetId);
expect(again.status).toBe(200);
expect(Object.keys(again.body).sort()).toEqual(COMPLETE_KEYS);
expect(again.body.assetId).toBe(t1Asset.assetId);
const fetched = await fetch(again.body.url);
expect(fetched.status).toBe(200);
expect(Buffer.from(await fetched.arrayBuffer())).toEqual(t1Asset.jpeg);
// audit 锚 resourceId=albumId:T1 + 全局 + 替换 + cover(uploadDisabled 用例)
// 各一条,幂等重复不新增。
expect(
await prisma.audit_logs.count({
where: { action: 'album.asset.add', resourceId: albumId },
}),
).toBe(4);
});
it('对象删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)', async () => {
const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
const 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,
});
try {
await s3.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: t1Asset.objectKey,
}),
);
} finally {
await s3.destroy();
}
const res = await completeAsset(ownerToken, albumId, t1Asset.assetId);
expect(res.status).toBe(200);
expect(res.body.assetId).toBe(t1Asset.assetId);
});
it('logo 冒烟:init+PUT+complete → 200;DB kind=logo 行生效', async () => {
const jpeg = await makeJpeg(24, 24, { r: 5, g: 5, b: 5 });
const uploaded = await uploadAsset(jpeg, {
kind: 'logo',
locale: undefined,
filename: 'logo.jpg',
});
const row = await prisma.album_assets.findUnique({
where: { id: uploaded.assetId },
});
expect(row).toMatchObject({ kind: 'logo', locale: null, state: 'effective' });
});
it('未认证 → 401(init 与 complete)', async () => {
const initRes = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums/${albumId}/assets`)
.send({ kind: 'banner', filename: 'a.jpg', bytes: 10, contentType: 'image/jpeg' });
expect(initRes.status).toBe(401);
const completeRes = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums/${albumId}/assets/${t1Asset.assetId}/complete`);
expect(completeRes.status).toBe(401);
});
});