179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createGenerationJob,
|
|
deleteGenerationJob,
|
|
listUsageEvents,
|
|
recordUsageEvent,
|
|
recordUsageForJob
|
|
} from "@/lib/server/data-store";
|
|
import { getAdminUsageReport, getPersonalUsageReport } from "@/lib/server/usage-service";
|
|
import { usagePresetRange } from "@/lib/usage";
|
|
import type { GenerationJob, UsageContext } from "@/lib/types";
|
|
|
|
let runtimeDir = "";
|
|
const previousEnv = new Map<string, string | undefined>();
|
|
const envNames = ["ZHINIAN_RUNTIME_DIR", "ZHINIAN_DATA_BACKEND"];
|
|
|
|
describe("usage metering and reports", () => {
|
|
beforeEach(async () => {
|
|
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-usage-"));
|
|
for (const name of envNames) previousEnv.set(name, process.env[name]);
|
|
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
|
process.env.ZHINIAN_DATA_BACKEND = "local";
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const name of envNames) restoreEnv(name, previousEnv.get(name));
|
|
previousEnv.clear();
|
|
await rm(runtimeDir, { force: true, recursive: true });
|
|
});
|
|
|
|
it("uses China Standard Time for preset boundaries", () => {
|
|
const range = usagePresetRange("month", new Date("2026-07-27T16:30:00.000Z"));
|
|
expect(range).toMatchObject({
|
|
startDate: "2026-07-01",
|
|
endDate: "2026-07-28",
|
|
from: "2026-07-01T00:00:00+08:00",
|
|
to: "2026-07-29T00:00:00+08:00",
|
|
dayCount: 28
|
|
});
|
|
});
|
|
|
|
it("records one immutable event per real platform job", async () => {
|
|
const job = await createUsageJob("job-platform", platformContext("owner-a", "tenant-a", "org-a"));
|
|
const first = await recordUsageForJob(job);
|
|
const second = await recordUsageForJob(job);
|
|
expect(second?.id).toBe(first?.id);
|
|
expect(await listUsageEvents({ source: "platform" })).toHaveLength(1);
|
|
|
|
await deleteGenerationJob(job.id);
|
|
const retained = await listUsageEvents({ source: "platform" });
|
|
expect(retained).toHaveLength(1);
|
|
expect(retained[0]).toMatchObject({
|
|
jobId: job.id,
|
|
quantity: 1,
|
|
estimatedUnit: "job",
|
|
organizationId: "org-a"
|
|
});
|
|
});
|
|
|
|
it("does not meter mock jobs or public API clients", async () => {
|
|
const mock = await createUsageJob("job-mock", platformContext("owner-a"), { provider: "mock" });
|
|
const api = await createUsageJob("job-api", undefined, { externalClientId: "partner-a" });
|
|
expect(await recordUsageForJob(mock)).toBeNull();
|
|
expect(await recordUsageForJob(api)).toBeNull();
|
|
expect(await listUsageEvents()).toHaveLength(0);
|
|
});
|
|
|
|
it("builds personal and administrator reports from distinct successful jobs", async () => {
|
|
await recordUsageEvent({
|
|
ownerId: "owner-a",
|
|
jobId: "job-a",
|
|
source: "platform",
|
|
capability: "image.generate",
|
|
provider: "bailian",
|
|
reqKey: "wan2.2-t2i-plus",
|
|
accountDisplayName: "账号 A",
|
|
tenantId: "tenant-a",
|
|
organizationId: "org-a",
|
|
organizationName: "组织 A",
|
|
quantity: 4,
|
|
estimatedUnit: "image",
|
|
createdAt: "2026-07-03T01:00:00.000Z"
|
|
});
|
|
await recordUsageEvent({
|
|
ownerId: "owner-b",
|
|
jobId: "job-b",
|
|
source: "platform",
|
|
capability: "video.generate",
|
|
provider: "seedance",
|
|
accountDisplayName: "账号 B",
|
|
quantity: 1,
|
|
estimatedUnit: "job",
|
|
createdAt: "2026-07-04T01:00:00.000Z"
|
|
});
|
|
await recordUsageEvent({
|
|
ownerId: "api:partner-a",
|
|
jobId: "job-api",
|
|
source: "api",
|
|
capability: "image.generate",
|
|
provider: "bailian",
|
|
quantity: 1,
|
|
estimatedUnit: "job",
|
|
createdAt: "2026-07-05T01:00:00.000Z"
|
|
});
|
|
await recordUsageEvent({
|
|
ownerId: "owner-a",
|
|
jobId: "job-mock",
|
|
source: "platform",
|
|
capability: "image.generate",
|
|
provider: "mock",
|
|
quantity: 1,
|
|
estimatedUnit: "job",
|
|
createdAt: "2026-07-06T01:00:00.000Z"
|
|
});
|
|
|
|
const now = new Date("2026-07-28T04:00:00.000Z");
|
|
const personal = await getPersonalUsageReport("owner-a", "month", now);
|
|
expect(personal.total).toBe(1);
|
|
expect(personal.byCapability.find((item) => item.key === "image.generate")?.count).toBe(1);
|
|
|
|
const report = await getAdminUsageReport({}, [{
|
|
organizationId: "org-a",
|
|
organizationName: "组织 A",
|
|
organizationBindTenantId: 1
|
|
}], now);
|
|
expect(report.summary).toMatchObject({ total: 2, activeAccounts: 2, activeOrganizations: 1 });
|
|
expect(report.organizations.map((row) => row.organizationName)).toEqual(["组织 A", "未归属组织"]);
|
|
|
|
const filtered = await getAdminUsageReport({ organizationId: "org-a" }, [], now);
|
|
expect(filtered.summary.total).toBe(1);
|
|
expect(filtered.accounts[0]?.accountName).toBe("账号 A");
|
|
expect(filtered.options.accounts.map((option) => option.value)).toEqual(["owner-a"]);
|
|
});
|
|
});
|
|
|
|
async function createUsageJob(
|
|
id: string,
|
|
usageContext?: UsageContext,
|
|
overrides: Partial<GenerationJob> = {}
|
|
): Promise<GenerationJob> {
|
|
return createGenerationJob({
|
|
id,
|
|
ownerId: usageContext?.accountId || "api:partner-a",
|
|
externalClientId: overrides.externalClientId,
|
|
capability: "image.generate",
|
|
provider: overrides.provider || "bailian",
|
|
reqKey: "wan2.2-t2i-plus",
|
|
status: "running",
|
|
inputAssetIds: [],
|
|
inputUrls: [],
|
|
outputAssetIds: [],
|
|
requestPayload: {},
|
|
usageContext
|
|
});
|
|
}
|
|
|
|
function platformContext(accountId: string, tenantId?: string, organizationId?: string): UsageContext {
|
|
return {
|
|
source: "platform",
|
|
accountId,
|
|
username: accountId,
|
|
displayName: accountId,
|
|
tenantId,
|
|
organizationId,
|
|
organizationName: organizationId ? "组织 A" : undefined
|
|
};
|
|
}
|
|
|
|
function restoreEnv(name: string, value: string | undefined) {
|
|
if (value === undefined) {
|
|
delete process.env[name];
|
|
return;
|
|
}
|
|
process.env[name] = value;
|
|
}
|