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
10 changes: 8 additions & 2 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions e2e/tests/admin-assumption.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand Down
6 changes: 4 additions & 2 deletions e2e/tests/expense-autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand Down
3 changes: 2 additions & 1 deletion e2e/tests/mobile-expense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand Down
1 change: 1 addition & 0 deletions e2e/tests/pro-rata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions e2e/tests/registration-onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -343,14 +344,30 @@ 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}`);
const observer = intersectionObservers.at(-1);
if (!observer) throw new Error("Missing IntersectionObserver");

observer.callback(
[{ target: element, isIntersecting } as IntersectionObserverEntry],
[buildIntersectionObserverEntry(element, isIntersecting)],
observer as unknown as IntersectionObserver,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
88 changes: 88 additions & 0 deletions frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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);
});
}
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./fixtures";
export * from "./fetch";
Loading
Loading