From 4e4346289dfed0c0c6e81d8b169e4514f6e73bb7 Mon Sep 17 00:00:00 2001 From: lawrencesunday311-sketch Date: Tue, 20 Oct 2026 01:25:06 +0100 Subject: [PATCH 1/3] Fix #900: repair invoiceStatus.ts broken imports and add unit tests invoiceStatus.ts imported areMilestoneConditionsSatisfied/getInvoiceMilestones from invoiceMilestones.ts (neither exists there) and getActiveDispute from a non-existent invoiceDisputes.ts module, making the file unresolvable. Wired getReleaseCheck to the real invoiceMilestones (getMilestones/canReleaseMilestone) and arbitrators (getDisputesByInvoice/isDisputeActive) APIs, and added unit tests covering status transitions and release gating for milestones and disputes. --- src/lib/invoiceStatus.test.ts | 124 ++++++++++++++++++++++++++++++++++ src/lib/invoiceStatus.ts | 20 +++--- 2 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 src/lib/invoiceStatus.test.ts diff --git a/src/lib/invoiceStatus.test.ts b/src/lib/invoiceStatus.test.ts new file mode 100644 index 0000000..63939b7 --- /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 b634cd5..635a7fe 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"); } From 60c481c9ff77a971cfe12bc44c5c279b1a44f666 Mon Sep 17 00:00:00 2001 From: lawrencesunday311-sketch Date: Tue, 20 Oct 2026 01:25:15 +0100 Subject: [PATCH 2/3] Fix #901: add integration tests for payment submission flow usePaymentSubmission drives the core payment-processing path (rate-lock validation, transaction simulation, confirm/cancel, and post-payment side effects) and had zero test coverage. Added tests covering rate lock expiry gating, simulation success/failure, and state reset. --- .../__tests__/usePaymentSubmission.test.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/hooks/__tests__/usePaymentSubmission.test.ts diff --git a/src/hooks/__tests__/usePaymentSubmission.test.ts b/src/hooks/__tests__/usePaymentSubmission.test.ts new file mode 100644 index 0000000..08dee75 --- /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, + }); + }); +}); From 11bd821302f3938ad67b6e2a2e0b8b225446830b Mon Sep 17 00:00:00 2001 From: lawrencesunday311-sketch Date: Tue, 20 Oct 2026 01:25:49 +0100 Subject: [PATCH 3/3] Fix #903: add e2e coverage for create-invoice wizard back navigation Playwright e2e already covers create/pay invoice, dashboard, verify and landing flows. CreateInvoicePage exposed a clickBack() helper that no spec exercised, leaving the wizard's back-navigation data-retention behavior (a real part of the critical create-invoice flow) untested. Added a test that fills step 1, advances, goes back, and asserts the entered recipient/amount are preserved. --- e2e/create-invoice.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/e2e/create-invoice.spec.ts b/e2e/create-invoice.spec.ts index a7c3fc2..154e0ca 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();