diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 147a3aac..e3a1f959 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -103,7 +103,9 @@ export async function confirmNewPeriod(page: Page) { /** * Log an expense via the /expenses/new form. * Browser should already be authenticated. Navigates to /expenses/new, - * fills the form, and submits. Ends on /dashboard after redirect. + * fills the form, and submits. On success the form stays on /expenses/new + * and shows a success toast; callers that need to verify dashboard state + * must navigate to /dashboard themselves. */ export async function logExpense( page: Page, @@ -129,7 +131,11 @@ export async function logExpense( } await page.getByRole("button", { name: "Log Expense" }).click(); - await page.waitForURL("**/dashboard"); + + const successToast = expense.proRata + ? "Expense schedule saved" + : "Expense saved"; + await expect(page.getByText(successToast)).toBeVisible(); } function capitalize(str: string): string { diff --git a/e2e/tests/admin-assumption.spec.ts b/e2e/tests/admin-assumption.spec.ts index 49c18ca9..b59bc090 100644 --- a/e2e/tests/admin-assumption.spec.ts +++ b/e2e/tests/admin-assumption.spec.ts @@ -22,6 +22,7 @@ test.describe("Admin Identity Assumption", () => { }); // Verify the expense is visible on the regular user's dashboard + await page.goto("/dashboard"); const regularUserRecentExpenses = page .getByText("Recent Expenses") .locator('xpath=ancestor::*[@data-slot="card"][1]'); diff --git a/e2e/tests/expense-autocomplete.spec.ts b/e2e/tests/expense-autocomplete.spec.ts index 718951a3..68ff4400 100644 --- a/e2e/tests/expense-autocomplete.spec.ts +++ b/e2e/tests/expense-autocomplete.spec.ts @@ -46,8 +46,9 @@ test.describe("Expense autocomplete smoke", () => { await page.getByLabel("Amount").fill("13.25"); await page.getByRole("button", { name: "Log Expense" }).click(); - await page.waitForURL("**/dashboard"); + await expect(page.getByText("Expense saved")).toBeVisible(); + await page.goto("/dashboard"); await expect(page.getByText("Autocomplete Coffee").first()).toBeVisible(); await expect(page.getByText("$13.25").first()).toBeVisible(); }); @@ -69,8 +70,9 @@ test.describe("Expense autocomplete smoke", () => { await page.getByLabel("Amount").fill("8.25"); await page.getByLabel("Essentials").check(); await page.getByRole("button", { name: "Log Expense" }).click(); - await page.waitForURL("**/dashboard"); + await expect(page.getByText("Expense saved")).toBeVisible(); + await page.goto("/dashboard"); await expect(page.getByText("Manual Fallback Expense").first()).toBeVisible(); await expect(page.getByText("$8.25").first()).toBeVisible(); }); diff --git a/e2e/tests/mobile-expense.spec.ts b/e2e/tests/mobile-expense.spec.ts index c8f0e12a..e2640d6f 100644 --- a/e2e/tests/mobile-expense.spec.ts +++ b/e2e/tests/mobile-expense.spec.ts @@ -36,9 +36,10 @@ test.describe("Mobile Expense Logging", () => { await page.getByLabel("Amount").fill("5.50"); await page.getByLabel("Essentials").check(); await page.getByRole("button", { name: "Log Expense" }).click(); - await page.waitForURL("**/dashboard"); + await expect(page.getByText("Expense saved")).toBeVisible(); // Step 3: Verify the expense appears on the dashboard + await page.goto("/dashboard"); const recentExpenses = page .getByText("Recent Expenses") .locator('xpath=ancestor::*[@data-slot="card"][1]'); diff --git a/e2e/tests/pro-rata.spec.ts b/e2e/tests/pro-rata.spec.ts index cc5daf48..46a99e9d 100644 --- a/e2e/tests/pro-rata.spec.ts +++ b/e2e/tests/pro-rata.spec.ts @@ -23,6 +23,7 @@ test.describe("Pro-rata Creation", () => { // Step 2: Verify current month shows $100 installment in recent expenses // The pro-rata splits $300 into 3 monthly installments of $100 + await page.goto("/dashboard"); await expect(page.getByText("Annual Subscription").first()).toBeVisible(); await expect(page.getByText("$100.00").first()).toBeVisible(); diff --git a/e2e/tests/registration-onboarding.spec.ts b/e2e/tests/registration-onboarding.spec.ts index 6de69c7f..4fa1fef5 100644 --- a/e2e/tests/registration-onboarding.spec.ts +++ b/e2e/tests/registration-onboarding.spec.ts @@ -35,6 +35,7 @@ test.describe("Registration → Onboarding → First Expense", () => { // Step 5: Verify the dashboard reflects the new expense // Recent expenses section should show the expense + await page.goto("/dashboard"); const recentExpenses = page .getByText("Recent Expenses") .locator('xpath=ancestor::*[@data-slot="card"][1]'); diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx index fc5129cc..3a1910f4 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx @@ -39,6 +39,7 @@ class MockMutationObserver implements MutationObserver { class MockIntersectionObserver implements IntersectionObserver { readonly root = null; readonly rootMargin = ""; + readonly scrollMargin = ""; readonly thresholds = []; callback: IntersectionObserverCallback; observe = vi.fn(); @@ -343,6 +344,22 @@ function mockVisibleMeasurements(root: HTMLElement): void { } } +function buildIntersectionObserverEntry( + target: Element, + isIntersecting: boolean, +): IntersectionObserverEntry { + const rect = target.getBoundingClientRect(); + return { + target, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + boundingClientRect: rect, + intersectionRect: rect, + rootBounds: null, + time: 0, + }; +} + function emitIntersection(id: string, isIntersecting: boolean): void { const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); @@ -350,7 +367,7 @@ function emitIntersection(id: string, isIntersecting: boolean): void { if (!observer) throw new Error("Missing IntersectionObserver"); observer.callback( - [{ target: element, isIntersecting } as IntersectionObserverEntry], + [buildIntersectionObserverEntry(element, isIntersecting)], observer as unknown as IntersectionObserver, ); } diff --git a/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx b/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx index 13758632..e2365a6b 100644 --- a/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx +++ b/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx @@ -22,7 +22,7 @@ import { useNewExpenseForm, EXPENSE_TYPES } from "./hooks/useNewExpenseForm"; /** * New expense form page. Allows users to log a standard expense. * Amount is entered in dollars (with decimals) and converted to cents - * before submission. On success, redirects to /dashboard. + * before submission. On success, shows feedback and resets the form. */ export function NewExpenseFeature({ user }: FinancePageProps) { const currencySymbol = getCurrencySymbol(user.currency); diff --git a/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts b/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts new file mode 100644 index 00000000..f13bd3a4 --- /dev/null +++ b/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts @@ -0,0 +1,88 @@ +import { vi } from "vitest"; + +import { mockTags, emptySuggestions } from "./fixtures"; + +// Shared fetch mock. Each test file resets this in beforeEach and layers its +// own mockResolvedValueOnce/mockRejectedValueOnce/mockImplementation as needed. +export const mockFetch = vi.fn(); +global.fetch = mockFetch; + +export function jsonResponse(body: unknown, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); +} + +type FetchResult = Promise; +export type FetchResponder = (url: string, init?: RequestInit) => FetchResult; +export type ResponderOrResult = FetchResponder | FetchResult; + +function toResponder(value: ResponderOrResult): FetchResponder { + return typeof value === "function" ? value : () => value; +} + +/** Per-endpoint overrides for {@link setNewExpenseFetchMock}. */ +export interface NewExpenseFetchOverrides { + tags?: ResponderOrResult; + suggestions?: ResponderOrResult; + expensePost?: ResponderOrResult; + proRataPost?: ResponderOrResult; + fallback?: ResponderOrResult; +} + +/** + * Assigns a default `mockFetch.mockImplementation` that handles the four + * endpoints touched by the new-expense form (tags, suggestions, expense POST, + * pro-rata POST). Each endpoint can be overridden with either a responder + * function or a pre-built response promise. Unhandled requests resolve to 404. + */ +export function setNewExpenseFetchMock( + overrides: NewExpenseFetchOverrides = {}, +): void { + const respondTags = toResponder( + overrides.tags ?? jsonResponse({ tags: mockTags }), + ); + const respondSuggestions = toResponder( + overrides.suggestions ?? jsonResponse(emptySuggestions), + ); + const respondExpensePost = toResponder( + overrides.expensePost ?? + jsonResponse( + { + expense: { + id: "exp-123", + name: "Coffee", + amount: 450, + currency: "USD", + expenseType: "desires", + status: "active", + }, + }, + 201, + ), + ); + const respondProRataPost = toResponder( + overrides.proRataPost ?? jsonResponse({ schedule: { id: "prorata-1" } }, 201), + ); + const respondFallback = toResponder( + overrides.fallback ?? jsonResponse({ message: "Unhandled request" }, 404), + ); + + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url.includes("/api/finance/tags")) { + return respondTags(url, init); + } + if (url.includes("/api/expenses/suggestions")) { + return respondSuggestions(url, init); + } + if (url.includes("/api/finance/prorata") && init?.method === "POST") { + return respondProRataPost(url, init); + } + if (url.includes("/api/expenses") && init?.method === "POST") { + return respondExpensePost(url, init); + } + return respondFallback(url, init); + }); +} diff --git a/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts b/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts new file mode 100644 index 00000000..3d2fe4f1 --- /dev/null +++ b/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts @@ -0,0 +1,54 @@ +import { buildUser, buildTag } from "@gofin/test-utils"; + +import type { ExpenseSuggestionsResponse } from "../../expense-autocomplete"; +import type { Tag } from "../../../types"; + +export const mockUser = buildUser({ + id: "user-1", + username: "alice", + email: "alice@example.com", +}); + +export const mockTags: Tag[] = [ + buildTag({ id: "tag-bills", name: "Bills", isDefault: true }), + buildTag({ id: "tag-food", name: "Food", isDefault: true }), +]; + +export const mockSuggestions: ExpenseSuggestionsResponse = { + data: [ + { + name: "Coffee Shop", + amount: 450, + currency: "USD", + expenseType: "desires", + tagId: "tag-food", + frequency: 4, + lastUsedAt: "2026-05-25T00:00:00Z", + recencyBucket: "last_7_days", + frecencyScore: 42, + }, + { + name: "Coffee Beans", + amount: 1200, + currency: "USD", + expenseType: "essentials", + tagId: "tag-food", + frequency: 2, + lastUsedAt: "2026-05-20T00:00:00Z", + recencyBucket: "last_30_days", + frecencyScore: 31, + }, + ], + total: 2, + page: 1, + pageSize: 50, + hasMore: false, +}; + +export const emptySuggestions: ExpenseSuggestionsResponse = { + data: [], + total: 0, + page: 1, + pageSize: 50, + hasMore: false, +}; diff --git a/frontend/apps/finance/src/features/new-expense/__mocks__/index.ts b/frontend/apps/finance/src/features/new-expense/__mocks__/index.ts new file mode 100644 index 00000000..ac483565 --- /dev/null +++ b/frontend/apps/finance/src/features/new-expense/__mocks__/index.ts @@ -0,0 +1,2 @@ +export * from "./fixtures"; +export * from "./fetch"; diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx index a1bd2cde..2f2904fb 100644 --- a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx +++ b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx @@ -1,14 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router"; -import type { User } from "@gofin/core"; +import { toast } from "sonner"; -import { NewExpenseFeature } from "../index"; import type { ExpenseSuggestionsResponse } from "../../expense-autocomplete"; +import { + jsonResponse, + mockFetch, + mockSuggestions, + mockTags, +} from "../__mocks__"; +import { + getSubmittedExpenseRequest, + renderNewExpense as renderNewExpenseFeature, +} from "./test-utils"; + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); -const mockFetch = vi.fn(); -global.fetch = mockFetch; +const mockToastSuccess = vi.mocked(toast.success); +const mockToastError = vi.mocked(toast.error); const mockNavigate = vi.fn(); vi.mock("react-router", async () => { @@ -19,127 +34,25 @@ vi.mock("react-router", async () => { }; }); -const mockUser: User = { - id: "user-1", - username: "alice", - email: "alice@example.com", - role: "user", - currency: "USD", - hasCompletedOnboarding: true, - createdAt: "2026-01-01T00:00:00Z", -}; - -const mockTags = [ - { - id: "tag-bills", - name: "Bills", - isDefault: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - { - id: "tag-food", - name: "Food", - isDefault: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, -]; - -const mockSuggestions: ExpenseSuggestionsResponse = { - data: [ - { - name: "Coffee Shop", - amount: 450, - currency: "USD", - expenseType: "desires", - tagId: "tag-food", - frequency: 4, - lastUsedAt: "2026-05-25T00:00:00Z", - recencyBucket: "last_7_days", - frecencyScore: 42, - }, - { - name: "Coffee Beans", - amount: 1200, - currency: "USD", - expenseType: "essentials", - tagId: "tag-food", - frequency: 2, - lastUsedAt: "2026-05-20T00:00:00Z", - recencyBucket: "last_30_days", - frecencyScore: 31, - }, - ], - total: 2, - page: 1, - pageSize: 50, - hasMore: false, -}; - -function jsonResponse(body: unknown, status = 200) { - return Promise.resolve({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - }); -} - function renderNewExpense( suggestions: ExpenseSuggestionsResponse = mockSuggestions, tags = mockTags, ) { - mockFetch.mockImplementation((url: string, init?: RequestInit) => { - if (url.includes("/api/finance/tags")) { - return jsonResponse({ tags }); - } - - if (url.includes("/api/expenses/suggestions")) { - return jsonResponse(suggestions); - } - - if (url.includes("/api/expenses") && init?.method === "POST") { - return jsonResponse({ expense: { id: "exp-1", name: "Custom Coffee" } }, 201); - } - - return jsonResponse({ message: "Unhandled request" }, 404); - }); - - return render( - - - , - ); + return renderNewExpenseFeature({ suggestions, tags }); } function renderNewExpenseWithFetchHandler( handler: (url: string, init?: RequestInit) => Promise, ) { - mockFetch.mockImplementation(handler); - - return render( - - - , - ); -} - -function getSubmittedExpenseRequest() { - const postCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("/api/expenses") && - !call[0].includes("/api/expenses/suggestions") && - call[1]?.method === "POST", - ); - - return JSON.parse(postCall?.[1]?.body as string); + return renderNewExpenseFeature({ fetchHandler: handler }); } describe("NewExpenseFeature autocomplete integration", () => { beforeEach(() => { mockFetch.mockReset(); mockNavigate.mockReset(); + mockToastSuccess.mockReset(); + mockToastError.mockReset(); }); it("updates only the name field when typing in the combobox", async () => { @@ -325,7 +238,8 @@ describe("NewExpenseFeature autocomplete integration", () => { await user.type(screen.getByLabelText("Amount"), "5.00"); await user.click(screen.getByRole("button", { name: "Log Expense" })); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/dashboard")); + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved")); + expect(mockNavigate).not.toHaveBeenCalled(); expect(getSubmittedExpenseRequest().name).toBe("Custom Coffee"); }); @@ -355,7 +269,8 @@ describe("NewExpenseFeature autocomplete integration", () => { await user.type(screen.getByLabelText("Amount"), "8.25"); await user.click(screen.getByRole("button", { name: "Log Expense" })); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/dashboard")); + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved")); + expect(mockNavigate).not.toHaveBeenCalled(); expect(getSubmittedExpenseRequest().name).toBe("Manual Expense"); }); @@ -442,6 +357,22 @@ describe("NewExpenseFeature autocomplete integration", () => { expect(screen.getByLabelText("essentials")).toBeChecked(); expect(screen.getByLabelText("Tag")).toHaveValue("tag-bills"); + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url.includes("/api/expenses") && init?.method === "POST") { + return new Promise(() => {}); + } + + if (url.includes("/api/finance/tags")) { + return jsonResponse({ tags: mockTags }); + } + + if (url.includes("/api/expenses/suggestions")) { + return jsonResponse(mockSuggestions); + } + + return jsonResponse({ message: "Unhandled request" }, 404); + }); + await user.clear(screen.getByLabelText("Name")); await user.type(screen.getByLabelText("Name"), "Plain Coffee{Enter}"); await user.tab(); @@ -485,7 +416,8 @@ describe("NewExpenseFeature autocomplete integration", () => { await user.type(screen.getByLabelText("Date"), "2026-05-02"); await user.click(screen.getByRole("button", { name: "Log Expense" })); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/dashboard")); + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved")); + expect(mockNavigate).not.toHaveBeenCalled(); expect(getSubmittedExpenseRequest()).toMatchObject({ name: "Edited Coffee", @@ -503,5 +435,8 @@ describe("NewExpenseFeature autocomplete integration", () => { await user.click(screen.getByRole("button", { name: "Log Expense" })); expect(screen.getByText("Name is required")).toBeInTheDocument(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-prorata.test.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-prorata.test.tsx index 0f069476..f746dc9a 100644 --- a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-prorata.test.tsx +++ b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-prorata.test.tsx @@ -1,12 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router"; -import { NewExpenseFeature } from "../index"; -import type { User } from "@gofin/core"; +import { toast } from "sonner"; -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { jsonResponse, mockFetch, mockTags } from "../__mocks__"; +import { + findProRataPostCall, + renderNewExpense, + waitForFormBootstrap, +} from "./test-utils"; + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const mockToastSuccess = vi.mocked(toast.success); +const mockToastError = vi.mocked(toast.error); const mockNavigate = vi.fn(); vi.mock("react-router", async () => { @@ -17,60 +29,12 @@ vi.mock("react-router", async () => { }; }); -const mockUser: User = { - id: "user-1", - username: "alice", - email: "alice@example.com", - role: "user", - currency: "USD", - hasCompletedOnboarding: true, - createdAt: "2026-01-01T00:00:00Z", -}; - -const mockTags = [ - { id: "tag-bills", name: "Bills", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, - { id: "tag-food", name: "Food", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, -]; - -function mockFormBootstrapResponses() { - mockFetch.mockImplementation((url: string) => { - if (url.includes("/api/finance/tags")) { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ tags: mockTags }), - }); - } - - if (url.includes("/api/expenses/suggestions")) { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: [], total: 0, page: 1, pageSize: 50, hasMore: false }), - }); - } - - return Promise.resolve({ - ok: false, - status: 404, - json: () => Promise.resolve({ message: "Unhandled request" }), - }); - }); -} - -function renderNewExpense() { - mockFormBootstrapResponses(); - return render( - - - , - ); -} - describe("NewExpenseFeature - Pro-rata flow", () => { beforeEach(() => { mockFetch.mockReset(); mockNavigate.mockReset(); + mockToastSuccess.mockReset(); + mockToastError.mockReset(); }); it("shows pro-rata months field when checkbox is toggled", async () => { @@ -107,9 +71,7 @@ describe("NewExpenseFeature - Pro-rata flow", () => { const user = userEvent.setup(); renderNewExpense(); - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); - }); + await waitForFormBootstrap(); await user.type(screen.getByLabelText("Name"), "Annual subscription"); await user.type(screen.getByLabelText("Amount"), "120.00"); @@ -123,15 +85,16 @@ describe("NewExpenseFeature - Pro-rata flow", () => { await waitFor(() => { expect(screen.getByText("Must be at least 2 months")).toBeInTheDocument(); }); + expect(findProRataPostCall()).toBeUndefined(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); }); - it("submits pro-rata expense to /api/finance/prorata endpoint", async () => { + it("submits pro-rata expense and resets visible pro-rata state", async () => { const user = userEvent.setup(); renderNewExpense(); - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); - }); + await waitForFormBootstrap(); mockFetch.mockResolvedValueOnce({ ok: true, @@ -160,30 +123,92 @@ describe("NewExpenseFeature - Pro-rata flow", () => { await user.click(screen.getByRole("button", { name: "Log Expense" })); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + expect(mockToastSuccess).toHaveBeenCalledWith("Expense schedule saved"); }); + expect(mockNavigate).not.toHaveBeenCalled(); - const proRataCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("/api/finance/prorata") && - call[1]?.method === "POST", - ); + const proRataCall = findProRataPostCall(); expect(proRataCall).toBeDefined(); const body = JSON.parse(proRataCall![1].body); expect(body.totalAmount).toBe(12000); expect(body.months).toBe(3); expect(body.name).toBe("Annual subscription"); expect(body.expenseType).toBe("savings"); + + await waitFor(() => { + expect(screen.getByLabelText("Spread across months")).not.toBeChecked(); + expect(screen.queryByLabelText("Number of months")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Name")).toHaveValue(""); + expect(screen.getByLabelText("Amount")).toHaveValue(null); + expect(screen.getByLabelText("essentials")).toBeChecked(); + }); }); - it("shows network error message when network error occurs", async () => { + it("uses the submitted request kind for in-flight pro-rata success toast", async () => { const user = userEvent.setup(); renderNewExpense(); + await waitForFormBootstrap(); + + let resolvePost!: (response: unknown) => void; + const pendingPost = new Promise((resolve) => { + resolvePost = resolve; + }); + + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url.includes("/api/finance/prorata") && init?.method === "POST") { + return pendingPost; + } + + if (url.includes("/api/finance/tags")) { + return jsonResponse({ tags: mockTags }); + } + + if (url.includes("/api/expenses/suggestions")) { + return jsonResponse({ data: [], total: 0, page: 1, pageSize: 50, hasMore: false }); + } + + return jsonResponse({ message: "Unhandled request" }, 404); + }); + + await user.type(screen.getByLabelText("Name"), "Annual subscription"); + await user.type(screen.getByLabelText("Amount"), "120.00"); + await user.click(screen.getByLabelText("Spread across months")); + await user.type(screen.getByLabelText("Number of months"), "3"); + + await user.click(screen.getByRole("button", { name: "Log Expense" })); + await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); + expect(findProRataPostCall()).toBeDefined(); + }); + const proRataCall = findProRataPostCall(); + const body = JSON.parse(proRataCall![1].body); + expect(body).toMatchObject({ + name: "Annual subscription", + totalAmount: 12000, + months: 3, + }); + + await user.click(screen.getByLabelText("Spread across months")); + + resolvePost({ + ok: true, + status: 201, + json: () => Promise.resolve({ schedule: { id: "prorata-1" } }), + }); + + await waitFor(() => { + expect(mockToastSuccess).toHaveBeenCalledWith("Expense schedule saved"); }); + expect(mockToastSuccess).not.toHaveBeenCalledWith("Expense saved"); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("shows network error message and generic failure toast when network error occurs", async () => { + const user = userEvent.setup(); + renderNewExpense(); + + await waitForFormBootstrap(); mockFetch.mockRejectedValueOnce(new TypeError("Network error")); @@ -197,6 +222,9 @@ describe("NewExpenseFeature - Pro-rata flow", () => { screen.getByText("Connection lost. Check your internet and try again."), ).toBeInTheDocument(); }); + expect(mockToastError).toHaveBeenCalledWith("Failed to save expense"); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it("clears pro-rata months when checkbox is unchecked", async () => { diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx index c20a32c5..b1102330 100644 --- a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx +++ b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx @@ -1,12 +1,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router"; -import { NewExpenseFeature } from "../index"; -import type { User } from "@gofin/core"; - -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { toast } from "sonner"; + +import { toLocalISODate } from "../../../lib/date-utils"; +import { + jsonResponse, + mockFetch, + mockUser, + setNewExpenseFetchMock, +} from "../__mocks__"; +import { + countFetchCalls, + findExpensePostCall, + renderNewExpense, + waitForFormBootstrap, +} from "./test-utils"; + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const mockToastSuccess = vi.mocked(toast.success); +const mockToastError = vi.mocked(toast.error); // Mock useNavigate const mockNavigate = vi.fn(); @@ -18,84 +37,25 @@ vi.mock("react-router", async () => { }; }); -const mockUser: User = { - id: "user-1", - username: "alice", - email: "alice@example.com", - role: "user", - currency: "USD", - hasCompletedOnboarding: true, - createdAt: "2026-01-01T00:00:00Z", -}; - -const mockTags = [ - { id: "tag-bills", name: "Bills", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, - { id: "tag-food", name: "Food", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, -]; - -function jsonResponse(body: unknown, status = 200) { - return Promise.resolve({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - }); -} - -let expensePostResponse: () => Promise; - -function setupFetchMocks() { - expensePostResponse = () => - jsonResponse({ - expense: { - id: "exp-123", - name: "Coffee", - amount: 450, - currency: "USD", - expenseType: "desires", - status: "active", - }, - }, 201); - - mockFetch.mockImplementation((url: string, init?: RequestInit) => { - if (url.includes("/api/expenses/suggestions")) { - return jsonResponse({ - data: [], - total: 0, - page: 1, - pageSize: 50, - hasMore: false, - }); - } - - if (url.includes("/api/finance/tags")) { - return jsonResponse({ tags: mockTags }); - } - - if (url.includes("/api/expenses") && init?.method === "POST") { - return expensePostResponse(); - } - - return jsonResponse({ message: "Unhandled request" }, 404); - }); -} - -function renderNewExpense(user: User = mockUser) { - setupFetchMocks(); - return render( - - - , - ); +function expectFormResetToFreshDefaults() { + expect(screen.getByLabelText("Name")).toHaveValue(""); + expect(screen.getByLabelText("Amount")).toHaveValue(null); + expect(screen.getByLabelText("essentials")).toBeChecked(); + expect(screen.getByLabelText("Tag")).toHaveValue("tag-bills"); + expect(screen.getByLabelText("Date")).toHaveValue(toLocalISODate()); } describe("NewExpenseFeature", () => { beforeEach(() => { mockFetch.mockReset(); mockNavigate.mockReset(); + mockToastSuccess.mockReset(); + mockToastError.mockReset(); }); - it("renders the expense form with all fields", () => { + it("renders the expense form with all fields", async () => { renderNewExpense(); + await waitForFormBootstrap(); expect(screen.getByText("New Expense")).toBeInTheDocument(); expect(screen.getByLabelText("Name")).toBeInTheDocument(); @@ -114,17 +74,16 @@ describe("NewExpenseFeature", () => { ).toBeInTheDocument(); }); - it("defaults date to today", () => { + it("defaults date to today", async () => { renderNewExpense(); + await waitForFormBootstrap(); - const dateInput = screen.getByLabelText("Date") as HTMLInputElement; - const today = new Date(); - const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; - expect(dateInput.value).toBe(expectedDate); + expect(screen.getByLabelText("Date")).toHaveValue(toLocalISODate()); }); - it("defaults expense type to essentials", () => { + it("defaults expense type to essentials", async () => { renderNewExpense(); + await waitForFormBootstrap(); const essentialsRadio = screen.getByLabelText( "essentials", @@ -132,8 +91,9 @@ describe("NewExpenseFeature", () => { expect(essentialsRadio.checked).toBe(true); }); - it("displays currency symbol from user profile", () => { - renderNewExpense({ ...mockUser, currency: "EUR" }); + it("displays currency symbol from user profile", async () => { + renderNewExpense({ user: { ...mockUser, currency: "EUR" } }); + await waitForFormBootstrap(); expect(screen.getByText("€")).toBeInTheDocument(); }); @@ -150,6 +110,9 @@ describe("NewExpenseFeature", () => { expect( screen.getByText("Amount must be greater than 0"), ).toBeInTheDocument(); + expect(findExpensePostCall()).toBeUndefined(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); }); it("shows validation error when amount is not entered", async () => { @@ -166,16 +129,16 @@ describe("NewExpenseFeature", () => { expect( screen.getByText("Amount must be greater than 0"), ).toBeInTheDocument(); + expect(findExpensePostCall()).toBeUndefined(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); }); it("converts dollar amount to cents and submits", async () => { const user = userEvent.setup(); renderNewExpense(); - // Wait for tags to load - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); - }); + await waitForFormBootstrap(); await user.type(screen.getByLabelText("Name"), "Coffee"); await user.type(screen.getByLabelText("Amount"), "4.50"); @@ -184,16 +147,12 @@ describe("NewExpenseFeature", () => { await user.click(screen.getByRole("button", { name: "Log Expense" })); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved"); }); + expect(mockNavigate).not.toHaveBeenCalled(); // Verify the POST request body - const postCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("/api/expenses") && - call[1]?.method === "POST", - ); + const postCall = findExpensePostCall(); expect(postCall).toBeDefined(); const body = JSON.parse(postCall![1].body); expect(body.amount).toBe(450); // $4.50 = 450 cents @@ -202,46 +161,59 @@ describe("NewExpenseFeature", () => { expect(body.currency).toBe("USD"); }); - it("redirects to /dashboard on successful submission", async () => { + it("resets standard success to fresh defaults without refetching bootstrap data", async () => { const user = userEvent.setup(); renderNewExpense(); - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); - }); + await waitForFormBootstrap(); + const tagFetchCount = countFetchCalls("/api/finance/tags"); + const suggestionsFetchCount = countFetchCalls("/api/expenses/suggestions"); - expensePostResponse = () => - jsonResponse({ - expense: { id: "exp-123", name: "Groceries", status: "active" }, - }, 201); + setNewExpenseFetchMock({ + expensePost: () => + jsonResponse({ + expense: { id: "exp-123", name: "Groceries", status: "active" }, + }, 201), + }); await user.type(screen.getByLabelText("Name"), "Groceries"); await user.type(screen.getByLabelText("Amount"), "25.00"); + await user.click(screen.getByLabelText("desires")); + await user.selectOptions(screen.getByLabelText("Tag"), "tag-food"); + await user.clear(screen.getByLabelText("Date")); + await user.type(screen.getByLabelText("Date"), "2026-05-01"); await user.click(screen.getByRole("button", { name: "Log Expense" })); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved"); }); + expect(mockNavigate).not.toHaveBeenCalled(); + + await waitFor(() => { + expectFormResetToFreshDefaults(); + }); + expect(countFetchCalls("/api/finance/tags")).toBe(tagFetchCount); + expect(countFetchCalls("/api/expenses/suggestions")).toBe(suggestionsFetchCount); }); - it("shows API error message on submission failure", async () => { + it("shows API error message and generic failure toast on submission failure", async () => { const user = userEvent.setup(); renderNewExpense(); - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); + await waitForFormBootstrap(); + + setNewExpenseFetchMock({ + expensePost: () => + jsonResponse( + { + code: "VALIDATION_ERROR", + message: "amount must be positive", + }, + 400, + ), }); - expensePostResponse = () => - jsonResponse( - { - code: "VALIDATION_ERROR", - message: "amount must be positive", - }, - 400, - ); - await user.type(screen.getByLabelText("Name"), "Coffee"); await user.type(screen.getByLabelText("Amount"), "5.00"); @@ -251,20 +223,19 @@ describe("NewExpenseFeature", () => { expect(screen.getByText("amount must be positive")).toBeInTheDocument(); }); - // Should NOT navigate on error + expect(mockToastError).toHaveBeenCalledWith("Failed to save expense"); + expect(mockToastSuccess).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); }); - it("disables submit button while submitting", async () => { + it("disables only the submit button while submitting", async () => { const user = userEvent.setup(); renderNewExpense(); - await waitFor(() => { - expect(screen.getByLabelText("Tag")).not.toHaveTextContent("Loading tags..."); - }); + await waitForFormBootstrap(); // Never-resolving promise to keep the submitting state - expensePostResponse = () => new Promise(() => {}); + setNewExpenseFetchMock({ expensePost: () => new Promise(() => {}) }); await user.type(screen.getByLabelText("Name"), "Coffee"); await user.type(screen.getByLabelText("Amount"), "5.00"); @@ -273,6 +244,11 @@ describe("NewExpenseFeature", () => { await user.click(submitButton); expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); + expect(screen.getByLabelText("Name")).toBeEnabled(); + expect(screen.getByLabelText("Amount")).toBeEnabled(); + expect(screen.getByLabelText("Date")).toBeEnabled(); + expect(screen.getByLabelText("Tag")).toBeEnabled(); + expect(screen.getByLabelText("Spread across months")).toBeEnabled(); }); it("allows selecting different expense types", async () => { diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/test-utils.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/test-utils.tsx new file mode 100644 index 00000000..ea2adb13 --- /dev/null +++ b/frontend/apps/finance/src/features/new-expense/__tests__/test-utils.tsx @@ -0,0 +1,90 @@ +import { expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import type { User } from "@gofin/core"; + +import { NewExpenseFeature } from "../index"; +import { + jsonResponse, + mockFetch, + mockUser, + setNewExpenseFetchMock, +} from "../__mocks__"; +import type { FetchResponder, ResponderOrResult } from "../__mocks__"; +import type { ExpenseSuggestionsResponse } from "../../expense-autocomplete"; +import type { Tag } from "../../../types"; + +export function countFetchCalls(path: string): number { + return mockFetch.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes(path), + ).length; +} + +export function findExpensePostCall() { + return mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("/api/expenses") && + !call[0].includes("/api/expenses/suggestions") && + call[1]?.method === "POST", + ); +} + +export function findProRataPostCall() { + return mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("/api/finance/prorata") && + call[1]?.method === "POST", + ); +} + +export function getSubmittedExpenseRequest() { + const postCall = findExpensePostCall(); + return JSON.parse(postCall?.[1]?.body as string); +} + +export async function waitForFormBootstrap() { + await waitFor(() => { + expect(screen.getByLabelText("Tag")).toHaveValue("tag-bills"); + expect(countFetchCalls("/api/expenses/suggestions")).toBe(1); + }); +} + +/** Options for {@link renderNewExpense}. */ +export interface RenderNewExpenseOptions { + user?: User; + tags?: Tag[]; + suggestions?: ExpenseSuggestionsResponse; + expensePost?: ResponderOrResult; + proRataPost?: ResponderOrResult; + /** Full escape hatch: replace the fetch mock implementation entirely. */ + fetchHandler?: FetchResponder; +} + +/** + * Wires up the shared fetch mock and renders the new-expense feature inside a + * router. Pass `suggestions`/`tags` to customize bootstrap data, per-endpoint + * overrides for POST behavior, or a full `fetchHandler` for custom routing. + */ +export function renderNewExpense(options: RenderNewExpenseOptions = {}) { + const { user = mockUser, tags, suggestions, expensePost, proRataPost, fetchHandler } = + options; + + if (fetchHandler) { + mockFetch.mockImplementation(fetchHandler); + } else { + setNewExpenseFetchMock({ + tags: tags ? jsonResponse({ tags }) : undefined, + suggestions: suggestions ? jsonResponse(suggestions) : undefined, + expensePost, + proRataPost, + }); + } + + return render( + + + , + ); +} diff --git a/frontend/apps/finance/src/features/new-expense/hooks/useExpenseFields.ts b/frontend/apps/finance/src/features/new-expense/hooks/useExpenseFields.ts index c0b882bd..58670a70 100644 --- a/frontend/apps/finance/src/features/new-expense/hooks/useExpenseFields.ts +++ b/frontend/apps/finance/src/features/new-expense/hooks/useExpenseFields.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import type { ExpenseFields, ValidateExpenseOptions } from "../../../lib/validate-expense-fields"; import { validateExpenseFields } from "../../../lib/validate-expense-fields"; +import { toLocalISODate } from "../../../lib/date-utils"; /** Initial values for useExpenseFields. All fields optional: unset fields use defaults. */ export interface ExpenseFieldsInit { @@ -29,21 +30,13 @@ export interface UseExpenseFieldsResult { amountCents: number; } -function todayISO(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - function buildInitialFields(init?: ExpenseFieldsInit): ExpenseFields { return { name: init?.name ?? "", amountDollars: init?.amountDollars ?? "", expenseType: init?.expenseType ?? "essentials", tagId: init?.tagId ?? "", - expenseDate: init?.expenseDate ?? todayISO(), + expenseDate: init?.expenseDate ?? toLocalISODate(), }; } diff --git a/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts b/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts index bf9f7e79..fa9a3cf6 100644 --- a/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts +++ b/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts @@ -1,6 +1,6 @@ -import { useState, useEffect, type FormEvent } from "react"; -import { useNavigate } from "react-router"; +import { useState, useEffect, type SyntheticEvent } from "react"; import { apiClient, useFormMutation } from "@gofin/api"; +import { toast } from "sonner"; import { EXPENSE_TYPES, type ExpenseType } from "@gofin/core"; import type { ExpenseFields } from "../../../lib/validate-expense-fields"; import type { @@ -15,11 +15,21 @@ import { createExpenseSuggestionPatch, type ExpenseSuggestion, } from "../../expense-autocomplete"; +import type { SubmittedExpenseKind } from "../types"; import { useExpenseFields } from "./useExpenseFields"; export { EXPENSE_TYPES }; export type { ExpenseType, ExpenseFields }; +const SUCCESS_TOAST_BY_KIND: Record = { + standard: "Expense saved", + proRata: "Expense schedule saved", +}; + +function getDefaultTagId(tags: Tag[]): string { + return tags[0]?.id ?? ""; +} + export interface NewExpenseFormState { tags: Tag[]; tagsLoading: boolean; @@ -37,7 +47,7 @@ export interface NewExpenseFormActions { setIsProRata: (checked: boolean) => void; setProRataMonths: (value: string) => void; applySuggestion: (suggestion: ExpenseSuggestion) => void; - handleSubmit: (event: FormEvent) => void; + handleSubmit: (event: SyntheticEvent) => void; } /** @@ -52,7 +62,6 @@ export function useNewExpenseForm(currency: string): { state: NewExpenseFormState; actions: NewExpenseFormActions; } { - const navigate = useNavigate(); const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth() + 1; @@ -64,8 +73,20 @@ export function useNewExpenseForm(currency: string): { const [isProRata, setIsProRata] = useState(false); const [proRataMonths, setProRataMonths] = useState(""); - const mutation = useFormMutation({ - onSuccess: () => navigate("/dashboard"), + const resetNewExpenseVisibleState = () => { + expenseFields.reset({ tagId: getDefaultTagId(tags) }); + setIsProRata(false); + setProRataMonths(""); + }; + + const mutation = useFormMutation({ + onSuccess: (submittedKind) => { + toast.success(SUCCESS_TOAST_BY_KIND[submittedKind]); + resetNewExpenseVisibleState(); + }, + onError: () => { + toast.error("Failed to save expense"); + }, }); useEffect(() => { @@ -73,8 +94,9 @@ export function useNewExpenseForm(currency: string): { try { const response = await apiClient("/api/finance/tags"); setTags(response.tags); - if (response.tags.length > 0) { - expenseFields.setField("tagId", response.tags[0].id); + const defaultTagId = getDefaultTagId(response.tags); + if (defaultTagId) { + expenseFields.setField("tagId", defaultTagId); } } catch { // Tags fail silently: form will show empty dropdown @@ -106,7 +128,7 @@ export function useNewExpenseForm(currency: string): { } } - function handleSubmit(event: FormEvent) { + function handleSubmit(event: SyntheticEvent) { event.preventDefault(); const isValid = expenseFields.validate({ @@ -132,7 +154,7 @@ export function useNewExpenseForm(currency: string): { method: "POST", body: JSON.stringify(body), }); - return; + return "proRata"; } const body: CreateExpenseRequest = { @@ -149,6 +171,7 @@ export function useNewExpenseForm(currency: string): { method: "POST", body: JSON.stringify(body), }); + return "standard"; }); } diff --git a/frontend/apps/finance/src/features/new-expense/types.ts b/frontend/apps/finance/src/features/new-expense/types.ts new file mode 100644 index 00000000..0aba811a --- /dev/null +++ b/frontend/apps/finance/src/features/new-expense/types.ts @@ -0,0 +1 @@ +export type SubmittedExpenseKind = "standard" | "proRata"; diff --git a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx index 31738ef7..865640d8 100644 --- a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx +++ b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx @@ -344,7 +344,7 @@ describe("ExportDataSection", () => { // Use a fetch that delays the POST response let resolvePost: ((value: Response) => void) | undefined; - const mockFetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const mockFetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { const method = init?.method ?? "GET"; if (method === "POST") { return new Promise((resolve) => { diff --git a/frontend/apps/finance/src/features/settings/hooks/useDefaultBudget.ts b/frontend/apps/finance/src/features/settings/hooks/useDefaultBudget.ts index 990257c2..12e28dfb 100644 --- a/frontend/apps/finance/src/features/settings/hooks/useDefaultBudget.ts +++ b/frontend/apps/finance/src/features/settings/hooks/useDefaultBudget.ts @@ -30,7 +30,7 @@ export function useDefaultBudget(user: User): { state: DefaultBudgetState; actio const [currency, setCurrency] = useState(user.currency); const [success, setSuccess] = useState(false); const [fetching, setFetching] = useState(true); - const timeoutRef = useRef(null); + const timeoutRef = useRef | null>(null); const mutation = useFormMutation({ onSuccess: () => { diff --git a/frontend/apps/finance/src/lib/__tests__/date-utils.test.ts b/frontend/apps/finance/src/lib/__tests__/date-utils.test.ts new file mode 100644 index 00000000..17e34cdd --- /dev/null +++ b/frontend/apps/finance/src/lib/__tests__/date-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { toLocalISODate } from "../date-utils"; + +describe("toLocalISODate", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats an injected date as a local YYYY-MM-DD string", () => { + expect(toLocalISODate(new Date(2026, 0, 5))).toBe("2026-01-05"); + }); + + it("zero-pads single-digit months and days", () => { + expect(toLocalISODate(new Date(2026, 8, 9))).toBe("2026-09-09"); + }); + + it("keeps two-digit months and days unpadded", () => { + expect(toLocalISODate(new Date(2026, 11, 25))).toBe("2026-12-25"); + }); + + it("uses today's local date when called with no argument", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 2, 7, 12, 0, 0)); + + expect(toLocalISODate()).toBe("2026-03-07"); + }); +}); diff --git a/frontend/apps/finance/src/lib/date-utils.ts b/frontend/apps/finance/src/lib/date-utils.ts new file mode 100644 index 00000000..b8521ca4 --- /dev/null +++ b/frontend/apps/finance/src/lib/date-utils.ts @@ -0,0 +1,15 @@ +/** + * Formats a `Date` into a local calendar date string in `YYYY-MM-DD` form. + * + * Uses the local timezone components (`getFullYear`/`getMonth`/`getDate`) + * rather than `toISOString`, which returns the UTC date and can be off by a + * day near midnight depending on the runtime timezone. The returned value is + * therefore the local calendar date, suitable for date inputs and expense + * dates. Defaults to the current date/time. + */ +export function toLocalISODate(date: Date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/frontend/apps/finance/tsconfig.app.json b/frontend/apps/finance/tsconfig.app.json index 9802f57b..0f674d83 100644 --- a/frontend/apps/finance/tsconfig.app.json +++ b/frontend/apps/finance/tsconfig.app.json @@ -7,6 +7,7 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "types": ["vitest/globals", "node"] } }