Files
discord-rpg-concept/shared/modules/dashboard/dashboard.service.test.ts

293 lines
11 KiB
TypeScript

import { describe, test, expect, mock, beforeEach } from "bun:test";
import { dashboardService } from "./dashboard.service";
// Mock DrizzleClient
const mockSelect = mock(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
const mockQuery = {
transactions: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
moderationCases: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
};
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
select: mockSelect,
query: mockQuery,
},
}));
describe("dashboardService", () => {
beforeEach(() => {
mockSelect.mockClear();
mockQuery.transactions.findMany.mockClear();
mockQuery.moderationCases.findMany.mockClear();
// Reset default mock implementation
mockSelect.mockImplementation(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
});
describe("getActiveUserCount", () => {
test("should return active user count from database", async () => {
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore ts(2322)
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "5" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(5);
expect(mockSelect).toHaveBeenCalled();
});
test("should return 0 when no users found", async () => {
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore ts(2322)
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "0" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(0);
});
});
describe("getTotalUserCount", () => {
test("should return total user count", async () => {
const count = await dashboardService.getTotalUserCount();
expect(count).toBe(5);
});
});
describe("getRecentTransactions", () => {
test("should return formatted transaction events", async () => {
const mockTx = [
{
type: "DAILY_REWARD",
description: "Daily reward",
createdAt: new Date(),
user: { username: "testuser" },
},
] as any;
mockQuery.transactions.findMany.mockResolvedValueOnce(mockTx);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("info");
expect(events[0]?.message).toContain("testuser");
expect(events[0]?.icon).toBe("☀️");
});
test("should handle empty transactions", async () => {
mockQuery.transactions.findMany.mockResolvedValueOnce([]);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(0);
});
});
describe("getRecentModerationCases", () => {
test("should return formatted moderation events", async () => {
const mockCases = [
{
type: "warn",
username: "baduser",
reason: "Spam",
createdAt: new Date(),
},
] as any;
mockQuery.moderationCases.findMany.mockResolvedValueOnce(mockCases);
const events = await dashboardService.getRecentModerationCases(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("error");
expect(events[0]?.message).toContain("WARN");
expect(events[0]?.message).toContain("baduser");
expect(events[0]?.icon).toBe("⚠️");
});
});
describe("getRecentEvents", () => {
test("should combine and sort transactions and moderation events", async () => {
const now = new Date();
const earlier = new Date(now.getTime() - 1000);
mockQuery.transactions.findMany.mockResolvedValueOnce([
{
type: "DAILY_REWARD",
description: "Daily",
createdAt: now,
user: { username: "user1" },
},
] as unknown as any[]);
mockQuery.moderationCases.findMany.mockResolvedValueOnce([
{
type: "warn",
username: "user2",
reason: "Test",
createdAt: earlier,
},
] as unknown as any[]);
const events = await dashboardService.getRecentEvents(10);
expect(events).toHaveLength(2);
// Should be sorted by timestamp (newest first)
const t0 = events[0]?.timestamp instanceof Date ? events[0].timestamp.getTime() : new Date(events[0]?.timestamp ?? 0).getTime();
const t1 = events[1]?.timestamp instanceof Date ? events[1].timestamp.getTime() : new Date(events[1]?.timestamp ?? 0).getTime();
expect(t0).toBeGreaterThanOrEqual(t1);
});
});
describe("recordEvent", () => {
test("should emit NEW_EVENT to systemEvents", async () => {
const mockEmit = mock((_event: string, _data: unknown) => { });
mock.module("@shared/lib/events", () => ({
systemEvents: {
emit: mockEmit,
},
EVENTS: {
DASHBOARD: {
NEW_EVENT: "dashboard:new_event",
}
}
}));
await dashboardService.recordEvent({
type: 'info',
message: 'Test Event',
icon: '🚀'
});
expect(mockEmit).toHaveBeenCalled();
const calls = mockEmit.mock.calls;
if (calls.length > 0 && calls[0]) {
expect(calls[0][0]).toBe("dashboard:new_event");
const data = calls[0][1] as { message: string, timestamp: string };
expect(data.message).toBe("Test Event");
expect(data.timestamp).toBeDefined();
// Verify it's an ISO string
expect(() => new Date(data.timestamp).toISOString()).not.toThrow();
} else {
throw new Error("mockEmit was not called with expected arguments");
}
});
});
describe("getActivityAggregation", () => {
test("should return exactly 24 data points representing the last 24 hours", async () => {
const now = new Date();
now.setHours(now.getHours(), 0, 0, 0);
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore
from: mock(() => ({
where: mock(() => ({
groupBy: mock(() => ({
orderBy: mock(() => Promise.resolve([
{
hour: now.toISOString(),
transactions: "10",
commands: "5"
}
]))
}))
}))
}))
}));
const activity = await dashboardService.getActivityAggregation();
expect(activity).toHaveLength(24);
// Check if the current hour matches our mock
const currentHourData = activity.find(a => new Date(a.hour).getTime() === now.getTime());
expect(currentHourData).toBeDefined();
expect(currentHourData?.transactions).toBe(10);
expect(currentHourData?.commands).toBe(5);
// Check if missing hours are filled with 0
const otherHour = activity.find(a => new Date(a.hour).getTime() !== now.getTime());
expect(otherHour?.transactions).toBe(0);
expect(otherHour?.commands).toBe(0);
});
test("should return 24 hours of zeros if database is empty", async () => {
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore
from: mock(() => ({
where: mock(() => ({
groupBy: mock(() => ({
orderBy: mock(() => Promise.resolve([]))
}))
}))
}))
}));
const activity = await dashboardService.getActivityAggregation();
expect(activity).toHaveLength(24);
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
});
test("should return 24 hours of zeros if database returns rows with null hours", async () => {
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore
from: mock(() => ({
where: mock(() => ({
groupBy: mock(() => ({
orderBy: mock(() => Promise.resolve([{ hour: null, transactions: "10", commands: "5" }]))
}))
}))
}))
}));
const activity = await dashboardService.getActivityAggregation();
expect(activity).toHaveLength(24);
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
});
test("should correctly map hours regardless of input sort order", async () => {
const now = new Date();
now.setHours(now.getHours(), 0, 0, 0);
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
mockSelect.mockImplementationOnce(() => ({
// @ts-ignore
from: mock(() => ({
where: mock(() => ({
groupBy: mock(() => ({
orderBy: mock(() => Promise.resolve([
{ hour: now.toISOString(), transactions: "10", commands: "5" },
{ hour: hourAgo.toISOString(), transactions: "20", commands: "10" }
]))
}))
}))
}))
}));
const activity = await dashboardService.getActivityAggregation();
const current = activity.find(a => a.hour === now.toISOString());
const past = activity.find(a => a.hour === hourAgo.toISOString());
expect(current?.transactions).toBe(10);
expect(past?.transactions).toBe(20);
});
});
});