// services/api/test/usage.e2e-spec.ts —— P4a Task 5 用量统计与阈值告警 e2e。 // 真实 AppCnModule × MySQL(cn 单侧——用量域不涉 ov/同步/MinIO 对象);照片 // 直种 + 直改 bytes 伪造超阈(避免真实上传 GB 级对象),评估经 PUT thresholds // 的「即刻重新评估」契约语义触发。覆盖: // ① 汇总形状与数学:相册聚合(原图/变体/合计/照片数)+ 全库 totals + // totalBytes 降序 + 标题 defaultLocale 解析 + 缺省阈值 null/null; // ② 访问控制:staff GET/PUT 403 AUTH_FORBIDDEN;未认证 401; // ③ 入参校验:负值/小数/字符串/超 2^53 → 400 VALIDATION_FAILED;空体=全保持; // ④ perAlbum 告警全链(A23):直改 bytes 超阈 → PUT → summary 活跃告警 // (kind/albumId/message 含字节数与相册标题)→ 再次评估去重(活跃唯一) // → 阈值上调 → resolvedAt 置位(后台可见已解除告警); // ⑤ storage 告警:全库 totals 超阈 → 活跃(albumId=null,message 含全库 // 字节数)→ 显式 null 清除维度 → 旧告警解除; // ⑥ 计数口径 + api 侧检查点:hidden 计入用量(PATCH 隐藏后 totals 不变, // 穿过可见性迁移检查点);deleted 不计(API 软删除后 totals 回落,穿过 // 软删除检查点)。 import { randomBytes } from 'node:crypto'; import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { AppCnModule } from '../src/app-cn.module'; import { hashSessionToken } from '../src/auth/session.guard'; import { configureApp } from '../src/shared/configure-app'; import { resetDb } from './reset'; jest.setTimeout(120_000); /** cuid 形状 ID(直种照片用,同 batch-ops 约定)。 */ function cuidLike(alias: string): string { return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; } describe('usage summary + threshold alerts (e2e)', () => { let cnApp: NestExpressApplication; let cnBase: string; let prismaCn: PrismaClient; let adminId: string; let ownerAId: string; let staffXId: string; let adminToken: string; let ownerAToken: string; let staffXToken: string; let albumAId: string; let albumBId: string; /** 直种照片 id(④ 超阈伪造与 ⑥ 软删除用)。 */ let pa1Id: string; let pa4Id: string; /** 直种照片(无真实对象:本 spec 只断言聚合/告警,不读图)。 */ async function seedPhoto( alias: string, albumId: string, opts: { bytes: number; visibility: 'published' | 'hidden' | 'unpublished'; variants: { variant: 'thumb' | 'browse'; bytes: number }[]; }, ): Promise { const photoId = cuidLike(alias); await prismaCn.photos.create({ data: { id: photoId, albumId, uploaderId: ownerAId, objectKey: `usage-test/originals/${photoId}.jpg`, bytes: opts.bytes, width: 800, height: 600, uploadedAt: new Date(), publishedAt: opts.visibility === 'unpublished' ? null : new Date(), processingState: 'ready', visibility: opts.visibility, entityVersion: 2, }, }); for (const v of opts.variants) { await prismaCn.photo_variants.create({ data: { photoId, variant: v.variant, objectKey: `usage-test/variants/${photoId}/${v.variant}.jpg`, bytes: v.bytes, version: 1, }, }); } return photoId; } function getSummary(token: string) { return request(cnBase) .get('/v1/admin/usage/summary') .set('Authorization', `Bearer ${token}`); } function putThresholds(token: string, body: object) { return request(cnBase) .put('/v1/admin/usage/thresholds') .set('Authorization', `Bearer ${token}`) .send(body); } beforeAll(async () => { await resetDb(); cnApp = await NestFactory.create(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); prismaCn = new PrismaClient(); const mkUser = (account: string, role: 'staff' | 'admin') => prismaCn.users.create({ data: { account, role, passwordHash: 'x', displayName: account }, select: { id: true }, }); adminId = (await mkUser('usage-admin', 'admin')).id; ownerAId = (await mkUser('usage-owner', 'staff')).id; staffXId = (await mkUser('usage-staff-x', 'staff')).id; const mkToken = async (userId: string): Promise => { const token = randomBytes(32).toString('hex'); await prismaCn.sessions.create({ data: { userId, tokenHash: hashSessionToken(token), expiresAt: new Date(Date.now() + 3_600_000), }, }); return token; }; adminToken = await mkToken(adminId); ownerAToken = await mkToken(ownerAId); staffXToken = await mkToken(staffXId); const mkAlbum = async (title: string) => { const res = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${ownerAToken}`) .send({ translations: [{ locale: 'zh-CN', title }], defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', }); expect(res.status).toBe(201); return res.body.id as string; }; albumAId = await mkAlbum('用量相册甲'); albumBId = await mkAlbum('用量相册乙'); // 相册 A:published(1000, thumb100+browse250) + hidden(500, browse50) + // unpublished(300, 无变体) + published(200, 无变体——⑥ 软删除用)。 // 相册 B:published(100)。① 的期望值据此推算。 pa1Id = await seedPhoto('usagePa1', albumAId, { bytes: 1000, visibility: 'published', variants: [ { variant: 'thumb', bytes: 100 }, { variant: 'browse', bytes: 250 }, ], }); await seedPhoto('usagePa2', albumAId, { bytes: 500, visibility: 'hidden', variants: [{ variant: 'browse', bytes: 50 }], }); await seedPhoto('usagePa3', albumAId, { bytes: 300, visibility: 'unpublished', variants: [], }); pa4Id = await seedPhoto('usagePa4', albumAId, { bytes: 200, visibility: 'published', variants: [], }); await seedPhoto('usagePb1', albumBId, { bytes: 100, visibility: 'published', variants: [], }); }); afterAll(async () => { await cnApp.close(); await prismaCn.$disconnect(); }); it('① 汇总形状与数学:相册聚合/totals/降序/标题/缺省阈值/空告警', async () => { const res = await getSummary(adminToken); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual([ 'albums', 'alerts', 'thresholds', 'totals', ]); // 相册条目按 totalBytes 降序(A 2400 > B 100)。 expect(res.body.albums).toHaveLength(2); expect(res.body.albums[0]).toEqual({ albumId: albumAId, title: '用量相册甲', photoCount: 4, originalBytes: 2000, variantBytes: 400, totalBytes: 2400, }); expect(res.body.albums[1]).toEqual({ albumId: albumBId, title: '用量相册乙', photoCount: 1, originalBytes: 100, variantBytes: 0, totalBytes: 100, }); expect(res.body.totals).toEqual({ photoCount: 5, totalBytes: 2500 }); expect(res.body.thresholds).toEqual({ storageBytes: null, perAlbumBytes: null, }); expect(res.body.alerts).toEqual([]); }); it('② 访问控制:staff 403;未认证 401', async () => { const staffGet = await getSummary(staffXToken); expect(staffGet.status).toBe(403); expect(staffGet.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const staffPut = await putThresholds(staffXToken, { perAlbumBytes: 1000, }); expect(staffPut.status).toBe(403); expect(staffPut.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const anon = await request(cnBase).get('/v1/admin/usage/summary'); expect(anon.status).toBe(401); expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); it('③ 入参校验:负值/小数/字符串/超 2^53 → 400;空体 = 全保持', async () => { for (const bad of [-1, 1.5, 'abc', 9007199254740992]) { const res = await putThresholds(adminToken, { storageBytes: bad }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } const negative = await putThresholds(adminToken, { perAlbumBytes: -5 }); expect(negative.status).toBe(400); expect(negative.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 空体:两维缺省=保持(仍为 null)。 const empty = await putThresholds(adminToken, {}); expect(empty.status).toBe(200); expect(empty.body).toEqual({ storageBytes: null, perAlbumBytes: null }); }); it('④ perAlbum 告警:超阈产生(含字节数/标题)→ 去重 → 上调解除', async () => { // 直改 DB 伪造超阈(避免上传 GB 级真实对象)。 await prismaCn.photos.update({ where: { id: pa1Id }, data: { bytes: 10_000_000 }, }); // 相册 A 合计 = 10_000_000 + 500 + 300 + 200(原图)+ 400(变体)= 10_001_400。 const put = await putThresholds(adminToken, { perAlbumBytes: 5_000_000 }); expect(put.status).toBe(200); expect(put.body).toEqual({ storageBytes: null, perAlbumBytes: 5_000_000 }); const res = await getSummary(adminToken); expect(res.status).toBe(200); expect(res.body.thresholds).toEqual({ storageBytes: null, perAlbumBytes: 5_000_000, }); const active = res.body.alerts.filter( (a: { resolvedAt: string | null }) => a.resolvedAt === null, ); expect(active).toHaveLength(1); expect(active[0].kind).toBe('perAlbum'); expect(active[0].albumId).toBe(albumAId); expect(active[0].message).toContain('用量相册甲'); // 字面量经模板插值为无分隔符十进制(10001400 = 10_001_400)。 expect(active[0].message).toContain('10001400'); expect(active[0].message).toContain('5000000'); // 去重:再次评估(PUT storage 维度触发)不产生第二条活跃告警。 const reevaluate = await putThresholds(adminToken, { storageBytes: 2_000_000_000, }); expect(reevaluate.status).toBe(200); expect( await prismaCn.usage_alerts.count({ where: { kind: 'perAlbum', albumId: albumAId, resolvedAt: null }, }), ).toBe(1); // 阈值上调超过实际用量 → 旧告警 resolvedAt 置位,活跃清空。 const raise = await putThresholds(adminToken, { perAlbumBytes: 20_000_000 }); expect(raise.status).toBe(200); expect(raise.body).toEqual({ storageBytes: 2_000_000_000, perAlbumBytes: 20_000_000, }); expect( await prismaCn.usage_alerts.count({ where: { kind: 'perAlbum', resolvedAt: null }, }), ).toBe(0); const resolvedRow = await prismaCn.usage_alerts.findFirstOrThrow({ where: { kind: 'perAlbum', albumId: albumAId }, }); expect(resolvedRow.resolvedAt).not.toBeNull(); // summary 仍可见已解除告警(含 resolvedAt)。 const after = await getSummary(adminToken); const resolvedShown = after.body.alerts.find( (a: { kind: string }) => a.kind === 'perAlbum', ); expect(resolvedShown.resolvedAt).not.toBeNull(); }); it('⑤ storage 告警:totals 超阈产生(albumId=null)→ null 清除维度解除', async () => { // 全库 totals = 10_001_400 + 100(相册 B)= 10_001_500 > 5_000_000。 const put = await putThresholds(adminToken, { storageBytes: 5_000_000 }); expect(put.status).toBe(200); expect(put.body).toEqual({ storageBytes: 5_000_000, perAlbumBytes: 20_000_000, }); const res = await getSummary(adminToken); const active = res.body.alerts.filter( (a: { resolvedAt: string | null }) => a.resolvedAt === null, ); expect(active).toHaveLength(1); expect(active[0].kind).toBe('storage'); expect(active[0].albumId).toBeNull(); // 全库 totals 10001500 = 10_001_500(无分隔符十进制)。 expect(active[0].message).toContain('10001500'); expect(active[0].message).toContain('5000000'); // 显式 null 清除维度(契约:清除后该维度停用)→ 活跃告警解除。 const clear = await putThresholds(adminToken, { storageBytes: null }); expect(clear.status).toBe(200); expect(clear.body).toEqual({ storageBytes: null, perAlbumBytes: 20_000_000, }); expect( await prismaCn.usage_alerts.count({ where: { kind: 'storage', resolvedAt: null }, }), ).toBe(0); const resolvedRow = await prismaCn.usage_alerts.findFirstOrThrow({ where: { kind: 'storage' }, }); expect(resolvedRow.resolvedAt).not.toBeNull(); }); it('⑥ 口径与 api 检查点:hidden 计入(PATCH 后不变);deleted 不计(软删后回落)', async () => { // 恢复 pa1 字节并清空两维阈值(后续断言回到可控基线)。 await prismaCn.photos.update({ where: { id: pa1Id }, data: { bytes: 1000 }, }); const reset = await putThresholds(adminToken, { storageBytes: null, perAlbumBytes: null, }); expect(reset.body).toEqual({ storageBytes: null, perAlbumBytes: null }); // PATCH 隐藏(穿过可见性迁移检查点):hidden 仍计用量——totals 不变。 const hide = await request(cnBase) .patch(`/v1/photos/${pa1Id}`) .set('Authorization', `Bearer ${ownerAToken}`) .send({ visibility: 'hidden' }); expect(hide.status).toBe(200); expect(hide.body.visibility).toBe('hidden'); const duringHidden = await getSummary(adminToken); const albumADuring = ( duringHidden.body.albums as { albumId: string }[] ).find((a) => a.albumId === albumAId); expect(albumADuring).toMatchObject({ photoCount: 4, originalBytes: 2000, variantBytes: 400, totalBytes: 2400, }); // 软删除(穿过软删除检查点):deleted 不计——相册 A 与全库回落。 const del = await request(cnBase) .delete(`/v1/photos/${pa4Id}`) .set('Authorization', `Bearer ${ownerAToken}`); expect(del.status).toBe(204); const after = await getSummary(adminToken); const albumAAfter = ( after.body.albums as { albumId: string }[] ).find((a) => a.albumId === albumAId); expect(albumAAfter).toMatchObject({ photoCount: 3, originalBytes: 1800, variantBytes: 400, totalBytes: 2200, }); expect(after.body.totals).toEqual({ photoCount: 4, totalBytes: 2300 }); }); });