diff --git a/e2e/create-invoice.spec.ts b/e2e/create-invoice.spec.ts index a7c3fc2b..154e0ca5 100644 --- a/e2e/create-invoice.spec.ts +++ b/e2e/create-invoice.spec.ts @@ -21,6 +21,23 @@ test.describe('Create invoice flow', () => { await expect(createPage.totalAmountInput).toHaveValue('200'); }); + test('back button returns to previous step without losing entered data', async ({ page }) => { + const createPage = new CreateInvoicePage(page); + await createPage.goto(); + + await createPage.fillRecipient('GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJQDP7DC3K2YSJEIPQ76PQXI'); + await createPage.fillAmount('150'); + await createPage.clickNext(); + + await expect(createPage.backButton).toBeVisible(); + await createPage.clickBack(); + + await expect(createPage.recipientInput).toHaveValue( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJQDP7DC3K2YSJEIPQ76PQXI', + ); + await expect(createPage.amountInput).toHaveValue('150'); + }); + test('fill form and submit redirects', async ({ page }) => { const createPage = new CreateInvoicePage(page); await createPage.goto(); diff --git a/src/hooks/__tests__/usePaymentSubmission.test.ts b/src/hooks/__tests__/usePaymentSubmission.test.ts new file mode 100644 index 00000000..08dee75c --- /dev/null +++ b/src/hooks/__tests__/usePaymentSubmission.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const mockSimulateTransaction = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/simulateTx", () => ({ + simulateTransaction: mockSimulateTransaction, +})); + +import { + usePaymentSubmission, + isRateLockExpired, +} from "../usePaymentSubmission"; + +describe("isRateLockExpired", () => { + it("returns false when there is no rate lock", () => { + expect(isRateLockExpired(null)).toBe(false); + expect(isRateLockExpired(undefined)).toBe(false); + }); + + it("returns false before expiry", () => { + const lock = { currency: "USD", rate: 0.1, expiresAt: 1000 }; + expect(isRateLockExpired(lock, 500)).toBe(false); + }); + + it("returns true at or after expiry", () => { + const lock = { currency: "USD", rate: 0.1, expiresAt: 1000 }; + expect(isRateLockExpired(lock, 1000)).toBe(true); + expect(isRateLockExpired(lock, 1500)).toBe(true); + }); +}); + +describe("usePaymentSubmission", () => { + beforeEach(() => { + vi.clearAllMocks(); + if (typeof window !== "undefined") { + localStorage.clear(); + } + }); + + it("initializes with a clean state", () => { + const { result } = renderHook(() => usePaymentSubmission()); + expect(result.current.state).toEqual({ + showSimulationPreview: false, + isSimulating: false, + simulationResult: null, + simulationError: null, + rateLockExpired: false, + }); + }); + + it("runs a simulation and stores the result on initiateDemoPayment", async () => { + mockSimulateTransaction.mockResolvedValue({ + success: true, + fee: 100, + effects: [], + }); + + const { result } = renderHook(() => usePaymentSubmission()); + const onConfirm = vi.fn().mockResolvedValue(undefined); + const onCancel = vi.fn(); + + await act(async () => { + await result.current.initiateDemoPayment("xdr-123", onConfirm, onCancel); + }); + + expect(mockSimulateTransaction).toHaveBeenCalledWith("xdr-123"); + expect(result.current.state.showSimulationPreview).toBe(true); + expect(result.current.state.isSimulating).toBe(false); + expect(result.current.state.simulationResult).toEqual({ + success: true, + fee: 100, + effects: [], + }); + expect(result.current.state.simulationError).toBeNull(); + }); + + it("surfaces a simulation error without throwing", async () => { + mockSimulateTransaction.mockRejectedValue(new Error("RPC unreachable")); + + const { result } = renderHook(() => usePaymentSubmission()); + + await act(async () => { + await result.current.initiateDemoPayment( + "xdr-bad", + vi.fn(), + vi.fn(), + ); + }); + + expect(result.current.state.isSimulating).toBe(false); + expect(result.current.state.simulationError).toBe("RPC unreachable"); + }); + + it("blocks initiation when the rate lock has already expired", async () => { + const { result } = renderHook(() => usePaymentSubmission()); + const expiredLock = { currency: "USD", rate: 0.1, expiresAt: 1 }; + + await act(async () => { + await result.current.initiateDemoPayment( + "xdr-123", + vi.fn(), + vi.fn(), + { rateLock: expiredLock }, + ); + }); + + expect(mockSimulateTransaction).not.toHaveBeenCalled(); + expect(result.current.state.rateLockExpired).toBe(true); + expect(result.current.state.simulationError).toMatch(/rate lock/i); + }); + + it("resetState clears simulation state back to defaults", async () => { + mockSimulateTransaction.mockResolvedValue({ + success: true, + fee: 1, + effects: [], + }); + const { result } = renderHook(() => usePaymentSubmission()); + + await act(async () => { + await result.current.initiateDemoPayment("xdr", vi.fn(), vi.fn()); + }); + expect(result.current.state.showSimulationPreview).toBe(true); + + act(() => { + result.current.resetState(); + }); + + expect(result.current.state).toEqual({ + showSimulationPreview: false, + isSimulating: false, + simulationResult: null, + simulationError: null, + rateLockExpired: false, + }); + }); +}); diff --git a/src/lib/invoiceStatus.test.ts b/src/lib/invoiceStatus.test.ts new file mode 100644 index 00000000..63939b7b --- /dev/null +++ b/src/lib/invoiceStatus.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + canTransition, + canReleaseInvoice, + getReleaseCheck, + isReleaseBlocked, + transitionToDisputed, + transitionToReleased, + STATUS_TRANSITIONS, +} from "./invoiceStatus"; +import { createMilestone, addCondition } from "./invoiceMilestones"; +import { createDispute } from "./arbitrators"; + +describe("invoiceStatus state machine", () => { + beforeEach(() => { + if (typeof window !== "undefined") { + localStorage.clear(); + } + }); + + describe("canTransition", () => { + it("allows transitions listed in STATUS_TRANSITIONS", () => { + for (const from of Object.keys(STATUS_TRANSITIONS) as Array< + keyof typeof STATUS_TRANSITIONS + >) { + for (const to of STATUS_TRANSITIONS[from]) { + expect(canTransition(from, to)).toBe(true); + } + } + }); + + it("rejects transitions that are not listed", () => { + expect(canTransition("Pending", "Released")).toBe(false); + expect(canTransition("Released", "Funded")).toBe(false); + }); + + it("rejects any transition out of Archived", () => { + expect(canTransition("Archived", "Pending")).toBe(false); + expect(STATUS_TRANSITIONS.Archived).toHaveLength(0); + }); + }); + + describe("getReleaseCheck / canReleaseInvoice / isReleaseBlocked", () => { + it("allows release when there are no milestones and no disputes", () => { + const check = getReleaseCheck("inv-no-milestones"); + expect(check.allowed).toBe(true); + expect(check.reasons).toHaveLength(0); + expect(canReleaseInvoice("inv-no-milestones")).toBe(true); + expect(isReleaseBlocked("inv-no-milestones")).toBe(false); + }); + + it("blocks release when a required milestone condition is unmet", () => { + const invoiceId = "inv-with-milestone"; + const milestone = createMilestone(invoiceId, 100); + addCondition(invoiceId, milestone.id, { + type: "deliverable", + description: "Deliver final asset", + required: true, + }); + + const check = getReleaseCheck(invoiceId); + expect(check.allowed).toBe(false); + expect(check.reasons).toContain( + "Milestone conditions have not been satisfied", + ); + expect(canReleaseInvoice(invoiceId)).toBe(false); + expect(isReleaseBlocked(invoiceId)).toBe(true); + }); + + it("blocks release when the invoice has an active dispute", () => { + const invoiceId = "inv-disputed"; + createDispute({ + invoiceId, + initiator: "GRAISER", + respondent: "GCOUNTER", + reason: "Work not delivered", + description: "Deliverable was never provided.", + }); + + const check = getReleaseCheck(invoiceId); + expect(check.allowed).toBe(false); + expect(check.reasons).toContain("Invoice has an unresolved dispute"); + }); + }); + + describe("transitionToDisputed", () => { + it("returns Disputed when transition is valid", () => { + expect(transitionToDisputed("Funded")).toBe("Disputed"); + }); + + it("throws when transition is invalid", () => { + expect(() => transitionToDisputed("Archived")).toThrow( + /Invalid status transition/, + ); + }); + }); + + describe("transitionToReleased", () => { + it("returns Released when transition is valid and release is unblocked", () => { + const invoiceId = "inv-release-ok"; + expect(transitionToReleased(invoiceId, "Funded")).toBe("Released"); + }); + + it("throws when the status transition itself is invalid", () => { + expect(() => transitionToReleased("inv-x", "Pending")).toThrow( + /Invalid status transition/, + ); + }); + + it("throws when release is blocked by an unmet milestone", () => { + const invoiceId = "inv-release-blocked"; + const milestone = createMilestone(invoiceId, 100); + addCondition(invoiceId, milestone.id, { + type: "approval", + description: "Client sign-off", + required: true, + }); + + expect(() => transitionToReleased(invoiceId, "Funded")).toThrow( + /Release blocked/, + ); + }); + }); +}); diff --git a/src/lib/invoiceStatus.ts b/src/lib/invoiceStatus.ts index b634cd52..635a7fe4 100644 --- a/src/lib/invoiceStatus.ts +++ b/src/lib/invoiceStatus.ts @@ -1,8 +1,5 @@ -import { - areMilestoneConditionsSatisfied, - getInvoiceMilestones, -} from "./invoiceMilestones"; -import { getActiveDispute } from "./invoiceDisputes"; +import { getMilestones, canReleaseMilestone } from "./invoiceMilestones"; +import { getDisputesByInvoice, isDisputeActive } from "./arbitrators"; export type InvoiceStatus = | "Pending" @@ -125,13 +122,18 @@ export function canReleaseInvoice(invoiceId: string): boolean { export function getReleaseCheck(invoiceId: string): ReleaseCheckResult { const reasons: string[] = []; - const dispute = getActiveDispute(invoiceId); - if (dispute) { + const activeDispute = getDisputesByInvoice(invoiceId).some((d) => + isDisputeActive(d), + ); + if (activeDispute) { reasons.push("Invoice has an unresolved dispute"); } - const milestones = getInvoiceMilestones(invoiceId); - if (milestones.length > 0 && !areMilestoneConditionsSatisfied(invoiceId)) { + const milestones = getMilestones(invoiceId); + const unmetMilestones = milestones.filter( + (m) => !m.released && !canReleaseMilestone(m), + ); + if (unmetMilestones.length > 0) { reasons.push("Milestone conditions have not been satisfied"); }