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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user