Skip to content

Commit e81cdf0

Browse files
Merge branch 'main' into feat/payment-certificate-deadline-suggester-source-bar-version-history-95-96-97-98
2 parents aaec22f + 96ca89f commit e81cdf0

79 files changed

Lines changed: 4453 additions & 1283 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const config = {
1111
},
1212
testMatch: ["**/__tests__/**/*.(test|spec).(ts|tsx)"],
1313
testPathIgnorePatterns: ["/node_modules/", "/src/app/search/__tests__/"],
14+
collectCoverageFrom: ["src/components/**/*.{ts,tsx}", "!src/components/**/*.d.ts"],
15+
coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
1416
};
1517

1618
module.exports = createJestConfig(config);

next.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
const withBundleAnalyzer = require("@next/bundle-analyzer")({
2+
enabled: process.env.ANALYZE === "true",
3+
});
4+
15
/** @type {import('next').NextConfig} */
26
const nextConfig = {
37
async headers() {
@@ -36,4 +40,4 @@ const nextConfig = {
3640
},
3741
};
3842

39-
module.exports = nextConfig;
43+
module.exports = withBundleAnalyzer(nextConfig);

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@stellar-split/sdk": "^0.1.0",
1515
"@stellar/freighter-api": "^3.0.0",
16+
"@tanstack/react-query": "^5.17.0",
1617
"canvas-confetti": "^1.9.3",
1718
"html2canvas": "^1.4.1",
1819
"next": "14.2.3",
@@ -22,13 +23,10 @@
2223
"recharts": "^2.12.7"
2324
},
2425
"devDependencies": {
26+
"@next/bundle-analyzer": "^14.2.3",
2527
"@playwright/test": "^1.40.0",
2628
"@testing-library/jest-dom": "^6.9.1",
2729
"@testing-library/react": "^16.3.2",
28-
"@types/canvas-confetti": "^1.9.0",
29-
"@types/html2canvas": "^1.0.0",
30-
"@types/jest": "^30.0.0",
31-
"@types/node": "^20.0.0",
3230
"@types/react": "^18.3.0",
3331
"@types/react-dom": "^18.3.0",
3432
"@testing-library/jest-dom": "^6.4.0",

src/__tests__/Button.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import Button from '@/components/Button';
4+
5+
describe('Button', () => {
6+
it('renders children', () => {
7+
render(<Button>Click me</Button>);
8+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
9+
});
10+
11+
it('is disabled when disabled prop is true', () => {
12+
render(<Button disabled>Click me</Button>);
13+
const btn = screen.getByRole('button');
14+
expect(btn).toBeDisabled();
15+
expect(btn).toHaveAttribute('aria-disabled', 'true');
16+
});
17+
18+
it('shows Loading… and disables when isLoading', () => {
19+
render(<Button isLoading>Click me</Button>);
20+
const btn = screen.getByRole('button');
21+
expect(btn).toHaveTextContent('Loading…');
22+
expect(btn).toBeDisabled();
23+
expect(btn).toHaveAttribute('aria-disabled', 'true');
24+
});
25+
26+
it('calls onClick when clicked', async () => {
27+
const onClick = jest.fn();
28+
render(<Button onClick={onClick}>Go</Button>);
29+
await userEvent.click(screen.getByRole('button'));
30+
expect(onClick).toHaveBeenCalledTimes(1);
31+
});
32+
33+
it('does not call onClick when disabled', async () => {
34+
const onClick = jest.fn();
35+
render(<Button disabled onClick={onClick}>Go</Button>);
36+
await userEvent.click(screen.getByRole('button'));
37+
expect(onClick).not.toHaveBeenCalled();
38+
});
39+
40+
it('applies primary variant classes by default', () => {
41+
render(<Button>Primary</Button>);
42+
expect(screen.getByRole('button')).toHaveClass('bg-indigo-600');
43+
});
44+
45+
it('applies secondary variant classes', () => {
46+
render(<Button variant="secondary">Secondary</Button>);
47+
expect(screen.getByRole('button')).toHaveClass('bg-gray-700');
48+
});
49+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import CountdownTimer from "@/components/CountdownTimer";
3+
4+
describe("CountdownTimer", () => {
5+
beforeEach(() => {
6+
jest.useFakeTimers();
7+
jest.setSystemTime(new Date("2026-06-30T14:00:00.000Z"));
8+
});
9+
10+
afterEach(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
it("renders a human-readable countdown and updates every second", () => {
15+
const deadline = Math.floor(new Date("2026-07-01T15:00:00.000Z").getTime() / 1000);
16+
17+
render(<CountdownTimer deadline={deadline} />);
18+
19+
expect(screen.getByText("1d 1h 0m 0s remaining")).toBeInTheDocument();
20+
21+
act(() => {
22+
jest.advanceTimersByTime(1000);
23+
});
24+
25+
expect(screen.getByText("1d 0h 59m 59s remaining")).toBeInTheDocument();
26+
});
27+
28+
it("shows the absolute expiration date in the tooltip", () => {
29+
const deadline = Math.floor(new Date("2026-07-01T15:00:00.000Z").getTime() / 1000);
30+
31+
render(<CountdownTimer deadline={deadline} />);
32+
33+
const timer = screen.getByText("1d 1h 0m 0s remaining");
34+
expect(timer).toHaveAttribute("title", "Expires July 1, 2026 at 15:00 UTC");
35+
});
36+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { render, screen } from '@testing-library/react';
2+
import PaymentProgress from '@/components/PaymentProgress';
3+
import type { Invoice } from '@stellar-split/sdk';
4+
5+
jest.mock('@stellar-split/sdk', () => ({
6+
formatAmount: (n: bigint) => `${n}`,
7+
}));
8+
9+
const makeInvoice = (funded: bigint, amount: bigint): Invoice =>
10+
({
11+
id: '1',
12+
funded,
13+
status: 'Pending',
14+
deadline: 0,
15+
recipients: [{ address: 'GABC', amount }],
16+
} as unknown as Invoice);
17+
18+
describe('PaymentProgress', () => {
19+
it('renders progressbar role', () => {
20+
render(<PaymentProgress funded={0n} total={100n} />);
21+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
22+
});
23+
24+
it('shows 0% when nothing funded', () => {
25+
render(<PaymentProgress funded={0n} total={100n} />);
26+
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
27+
});
28+
29+
it('shows 50% when half funded', () => {
30+
render(<PaymentProgress funded={50n} total={100n} />);
31+
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
32+
});
33+
34+
it('clamps at 100%', () => {
35+
render(<PaymentProgress funded={200n} total={100n} />);
36+
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100');
37+
});
38+
39+
it('shows 0% when total is 0', () => {
40+
render(<PaymentProgress funded={0n} total={0n} />);
41+
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
42+
});
43+
44+
it('derives amounts from invoice prop', () => {
45+
render(<PaymentProgress invoice={makeInvoice(25n, 100n)} />);
46+
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
47+
});
48+
49+
it('shows funded/total text when invoice is provided', () => {
50+
render(<PaymentProgress invoice={makeInvoice(40n, 200n)} />);
51+
expect(screen.getByText(/40.*200.*USDC funded/)).toBeInTheDocument();
52+
});
53+
54+
it('does not show funded text without invoice', () => {
55+
render(<PaymentProgress funded={50n} total={100n} />);
56+
expect(screen.queryByText(/USDC funded/)).not.toBeInTheDocument();
57+
});
58+
});

src/__tests__/InvoiceCard.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { render, screen } from '@testing-library/react';
2+
import InvoiceCard from '@/components/InvoiceCard';
3+
import type { Invoice } from '@stellar-split/sdk';
4+
5+
jest.mock('@stellar-split/sdk', () => ({
6+
formatAmount: (n: bigint) => `${n}`,
7+
truncateAddress: (s: string) => `${s.slice(0, 4)}...${s.slice(-4)}`,
8+
}));
9+
10+
jest.mock('@/components/PaymentProgress', () => () => <div data-testid="payment-progress" />);
11+
jest.mock('@/components/CountdownTimer', () => () => <div data-testid="countdown-timer" />);
12+
13+
const invoice: Invoice = {
14+
id: '42',
15+
funded: 50n,
16+
status: 'Pending',
17+
deadline: 0,
18+
recipients: [
19+
{ address: 'GABCDEF1234', amount: 100n },
20+
{ address: 'GXYZ9876WXYZ', amount: 100n },
21+
],
22+
} as unknown as Invoice;
23+
24+
describe('InvoiceCard', () => {
25+
it('renders invoice id', () => {
26+
render(<InvoiceCard invoice={invoice} />);
27+
expect(screen.getByText(/Invoice #42/)).toBeInTheDocument();
28+
});
29+
30+
it('renders status badge', () => {
31+
render(<InvoiceCard invoice={invoice} />);
32+
expect(screen.getByText('Pending')).toBeInTheDocument();
33+
});
34+
35+
it('renders truncated recipient addresses', () => {
36+
render(<InvoiceCard invoice={invoice} />);
37+
expect(screen.getByText('GABC...1234')).toBeInTheDocument();
38+
expect(screen.getByText('GXYZ...WXYZ')).toBeInTheDocument();
39+
});
40+
41+
it('renders funded amount', () => {
42+
render(<InvoiceCard invoice={invoice} />);
43+
expect(screen.getByText(/50.*USDC funded/)).toBeInTheDocument();
44+
});
45+
46+
it('renders PaymentProgress', () => {
47+
render(<InvoiceCard invoice={invoice} />);
48+
expect(screen.getByTestId('payment-progress')).toBeInTheDocument();
49+
});
50+
51+
it('renders displayNumber when provided', () => {
52+
render(<InvoiceCard invoice={invoice} displayNumber="INV-001" />);
53+
expect(screen.getByText('(INV-001)')).toBeInTheDocument();
54+
});
55+
56+
it('renders CountdownTimer when deadline > 0', () => {
57+
const withDeadline = { ...invoice, deadline: Math.floor(Date.now() / 1000) + 3600 };
58+
render(<InvoiceCard invoice={withDeadline} />);
59+
expect(screen.getByTestId('countdown-timer')).toBeInTheDocument();
60+
});
61+
62+
it('does not render CountdownTimer when deadline is 0', () => {
63+
render(<InvoiceCard invoice={invoice} />);
64+
expect(screen.queryByTestId('countdown-timer')).not.toBeInTheDocument();
65+
});
66+
});

src/__tests__/PayModal.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from "react";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import PayModal, { getPaymentErrorMessage } from "@/components/PayModal";
4+
import type { Invoice } from "@stellar-split/sdk";
5+
6+
const SCALE = 10_000_000n;
7+
8+
jest.mock("@stellar-split/sdk", () => ({
9+
formatAmount: (value: bigint) => (Number(value) / 10_000_000).toFixed(2),
10+
parseAmount: (value: string) => BigInt(Math.round(Number(value) * 10_000_000)),
11+
}));
12+
13+
jest.mock("@/lib/stellar", () => ({
14+
USDC_CONTRACT_ID: "CUSDC",
15+
fetchUsdcBalance: jest.fn().mockResolvedValue(1_000_000_000n),
16+
}));
17+
18+
jest.mock("@/components/FocusTrap", () => ({
19+
__esModule: true,
20+
default: function FocusTrap({ children }: { children: React.ReactNode }) {
21+
return <>{children}</>;
22+
},
23+
}));
24+
25+
const invoice: Invoice = {
26+
id: "inv-286",
27+
creator: "GCREATOR",
28+
recipients: [{ address: "GPAYER", amount: 50n * SCALE }],
29+
token: "CUSDC",
30+
deadline: 0,
31+
funded: 25n * SCALE,
32+
status: "Pending",
33+
payments: [{ payer: "GPAYER", amount: 10n * SCALE }],
34+
};
35+
36+
describe("PayModal", () => {
37+
it("defaults to remaining share, shows balance, pays with tip, and renders explorer link", async () => {
38+
const onPay = jest.fn().mockResolvedValue({ txHash: "abc123hash" });
39+
40+
render(
41+
<PayModal
42+
invoice={invoice}
43+
total={100n * SCALE}
44+
publicKey="GPAYER"
45+
onPay={onPay}
46+
onClose={jest.fn()}
47+
/>
48+
);
49+
50+
const amountInput = await screen.findByLabelText(/amount \(usdc\)/i);
51+
expect(amountInput).toHaveValue(40);
52+
expect(await screen.findByText(/100.00 USDC available/i)).toBeInTheDocument();
53+
54+
fireEvent.change(screen.getByLabelText(/tip \(optional\)/i), {
55+
target: { value: "2" },
56+
});
57+
fireEvent.click(screen.getByLabelText(/donate on failure/i));
58+
fireEvent.click(screen.getByRole("button", { name: /review & pay/i }));
59+
fireEvent.click(screen.getByRole("button", { name: /confirm & pay/i }));
60+
61+
await waitFor(() =>
62+
expect(onPay).toHaveBeenCalledWith(
63+
42n * SCALE,
64+
undefined,
65+
expect.objectContaining({ tip: 2n * SCALE, donateOnFailure: true })
66+
)
67+
);
68+
expect(await screen.findByText(/payment confirmed/i)).toBeInTheDocument();
69+
expect(screen.getByRole("link", { name: /view on stellar expert/i })).toHaveAttribute(
70+
"href",
71+
expect.stringContaining("/abc123hash")
72+
);
73+
});
74+
75+
it("maps known payment errors to human-readable messages", () => {
76+
expect(getPaymentErrorMessage("insufficient balance")).toMatch(/balance is too low/i);
77+
expect(getPaymentErrorMessage("invoice closed")).toMatch(/no longer accepting payments/i);
78+
expect(getPaymentErrorMessage("user rejected request")).toMatch(/signing request was cancelled/i);
79+
});
80+
});

src/__tests__/StatusBadge.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { render, screen } from '@testing-library/react';
2+
import StatusBadge from '@/components/StatusBadge';
3+
4+
describe('StatusBadge', () => {
5+
it.each(['Pending', 'Released', 'Refunded'] as const)('renders %s status', (status) => {
6+
render(<StatusBadge status={status} />);
7+
expect(screen.getByText(status)).toBeInTheDocument();
8+
});
9+
10+
it('applies yellow styles for Pending', () => {
11+
render(<StatusBadge status="Pending" />);
12+
expect(screen.getByText('Pending')).toHaveClass('text-yellow-600');
13+
});
14+
15+
it('applies green styles for Released', () => {
16+
render(<StatusBadge status="Released" />);
17+
expect(screen.getByText('Released')).toHaveClass('text-green-600');
18+
});
19+
20+
it('applies gray styles for Refunded', () => {
21+
render(<StatusBadge status="Refunded" />);
22+
expect(screen.getByText('Refunded')).toHaveClass('text-gray-600');
23+
});
24+
});

0 commit comments

Comments
 (0)