361a72f300
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>
599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
// 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);
|
||
});
|
||
});
|