Simpl-Resultat/src/services/reportService.test.ts
le king fu ac9c8afc4a
All checks were successful
PR Check / rust (pull_request) Successful in 24m54s
PR Check / frontend (pull_request) Successful in 2m32s
PR Check / rust (push) Successful in 24m14s
PR Check / frontend (push) Successful in 2m26s
feat: reports hub with highlights panel and detailed highlights page (#71)
- Transform /reports into a hub: highlights panel + 4 nav cards
- New service: reportService.getHighlights (parameterised SQL, deterministic
  via referenceDate argument for tests, computes current-month balance, YTD,
  12-month sparkline series, top expense movers vs previous month, top recent
  transactions within configurable 30/60/90 day window)
- Extended types: HighlightsData, HighlightMover, MonthBalance
- Wired useHighlights hook with reducer + window-days state
- Hub tiles (flat naming under src/components/reports):
  HubNetBalanceTile, HubTopMoversTile, HubTopTransactionsTile,
  HubHighlightsPanel, HubReportNavCard
- Detailed ReportsHighlightsPage: balance tiles, sortable top movers table,
  diverging bar chart (Recharts + patterns SVG), top transactions list with
  30/60/90 window toggle; ViewModeToggle persistence keyed as
  reports-viewmode-highlights
- New i18n keys: reports.hub.*, reports.highlights.*
- 5 new vitest cases: empty profile, parameterised queries, window sizing,
  delta computation, zero-previous divisor handling

Fixes #71

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:47:55 -04:00

268 lines
9.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { getCategoryOverTime, getHighlights } from "./reportService";
// Mock the db module
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("getCategoryOverTime", () => {
it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime();
const topCatSQL = mockSelect.mock.calls[0][0] as string;
// No WHERE clause should appear (no filters at all)
expect(topCatSQL).not.toContain("WHERE");
});
it("applies typeFilter via category type when provided", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, undefined, "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense')");
expect(topCatSQL).toContain("WHERE");
expect(topCatParams[0]).toBe("expense");
});
it("applies income typeFilter correctly", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 50, undefined, "income");
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatParams[0]).toBe("income");
// Date params follow
expect(topCatParams[1]).toBe("2025-01-01");
expect(topCatParams[2]).toBe("2025-06-30");
});
it("applies date range filters", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-12-31");
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.date >= $1");
expect(topCatSQL).toContain("t.date <= $2");
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("applies sourceId filter", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, 3);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id");
expect(topCatParams[0]).toBe(3);
});
it("combines typeFilter, date range, and sourceId", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2");
expect(topCatSQL).toContain("t.date <= $3");
expect(topCatSQL).toContain("t.source_id = $4");
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
});
it("returns correct structure with categories, data, colors, and categoryIds", async () => {
mockSelect
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
])
.mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
{ month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
expect(result.categories).toEqual(["Food", "Transport"]);
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
});
it("groups non-top-N categories into Other", async () => {
mockSelect
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
])
.mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
});
});
describe("getHighlights", () => {
const REF = "2026-04-14";
function queueEmpty(n: number) {
for (let i = 0; i < n; i++) {
mockSelect.mockResolvedValueOnce([]);
}
}
it("computes windows and returns zeroed data on an empty profile", async () => {
queueEmpty(5); // currentBalance, ytd, series, movers, recent
const result = await getHighlights(30, REF);
expect(result.currentMonth).toBe("2026-04");
expect(result.netBalanceCurrent).toBe(0);
expect(result.netBalanceYtd).toBe(0);
expect(result.monthlyBalanceSeries).toHaveLength(12);
expect(result.monthlyBalanceSeries[11].month).toBe("2026-04");
expect(result.monthlyBalanceSeries[0].month).toBe("2025-05");
expect(result.topMovers).toEqual([]);
expect(result.topTransactions).toEqual([]);
});
it("parameterises every query with no inlined strings", async () => {
queueEmpty(5);
await getHighlights(60, REF);
for (const call of mockSelect.mock.calls) {
const sql = call[0] as string;
const params = call[1] as unknown[];
expect(sql).not.toContain(`'${REF}'`);
expect(Array.isArray(params)).toBe(true);
}
// First call uses current month range parameters
const firstParams = mockSelect.mock.calls[0][1] as unknown[];
expect(firstParams[0]).toBe("2026-04-01");
expect(firstParams[1]).toBe("2026-04-30");
// YTD call uses year start
const ytdParams = mockSelect.mock.calls[1][1] as unknown[];
expect(ytdParams[0]).toBe("2026-01-01");
expect(ytdParams[1]).toBe(REF);
});
it("uses a 60-day window for top transactions when requested", async () => {
queueEmpty(5);
await getHighlights(60, REF);
const recentParams = mockSelect.mock.calls[4][1] as unknown[];
// 60-day window ending at REF: start = 2026-04-14 - 59 days = 2026-02-14
expect(recentParams[0]).toBe("2026-02-14");
expect(recentParams[1]).toBe(REF);
expect(recentParams[2]).toBe(10);
});
it("computes deltaAbs and deltaPct from movers rows", async () => {
mockSelect
.mockResolvedValueOnce([{ net: -500 }]) // current balance
.mockResolvedValueOnce([{ net: -1800 }]) // ytd
.mockResolvedValueOnce([
{ month: "2026-04", net: -500 },
{ month: "2026-03", net: -400 },
]) // series
.mockResolvedValueOnce([
{
category_id: 1,
category_name: "Restaurants",
category_color: "#f97316",
current_total: 240,
previous_total: 200,
},
{
category_id: 2,
category_name: "Groceries",
category_color: "#10b981",
current_total: 85,
previous_total: 170,
},
])
.mockResolvedValueOnce([]); // recent
const result = await getHighlights(30, REF);
expect(result.netBalanceCurrent).toBe(-500);
expect(result.netBalanceYtd).toBe(-1800);
expect(result.topMovers).toHaveLength(2);
expect(result.topMovers[0]).toMatchObject({
categoryName: "Restaurants",
currentAmount: 240,
previousAmount: 200,
deltaAbs: 40,
});
expect(result.topMovers[0].deltaPct).toBeCloseTo(20, 4);
expect(result.topMovers[1].deltaAbs).toBe(-85);
expect(result.topMovers[1].deltaPct).toBeCloseTo(-50, 4);
});
it("returns deltaPct=null when previous month total is zero", async () => {
mockSelect
.mockResolvedValueOnce([{ net: 0 }])
.mockResolvedValueOnce([{ net: 0 }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
category_id: 3,
category_name: "New expense",
category_color: "#3b82f6",
current_total: 120,
previous_total: 0,
},
])
.mockResolvedValueOnce([]);
const result = await getHighlights(30, REF);
expect(result.topMovers[0].deltaPct).toBeNull();
expect(result.topMovers[0].deltaAbs).toBe(120);
});
});