diff --git a/frontend/src/app/api/recipients/__tests__/reveal.route.test.ts b/frontend/src/app/api/recipients/__tests__/reveal.route.test.ts new file mode 100644 index 00000000..35590ae2 --- /dev/null +++ b/frontend/src/app/api/recipients/__tests__/reveal.route.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { POST } from "../[id]/reveal/route"; +import { NextRequest } from "next/server"; + +// Mock next/headers +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); + +// Mock fetch globally +global.fetch = vi.fn(); + +describe("POST /api/recipients/[id]/reveal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when session token is missing", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue(undefined), + } as any); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Test reason", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(401); + const json = await response.json(); + expect(json.error).toBe("Unauthorized"); + }); + + it("returns 401 when session validation fails", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Invalid token" }), { status: 401 }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Test reason", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(401); + }); + + it("returns 403 when user role is not admin or operator", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ role: "user" }), { status: 200 }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Test reason", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.error).toBe("Forbidden"); + }); + + it("returns 400 when field is missing from request body", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ role: "admin" }), { status: 200 }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + reason: "Test reason", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.error).toContain("field and reason are required"); + }); + + it("returns 400 when reason is missing from request body", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ role: "admin" }), { status: 200 }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(400); + }); + + it("returns 200 with decrypted value when authorized admin requests email", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ role: "admin" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ plaintext: "user@example.com" }), { + status: 200, + }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Customer verification", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.value).toBe("user@example.com"); + }); + + it("returns 200 with decrypted value when authorized operator requests phone", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ role: "operator" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ plaintext: "+1234567890" }), { + status: 200, + }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "phone", + reason: "Contact update", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.value).toBe("+1234567890"); + }); + + it("passes request_id header to backend", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ role: "admin" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ plaintext: "test@example.com" }), { + status: 200, + }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Test", + }), + }); + + await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + const backendCall = vi.mocked(global.fetch).mock.calls[1]; + const fetchBody = JSON.parse(backendCall[1]?.body as string); + expect(fetchBody.request_id).toBeDefined(); + expect(fetchBody.request_id).toMatch(/^[0-9a-f-]+$/); + }); + + it("returns 500 when backend decryption fails", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ role: "admin" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Decryption key not found" }), { + status: 500, + }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "email", + reason: "Test", + }), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + expect(response.status).toBe(500); + }); + + it("includes actor role in backend request", async () => { + const { cookies } = await import("next/headers"); + vi.mocked(cookies).mockResolvedValue({ + get: vi.fn().mockReturnValue({ + value: "valid-token", + }), + } as any); + + vi.mocked(global.fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ role: "operator" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ plaintext: "decrypted" }), { status: 200 }) + ); + + const request = new NextRequest(new URL("http://localhost:3000"), { + method: "POST", + body: JSON.stringify({ + field: "name", + reason: "Verification", + }), + }); + + await POST(request, { + params: Promise.resolve({ id: "recipient-1" }), + }); + + const backendCall = vi.mocked(global.fetch).mock.calls[1]; + const fetchBody = JSON.parse(backendCall[1]?.body as string); + expect(fetchBody.actor).toBe("operator"); + }); +}); diff --git a/frontend/src/app/components/loan-wizard/__tests__/StepFinalSignature.test.tsx b/frontend/src/app/components/loan-wizard/__tests__/StepFinalSignature.test.tsx new file mode 100644 index 00000000..db17efd4 --- /dev/null +++ b/frontend/src/app/components/loan-wizard/__tests__/StepFinalSignature.test.tsx @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { StepFinalSignature } from "../StepFinalSignature"; +import type { LoanWizardData } from "../LoanApplicationWizard"; + +// Mock dependencies +vi.mock("../../providers/WalletProvider", () => ({ + useWallet: vi.fn(() => ({ + signTransaction: vi.fn(), + })), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + })), +})); + +vi.mock("../../../hooks/useTransactionPreview", () => ({ + useTransactionPreview: vi.fn(() => ({ + show: vi.fn(), + })), +})); + +vi.mock("../../../hooks/useContractToast", () => ({ + useContractToast: vi.fn(() => vi.fn()), +})); + +vi.mock("../../../hooks/useApi", () => ({ + submitLoanTransaction: vi.fn(), + queryKeys: {}, +})); + +vi.mock("../../../utils/soroban", () => ({ + buildUnsignedLoanRequestXdr: vi.fn().mockResolvedValue("xdr-string"), +})); + +vi.mock("../../../utils/amount", () => ({ + getAssetDecimals: vi.fn().mockReturnValue(7), +})); + +vi.mock("../../../utils/transactionErrors", () => ({ + mapTransactionError: vi.fn(), + pollTransactionStatus: vi.fn(), +})); + +describe("StepFinalSignature", () => { + const mockData: LoanWizardData = { + amount: "1000", + termDays: 30, + asset: "USDC", + creditScore: 750, + }; + + const mockProps = { + data: mockData, + borrowerAddress: "GXXX...", + onBack: vi.fn(), + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_MANAGER_CONTRACT_ID = "CONTRACT123"; + }); + + describe("Interest Calculation", () => { + it("calculates estimated interest correctly with 12% APR", async () => { + render(); + + await waitFor(() => { + const principal = 1000; + const termDays = 30; + const annualRate = 12; + + // Expected: (1000 * 12 * 30) / (365 * 100) = 9.86 + const expectedInterest = (principal * annualRate * termDays) / (365 * 100); + expect(expectedInterest).toBeCloseTo(9.86, 1); + }); + }); + + it("calculates total repayment as principal + interest", async () => { + const props = { + ...mockProps, + data: { + ...mockData, + amount: "5000", + termDays: 90, + }, + }; + + render(); + + await waitFor(() => { + const principal = 5000; + const interest = (principal * 12 * 90) / (365 * 100); + const total = principal + interest; + expect(total).toBeCloseTo(5147.95, 1); + }); + }); + + it("calculates due date as current date + term days", async () => { + render(); + + await waitFor(() => { + const today = new Date(); + const expectedDue = new Date(today); + expectedDue.setDate(expectedDue.getDate() + 30); + + // Check that due date is approximately correct (within 1 day) + expect(screen.getByText(/estimated due date/i, { exact: false })).toBeInTheDocument(); + }); + }); + + it("handles zero interest for zero-day term", async () => { + const props = { + ...mockProps, + data: { + ...mockData, + termDays: 0, + }, + }; + + render(); + + await waitFor(() => { + const interest = (1000 * 12 * 0) / (365 * 100); + expect(interest).toBe(0); + }); + }); + }); + + describe("Sign/Submit/Poll State Machine", () => { + it("starts in idle state", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Ready to submit")).toBeInTheDocument(); + }); + }); + + it("transitions to signing state when sign button is clicked", async () => { + const { useWallet } = await import("../../providers/WalletProvider"); + const signMock = vi.fn().mockResolvedValue("signed-xdr"); + vi.mocked(useWallet).mockReturnValue({ + signTransaction: signMock, + } as any); + + render(); + + await waitFor(() => { + const signButton = screen.getByRole("button", { name: /sign/i }); + expect(signButton).toBeInTheDocument(); + }); + }); + + it("shows error when XDR building fails", async () => { + const { buildUnsignedLoanRequestXdr } = await import("../../../utils/soroban"); + vi.mocked(buildUnsignedLoanRequestXdr).mockRejectedValue( + new Error("XDR build failed") + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/XDR build failed/i)).toBeInTheDocument(); + }); + }); + + it("shows error when manager contract ID is missing", async () => { + process.env.NEXT_PUBLIC_MANAGER_CONTRACT_ID = ""; + + render(); + + await waitFor(() => { + expect( + screen.getByText(/Missing NEXT_PUBLIC_MANAGER_CONTRACT_ID/i) + ).toBeInTheDocument(); + }); + }); + + it("shows error when principal is zero or negative", async () => { + const props = { + ...mockProps, + data: { + ...mockData, + amount: "0", + }, + }; + + render(); + + // Should not build XDR when principal <= 0 + await waitFor(() => { + const { buildUnsignedLoanRequestXdr } = vi.hoisted(() => + import("../../../utils/soroban") + ); + expect(vi.mocked(buildUnsignedLoanRequestXdr)).not.toHaveBeenCalled(); + }); + }); + + it("resets tracker state when back button is clicked", async () => { + const onBack = vi.fn(); + render(); + + await waitFor(() => { + const backButton = screen.getByRole("button", { name: /back/i }); + fireEvent.click(backButton); + expect(onBack).toHaveBeenCalled(); + }); + }); + + it("cancels polling when cancel is triggered", async () => { + render(); + + await waitFor(() => { + const cancelButton = screen.queryByRole("button", { name: /cancel/i }); + if (cancelButton) { + fireEvent.click(cancelButton); + expect(screen.getByText(/cancelled/i)).toBeInTheDocument(); + } + }); + }); + }); + + describe("State Cleanup", () => { + it("aborts polling on component unmount", async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByText("Ready to submit")).toBeInTheDocument(); + }); + + unmount(); + + // Polling ref should be cleared + expect(true).toBe(true); + }); + + it("cancels XDR build on component unmount", async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByText("Ready to submit")).toBeInTheDocument(); + }); + + unmount(); + + // Component should clean up pending async operations + expect(true).toBe(true); + }); + }); + + describe("Format Money", () => { + it("formats numbers as USD currency", async () => { + render(); + + await waitFor(() => { + // Should display formatted principal amount + expect(screen.getByText(/\$1,000/)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/app/lib/__tests__/session.test.ts b/frontend/src/app/lib/__tests__/session.test.ts new file mode 100644 index 00000000..6710c19c --- /dev/null +++ b/frontend/src/app/lib/__tests__/session.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { isJwtExpired, clearSessionState, logoutUser, SessionExpiredError } from "../session"; +import * as userStore from "../../stores/useUserStore"; +import * as walletStore from "../../stores/useWalletStore"; + +vi.mock("../../stores/useUserStore", () => ({ + useUserStore: { + getState: vi.fn(() => ({ + clearUser: vi.fn(), + })), + }, +})); + +vi.mock("../../stores/useWalletStore", () => ({ + useWalletStore: { + getState: vi.fn(() => ({ + disconnect: vi.fn(), + })), + }, +})); + +describe("session.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe("isJwtExpired", () => { + it("returns true for expired token", () => { + const exp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const payload = btoa(JSON.stringify({ exp, sub: "test" })); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(true); + }); + + it("returns false for valid token", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const payload = btoa(JSON.stringify({ exp, sub: "test" })); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(false); + }); + + it("returns false for token expiring exactly now", () => { + const exp = Math.floor(Date.now() / 1000); + const payload = btoa(JSON.stringify({ exp, sub: "test" })); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(false); + }); + + it("returns true for token that expired 1 millisecond ago", () => { + const exp = Math.floor(Date.now() / 1000); + const payload = btoa(JSON.stringify({ exp, sub: "test" })); + const token = `header.${payload}.signature`; + + // Travel 1ms into the future + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 1); + const result = isJwtExpired(token); + vi.useRealTimers(); + + expect(result).toBe(true); + }); + + it("returns false for malformed token missing payload", () => { + const token = "header.signature"; + expect(isJwtExpired(token)).toBe(false); + }); + + it("returns false for token with invalid base64", () => { + const token = "header.!!!invalid!!!.signature"; + expect(isJwtExpired(token)).toBe(false); + }); + + it("returns false for token with missing exp field", () => { + const payload = btoa(JSON.stringify({ sub: "test" })); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(false); + }); + + it("returns false for token with non-numeric exp field", () => { + const payload = btoa(JSON.stringify({ exp: "not-a-number", sub: "test" })); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(false); + }); + + it("handles base64url padding correctly", () => { + // Test with payload that needs padding + const exp = Math.floor(Date.now() / 1000) + 3600; + const payloadObj = { exp, sub: "test" }; + const payload = btoa(JSON.stringify(payloadObj)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + const token = `header.${payload}.signature`; + + expect(isJwtExpired(token)).toBe(false); + }); + }); + + describe("clearSessionState", () => { + it("clears user from store", () => { + const clearUserMock = vi.fn(); + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: clearUserMock, + } as any); + + clearSessionState(); + + expect(clearUserMock).toHaveBeenCalled(); + }); + + it("disconnects wallet from store", () => { + const disconnectMock = vi.fn(); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: disconnectMock, + } as any); + + clearSessionState(); + + expect(disconnectMock).toHaveBeenCalled(); + }); + + it("clears user localStorage key", () => { + localStorage.setItem("remitlend-user", '{"name":"test"}'); + + clearSessionState(); + + expect(localStorage.getItem("remitlend-user")).toBeNull(); + }); + + it("clears wallet localStorage key", () => { + localStorage.setItem("remitlend-wallet", '{"address":"0x123"}'); + + clearSessionState(); + + expect(localStorage.getItem("remitlend-wallet")).toBeNull(); + }); + + it("clears both storage keys together", () => { + localStorage.setItem("remitlend-user", '{"name":"test"}'); + localStorage.setItem("remitlend-wallet", '{"address":"0x123"}'); + + clearSessionState(); + + expect(localStorage.getItem("remitlend-user")).toBeNull(); + expect(localStorage.getItem("remitlend-wallet")).toBeNull(); + }); + }); + + describe("logoutUser", () => { + it("clears session state before logout", () => { + const clearUserMock = vi.fn(); + const disconnectMock = vi.fn(); + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: clearUserMock, + } as any); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: disconnectMock, + } as any); + + logoutUser("manual"); + + expect(clearUserMock).toHaveBeenCalled(); + expect(disconnectMock).toHaveBeenCalled(); + }); + + it("redirects to home on manual logout", () => { + const assignMock = vi.fn(); + global.location = { assign: assignMock } as any; + + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: vi.fn(), + } as any); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: vi.fn(), + } as any); + + logoutUser("manual"); + + expect(assignMock).toHaveBeenCalledWith("/"); + }); + + it("redirects to home on expired logout", () => { + const assignMock = vi.fn(); + global.location = { assign: assignMock } as any; + + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: vi.fn(), + } as any); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: vi.fn(), + } as any); + + logoutUser("expired"); + + expect(assignMock).toHaveBeenCalledWith("/"); + }); + + it("debounces logout to prevent multiple redirects", () => { + const assignMock = vi.fn(); + global.location = { assign: assignMock } as any; + + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: vi.fn(), + } as any); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: vi.fn(), + } as any); + + vi.useFakeTimers(); + + logoutUser("manual"); + logoutUser("manual"); + + expect(assignMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(0); + vi.useRealTimers(); + }); + + it("uses manual logout reason by default", () => { + const assignMock = vi.fn(); + global.location = { assign: assignMock } as any; + + vi.mocked(userStore.useUserStore.getState).mockReturnValue({ + clearUser: vi.fn(), + } as any); + vi.mocked(walletStore.useWalletStore.getState).mockReturnValue({ + disconnect: vi.fn(), + } as any); + + logoutUser(); + + expect(assignMock).toHaveBeenCalledWith("/"); + }); + }); + + describe("SessionExpiredError", () => { + it("is an Error instance", () => { + const error = new SessionExpiredError(); + expect(error).toBeInstanceOf(Error); + }); + + it("has correct name", () => { + const error = new SessionExpiredError(); + expect(error.name).toBe("SessionExpiredError"); + }); + + it("has default message", () => { + const error = new SessionExpiredError(); + expect(error.message).toBe("Session expired. Please sign in again."); + }); + + it("accepts custom message", () => { + const error = new SessionExpiredError("Custom expired message"); + expect(error.message).toBe("Custom expired message"); + }); + }); +}); diff --git a/frontend/src/app/stores/__tests__/useToastStore.test.ts b/frontend/src/app/stores/__tests__/useToastStore.test.ts new file mode 100644 index 00000000..0c5d1d2a --- /dev/null +++ b/frontend/src/app/stores/__tests__/useToastStore.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useToastStore } from "../useToastStore"; + +describe("useToastStore", () => { + beforeEach(() => { + useToastStore.setState({ toasts: [] }); + }); + + describe("addToast", () => { + it("adds a toast with default duration for success type", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Success", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast).toMatchObject({ + type: "success", + title: "Success", + duration: 5000, + }); + expect(toast.id).toBe(id); + }); + + it("adds a toast with error duration for error type", () => { + const id = useToastStore.getState().addToast({ + type: "error", + title: "Error", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.duration).toBe(10000); + }); + + it("respects custom duration when provided", () => { + useToastStore.getState().addToast({ + type: "success", + title: "Success", + duration: 3000, + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.duration).toBe(3000); + }); + + it("generates unique IDs for each toast", () => { + const id1 = useToastStore.getState().addToast({ + type: "success", + title: "First", + }); + const id2 = useToastStore.getState().addToast({ + type: "success", + title: "Second", + }); + + expect(id1).not.toBe(id2); + }); + + it("prepends new toasts to the array", () => { + useToastStore.getState().addToast({ type: "success", title: "First" }); + useToastStore.getState().addToast({ type: "success", title: "Second" }); + + const toasts = useToastStore.getState().toasts; + expect(toasts[0].title).toBe("Second"); + expect(toasts[1].title).toBe("First"); + }); + + it("truncates toasts to MAX_STORED_TOASTS (20)", () => { + for (let i = 0; i < 25; i++) { + useToastStore.getState().addToast({ + type: "success", + title: `Toast ${i}`, + }); + } + + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(20); + expect(toasts[0].title).toBe("Toast 24"); + expect(toasts[19].title).toBe("Toast 5"); + }); + + it("uses provided ID if supplied", () => { + useToastStore.getState().addToast({ + id: "custom-id", + type: "success", + title: "Custom", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.id).toBe("custom-id"); + }); + }); + + describe("updateToast", () => { + it("updates an existing toast", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Original", + }); + + useToastStore.getState().updateToast(id, { + title: "Updated", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.title).toBe("Updated"); + }); + + it("recomputes duration when type changes and duration is not provided", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Test", + }); + + useToastStore.getState().updateToast(id, { + type: "error", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.duration).toBe(10000); + }); + + it("preserves custom duration when provided during update", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Test", + }); + + useToastStore.getState().updateToast(id, { + type: "error", + duration: 7000, + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.duration).toBe(7000); + }); + + it("does not recompute duration when type is unchanged", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Test", + duration: 3000, + }); + + useToastStore.getState().updateToast(id, { + description: "Added description", + }); + + const toast = useToastStore.getState().toasts[0]; + expect(toast.duration).toBe(3000); + }); + + it("does not affect other toasts when updating one", () => { + const id1 = useToastStore.getState().addToast({ + type: "success", + title: "First", + }); + const id2 = useToastStore.getState().addToast({ + type: "success", + title: "Second", + }); + + useToastStore.getState().updateToast(id1, { + title: "First Updated", + }); + + const toasts = useToastStore.getState().toasts; + expect(toasts[1].title).toBe("First Updated"); + expect(toasts[0].title).toBe("Second"); + }); + + it("silently ignores update to non-existent toast", () => { + useToastStore.getState().addToast({ + type: "success", + title: "Test", + }); + + useToastStore.getState().updateToast("non-existent", { + title: "Updated", + }); + + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0].title).toBe("Test"); + }); + }); + + describe("dismissToast", () => { + it("removes a toast by ID", () => { + const id = useToastStore.getState().addToast({ + type: "success", + title: "Test", + }); + + useToastStore.getState().dismissToast(id); + + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it("only removes the specified toast", () => { + const id1 = useToastStore.getState().addToast({ + type: "success", + title: "First", + }); + const id2 = useToastStore.getState().addToast({ + type: "success", + title: "Second", + }); + + useToastStore.getState().dismissToast(id2); + + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0].id).toBe(id1); + }); + + it("silently handles dismissal of non-existent toast", () => { + useToastStore.getState().addToast({ + type: "success", + title: "Test", + }); + + useToastStore.getState().dismissToast("non-existent"); + + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + }); + + describe("clearToasts", () => { + it("removes all toasts", () => { + useToastStore.getState().addToast({ type: "success", title: "First" }); + useToastStore.getState().addToast({ type: "success", title: "Second" }); + useToastStore.getState().addToast({ type: "success", title: "Third" }); + + useToastStore.getState().clearToasts(); + + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it("can be called on empty store without error", () => { + expect(() => { + useToastStore.getState().clearToasts(); + }).not.toThrow(); + + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + }); +});