2026-09-17 22:42:13 +08:00
|
|
|
|
// 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';
|
2026-09-17 23:00:19 +08:00
|
|
|
|
import { Queue } from 'bullmq';
|
2026-09-17 22:42:13 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-17 23:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 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 () => {
|
2026-09-28 18:38:36 +08:00
|
|
|
|
// bytes=1002:C-bytes 完成后本册已有 member 的 1000 字节非 deleted 照片,
|
|
|
|
|
|
// 沿用 1000 会被 init ③.5 服务端查重拦截(90dcc58),换字节数绕开。
|
|
|
|
|
|
const init = await initAs(memberToken, 'C-type', { bytes: 1002 });
|
2026-09-17 23:00:19 +08:00
|
|
|
|
// 声明 image/jpeg,实传 application/octet-stream:PUT 成功(签名不覆盖该头),
|
|
|
|
|
|
// complete 时 head().contentType 与申明值比对兜底。
|
2026-09-28 18:38:36 +08:00
|
|
|
|
expect(await putStaging(init.credential, 1002, 'application/octet-stream')).toBe(true);
|
2026-09-17 23:00:19 +08:00
|
|
|
|
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 () => {
|
2026-09-28 18:38:36 +08:00
|
|
|
|
// bytes=1003:同上,避开 C-bytes 已完成的 1000 字节照片(init ③.5 查重)。
|
|
|
|
|
|
const init = await initAs(memberToken, 'C-happy', { bytes: 1003 });
|
2026-09-17 23:00:19 +08:00
|
|
|
|
cHappy = { uploadId: init.uploadId, photoId: init.photoId, objectKey: init.objectKey };
|
2026-09-28 18:38:36 +08:00
|
|
|
|
expect(await putStaging(init.credential, 1003)).toBe(true);
|
2026-09-17 23:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
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({
|
2026-09-28 18:38:36 +08:00
|
|
|
|
bytes: 1003,
|
2026-09-17 23:00:19 +08:00
|
|
|
|
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);
|
2026-09-28 18:38:36 +08:00
|
|
|
|
expect(stagingHead?.bytes).toBe(1003);
|
|
|
|
|
|
expect(finalHead?.bytes).toBe(1003);
|
2026-09-17 23:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 手工删除 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');
|
2026-09-28 18:38:36 +08:00
|
|
|
|
expect((await storage.head(photo?.objectKey as string))?.bytes).toBe(1003);
|
2026-09-17 23:00:19 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
2026-09-19 00:59:39 +08:00
|
|
|
|
'sourceDeviceId',
|
2026-09-17 23:00:19 +08:00
|
|
|
|
'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,
|
2026-09-28 18:38:36 +08:00
|
|
|
|
expectedBytes: 1003,
|
2026-09-17 23:00:19 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-19 00:59:39 +08:00
|
|
|
|
|
|
|
|
|
|
it('sourceDeviceId:init 携带 → 落库并回显;幂等重放换设备保留首值(first-writer)', async () => {
|
2026-09-28 18:38:36 +08:00
|
|
|
|
// 设备 A 初始化(P3 Task 3 契约增补,A19 设备来源语义)。bytes=1004:
|
|
|
|
|
|
// 避开本册已完成的 1000(C-bytes)字节数(init ③.5 查重)。
|
2026-09-19 00:59:39 +08:00
|
|
|
|
const first = await initAs(memberToken, 'DEV-1', {
|
2026-09-28 18:38:36 +08:00
|
|
|
|
bytes: 1004,
|
2026-09-19 00:59:39 +08:00
|
|
|
|
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', {
|
2026-09-28 18:38:36 +08:00
|
|
|
|
bytes: 1004,
|
2026-09-19 00:59:39 +08:00
|
|
|
|
sourceDeviceId: 'device-bbbb-2222',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(replay.uploadId).toBe(first.uploadId);
|
|
|
|
|
|
expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111');
|
|
|
|
|
|
|
2026-09-28 18:38:36 +08:00
|
|
|
|
// 未携带 → null(老客户端兼容)。bytes=1005 同理避开已完成字节数。
|
|
|
|
|
|
await initAs(memberToken, 'DEV-2', { bytes: 1005 });
|
2026-09-19 00:59:39 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-17 23:00:19 +08:00
|
|
|
|
});
|