Files
photo-live/services/api/test/uploads.e2e-spec.ts
T

1150 lines
43 KiB
TypeScript
Raw Normal View History

// 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);
});
});