Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 219 additions & 72 deletions apps/frontend/components/escrow/CreateEscrowWizard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateEscrowWizard from './CreateEscrowWizard';
import { isConnected } from '@stellar/freighter-api';
import { ToastProvider } from '@/app/contexts/ToastProvider';

jest.mock('@stellar/freighter-api', () => ({
isConnected: jest.fn(),
Expand All @@ -11,98 +11,245 @@ jest.mock('@stellar/freighter-api', () => ({
}));

jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode, href: string }) => (
return ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
);
});

const VALID_STELLAR_ADDRESS =
'GDZ667HFMKM7HDKUYM2Q22TX4CSKOAG56ZXQ6MOR6LNOXX5CL6Y4MEEA';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));

const DRAFT_KEY = 'create_escrow_wizard_draft';
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;

function renderWizard() {
return render(
<ToastProvider>
<CreateEscrowWizard />
</ToastProvider>
);
}

function seedDraft(overrides: Record<string, unknown> = {}) {
const draft = {
currentStep: 1,
formData: { asset: 'XLM', milestones: [], conditions: [], title: 'Saved Escrow Title' },
selectedTemplateId: undefined,
savedAt: Date.now(),
...overrides,
};
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
return draft;
}

async function goToBasicInfo(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(screen.getByText('Basic Information')).toBeInTheDocument());
}

beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
window.history.pushState({}, '', '/escrow/create');
});

afterEach(() => {
jest.useRealTimers();
});

