419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
|
|
// 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<string> {
|
|||
|
|
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<NestExpressApplication>(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<string> => {
|
|||
|
|
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 });
|
|||
|
|
});
|
|||
|
|
});
|