Files
photo-live/services/api/test/uploads.e2e-spec.ts
T
浅念 805f77be76 feat(api): upload duplicate pre-check endpoint (checkUploadDuplicates)
「上传查重前置到入队前」Task 1:POST /v1/uploads/duplicates/check 只读批量
查重预检——用户点上传(确认入队)时刻预判服务端历史重复(跨设备/重装/改名
均可查到),不再等任务在上传中心以 PHOTO_DUPLICATE 失败才暴露。

- 契约纯增量:/uploads/duplicates/check + UploadDuplicateCheck(Item|Request|
  Duplicate|Response) schemas;regen openapi.d.ts。
- 判定谓词与 init ③.5 服务端查重逐字一致:同 albumId + uploaderId=当前用户
  + bytes 相等 + visibility != 'deleted'(filename 刻意不参与),保证「预检
  即真相」;命中条目按请求下标逐项返回(同 bytes 多请求条目各一条)。
- 权限链同 init(canView 404 防枚举 / canUpload 403 / 相册空值兜底 404),
  刻意不校验 uploadEnabled:只读预览,init 真正入队时仍按开关 409 兜底。
- 查询走 idx_photos_dedup:请求条目去重字节集一次 findMany,内存建
  bytes→最早 uploadedAt 映射后按请求下标回射。
- items 1..500(minItems/maxItems + 嵌套逐项校验,越界 400)。
- e2e 追加 describe 7 用例:命中含双条目同 bytes/未命中/uploaderId 隔离/
  deleted 不命中/非成员 404/items 边界 400/未认证 401。

契约 lint ✅ · contracts+api typecheck ✅ · api 单测 56/56 ✅ ·
uploads e2e 28/28 ✅

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