describe('CreateEscrowWizard', () => {
beforeEach(() => {
jest.clearAllMocks();
it('renders the template step by default', () => {
renderWizard();
expect(screen.getByText('Choose a Template')).toBeInTheDocument();
});

it('renders the first step by default', () => {
render(<CreateEscrowWizard />);
expect(screen.getByText('Basic Information')).toBeInTheDocument();
it('moves from the template step to basic info without requiring a template', async () => {
const user = userEvent.setup();
renderWizard();
await goToBasicInfo(user);
});

it('validates current step before moving to the next one', async () => {
render(<CreateEscrowWizard />);
const nextButton = screen.getByText('Next');
fireEvent.click(nextButton);
it('validates the current step before moving to the next one', async () => {
const user = userEvent.setup();
renderWizard();
await goToBasicInfo(user);

await user.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => {
expect(screen.getByText('Title must be at least 5 characters')).toBeInTheDocument();
});
});
});

describe('CreateEscrowWizard - step navigation (URL + back button)', () => {
it('reflects the current step in the ?step= URL query parameter', async () => {
const user = userEvent.setup();
renderWizard();
expect(window.location.search).toBe('');

await goToBasicInfo(user);
expect(window.location.search).toBe('?step=1');
});

it('lets the browser back button navigate to the previous wizard step instead of leaving the page', async () => {
const user = userEvent.setup();
renderWizard();

await goToBasicInfo(user);
expect(window.location.search).toBe('?step=1');

await act(async () => {
window.history.back();
});

await waitFor(() => expect(screen.getByText('Choose a Template')).toBeInTheDocument());
expect(window.location.search).toBe('');
});
});

describe('CreateEscrowWizard - draft autosave', () => {
it('auto-saves the wizard state to localStorage 1s after a change and shows the draft indicator', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
renderWizard();

await user.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(screen.getByText('Basic Information')).toBeInTheDocument());

await user.type(screen.getByLabelText(/Title/i), 'My new escrow');

expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();

act(() => {
jest.advanceTimersByTime(1000);
});

const stored = JSON.parse(window.localStorage.getItem(DRAFT_KEY) || 'null');
expect(stored.formData.title).toBe('My new escrow');
expect(stored.currentStep).toBe(1);
expect(screen.getByText(/Draft saved at/i)).toBeInTheDocument();
});

it('lets the user manually save the draft immediately via the Save draft button', async () => {
const user = userEvent.setup();
renderWizard();

await goToBasicInfo(user);
await user.type(screen.getByLabelText(/Title/i), 'Manual save test');

await user.click(screen.getByRole('button', { name: /Save draft/i }));

const stored = JSON.parse(window.localStorage.getItem(DRAFT_KEY) || 'null');
expect(stored.formData.title).toBe('Manual save test');
expect(screen.getByText(/Draft saved at/i)).toBeInTheDocument();
});

it('shows the default XLM asset as a read-only selector on the terms step', async () => {
it('warns before unload when there are unsaved changes past the first step', async () => {
const user = userEvent.setup();
render(<CreateEscrowWizard />);
renderWizard();

await goToBasicInfo(user);

const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
window.dispatchEvent(event);

expect(preventDefaultSpy).toHaveBeenCalled();
});

it('does not warn before unload while still on the first (template) step', () => {
renderWizard();

const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
window.dispatchEvent(event);

expect(preventDefaultSpy).not.toHaveBeenCalled();
});
});

await user.type(screen.getByLabelText(/Title/i), 'Project Development');
await user.selectOptions(screen.getByLabelText(/Category/i), 'service');
await user.type(screen.getByLabelText(/Description/i), 'This is a long enough description for the test.');
await user.click(screen.getByRole('button', { name: /Next/i }));
describe('CreateEscrowWizard - draft restore', () => {
it('shows a Resume draft? prompt on mount when a saved draft exists, and resumes it', async () => {
seedDraft();
const user = userEvent.setup();
renderWizard();

await waitFor(() => expect(screen.getByText(/Counterparty Address/i)).toBeInTheDocument());
await user.type(screen.getByLabelText(/Counterparty Address/i), VALID_STELLAR_ADDRESS);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(await screen.findByText('Resume draft?')).toBeInTheDocument();

await waitFor(() => expect(screen.getByText(/Amount/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'Resume' }));

const assetField = screen.getByLabelText(/Asset/i);
expect(assetField).toBeDisabled();
expect(assetField).toHaveValue('XLM');
expect(screen.getByText('XLM is currently the only supported escrow asset.')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Basic Information')).toBeInTheDocument());
expect(screen.getByLabelText(/Title/i)).toHaveValue('Saved Escrow Title');
expect(window.location.search).toBe('?step=1');
});

it('navigates through all steps with valid data', async () => {
it('discards the saved draft when Discard is clicked in the resume prompt', async () => {
seedDraft();
const user = userEvent.setup();
render(<CreateEscrowWizard />);

// Step 0: Basic Info
const title = screen.getByLabelText(/Title/i);
const category = screen.getByLabelText(/Category/i);
const description = screen.getByLabelText(/Description/i);

await user.type(title, 'Project Development');
await user.selectOptions(category, 'service');
await user.type(description, 'This is a long enough description for the test.');

await user.click(screen.getByRole('button', { name: /Next/i }));

// Step 1: Parties
await waitFor(() => expect(screen.getByText(/Counterparty Address/i)).toBeInTheDocument());
await user.type(screen.getByLabelText(/Counterparty Address/i), VALID_STELLAR_ADDRESS);
await user.click(screen.getByRole('button', { name: /Next/i }));

// Step 2: Terms
await waitFor(() => expect(screen.getByText(/Amount/i)).toBeInTheDocument());
await user.type(screen.getByLabelText(/Amount/i), '100');

const dateInput = screen.getByLabelText(/Deadline/i);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
const dateString = futureDate.toISOString().slice(0, 16);
fireEvent.change(dateInput, { target: { value: dateString } });

await user.click(screen.getByRole('button', { name: /Next/i }));

// Step 3: Optional milestones
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Milestones' })).toBeInTheDocument()
);
await user.click(screen.getByRole('button', { name: /Next/i }));

// Step 4: Optional conditions
await waitFor(() => expect(screen.getByText(/Release Conditions/i)).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: /Next/i }));

// Step 5: Review
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
expect(screen.getByText('Project Development')).toBeInTheDocument();
renderWizard();

expect(await screen.findByText('Resume draft?')).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Discard' }));

expect(screen.queryByText('Resume draft?')).not.toBeInTheDocument();
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
expect(screen.getByText('Choose a Template')).toBeInTheDocument();
});

it('clears drafts older than 7 days and does not prompt to resume', async () => {
seedDraft({ savedAt: Date.now() - SEVEN_DAYS_MS - 1000 });
renderWizard();

await waitFor(() => expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull());
expect(screen.queryByText('Resume draft?')).not.toBeInTheDocument();
});
});

describe('CreateEscrowWizard - cancel / discard-on-navigate', () => {
it('shows a Discard draft? confirmation when canceling mid-wizard with unsaved changes', async () => {
const user = userEvent.setup();
renderWizard();

await goToBasicInfo(user);

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(screen.getByText('Discard draft?')).toBeInTheDocument();
expect(mockPush).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'Discard draft' }));

expect(mockPush).toHaveBeenCalledWith('/dashboard');
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
});

it('lets the user back out of the discard confirmation and keep working', async () => {
const user = userEvent.setup();
renderWizard();

await goToBasicInfo(user);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
const heading = screen.getByText('Discard draft?');
expect(heading).toBeInTheDocument();

const modal = heading.closest('div')!.parentElement as HTMLElement;
await user.click(within(modal).getByRole('button', { name: 'Cancel' }));

expect(screen.queryByText('Discard draft?')).not.toBeInTheDocument();
expect(mockPush).not.toHaveBeenCalled();
expect(screen.getByText('Basic Information')).toBeInTheDocument();
});

it('cancels immediately without a confirmation when there are no unsaved changes', async () => {
const user = userEvent.setup();
renderWizard();

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(mockPush).toHaveBeenCalledWith('/dashboard');
expect(screen.queryByText('Discard draft?')).not.toBeInTheDocument();
});
});
Loading
Loading