Skip to content
Merged
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
13 changes: 13 additions & 0 deletions src/app/payment/dashboard/PaymentDashboardContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react';
import { PaymentHistory } from '@/components/payment/PaymentHistory';
import { PaymentQueuePanel } from '@/components/payment/PaymentQueuePanel';
import { featureFlags } from '@/config/payment';

/**
Expand Down Expand Up @@ -43,6 +44,18 @@ export function PaymentDashboardContent() {
</div>
)}

{/* Offline payment queue management (#4). Shown when a provider is
configured — the queue itself works offline regardless, but the panel
is only meaningful where payments can actually be made. */}
{!noProvidersConfigured && (
<section aria-labelledby="payment-queue-heading">
<h2 id="payment-queue-heading" className="sr-only">
Offline payment queue
</h2>
<PaymentQueuePanel />
</section>
)}

<section aria-labelledby="payment-history-heading">
<h2 id="payment-history-heading" className="mb-4 text-2xl font-bold">
Payment History
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import PaymentQueuePanel from './PaymentQueuePanel';
import type { PaymentQueueItem } from '@/lib/offline-queue/types';

expect.extend(toHaveNoViolations);

const items: PaymentQueueItem[] = [
{
id: 1,
status: 'failed',
retries: 2,
createdAt: Date.now(),
type: 'payment_intent',
data: { amount: 1000 },
lastError: 'Network error',
} as PaymentQueueItem,
];

let queueData: PaymentQueueItem[] = [];

vi.mock('@/lib/offline-queue/payment-adapter', () => ({
paymentQueue: {
getQueue: () => Promise.resolve(queueData),
retryFailed: () => Promise.resolve(0),
sync: () =>
Promise.resolve({ success: 0, failed: 0, skipped: 0, conflicted: 0 }),
clear: () => Promise.resolve(),
},
}));

vi.mock('@/hooks/useOfflineStatus', () => ({
useOfflineStatus: () => ({ isOffline: false }),
}));

describe('PaymentQueuePanel Accessibility', () => {
beforeEach(() => {
queueData = [];
});

it('has no violations in the empty state', async () => {
const { container } = render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() => expect(container.firstChild).toBeInTheDocument());
expect(await axe(container)).toHaveNoViolations();
});

it('has no violations with queued items', async () => {
queueData = items;
const { container, findByTestId } = render(
<PaymentQueuePanel pollIntervalMs={999999} />
);
await findByTestId('queue-items');
expect(await axe(container)).toHaveNoViolations();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import PaymentQueuePanel from './PaymentQueuePanel';

const meta: Meta<typeof PaymentQueuePanel> = {
title: 'Features/Payment/PaymentQueuePanel',
component: PaymentQueuePanel,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'User-facing management surface for the offline payment queue (#4) — status, queued items + retry counts, manual retry, and clear-queue. Reads the live `paymentQueue`; in Storybook (empty IndexedDB) it shows the empty state.',
},
},
},
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: { pollIntervalMs: 999999 },
};
126 changes: 126 additions & 0 deletions src/components/payment/PaymentQueuePanel/PaymentQueuePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PaymentQueuePanel from './PaymentQueuePanel';
import type { PaymentQueueItem } from '@/lib/offline-queue/types';

const mockQueue = {
getQueue: vi.fn(),
retryFailed: vi.fn(),
sync: vi.fn(),
clear: vi.fn(),
};
let offline = false;

vi.mock('@/lib/offline-queue/payment-adapter', () => ({
paymentQueue: {
getQueue: (...a: unknown[]) => mockQueue.getQueue(...a),
retryFailed: (...a: unknown[]) => mockQueue.retryFailed(...a),
sync: (...a: unknown[]) => mockQueue.sync(...a),
clear: (...a: unknown[]) => mockQueue.clear(...a),
},
}));

vi.mock('@/hooks/useOfflineStatus', () => ({
useOfflineStatus: () => ({ isOffline: offline }),
}));

function makeItem(over: Partial<PaymentQueueItem> = {}): PaymentQueueItem {
return {
id: 1,
status: 'pending',
retries: 0,
createdAt: Date.now(),
type: 'payment_intent',
data: { amount: 1000 },
...over,
} as PaymentQueueItem;
}

describe('PaymentQueuePanel', () => {
beforeEach(() => {
vi.clearAllMocks();
offline = false;
mockQueue.getQueue.mockResolvedValue([]);
mockQueue.retryFailed.mockResolvedValue(0);
mockQueue.sync.mockResolvedValue({
success: 0,
failed: 0,
skipped: 0,
conflicted: 0,
});
mockQueue.clear.mockResolvedValue(undefined);
});

it('shows the empty state when the queue is empty', async () => {
render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() =>
expect(screen.getByTestId('queue-count')).toHaveTextContent(
/no queued payments/i
)
);
expect(screen.getByTestId('queue-retry')).toBeDisabled();
expect(screen.getByTestId('queue-clear')).toBeDisabled();
});

it('lists queued items with their retry counts', async () => {
mockQueue.getQueue.mockResolvedValue([
makeItem({ id: 1, retries: 2 }),
makeItem({ id: 2, status: 'failed', retries: 5, lastError: 'boom' }),
]);
render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() =>
expect(screen.getByTestId('queue-items')).toBeInTheDocument()
);
expect(screen.getByTestId('queue-item-retries-1')).toHaveTextContent(
/attempt 2\/5/i
);
// At max retries → "Max retries"
expect(screen.getByTestId('queue-item-retries-2')).toHaveTextContent(
/max retries/i
);
expect(screen.getByText('boom')).toBeInTheDocument();
expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 pending/i);
expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 failed/i);
});

it('retry calls retryFailed then sync', async () => {
mockQueue.getQueue.mockResolvedValue([
makeItem({ status: 'failed', retries: 1 }),
]);
render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() =>
expect(screen.getByTestId('queue-retry')).toBeEnabled()
);
fireEvent.click(screen.getByTestId('queue-retry'));
await waitFor(() => expect(mockQueue.retryFailed).toHaveBeenCalledTimes(1));
expect(mockQueue.sync).toHaveBeenCalledTimes(1);
});

it('disables Retry now when offline', async () => {
offline = true;
mockQueue.getQueue.mockResolvedValue([makeItem()]);
render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() =>
expect(screen.getByTestId('queue-conn-state')).toHaveTextContent(
/offline/i
)
);
expect(screen.getByTestId('queue-retry')).toBeDisabled();
});

it('clear requires confirmation before calling clear()', async () => {
mockQueue.getQueue.mockResolvedValue([makeItem()]);
render(<PaymentQueuePanel pollIntervalMs={999999} />);
await waitFor(() =>
expect(screen.getByTestId('queue-clear')).toBeEnabled()
);

fireEvent.click(screen.getByTestId('queue-clear'));
// Confirmation appears; clear NOT yet called.
expect(screen.getByTestId('queue-clear-confirm')).toBeInTheDocument();
expect(mockQueue.clear).not.toHaveBeenCalled();

fireEvent.click(screen.getByTestId('queue-clear-confirm-yes'));
await waitFor(() => expect(mockQueue.clear).toHaveBeenCalledTimes(1));
});
});
Loading
Loading