245 lines
9.0 KiB
TypeScript
245 lines
9.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getAdminActiveUserKpiCounts, getAdminTenantKpiCounts } from "@hivegrid/db/kpi.server";
|
|
import { StatChange } from "~/application/dtos/stats/StatChange";
|
|
import { getDbClient } from "@hivegrid/db/client.server";
|
|
import { listAuditEvents } from "@hivegrid/db/audit-events-query.server";
|
|
import { getAdminDashboardOverview, getAdminDashboardStats } from "./adminDashboardService";
|
|
import { getMrr } from "./.server/subscriptionService";
|
|
import { adminGetAllTenantsIdsAndNames } from "../db/tenants.db.server";
|
|
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
import { getPlatformOutboxCounts } from "@hivegrid/db/admin-dashboard.server";
|
|
|
|
vi.mock("@hivegrid/db/kpi.server", () => ({
|
|
getAdminActiveUserKpiCounts: vi.fn(),
|
|
getAdminTenantKpiCounts: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@hivegrid/db/client.server", () => ({
|
|
getDbClient: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@hivegrid/db/audit-events-query.server", () => ({
|
|
listAuditEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@hivegrid/db/admin-dashboard.server", () => ({
|
|
getPlatformOutboxCounts: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./.server/subscriptionService", () => ({
|
|
getMrr: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../db/tenants.db.server", () => ({
|
|
adminGetAllTenantsIdsAndNames: vi.fn(),
|
|
}));
|
|
|
|
const adminTenants = ["tenant_1", "tenant_2", "tenant_3", "tenant_4", "tenant_5"].map((id) => ({
|
|
id,
|
|
name: id,
|
|
slug: id,
|
|
}));
|
|
|
|
const translations: Record<string, string> = {
|
|
"admin.kpi.accounts.name": "Translated accounts",
|
|
"admin.kpi.accounts.hint": "Translated total accounts",
|
|
"admin.kpi.mrr.name": "Translated MRR",
|
|
"admin.kpi.mrr.hint": "Translated monthly revenue",
|
|
"admin.kpi.mrr.previousStat": "{{0}} translated accounts",
|
|
"admin.kpi.activeUsers.name": "Translated MAU",
|
|
"admin.kpi.activeUsers.hint": "Translated monthly active users",
|
|
};
|
|
|
|
function t(key: string) {
|
|
return translations[key] ?? key;
|
|
}
|
|
|
|
describe("getAdminDashboardStats", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(adminGetAllTenantsIdsAndNames).mockResolvedValue(adminTenants);
|
|
vi.mocked(getMrr).mockResolvedValue({ total: 1234.5, count: 7 });
|
|
});
|
|
|
|
it("builds translated KPI stats from package counts and forwards the selected period", async () => {
|
|
const gte = new Date("2026-01-01T00:00:00.000Z");
|
|
vi.mocked(getAdminTenantKpiCounts).mockResolvedValue({ added: 3, total: 10 });
|
|
vi.mocked(getAdminActiveUserKpiCounts).mockResolvedValue({ added: 2, total: 8 });
|
|
|
|
const stats = await getAdminDashboardStats({ t, gte });
|
|
|
|
expect(getAdminTenantKpiCounts).toHaveBeenCalledTimes(1);
|
|
expect(getAdminTenantKpiCounts).toHaveBeenCalledWith({ gte });
|
|
expect(getAdminActiveUserKpiCounts).toHaveBeenCalledTimes(1);
|
|
expect(getAdminActiveUserKpiCounts).toHaveBeenCalledWith({ gte });
|
|
expect(getMrr).toHaveBeenCalledTimes(1);
|
|
expect(getMrr).toHaveBeenCalledWith(
|
|
"eur",
|
|
adminTenants.map((tenant) => tenant.id)
|
|
);
|
|
expect(stats).toEqual([
|
|
{
|
|
name: "Translated accounts",
|
|
hint: "Translated total accounts",
|
|
stat: "10",
|
|
previousStat: "7",
|
|
change: "+3",
|
|
changeType: StatChange.Increase,
|
|
path: "/accounts",
|
|
},
|
|
{
|
|
name: "Translated MRR",
|
|
hint: "Translated monthly revenue",
|
|
stat: "€1,234.50",
|
|
previousStat: "7 translated accounts",
|
|
},
|
|
{
|
|
name: "Translated MAU",
|
|
hint: "Translated monthly active users",
|
|
stat: "8",
|
|
previousStat: "6",
|
|
change: "+2",
|
|
changeType: StatChange.Increase,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps zero additions visible with equal change type and missing translations as key text", async () => {
|
|
vi.mocked(getAdminTenantKpiCounts).mockResolvedValue({ added: 0, total: 5 });
|
|
vi.mocked(getAdminActiveUserKpiCounts).mockResolvedValue({ added: 0, total: 0 });
|
|
vi.mocked(getMrr).mockResolvedValue({ total: 0, count: 0 });
|
|
|
|
const stats = await getAdminDashboardStats({ t: (key) => key });
|
|
|
|
expect(getAdminTenantKpiCounts).toHaveBeenCalledWith({ gte: undefined });
|
|
expect(getAdminActiveUserKpiCounts).toHaveBeenCalledWith({ gte: undefined });
|
|
expect(stats[0]).toMatchObject({
|
|
name: "admin.kpi.accounts.name",
|
|
hint: "admin.kpi.accounts.hint",
|
|
stat: "5",
|
|
previousStat: "5",
|
|
change: "+0",
|
|
changeType: StatChange.Equal,
|
|
path: "/accounts",
|
|
});
|
|
expect(stats[1]).toMatchObject({
|
|
name: "admin.kpi.mrr.name",
|
|
hint: "admin.kpi.mrr.hint",
|
|
stat: "€0.00",
|
|
previousStat: "admin.kpi.mrr.previousStat",
|
|
});
|
|
expect(stats[2]).toMatchObject({
|
|
name: "admin.kpi.activeUsers.name",
|
|
hint: "admin.kpi.activeUsers.hint",
|
|
stat: "0",
|
|
previousStat: "0",
|
|
change: "+0",
|
|
changeType: StatChange.Increase,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getAdminDashboardOverview", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(adminGetAllTenantsIdsAndNames).mockResolvedValue(adminTenants);
|
|
vi.stubEnv("BULLMQ_REDIS_URL", "redis://localhost:6379/0");
|
|
vi.stubEnv("MEILI_URL", "http://localhost:7700");
|
|
vi.stubEnv("MEILI_API_KEY", "devkey");
|
|
vi.stubEnv("LAGO_API_URL", "http://lago.local");
|
|
vi.stubEnv("LAGO_API_KEY", "lago-key");
|
|
vi.stubEnv("MOLLIE_API_KEY", "mollie-key");
|
|
vi.stubEnv("MOLLIE_PROFILE_ID", "profile-id");
|
|
vi.stubEnv("SENTRY_ENVIRONMENT", "staging");
|
|
vi.stubEnv("SENTRY_RELEASE", "abc123");
|
|
vi.stubEnv("SENTRY_DSN", "https://example.invalid/1");
|
|
vi.mocked(listAuditEvents).mockResolvedValue({
|
|
rows: [
|
|
{
|
|
id: "audit-1",
|
|
createdAt: new Date("2026-07-01T10:00:00.000Z"),
|
|
summaryText: "Tenant aktualisiert",
|
|
action: "tenant.update",
|
|
actorEmail: "ops@example.test",
|
|
targetType: "Tenant",
|
|
sourceModule: "admin",
|
|
actorType: "user",
|
|
outcome: "success",
|
|
},
|
|
],
|
|
total: 1,
|
|
take: 5,
|
|
skip: 0,
|
|
} as never);
|
|
vi.mocked(getPlatformOutboxCounts).mockResolvedValue({ pendingOrProcessing: 5, failed: 6 });
|
|
});
|
|
|
|
it("builds billing, system, deploy, and audit overview signals", async () => {
|
|
vi.mocked(getDbClient).mockReturnValue(createDashboardClient({
|
|
failedWebhookCount: 4,
|
|
}) as never);
|
|
|
|
const overview = await runWithRlsContext({ userId: "operator_1" }, () => getAdminDashboardOverview());
|
|
|
|
expect(overview.billing).toMatchObject({
|
|
openIssueCount: 6,
|
|
pastDueCount: 2,
|
|
frozenCount: 1,
|
|
missingProviderCount: 3,
|
|
});
|
|
expect(overview.billing.signals).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "billing-issues", value: "6", tone: "warning", href: "/system/billing" }),
|
|
expect.objectContaining({ id: "billing-past-due", value: "2", tone: "danger", href: "/system/billing" }),
|
|
])
|
|
);
|
|
expect(overview.system.signals).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "redis", value: "OK", tone: "success" }),
|
|
expect.objectContaining({ id: "webhooks", value: "4", tone: "warning", href: "/system/webhooks" }),
|
|
expect.objectContaining({ id: "outbox", value: "5/6", tone: "danger", href: "/events" }),
|
|
])
|
|
);
|
|
expect(overview.deploy).toMatchObject({ environment: "staging", release: "abc123" });
|
|
expect(overview.recentEvents).toEqual([
|
|
expect.objectContaining({
|
|
id: "audit-1",
|
|
label: "Tenant aktualisiert",
|
|
detail: "ops@example.test · Tenant",
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
href: "/audit",
|
|
}),
|
|
]);
|
|
expect(listAuditEvents).toHaveBeenCalledWith({ take: 5, skip: 0 });
|
|
expect(getPlatformOutboxCounts).toHaveBeenCalledWith(undefined, {
|
|
allowMarkedLocalDatabaseFallback: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
function createDashboardClient({
|
|
failedWebhookCount,
|
|
}: {
|
|
failedWebhookCount: number;
|
|
}) {
|
|
const subscriptions = new Map([
|
|
["tenant_1", { status: "past_due", pastDueSince: null, frozenAt: null, planCode: "pro", lagoCustomerId: null, mollieCustomerId: "m_1" }],
|
|
["tenant_2", { status: "trial", pastDueSince: new Date(), frozenAt: null, planCode: null, lagoCustomerId: "l_2", mollieCustomerId: "m_2" }],
|
|
["tenant_3", { status: "frozen", pastDueSince: null, frozenAt: new Date(), planCode: "pro", lagoCustomerId: "l_3", mollieCustomerId: null }],
|
|
["tenant_4", { status: "active", pastDueSince: null, frozenAt: null, planCode: "pro", lagoCustomerId: null, mollieCustomerId: "m_4" }],
|
|
["tenant_5", { status: "trial", pastDueSince: null, frozenAt: null, planCode: null, lagoCustomerId: "l_5", mollieCustomerId: "m_5" }],
|
|
]);
|
|
const tenantSubscriptionFindUnique = vi.fn(async ({ where }: { where: { tenantId: string } }) => {
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "operator_1", tenantId: where.tenantId });
|
|
return subscriptions.get(where.tenantId) ?? null;
|
|
});
|
|
return {
|
|
tenantSubscription: {
|
|
findUnique: tenantSubscriptionFindUnique,
|
|
},
|
|
eventWebhookAttempt: {
|
|
count: vi.fn().mockResolvedValue(failedWebhookCount),
|
|
},
|
|
};
|
|
}
|