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);
+ });
+ });
+});