1150 lines
43 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/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 { Queue } from 'bullmq';
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);
});
});
// ── Task 19:renew / complete / history / 队列入队 ──
describe('uploads renew & complete & history (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
/** 测试侧独立 Queue 实例(同一 Redis):peek photo-process 队列;beforeAll 清空。 */
let queue: Queue;
/** 自建 ioredis 连接(BullMQ 不接管所有权,close 后须自行断开,否则 jest 挂起)。 */
let queueConnection: import('ioredis').default | null = null;
let ownerId: string;
let memberId: string;
let removedId: string;
let outsiderToken: string;
let ownerToken: string;
let memberToken: string;
let removedToken: string;
let albumId: string;
/** 各用例捕获的会话锚点(clientTaskId → 会话/照片 id)。 */
let r1: { uploadId: string; photoId: string; objectKey: string };
let cBytes: { uploadId: string; photoId: string };
let cHappy: { uploadId: string; photoId: string; objectKey: string };
const initAs = async (
token: string,
clientTaskId: string,
overrides: Record<string, unknown> = {},
): Promise<{ uploadId: string; photoId: string; objectKey: string; credential: { url: string; method: string; headers: Record<string, string> } }> => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.send({
albumId,
clientTaskId,
filename: 'a.jpg',
bytes: 1000,
contentType: 'image/jpeg',
...overrides,
});
expect(res.status).toBe(201);
return res.body;
};
const renew = (token: string, uploadId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/${uploadId}/renew`)
.set('Authorization', `Bearer ${token}`);
const complete = (token: string, uploadId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/${uploadId}/complete`)
.set('Authorization', `Bearer ${token}`);
const listUploads = (token: string, query?: Record<string, string | number>) =>
request(app.getHttpServer())
.get(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
/** 按凭证实写 staging(覆盖默认 content-type 以构造核验失败用例)。 */
const putStaging = async (
credential: { url: string; method: string; headers: Record<string, string> },
bytes: number,
contentType?: string,
): Promise<boolean> => {
const res = await fetch(credential.url, {
method: credential.method,
headers:
contentType === undefined
? credential.headers
: { 'Content-Type': contentType },
body: new Uint8Array(Buffer.alloc(bytes, 7)),
});
return res.ok;
};
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('rc-owner')).id;
memberId = (await mkUser('rc-member')).id;
removedId = (await mkUser('rc-removed')).id;
const outsiderId = (await mkUser('rc-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);
removedToken = await mkToken(removedId);
outsiderToken = await mkToken(outsiderId);
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '完成核验相册' },
{ locale: 'en', title: 'Complete' },
],
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.createMany({
data: [
{ albumId, userId: memberId, role: 'collaborator' },
{ albumId, userId: removedId, role: 'collaborator' },
],
});
// 测试侧队列实例:与生产者同一 Redis/队列名(契约常量),先清空历史。
const { QUEUE_PHOTO_PROCESS } = await import('@photo/contracts');
const IORedis = (await import('ioredis')).default;
queueConnection = new IORedis(
process.env.REDIS_URL_CN ?? 'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
queue = new Queue(QUEUE_PHOTO_PROCESS, { connection: queueConnection });
await queue.obliterate({ force: true });
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
await queue.close();
// BullMQ 对外部传入的连接不接管生命周期:queue.close 不 disconnect 它。
await queueConnection?.quit().catch(() => undefined);
});
it('renew → 200 新凭证同 objectKey;会话续期仍 pending', async () => {
const init = await initAs(memberToken, 'R1');
r1 = init;
const res = await renew(memberToken, init.uploadId);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS);
expect(res.body.uploadId).toBe(init.uploadId);
expect(res.body.photoId).toBe(init.photoId);
expect(res.body.objectKey).toBe(init.objectKey);
expect(res.body.credential.method).toBe('PUT');
expect(res.body.credential.headers['Content-Type']).toBe('image/jpeg');
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
// renew 滑动续期会话 expiresAt(契约:凭证过期 → renew 再试)。
expect(session?.expiresAt.getTime()).toBeGreaterThan(Date.now() + 800_000);
});
it('renew 守卫:uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED;非本人/未知会话/被移除成员 → 403', async () => {
// 相册关闭上传:续签被拒(§4.9),owner 亦同(开关是相册级运营状态)。
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: false },
});
try {
const disabled = await renew(memberToken, r1.uploadId);
expect(disabled.status).toBe(409);
expect(disabled.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED);
} finally {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: true },
});
}
// 被移除成员:先建会话(此时仍是成员),移除后 renew/complete 双拦截(A02)。
const removedSession = await initAs(removedToken, 'C-removed');
await prisma.album_members.delete({
where: { albumId_userId: { albumId, userId: removedId } },
});
const removedRenew = await renew(removedToken, removedSession.uploadId);
expect(removedRenew.status).toBe(403);
expect(removedRenew.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const removedComplete = await complete(removedToken, removedSession.uploadId);
expect(removedComplete.status).toBe(403);
expect(removedComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
// 非本人会话与未知会话同码 403(契约:不区分以避免枚举)。
const notOwner = await renew(ownerToken, r1.uploadId);
expect(notOwner.status).toBe(403);
expect(notOwner.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const unknown = await renew(memberToken, 'no-such-upload');
expect(unknown.status).toBe(403);
expect(unknown.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
});
it('complete:未上传 → 400 UPLOAD_VERIFY_FAILED,会话保持 pending 可重试', async () => {
const init = await initAs(memberToken, 'C-before');
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
});
it('complete:字节数不符 → 400;补传正确字节数后 complete 成功(请求级失败不毒化会话)', async () => {
const init = await initAs(memberToken, 'C-bytes');
cBytes = { uploadId: init.uploadId, photoId: init.photoId };
expect(await putStaging(init.credential, 500)).toBe(true); // 申明 1000 实传 500
const mismatch = await complete(memberToken, init.uploadId);
expect(mismatch.status).toBe(400);
expect(mismatch.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
// 重试路径:重传正确字节数(同 staging 键覆盖写)→ complete 通过。
expect(await putStaging(init.credential, 1000)).toBe(true);
const retry = await complete(memberToken, init.uploadId);
expect(retry.status).toBe(200);
expect(retry.body.processingState).toBe('uploaded');
});
it('complete:contentType 不符(presign 不签名 content-type,服务端兜底)→ 400', async () => {
// bytes=1002:C-bytes 完成后本册已有 member 的 1000 字节非 deleted 照片,
// 沿用 1000 会被 init ③.5 服务端查重拦截(90dcc58),换字节数绕开。
const init = await initAs(memberToken, 'C-type', { bytes: 1002 });
// 声明 image/jpeg,实传 application/octet-stream:PUT 成功(签名不覆盖该头),
// complete 时 head().contentType 与申明值比对兜底。
expect(await putStaging(init.credential, 1002, 'application/octet-stream')).toBe(true);
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED);
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('pending');
});
it('complete 成功 → 200 uploaded;photo/session/photo_jobs/audit 同事务落库;重复 complete 幂等(A03)', async () => {
// bytes=1003:同上,避开 C-bytes 已完成的 1000 字节照片(init ③.5 查重)。
const init = await initAs(memberToken, 'C-happy', { bytes: 1003 });
cHappy = { uploadId: init.uploadId, photoId: init.photoId, objectKey: init.objectKey };
expect(await putStaging(init.credential, 1003)).toBe(true);
const res = await complete(memberToken, init.uploadId);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(['photoId', 'processingState']);
expect(res.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' });
// DB 四处一致性。
const photo = await prisma.photos.findUnique({ where: { id: init.photoId } });
expect(photo).toMatchObject({
bytes: 1003,
uploadedAt: expect.any(Date),
processingState: 'uploaded',
visibility: 'unpublished',
});
const session = await prisma.upload_sessions.findUnique({
where: { id: init.uploadId },
});
expect(session?.state).toBe('confirmed');
const jobs = await prisma.photo_jobs.findMany({
where: { photoId: init.photoId },
});
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({ type: 'process', state: 'pending' });
const audits = await prisma.audit_logs.findMany({
where: { action: 'upload.complete', resourceId: init.photoId },
});
expect(audits).toHaveLength(1);
expect(audits[0]?.actorId).toBe(memberId);
// 幂等重复 complete:同结果,不产生第二个 job/audit。
const again = await complete(memberToken, init.uploadId);
expect(again.status).toBe(200);
expect(again.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' });
expect(
await prisma.photo_jobs.count({ where: { photoId: init.photoId } }),
).toBe(1);
expect(
await prisma.audit_logs.count({
where: { action: 'upload.complete', resourceId: init.photoId },
}),
).toBe(1);
});
it('受控复制核验:originals 与 staging 并存且字节一致;删除 staging 后 re-complete 仍 200(copyIfAbsent created:false 路径)', async () => {
const { createStorageFromEnv } = await import('@photo/storage');
const storage = createStorageFromEnv();
const photo = await prisma.photos.findUnique({ where: { id: cHappy.photoId } });
expect(photo?.objectKey).toBe(
`photo-live/dev/albums/${albumId}/originals/${cHappy.photoId}.jpg`,
);
const stagingHead = await storage.head(cHappy.objectKey);
const finalHead = await storage.head(photo?.objectKey as string);
expect(stagingHead?.bytes).toBe(1003);
expect(finalHead?.bytes).toBe(1003);
// 手工删除 staging(模拟生命周期清理/超时)后重复 complete:originals 已在,
// copyIfAbsent 幂等返回 created:false,不再依赖 staging 源。
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: cHappy.objectKey,
}),
);
} finally {
await s3.destroy();
}
expect(await storage.head(cHappy.objectKey)).toBeNull();
const res = await complete(memberToken, cHappy.uploadId);
expect(res.status).toBe(200);
expect(res.body.processingState).toBe('uploaded');
expect((await storage.head(photo?.objectKey as string))?.bytes).toBe(1003);
});
it('GET /uploads:本人会话新在前,photo 状态内嵌,游标翻页,跨用户隔离', async () => {
// 回写互不相同的 createdAt,排序断言与写入时序解耦(同 members 列表先例)。
const order: Array<{ taskId: string; at: string }> = [
{ taskId: 'R1', at: '2026-09-12T08:00:00.000Z' }, // 最旧
{ taskId: 'C-before', at: '2026-09-12T09:00:00.000Z' },
{ taskId: 'C-bytes', at: '2026-09-12T10:00:00.000Z' },
{ taskId: 'C-type', at: '2026-09-12T11:00:00.000Z' },
{ taskId: 'C-happy', at: '2026-09-12T12:00:00.000Z' }, // 最新
];
for (const { taskId, at } of order) {
await prisma.upload_sessions.updateMany({
where: { userId: memberId, clientTaskId: taskId },
data: { createdAt: new Date(at) },
});
}
const page1 = await listUploads(memberToken, { limit: 2 });
expect(page1.status).toBe(200);
expect(
page1.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['C-happy', 'C-type']);
expect(Object.keys(page1.body.items[0]).sort()).toEqual([
'albumId',
'clientTaskId',
'contentType',
'createdAt',
'expectedBytes',
'photo',
'sourceDeviceId',
'state',
'uploadId',
]);
expect(Object.keys(page1.body.items[0].photo).sort()).toEqual([
'photoId',
'processingState',
'visibility',
]);
// confirmed 会话内嵌服务端进度;pending 会话保持 pending_upload/unpublished。
expect(page1.body.items[0]).toMatchObject({
state: 'confirmed',
albumId,
expectedBytes: 1003,
contentType: 'image/jpeg',
photo: {
photoId: cHappy.photoId,
processingState: 'uploaded',
visibility: 'unpublished',
},
});
expect(page1.body.items[1]).toMatchObject({
state: 'pending',
photo: { processingState: 'pending_upload', visibility: 'unpublished' },
});
expect(page1.body.hasMore).toBe(true);
expect(typeof page1.body.nextCursor).toBe('string');
const page2 = await listUploads(memberToken, {
limit: 2,
cursor: page1.body.nextCursor,
});
expect(page2.status).toBe(200);
expect(
page2.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['C-bytes', 'C-before']);
expect(page2.body.hasMore).toBe(true);
const page3 = await listUploads(memberToken, {
limit: 2,
cursor: page2.body.nextCursor,
});
expect(
page3.body.items.map((i: { clientTaskId: string }) => i.clientTaskId),
).toEqual(['R1']);
expect(page3.body.hasMore).toBe(false);
expect(page3.body.nextCursor).toBeNull();
// 跨用户隔离:removed(仅有已被拦截的会话)与 outsider 看不到 member 会话。
const outsiderList = await listUploads(outsiderToken);
expect(outsiderList.status).toBe(200);
expect(outsiderList.body.items).toHaveLength(0);
const removedList = await listUploads(removedToken);
expect(removedList.status).toBe(200);
expect(removedList.body.items).toHaveLength(1); // 仅自己的 C-removed
// 非法 cursor → 400。
const bad = await listUploads(memberToken, { cursor: 'garbage' });
expect(bad.status).toBe(400);
expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('BullMQ 入队:complete 后 photo-process 队列恰有一条对应 job(重复 complete 不重复入队)', async () => {
// C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId
// 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。
await new Promise((resolve) => setTimeout(resolve, 200));
const jobs = await queue.getJobs(
['waiting', 'delayed', 'active', 'completed', 'failed'],
0,
-1,
);
const byPhoto = new Map<string, number>();
for (const job of jobs) {
const photoId = (job.data as { photoId?: string }).photoId ?? '(none)';
byPhoto.set(photoId, (byPhoto.get(photoId) ?? 0) + 1);
}
expect(byPhoto.get(cHappy.photoId)).toBe(1);
expect(byPhoto.get(cBytes.photoId)).toBe(1);
// 会话归属成员的全部完成(两次 complete 幂等重复不重复入队)——队列中不
// 存在其他 member 照片之外的 job。
expect(byPhoto.size).toBe(2);
});
it('sourceDeviceId:init 携带 → 落库并回显;幂等重放换设备保留首值(first-writer)', async () => {
// 设备 A 初始化(P3 Task 3 契约增补,A19 设备来源语义)。bytes=1004:
// 避开本册已完成的 1000(C-bytes)字节数(init ③.5 查重)。
const first = await initAs(memberToken, 'DEV-1', {
bytes: 1004,
sourceDeviceId: 'device-aaaa-1111',
});
const findByTask = async (clientTaskId: string) => {
const list = await listUploads(memberToken, { limit: 100 });
expect(list.status).toBe(200);
return list.body.items.find(
(i: { clientTaskId: string }) => i.clientTaskId === clientTaskId,
);
};
expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111');
// 同 clientTaskId 换设备 B 重放:复用原会话,sourceDeviceId 保留首值。
const replay = await initAs(memberToken, 'DEV-1', {
bytes: 1004,
sourceDeviceId: 'device-bbbb-2222',
});
expect(replay.uploadId).toBe(first.uploadId);
expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111');
// 未携带 → null(老客户端兼容)。bytes=1005 同理避开已完成字节数。
await initAs(memberToken, 'DEV-2', { bytes: 1005 });
expect((await findByTask('DEV-2'))?.sourceDeviceId).toBeNull();
// 长度越界(<8 / >64)→ 400 VALIDATION_FAILED。
const rawInit = (sourceDeviceId: string) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${memberToken}`)
.send({
albumId,
clientTaskId: 'DEV-bad',
filename: 'a.jpg',
bytes: 1000,
contentType: 'image/jpeg',
sourceDeviceId,
});
const tooShort = await rawInit('short7');
expect(tooShort.status).toBe(400);
expect(tooShort.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const tooLong = await rawInit('x'.repeat(65));
expect(tooLong.status).toBe(400);
expect(tooLong.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
});
// ── 查重预检(checkUploadDuplicates,「上传查重前置到入队前」计划 Task 1)──
describe('uploads duplicates check (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let memberAId: string;
let memberBId: string;
let memberAToken: string;
let outsiderToken: string;
let albumId: string;
/** memberA 既有可见照片字节数(固定 uploadedAt 供逐字段断言回显)。 */
const BYTES_A = 777_777;
const UPLOADED_AT_A = '2026-09-20T08:00:00.000Z';
/** memberB(同册协作者)照片字节数(uploaderId 隔离用例)。 */
const BYTES_B_OTHER = 888_888;
/** memberA 已软删除照片字节数(visibility=deleted 不参与查重)。 */
const BYTES_A_DELETED = 999_999;
const check = (token: string, body: object) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/duplicates/check`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const item = (filename: string, bytes: number) => ({ filename, bytes });
/** 直种 photos 裸行:预检只读 photos 列,无变体/对象存储依赖。 */
const seedPhoto = (
uploaderId: string,
bytes: number,
options: { visibility?: string; uploadedAt?: Date } = {},
) =>
prisma.photos.create({
data: {
albumId,
uploaderId,
objectKey: `photo-live/dev/albums/${albumId}/originals/dc-${uploaderId}-${bytes}-${Math.random().toString(36).slice(2, 8)}.jpg`,
bytes,
uploadedAt: options.uploadedAt ?? new Date(),
visibility: options.visibility ?? 'unpublished',
},
select: { id: true },
});
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 },
});
const ownerId = (await mkUser('dc-owner')).id;
memberAId = (await mkUser('dc-member-a')).id;
memberBId = (await mkUser('dc-member-b')).id;
const outsiderId = (await mkUser('dc-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;
};
const ownerToken = await mkToken(ownerId);
memberAToken = await mkToken(memberAId);
outsiderToken = await mkToken(outsiderId);
// 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),A/B 为协作者。
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '查重预检相册' },
{ locale: 'en', title: 'DupCheck' },
],
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.createMany({
data: [
{ albumId, userId: memberAId, role: 'collaborator' },
{ albumId, userId: memberBId, role: 'collaborator' },
],
});
// memberA 既有可见照片(非 deleted,uploadedAt 固定)。
await seedPhoto(memberAId, BYTES_A, { uploadedAt: new Date(UPLOADED_AT_A) });
// memberB 同册照片:memberA 查同 bytes 不得命中(uploaderId 隔离)。
await seedPhoto(memberBId, BYTES_B_OTHER);
// memberA 已软删除照片:谓词 visibility != deleted,不得命中。
await seedPhoto(memberAId, BYTES_A_DELETED, { visibility: 'deleted' });
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
it('命中:既有可见照片同 bytes → 按请求下标逐项返回(两个同 bytes 条目各一条)', async () => {
const res = await check(memberAToken, {
albumId,
items: [
item('first.jpg', 111_111),
item('a.jpg', BYTES_A),
item('b.jpg', BYTES_A),
],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toHaveLength(2);
// 命中子集按请求下标升序;每命中一个「请求条目」一条(index=1/2 共享命中)。
expect(res.body.duplicates[0]).toEqual({
index: 1,
bytes: BYTES_A,
uploadedAt: UPLOADED_AT_A,
});
expect(res.body.duplicates[1]).toEqual({
index: 2,
bytes: BYTES_A,
uploadedAt: UPLOADED_AT_A,
});
});
it('未命中字节数 → 空数组', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('x.jpg', 111_111), item('y.png', 222_222)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('uploaderId 隔离:同册他人上传的同 bytes 照片不命中(谓词同 init ③.5)', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('other.jpg', BYTES_B_OTHER)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('visibility=deleted 的照片不命中', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('gone.jpg', BYTES_A_DELETED)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('非成员 → 404 ALBUM_NOT_FOUND(防枚举,同 init)', async () => {
const res = await check(outsiderToken, {
albumId,
items: [item('a.jpg', BYTES_A)],
});
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
});
it('items 边界:空数组 → 400;501 项 → 400 VALIDATION_FAILED', async () => {
const empty = await check(memberAToken, { albumId, items: [] });
expect(empty.status).toBe(400);
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const overflow = await check(memberAToken, {
albumId,
items: Array.from({ length: 501 }, (_, i) => item(`f${i}.jpg`, 1_000_000 + i)),
});
expect(overflow.status).toBe(400);
expect(overflow.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('未认证 → 401', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/duplicates/check`)
.send({ albumId, items: [item('a.jpg', BYTES_A)] });
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});