feat(api): idempotent upload session init with presigned credential

POST /v1/uploads (Task 18, A03): @@unique(userId, clientTaskId) idempotency
anchor — same params return the original session (201, no duplicate photo);
param drift (album/bytes/contentType/extension) -> 409 UPLOAD_SESSION_CONFLICT.
photos.objectKey is set to the FINAL originals key at init while the session
holds the staging key; IDs come from an app-side cuid-like generator so both
self-referencing keys are written in one transaction. Credential is a real
MinIO presigned PUT (SIGNED_URL_TTL_SECONDS, lazily read). Permission order:
album invisible 404 -> member 403 -> uploadEnabled 409 -> size/ext 400 ->
category ownership 400. No audit on init (audit anchors on complete).

Pre-step for Task 19: QUEUE_PHOTO_PROCESS contract constant + lazy-safe
BullMQ queue producer (Redis down at boot must not crash the app; enqueue
failure only logs — sweep compensates). e2e reset now also wipes the MinIO
photo-live/dev prefix; jest maps @photo/storage to TS source.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 22:42:13 +08:00
parent e04e1f3bdf
commit a2a3ccd340
15 changed files with 1252 additions and 1 deletions
+426
View File
@@ -0,0 +1,426 @@
// services/api/test/uploads.e2e-spec.ts —— Task 18/19 上传域 e2e(先写后实现)。
// 认证同 Task 15:Prisma 直种 sessions 行换 Bearer 令牌;相册/成员 fixture 经
// Prisma 直写。真实 MinIO(compose photo-live-minio-1):presign PUT 实际写入、
// head 核验、copyIfAbsent 受控复制;真实 Redis(BullMQ photo-process 队列 peek)。
// 覆盖(A03 幂等 / A02 逐次权限):
// init:成员初始化→201 有效凭证(PUT 实写 MinIO head 可见)+ DB 双行
// (photos.objectKey=正式 originals 键;upload_sessions.objectKey=staging 键);
// 同 clientTaskId 同参→201 复用同一 uploadId+photoId(不重复建行);
// 同 clientTaskId 异参(album/bytes/contentType/扩展名)→409 UPLOAD_SESSION_CONFLICT;
// 不同 clientTaskId 同名文件→两会话两照片(同名不覆盖);
// 非成员(含他册成员)→404 ALBUM_NOT_FOUND;uploadEnabled=false→409
// (owner 亦拒);超限/类型/缺字段/坏扩展名/异册 categoryId→400;
// init 不落 audit(高频低风险;审计在 complete)。
// renew/complete/history(Task 19,见下方 describe)。
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';
/** 契约 UploadInitResponse 字段全集(形状精确断言用)。 */
const INIT_KEYS = ['uploadId', 'photoId', 'objectKey', 'credential'].sort();
const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort();
/** 测试隔离边界内的对象键根(与 reset.ts 的 MinIO 清理前缀一致)。 */
const KEY_ROOT = 'photo-live/dev';
describe('uploads init (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
let memberId: string;
/** 仅属于 otherAlbum 的成员(「他册成员对本册不可见」用例)。 */
let otherMemberId: string;
let outsiderId: string;
let ownerToken: string;
let memberToken: string;
let otherMemberToken: string;
let outsiderToken: string;
/** T1 幂等锚会话 id(首个用例捕获,后续用例复用断言)。 */
let t1UploadId: string;
let albumId: string;
let otherAlbumId: string;
/** album 内合法分类(Prisma 直建)。 */
let categoryId: string;
/** otherAlbum 的分类(跨册 400 用例)。 */
let foreignCategoryId: string;
const postInit = (token: string, body: object) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const initBody = (overrides: Partial<Record<string, unknown>> = {}) => ({
albumId,
clientTaskId: 'T1',
filename: 'a.jpg',
bytes: 1000,
contentType: 'image/jpeg',
...overrides,
});
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('up-owner')).id;
memberId = (await mkUser('up-member')).id;
otherMemberId = (await mkUser('up-other-member')).id;
outsiderId = (await mkUser('up-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);
memberToken = await mkToken(memberId);
otherMemberToken = await mkToken(otherMemberId);
outsiderToken = await mkToken(outsiderId);
// 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),member 为协作者;
// 相册 B 属 owner(Prisma 直建 + owner 成员行),member 亦加入 B(跨册幂等
// 冲突用例需要同一用户在两册均可见),otherMember 仅在 B(他册成员 404 用例)。
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '上传相册' },
{ locale: 'en', title: 'Uploads' },
],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T02:00:00.000Z',
});
expect(res.status).toBe(201);
albumId = res.body.id as string;
await prisma.album_members.create({
data: { albumId, userId: memberId, role: 'collaborator' },
});
const other = await prisma.albums.create({
data: {
publicToken: 'otheralbum0000000000000a',
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
otherAlbumId = other.id;
await prisma.album_members.createMany({
data: [
{ albumId: otherAlbumId, userId: ownerId, role: 'owner' },
{ albumId: otherAlbumId, userId: memberId, role: 'collaborator' },
{ albumId: otherAlbumId, userId: otherMemberId, role: 'collaborator' },
],
});
categoryId = (
await prisma.categories.create({
data: { albumId, sortOrder: 0 },
select: { id: true },
})
).id;
await prisma.category_translations.create({
data: { categoryId, locale: 'zh-CN', name: '默认' },
});
foreignCategoryId = (
await prisma.categories.create({
data: { albumId: otherAlbumId, sortOrder: 0 },
select: { id: true },
})
).id;
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
it('成员初始化 → 201;凭证可实写 MinIO;DB:staging 会话行 + 正式键 photos 行', async () => {
const res = await postInit(memberToken, initBody());
expect(res.status).toBe(201);
expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS);
expect(Object.keys(res.body.credential).sort()).toEqual(CREDENTIAL_KEYS);
expect(res.body.credential).toMatchObject({
mode: 'presigned',
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
});
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
const { uploadId, photoId, objectKey } = res.body as {
uploadId: string;
photoId: string;
objectKey: string;
};
t1UploadId = uploadId;
expect(objectKey).toBe(`${KEY_ROOT}/staging/${uploadId}/${photoId}.jpg`);
// 凭证真实可用:按凭证 PUT 后 head 可见精确字节数(fetch 全局可用,Node 22)。
const body = Buffer.alloc(1000, 1);
const put = await fetch(res.body.credential.url, {
method: res.body.credential.method,
headers: res.body.credential.headers,
body: new Uint8Array(body),
});
expect(put.ok).toBe(true);
// DB:会话行(pending、expiresAt≈now+SIGNED_URL_TTL_SECONDS)。
const ttl = Number.parseInt(
process.env.SIGNED_URL_TTL_SECONDS ?? '900',
10,
);
const session = await prisma.upload_sessions.findUnique({
where: { id: uploadId },
});
expect(session).toMatchObject({
userId: memberId,
albumId,
clientTaskId: 'T1',
photoId,
objectKey,
expectedBytes: 1000,
contentType: 'image/jpeg',
state: 'pending',
});
const drift =
(session?.expiresAt.getTime() ?? Number.NaN) - (Date.now() + ttl * 1000);
expect(Math.abs(drift)).toBeLessThan(10_000);
// DB:photos 行——objectKey 是 FINAL originals 键(init 即写入),未发布待传。
const photo = await prisma.photos.findUnique({ where: { id: photoId } });
expect(photo).toMatchObject({
albumId,
uploaderId: memberId,
categoryId: null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: 0,
processingState: 'pending_upload',
visibility: 'unpublished',
});
});
it('同 clientTaskId 同参重发 → 201 复用同一 uploadId+photoId+objectKey(A03,无第二行)', async () => {
const first = await postInit(memberToken, initBody());
expect(first.status).toBe(201);
const again = await postInit(memberToken, initBody());
expect(again.status).toBe(201);
expect(again.body.uploadId).toBe(first.body.uploadId);
expect(again.body.photoId).toBe(first.body.photoId);
expect(again.body.objectKey).toBe(first.body.objectKey);
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(1);
expect(await prisma.photos.count({ where: { albumId } })).toBe(1);
});
it('同 clientTaskId 异参(album/bytes/contentType/扩展名)→ 409 UPLOAD_SESSION_CONFLICT', async () => {
// T1 锚定 albumId/1000/image/jpeg/a.jpg(前述用例)。
const albumConflict = await postInit(
memberToken,
initBody({ albumId: otherAlbumId }),
);
expect(albumConflict.status).toBe(409);
expect(albumConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
const bytesConflict = await postInit(memberToken, initBody({ bytes: 2000 }));
expect(bytesConflict.status).toBe(409);
expect(bytesConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
const typeConflict = await postInit(
memberToken,
initBody({ filename: 'a.png', contentType: 'image/png', bytes: 2000 }),
);
expect(typeConflict.status).toBe(409);
// filename 未持久化(schema 无该列):以扩展名近似锚定——同 clientTaskId 换
// 扩展名视为参数漂移 → 409。
const extConflict = await postInit(
memberToken,
initBody({ filename: 'b.png' }),
);
expect(extConflict.status).toBe(409);
expect(extConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
// 对应地:同扩展名改名(a2.jpg)不构成漂移 → 仍复用原会话(幂等意图)。
const rename = await postInit(memberToken, initBody({ filename: 'a2.jpg' }));
expect(rename.status).toBe(201);
expect(rename.body.uploadId).toBe(t1UploadId);
expect(rename.body.photoId).toBe(
(await prisma.upload_sessions.findUnique({ where: { id: t1UploadId } }))
?.photoId,
);
});
it('不同 clientTaskId 同名文件 → 两会话两照片(同名不覆盖,A03)', async () => {
const one = await postInit(memberToken, initBody({ clientTaskId: 'same-name-1' }));
const two = await postInit(memberToken, initBody({ clientTaskId: 'same-name-2' }));
expect(one.status).toBe(201);
expect(two.status).toBe(201);
expect(one.body.uploadId).not.toBe(two.body.uploadId);
expect(one.body.photoId).not.toBe(two.body.photoId);
expect(one.body.objectKey).not.toBe(two.body.objectKey);
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(3);
expect(await prisma.photos.count({ where: { albumId } })).toBe(3);
});
it('非成员 → 404 ALBUM_NOT_FOUND(他册成员同)', async () => {
const res = await postInit(outsiderToken, initBody({ clientTaskId: 'T-out' }));
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// otherMember(B 册成员)对 A 不可见 → 同码 404。
const foreign = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${otherMemberToken}`)
.send(initBody({ clientTaskId: 'T-foreign' }));
expect(foreign.status).toBe(404);
expect(foreign.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
});
it('uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED(owner 亦拒)', async () => {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: false },
});
try {
const res = await postInit(ownerToken, initBody({ clientTaskId: 'T-off' }));
expect(res.status).toBe(409);
expect(res.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED);
// 未创建任何行(开关是相册级运营状态,先于参数与建行)。
expect(
await prisma.upload_sessions.count({ where: { clientTaskId: 'T-off' } }),
).toBe(0);
} finally {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: true },
});
}
});
it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调,逐请求惰性读取)→ 400', async () => {
const previous = process.env.UPLOAD_MAX_BYTES;
process.env.UPLOAD_MAX_BYTES = '500';
try {
const res = await postInit(memberToken, initBody({ clientTaskId: 'T-big' }));
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 边界内(=500)通过。
const edge = await postInit(
memberToken,
initBody({ clientTaskId: 'T-edge', bytes: 500 }),
);
expect(edge.status).toBe(201);
} finally {
if (previous === undefined) {
delete process.env.UPLOAD_MAX_BYTES;
} else {
process.env.UPLOAD_MAX_BYTES = previous;
}
}
});
it('contentType 白名单外 / 缺字段 / bytes<1 / 坏扩展名 → 400 VALIDATION_FAILED', async () => {
const badType = await postInit(
memberToken,
initBody({ clientTaskId: 'T-plain', contentType: 'text/plain' }),
);
expect(badType.status).toBe(400);
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const missing = await postInit(memberToken, {
albumId,
clientTaskId: 'T-missing',
bytes: 100,
contentType: 'image/jpeg',
});
expect(missing.status).toBe(400);
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const zeroBytes = await postInit(
memberToken,
initBody({ clientTaskId: 'T-zero', bytes: 0 }),
);
expect(zeroBytes.status).toBe(400);
const badExt = await postInit(
memberToken,
initBody({ clientTaskId: 'T-ext', filename: 'virus.exe' }),
);
expect(badExt.status).toBe(400);
expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('categoryId:未知/异册 → 400;本册合法 → 201 且 photos.categoryId 落库', async () => {
const unknown = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-unknown', categoryId: 'no-such-category' }),
);
expect(unknown.status).toBe(400);
expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const foreign = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-foreign', categoryId: foreignCategoryId }),
);
expect(foreign.status).toBe(400);
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const ok = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-ok', categoryId }),
);
expect(ok.status).toBe(201);
const photo = await prisma.photos.findUnique({
where: { id: ok.body.photoId },
});
expect(photo?.categoryId).toBe(categoryId);
});
it('init 不落 audit(高频低风险;审计在 complete,A06 前置约定)', async () => {
const audits = await prisma.audit_logs.count({
where: { action: { startsWith: 'upload.' } },
});
expect(audits).toBe(0);
});
it('未认证 → 401', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.send(initBody({ clientTaskId: 'T-anon' }));
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});