* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Ship a cart-then-commit mobile scanner so phone sessions stay on the camera. Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
6.4 KiB
JavaScript
225 lines
6.4 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
|
|
const routeApiMocks = vi.hoisted(() => ({
|
|
addScannedCardToOwned: vi.fn().mockResolvedValue(undefined),
|
|
addScannedCardToCollection: vi.fn().mockResolvedValue(undefined),
|
|
addScannedCardToDeck: vi.fn().mockResolvedValue(undefined),
|
|
routeScannedCardToDestination: vi.fn().mockResolvedValue(undefined),
|
|
fetchScannerCollections: vi.fn().mockResolvedValue([]),
|
|
fetchScannerDecks: vi.fn().mockResolvedValue([]),
|
|
fetchBatchOwnership: vi.fn().mockResolvedValue({}),
|
|
createScannerCollection: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../lib/scanner-route-api.js', () => ({
|
|
addScannedCardToOwned: routeApiMocks.addScannedCardToOwned,
|
|
addScannedCardToCollection: routeApiMocks.addScannedCardToCollection,
|
|
addScannedCardToDeck: routeApiMocks.addScannedCardToDeck,
|
|
routeScannedCardToDestination: routeApiMocks.routeScannedCardToDestination,
|
|
fetchScannerCollections: routeApiMocks.fetchScannerCollections,
|
|
fetchScannerDecks: routeApiMocks.fetchScannerDecks,
|
|
fetchBatchOwnership: routeApiMocks.fetchBatchOwnership,
|
|
createScannerCollection: routeApiMocks.createScannerCollection,
|
|
}));
|
|
|
|
import { useScannerQueue } from '../../lib/use-scanner-queue.js';
|
|
import { clearScannerCartStorage } from '../../lib/scanner-session.js';
|
|
|
|
const {
|
|
addScannedCardToOwned,
|
|
routeScannedCardToDestination,
|
|
} = routeApiMocks;
|
|
|
|
const scanDefaults = { condition: 'NM', isFoil: false };
|
|
const user = { userId: 1, email: 'test@example.com', role: 'user' };
|
|
|
|
describe('useScannerQueue — cart model', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
sessionStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearScannerCartStorage();
|
|
});
|
|
|
|
it('handleCardScanned enqueues locally without route helpers', async () => {
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: { type: 'owned', id: null, label: 'My Collection' },
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Pikachu',
|
|
set: 'Base',
|
|
databaseId: 101,
|
|
});
|
|
});
|
|
|
|
expect(routeScannedCardToDestination).not.toHaveBeenCalled();
|
|
expect(addScannedCardToOwned).not.toHaveBeenCalled();
|
|
expect(result.current.scannedCards).toHaveLength(1);
|
|
const card = result.current.scannedCards[0];
|
|
expect(card).toMatchObject({
|
|
name: 'Pikachu',
|
|
set: 'Base',
|
|
processed: false,
|
|
});
|
|
expect(card.id).toBeTruthy();
|
|
expect(result.current.selectedCards.has(card.id)).toBe(true);
|
|
});
|
|
|
|
it('handleBulkAction owned commits each selected card and removes them from the cart', async () => {
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: null,
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Bolt',
|
|
set: 'Alpha',
|
|
databaseId: 201,
|
|
});
|
|
});
|
|
|
|
const cardId = result.current.scannedCards[0].id;
|
|
|
|
await act(async () => {
|
|
await result.current.handleBulkAction('owned');
|
|
});
|
|
|
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
|
expect(addScannedCardToOwned).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: cardId, name: 'Bolt', set: 'Alpha' })
|
|
);
|
|
expect(result.current.scannedCards).toHaveLength(0);
|
|
expect(result.current.selectedCards.size).toBe(0);
|
|
});
|
|
|
|
it('commitSelectedToOwned returns successful ids instead of reading stale queue state', async () => {
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: null,
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Jace',
|
|
set: 'Origins',
|
|
databaseId: 301,
|
|
});
|
|
});
|
|
|
|
const cardId = result.current.scannedCards[0].id;
|
|
let successfulIds;
|
|
await act(async () => {
|
|
successfulIds = await result.current.commitSelectedToOwned();
|
|
});
|
|
|
|
expect(successfulIds).toEqual([cardId]);
|
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
|
expect(result.current.scannedCards).toHaveLength(0);
|
|
});
|
|
|
|
it('commitSelectedToOwned returns an empty array when the API fails', async () => {
|
|
addScannedCardToOwned.mockRejectedValueOnce(new Error('network'));
|
|
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: null,
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Jace',
|
|
set: 'Origins',
|
|
databaseId: 301,
|
|
});
|
|
});
|
|
|
|
let successfulIds;
|
|
await act(async () => {
|
|
successfulIds = await result.current.commitSelectedToOwned();
|
|
});
|
|
|
|
expect(successfulIds).toEqual([]);
|
|
expect(result.current.scannedCards).toHaveLength(1);
|
|
});
|
|
|
|
it('commitSelectedToOwned routes through handleBulkAction owned', async () => {
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: null,
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Jace',
|
|
set: 'Origins',
|
|
databaseId: 301,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.commitSelectedToOwned();
|
|
});
|
|
|
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
|
expect(result.current.scannedCards).toHaveLength(0);
|
|
});
|
|
|
|
it('persists cart state to sessionStorage on scan', async () => {
|
|
const { result } = renderHook(() =>
|
|
useScannerQueue({
|
|
user,
|
|
sessionDestination: null,
|
|
scanDefaults,
|
|
deckMode: null,
|
|
})
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleCardScanned({
|
|
name: 'Liliana',
|
|
set: 'Midnight',
|
|
databaseId: 401,
|
|
});
|
|
});
|
|
|
|
const cardId = result.current.scannedCards[0].id;
|
|
|
|
await waitFor(() => {
|
|
const raw = sessionStorage.getItem('deckhearth:scanner-cart');
|
|
expect(raw).toBeTruthy();
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.scannedCards).toHaveLength(1);
|
|
expect(parsed.selectedCardIds).toEqual([cardId]);
|
|
});
|
|
|
|
expect(result.current.unprocessedCount).toBe(1);
|
|
});
|
|
});
|