Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ services:
mfe:
environment:
- COOKIE_SECURE=false
- NODE_ENV=development
command: sh -lc "cd /app && npm ci && npm run dev --workspace=@gofin/shell"
volumes:
- ./frontend:/app
- /app/node_modules
Expand Down
55 changes: 36 additions & 19 deletions frontend/apps/finance/src/__tests__/monthly-trends.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MonthlyTrendsSection } from "@/features/dashboard";
import { TrendsSection } from "@/features/dashboard";
import type { TrendPoint } from "@/types";

const mockTrendData: TrendPoint[] = [
Expand Down Expand Up @@ -79,30 +79,28 @@ const mockTrendData: TrendPoint[] = [
},
];

describe("MonthlyTrendsSection", () => {
it("renders section with heading and charts when data is provided", () => {
const onToggle = () => {};
describe("TrendsSection", () => {
it("renders Select with Monthly Spending as default and the spending chart", () => {
render(
<MonthlyTrendsSection
<TrendsSection
trendData={mockTrendData}
trendMonths={6}
onToggle={onToggle}
onToggle={() => {}}
currency="USD"
/>,
);

expect(screen.getByText("Monthly Trends")).toBeInTheDocument();
expect(screen.getByText("Monthly Spending")).toBeInTheDocument();
expect(screen.getByText("Category Split")).toBeInTheDocument();
expect(screen.getByLabelText("Select trend chart")).toBeInTheDocument();
// Monthly Spending appears in both the Select trigger and chart title
expect(screen.getAllByText("Monthly Spending").length).toBeGreaterThanOrEqual(1);
});

it("renders nothing when data is empty", () => {
const onToggle = () => {};
const { container } = render(
<MonthlyTrendsSection
<TrendsSection
trendData={[]}
trendMonths={6}
onToggle={onToggle}
onToggle={() => {}}
currency="USD"
/>,
);
Expand All @@ -111,12 +109,11 @@ describe("MonthlyTrendsSection", () => {
});

it("renders toggle group with 6M and 12M options", () => {
const onToggle = () => {};
render(
<MonthlyTrendsSection
<TrendsSection
trendData={mockTrendData}
trendMonths={6}
onToggle={onToggle}
onToggle={() => {}}
currency="USD"
/>,
);
Expand All @@ -133,7 +130,7 @@ describe("MonthlyTrendsSection", () => {
};

render(
<MonthlyTrendsSection
<TrendsSection
trendData={mockTrendData}
trendMonths={6}
onToggle={onToggle}
Expand All @@ -148,12 +145,11 @@ describe("MonthlyTrendsSection", () => {
});

it("marks current toggle value as active", () => {
const onToggle = () => {};
render(
<MonthlyTrendsSection
<TrendsSection
trendData={mockTrendData}
trendMonths={12}
onToggle={onToggle}
onToggle={() => {}}
currency="USD"
/>,
);
Expand All @@ -164,4 +160,25 @@ describe("MonthlyTrendsSection", () => {
const toggle6M = screen.getByRole("radio", { name: "6 months" });
expect(toggle6M).toHaveAttribute("data-state", "off");
});

it("switches to Category Split chart when selected", async () => {
const user = userEvent.setup();
render(
<TrendsSection
trendData={mockTrendData}
trendMonths={6}
onToggle={() => {}}
currency="USD"
/>,
);

// Open Select and choose Category Split
const trigger = screen.getByLabelText("Select trend chart");
await user.click(trigger);
const categorySplitOption = await screen.findByRole("option", { name: "Category Split" });
await user.click(categorySplitOption);

// Category Split appears in both Select trigger and chart title
expect(screen.getAllByText("Category Split").length).toBeGreaterThanOrEqual(2);
});
});
11 changes: 11 additions & 0 deletions frontend/apps/finance/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
import "@testing-library/jest-dom/vitest";

// Radix UI Select uses pointer capture and scroll APIs not available in JSDOM.
// Provide stubs to prevent runtime errors during tests.
if (typeof Element.prototype.hasPointerCapture === "undefined") {
Element.prototype.hasPointerCapture = () => false;
Element.prototype.setPointerCapture = () => {};
Element.prototype.releasePointerCapture = () => {};
}
if (typeof Element.prototype.scrollIntoView === "undefined") {
Element.prototype.scrollIntoView = () => {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { BreakdownSection } from "../components/BreakdownSection";
import type { ExpenseFrecencyDataState } from "../hooks/useExpenseFrecencyData";

const mockTagSpending = [
{ tagId: "tag-food", tagName: "Food", amount: 50000, percentOfTotal: 91.74 },
{ tagId: "tag-social", tagName: "Social", amount: 4500, percentOfTotal: 8.26 },
];

const mockFrecencyData: ExpenseFrecencyDataState = {
status: "success",
suggestions: [
{
name: "Groceries",
amount: 50000,
currency: "USD",
expenseType: "essentials",
frequency: 114,
lastUsedAt: "2026-05-02T10:00:00Z",
recencyBucket: "last_7_days",
frecencyScore: 145,
tagId: "tag-food",
},
],
errorMessage: null,
};

function renderBreakdown(props?: Partial<Parameters<typeof BreakdownSection>[0]>) {
return render(
<MemoryRouter>
<BreakdownSection
tagSpending={mockTagSpending}
expenseFrecencyData={mockFrecencyData}
currency="USD"
{...props}
/>
</MemoryRouter>,
);
}

describe("BreakdownSection", () => {
it("renders Select with 'Spending by Tag' as default", () => {
renderBreakdown();
expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument();
expect(screen.getAllByText("Spending by Tag").length).toBeGreaterThanOrEqual(1);
});

it("shows TagSpendingChart by default", () => {
renderBreakdown();
// TagSpendingChart renders a card with title "Spending by Tag"
expect(screen.getAllByText("Spending by Tag").length).toBeGreaterThanOrEqual(2);
});

it("switches to Repeated Expenses chart when selected", async () => {
const user = userEvent.setup();
renderBreakdown();

const trigger = screen.getByLabelText("Select breakdown chart");
await user.click(trigger);
const option = await screen.findByRole("option", { name: "Repeated Expenses" });
await user.click(option);

// ExpenseFrecencyChart renders with success state content
expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument();
// Tag spending chart card should no longer be present
expect(screen.queryByText("Spending by Tag")).toBeNull();
});

it("shows empty state when tag spending is empty and frecency is empty", () => {
renderBreakdown({
tagSpending: [],
expenseFrecencyData: { status: "empty", suggestions: [], errorMessage: null },
});
// TagSpendingChart still renders its card even with no data (no tags shown)
expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,11 @@ describe("DashboardFeature - Budget Settings Editor Save", () => {
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Monthly Trends")).toBeInTheDocument();
expect(screen.getByLabelText("Select trend chart")).toBeInTheDocument();
});

expect(screen.getByText("Monthly Spending")).toBeInTheDocument();
expect(screen.getByText("Category Split")).toBeInTheDocument();
// Default chart is "Monthly Spending" shown in Select trigger and chart title
expect(screen.getAllByText("Monthly Spending").length).toBeGreaterThanOrEqual(1);
});

it("shows network error message when budget save has connection failure", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,26 @@ describe("DashboardFeature", () => {
...dashboardDataWithExpensesRoutes(),
});
globalThis.fetch = mockApi as unknown as typeof fetch;
const user = userEvent.setup();
renderDashboard();

// Wait for the Breakdown section to render with default "Spending by Tag"
await waitFor(() => {
expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument();
});

// Switch to Repeated Expenses via the breakdown Select
const breakdownTrigger = screen.getByLabelText("Select breakdown chart");
await user.click(breakdownTrigger);
const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" });
await user.click(repeatedOption);

await waitFor(() => {
expect(screen.getByText("Repeated Expenses")).toBeInTheDocument();
expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument();
});

expect(screen.getAllByText("Groceries").length).toBeGreaterThan(0);
expect(screen.getAllByText("Coffee").length).toBeGreaterThan(0);
expect(screen.getByText(/Frequency shows how often/i)).toBeInTheDocument();
expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Today");
expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 7 days");
expect(screen.getByLabelText("Recency legend")).toHaveTextContent("Last 30 days");
Expand All @@ -288,16 +299,24 @@ describe("DashboardFeature", () => {
"/api/finance/periods/current": { body: { period: testPeriod } },
...dashboardDataEmptyRoutes(),
}) as unknown as typeof fetch;
const user = userEvent.setup();
renderDashboard();

await waitFor(() => {
expect(screen.getByText("No expenses yet")).toBeInTheDocument();
});

expect(screen.getByText("Repeated Expenses")).toBeInTheDocument();
expect(
screen.getByText(/Not enough expense history yet/i),
).toBeInTheDocument();
// Switch to Repeated Expenses via the breakdown Select
const breakdownTrigger = screen.getByLabelText("Select breakdown chart");
await user.click(breakdownTrigger);
const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" });
await user.click(repeatedOption);

await waitFor(() => {
expect(
screen.getByText(/Not enough expense history yet/i),
).toBeInTheDocument();
});
});

it("keeps other dashboard sections rendering when repeated-expenses fetch fails", async () => {
Expand All @@ -309,16 +328,24 @@ describe("DashboardFeature", () => {
body: { code: "INTERNAL_SERVER_ERROR", message: "Suggestions failed" },
},
}) as unknown as typeof fetch;
const user = userEvent.setup();
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Recent Expenses")).toBeInTheDocument();
expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1);
});

expect(screen.getByText("Repeated Expenses")).toBeInTheDocument();
expect(
screen.getByText("Repeated expenses are unavailable right now."),
).toBeInTheDocument();
// Switch to Repeated Expenses via the breakdown Select
const breakdownTrigger = screen.getByLabelText("Select breakdown chart");
await user.click(breakdownTrigger);
const repeatedOption = await screen.findByRole("option", { name: "Repeated Expenses" });
await user.click(repeatedOption);

await waitFor(() => {
expect(
screen.getByText("Repeated expenses are unavailable right now."),
).toBeInTheDocument();
});
expect(screen.queryByText("Suggestions failed")).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -712,7 +739,7 @@ describe("DashboardFeature", () => {
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Recent Expenses")).toBeInTheDocument();
expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1);
});

expect(screen.getAllByText("Groceries").length).toBeGreaterThan(0);
Expand All @@ -729,7 +756,7 @@ describe("DashboardFeature", () => {
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Recent Expenses")).toBeInTheDocument();
expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1);
});

const viewAllLink = screen.getByRole("link", { name: /view all/i });
Expand All @@ -744,7 +771,7 @@ describe("DashboardFeature", () => {
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Recent Expenses")).toBeInTheDocument();
expect(screen.getAllByText("Recent Expenses").length).toBeGreaterThanOrEqual(1);
});

// totalSpent from summary: 54500 cents = $545.00
Expand Down Expand Up @@ -859,7 +886,7 @@ describe("DashboardFeature", () => {
renderDashboard();

await waitFor(() => {
expect(screen.getByText("Spending Pace")).toBeInTheDocument();
expect(screen.getAllByText("Spending Pace").length).toBeGreaterThanOrEqual(1);
});

expect(screen.getByText("Daily Average")).toBeInTheDocument();
Expand Down Expand Up @@ -972,11 +999,15 @@ describe("DashboardFeature", () => {
const { container } = renderDashboard();

await waitFor(() => {
expect(screen.getByText("Spending by Tag")).toBeInTheDocument();
expect(screen.getByLabelText("Select breakdown chart")).toBeInTheDocument();
});

// Charts are wrapped in a hidden md:block container
const hiddenCharts = container.querySelector(".hidden.md\\:block");
expect(hiddenCharts).not.toBeNull();
// Spending Pace + Historical Comparison are in a hidden md:grid container
const gridContainer = container.querySelector(".hidden.md\\:grid");
expect(gridContainer).not.toBeNull();
});
});

Expand Down Expand Up @@ -1057,7 +1088,7 @@ describe("DashboardFeature", () => {
expect(screen.getByTestId("historical-comparison")).toBeInTheDocument();
});

expect(screen.getByText("Historical Comparison")).toBeInTheDocument();
expect(screen.getAllByText("Historical Comparison").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Current Period")).toBeInTheDocument();
expect(screen.getByText("Previous Period")).toBeInTheDocument();
expect(screen.getByText("$480.00")).toBeInTheDocument();
Expand Down
Loading
Loading