test(scanner): cover redesign API and component surfaces (#47)
Add vitest specs for user-cards quantity validation, scan upload-image auth/rate-limit/MIME gates, ScannerDestinationPicker, and ScannedCardItem. Suite grows 21 → 37 tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
30b21b42c5
commit
49d1e62fa7
7 changed files with 401 additions and 3 deletions
|
|
@ -25,8 +25,8 @@ Reports posted to [PR #44](https://github.com/varutasu/tcg-vault/pull/44#issueco
|
|||
| Slug | Priority | Source |
|
||||
| --- | --- | --- |
|
||||
| `scanner-redesign-a11y-fixes` | P1 | **RESOLVED** — PR #45 |
|
||||
| `scanner-user-cards-quantity-guard` | P2 | **In progress** — PR pending |
|
||||
| `test-scanner-redesign-surfaces` | P2 | Reviewer — unit tests for new components + upload route |
|
||||
| `scanner-user-cards-quantity-guard` | P2 | **RESOLVED** — PR #46 |
|
||||
| `test-scanner-redesign-surfaces` | P2 | **In progress** — PR pending |
|
||||
| `document-condition-foil-destination-semantics` | P3 | Reviewer — clarify or migrate condition/foil for collection/deck rows |
|
||||
| `camera-scanner-token-cleanup` | P3 | Design system — replace hardcoded hex overlay colors in CameraScanner |
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ success_metric: |
|
|||
the decks/[id]/cards handler contract.
|
||||
depends_on:
|
||||
- redesign-scanner-flow
|
||||
status: open
|
||||
status: closed
|
||||
created: 2026-05-27
|
||||
---
|
||||
|
||||
|
|
|
|||
27
.convoys/test-scanner-redesign-surfaces.md
Normal file
27
.convoys/test-scanner-redesign-surfaces.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
name: test-scanner-redesign-surfaces
|
||||
classification: test
|
||||
success_metric: |
|
||||
Vitest covers scanner redesign API validation and key component behaviors
|
||||
(destination picker, scanned card row, upload-image auth/rate-limit/MIME).
|
||||
depends_on:
|
||||
- redesign-scanner-flow
|
||||
status: open
|
||||
created: 2026-05-27
|
||||
---
|
||||
|
||||
# Convoy: test-scanner-redesign-surfaces
|
||||
|
||||
P2 follow-up from `audit-redesign-scanner-flow-44` reviewer report.
|
||||
|
||||
## Scope
|
||||
|
||||
- `test/api/user-cards.test.js` — quantity validation
|
||||
- `test/api/scan/upload-image.test.js` — auth, rate limit, MIME rejection
|
||||
- `test/components/ScannerDestinationPicker.test.js` — game filter + destination toggle
|
||||
- `test/components/ScannedCardItem.test.js` — ownership badge + metadata controls
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `npm run test:run` green with new tests exercising real behavior (not implementation trivia).
|
||||
2. No live DB or Blob calls in tests.
|
||||
104
test/api/scan/upload-image.test.js
Normal file
104
test/api/scan/upload-image.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid'),
|
||||
}));
|
||||
|
||||
vi.mock('@vercel/blob', () => ({
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/rate-limit.js', () => ({
|
||||
checkUploadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
import { put } from '@vercel/blob';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware.js';
|
||||
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
|
||||
import handler from '../../../pages/api/scan/upload-image.js';
|
||||
|
||||
const TINY_JPEG =
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AAf/Z';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('POST /api/scan/upload-image', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 7, email: 'a@b.c', role: 'user' });
|
||||
checkUploadRateLimit.mockResolvedValue({ allowed: true, remaining: 9, reset: Date.now() + 60_000 });
|
||||
put.mockResolvedValue({ url: 'https://blob.example/scans/7/test-uuid.jpg' });
|
||||
});
|
||||
|
||||
it('returns 405 for non-POST methods', async () => {
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET' }, res);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getUserFromRequest.mockResolvedValue(null);
|
||||
const res = createRes();
|
||||
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 429 when upload rate limit is exceeded', async () => {
|
||||
checkUploadRateLimit.mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset: Date.now() + 30_000,
|
||||
});
|
||||
const res = createRes();
|
||||
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||||
expect(res.statusCode).toBe(429);
|
||||
expect(res.headers['Retry-After']).toBeDefined();
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for unsupported image MIME in data URL', async () => {
|
||||
const res = createRes();
|
||||
await handler(
|
||||
{ method: 'POST', body: { imageData: 'data:image/gif;base64,AAAA' } },
|
||||
res
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toMatch(/JPEG, PNG, or WebP/);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads a valid JPEG data URL and returns the blob URL', async () => {
|
||||
const res = createRes();
|
||||
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.url).toBe('https://blob.example/scans/7/test-uuid.jpg');
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
'scans/7/test-uuid.jpg',
|
||||
expect.any(Buffer),
|
||||
expect.objectContaining({ access: 'public', contentType: 'image/jpeg' })
|
||||
);
|
||||
});
|
||||
});
|
||||
82
test/api/user-cards.test.js
Normal file
82
test/api/user-cards.test.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@vercel/postgres', () => ({ sql: vi.fn() }));
|
||||
vi.mock('../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '@vercel/postgres';
|
||||
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||||
import handler from '../../pages/api/user-cards.js';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('POST /api/user-cards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
sql.mockResolvedValue({ rows: [] });
|
||||
});
|
||||
|
||||
it('returns 400 when quantity is not a number', async () => {
|
||||
const req = {
|
||||
method: 'POST',
|
||||
body: { cardId: 42, quantity: 'abc' },
|
||||
};
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('Quantity must be at least 1');
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when quantity is zero', async () => {
|
||||
const req = {
|
||||
method: 'POST',
|
||||
body: { cardId: 42, quantity: 0 },
|
||||
};
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('Quantity must be at least 1');
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts parsed quantity for a new owned card', async () => {
|
||||
sql.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const req = {
|
||||
method: 'POST',
|
||||
body: { cardId: 42, quantity: '3', condition: 'LP', is_foil: true },
|
||||
};
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sql).toHaveBeenCalledTimes(2);
|
||||
const insertCall = sql.mock.calls[1];
|
||||
expect(insertCall[2]).toBe(42);
|
||||
expect(insertCall[3]).toBe(3);
|
||||
expect(insertCall[4]).toBe('LP');
|
||||
expect(insertCall[5]).toBe(true);
|
||||
});
|
||||
});
|
||||
91
test/components/ScannedCardItem.test.js
Normal file
91
test/components/ScannedCardItem.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import ScannedCardItem from '../../components/ScannedCardItem.js';
|
||||
|
||||
const BASE_CARD = {
|
||||
id: 'scan-1',
|
||||
name: 'Lightning Bolt',
|
||||
set: 'Alpha',
|
||||
databaseId: 99,
|
||||
quantity: 1,
|
||||
condition: 'NM',
|
||||
isFoil: false,
|
||||
image_url: null,
|
||||
processed: false,
|
||||
};
|
||||
|
||||
function renderItem(overrides = {}, props = {}) {
|
||||
const card = { ...BASE_CARD, ...overrides };
|
||||
const defaultProps = {
|
||||
card,
|
||||
collections: [{ id: 1, name: 'Trade Binder' }],
|
||||
decks: [{ id: 2, name: 'Burn', game: 'mtg' }],
|
||||
selected: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
onIncrement: vi.fn(),
|
||||
onDecrement: vi.fn(),
|
||||
onUpdateMetadata: vi.fn(),
|
||||
onMarkOwned: vi.fn(),
|
||||
onAddToCollection: vi.fn(),
|
||||
onAddToDeck: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(<ScannedCardItem {...defaultProps} />);
|
||||
}
|
||||
|
||||
describe('ScannedCardItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ quantity: 2 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('announces ownership count with role=status', async () => {
|
||||
renderItem();
|
||||
|
||||
const badge = await screen.findByRole('status', {
|
||||
name: /you already own 2 copies/i,
|
||||
});
|
||||
expect(badge.textContent).toContain('You own 2');
|
||||
});
|
||||
|
||||
it('calls onUpdateMetadata when condition changes', () => {
|
||||
const onUpdateMetadata = vi.fn();
|
||||
renderItem({}, { onUpdateMetadata });
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: /condition/i }), {
|
||||
target: { value: 'LP' },
|
||||
});
|
||||
expect(onUpdateMetadata).toHaveBeenCalledWith({ condition: 'LP' });
|
||||
});
|
||||
|
||||
it('calls onUpdateMetadata when foil is toggled', () => {
|
||||
const onUpdateMetadata = vi.fn();
|
||||
renderItem({}, { onUpdateMetadata });
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /foil/i }));
|
||||
expect(onUpdateMetadata).toHaveBeenCalledWith({ isFoil: true });
|
||||
});
|
||||
|
||||
it('does not fetch ownership when the card is not in the catalog', async () => {
|
||||
renderItem({ databaseId: null });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
94
test/components/ScannerDestinationPicker.test.js
Normal file
94
test/components/ScannerDestinationPicker.test.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ScannerDestinationPicker from '../../components/ScannerDestinationPicker.js';
|
||||
|
||||
const COLLECTIONS = [
|
||||
{ id: 1, name: 'Modern Staples', tcg: 'MTG' },
|
||||
{ id: 2, name: 'Pokémon Binder', tcg: 'Pokemon' },
|
||||
];
|
||||
|
||||
const DECKS = [
|
||||
{ id: 10, name: 'Commander', game: 'mtg' },
|
||||
{ id: 11, name: 'Standard', game: 'pokemon' },
|
||||
];
|
||||
|
||||
describe('ScannerDestinationPicker', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows the active destination type with aria-pressed', () => {
|
||||
render(
|
||||
<ScannerDestinationPicker
|
||||
gameFilter="All"
|
||||
onGameFilterChange={vi.fn()}
|
||||
destination={{ type: 'owned', id: null, label: 'My owned cards' }}
|
||||
onDestinationChange={vi.fn()}
|
||||
collections={COLLECTIONS}
|
||||
decks={DECKS}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /owned/i }).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: /collection/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('calls onDestinationChange when switching to a collection', () => {
|
||||
const onDestinationChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ScannerDestinationPicker
|
||||
gameFilter="All"
|
||||
onGameFilterChange={vi.fn()}
|
||||
destination={{ type: 'owned', id: null, label: 'My owned cards' }}
|
||||
onDestinationChange={onDestinationChange}
|
||||
collections={COLLECTIONS}
|
||||
decks={DECKS}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collection/i }));
|
||||
|
||||
expect(onDestinationChange).toHaveBeenCalledWith({
|
||||
type: 'collection',
|
||||
id: 1,
|
||||
label: 'Modern Staples',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters decks by game and disables deck toggle when none match', () => {
|
||||
render(
|
||||
<ScannerDestinationPicker
|
||||
gameFilter="MTG"
|
||||
onGameFilterChange={vi.fn()}
|
||||
destination={{ type: 'deck', id: 10, label: 'Commander' }}
|
||||
onDestinationChange={vi.fn()}
|
||||
collections={COLLECTIONS}
|
||||
decks={DECKS}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /deck/i }).disabled).toBe(false);
|
||||
expect(screen.queryByText(/Standard/)).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onGameFilterChange when the game select changes', () => {
|
||||
const onGameFilterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ScannerDestinationPicker
|
||||
gameFilter="All"
|
||||
onGameFilterChange={onGameFilterChange}
|
||||
destination={{ type: 'owned', id: null, label: 'My owned cards' }}
|
||||
onDestinationChange={vi.fn()}
|
||||
collections={COLLECTIONS}
|
||||
decks={DECKS}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: /game filter/i }), {
|
||||
target: { value: 'Lorcana' },
|
||||
});
|
||||
expect(onGameFilterChange).toHaveBeenCalledWith('Lorcana');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue