Files
photo-live/services/api/test/uploads.e2e-spec.ts
T
浅念 31ba03de11 feat(api): password change endpoint and source device id on uploads
P3 Task 3 契约增补批次(单一事实来源 openapi.yaml + cn 实现 + e2e):

- POST /v1/auth/password-change(operationId changePassword,需求 4.1 的
  P1 契约缺口补齐):Bearer + {oldPassword 1-128, newPassword 8-128} → 204;
  旧密码错误 401 AUTH_INVALID_CREDENTIALS;成功后事务内更新 argon2 哈希、
  撤销该用户除当前会话外的全部其他会话(当前会话保留,本人不下线)、写
  审计 auth.password_change。
- sourceDeviceId(A19 设备来源语义):UploadInitRequest 可选 8-64 位设备
  标识;upload_sessions 落列(VARCHAR(64) utf8mb4_0900_bin,可空,存量
  行 NULL 兼容);GET /uploads 条目回显;幂等重放不比对(first-writer,
  首次落库值不随重放漂移)。
- e2e:auth 增 4 例(未认证 401 / 新密码过短 400 / 旧密码错误 401 且会话
  无副作用 / 成功后他端会话撤销当前保留 + 新旧密码登录裁决 + 审计);
  uploads 增 1 例(携带落库回显、换设备重放保留首值、缺省 null、长度
  越界 400)。api e2e 179 → 184;contracts gen/redocly/单测、worker、
  sync 门全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:59:39 +08:00

945 lines
36 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 () => {
const init = await initAs(memberToken, 'C-type');
// 声明 image/jpeg,实传 application/octet-stream:PUT 成功(签名不覆盖该头),
// complete 时 head().contentType 与申明值比对兜底。
expect(await putStaging(init.credential, 1000, '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 () => {
const init = await initAs(memberToken, 'C-happy');
cHappy = { uploadId: init.uploadId, photoId: init.photoId, objectKey: init.objectKey };
expect(await putStaging(init.credential, 1000)).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: 1000,
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(1000);
expect(finalHead?.bytes).toBe(1000);
// 手工删除 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(1000);
});
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: 1000,
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 设备来源语义)。
const first = await initAs(memberToken, 'DEV-1', {
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', {
sourceDeviceId: 'device-bbbb-2222',
});
expect(replay.uploadId).toBe(first.uploadId);
expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111');
// 未携带 → null(老客户端兼容)。
await initAs(memberToken, 'DEV-2');
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);
});
});