Appearance
Buylist URL + CSV Import Entry Points Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give merchants two new first-class entry points on the Buy Order tab โ paste an Archidekt deck URL, or upload a ManaBox/TCGplayer CSV whose format is detected automatically โ both feeding the existing match/price/rate pipeline unchanged.
Architecture: A new server/services/deckUrlImportService.js holds a connector registry (Archidekt enabled, Moxfield registered-but-disabled). It turns a merchant-pasted URL into the existing ParsedLine shape, which generateQuote already knows how to price. The pasted URL is never fetched โ it is parsed for a numeric deck id, and the request URL is constructed by us against a hardcoded host. CSV format detection moves server-side so the client's format dropdown can be retired.
Tech Stack: Node.js/Express (CommonJS), Zod, node-fetch v2 (already a dependency), express-rate-limit, React 18 + retroui, Vitest (test files are ESM).
Design spec: docs/superpowers/specs/2026-07-26-buylist-url-csv-import-design.md โ read its Vendor Research and Field Mapping sections before starting. Every literal in this plan traces to a real captured response documented there.
Global Constraints โ
- Never invent a literal (CLAUDE.md ยง5.4). Every Archidekt field path below was verified against a live response captured 2026-07-26 from
https://archidekt.com/api/decks/365563/(HTTP 200, 330907 bytes). Two paths are counter-intuitive and must be copied exactly:collectorNumberis oncard, not oncard.oracleCard; andedition.editioncodeis lowercase ("a25") and must be uppercased. - The user-supplied URL is never fetched. Parse it, extract a digits-only id, construct our own URL against a hardcoded host. No redirect following, no
endsWithhost matching. gameis never defaulted (ยง5.5). No|| 'mtg'anywhere in this diff.gameflows client โ schema โ service as it does today.- Public portal is out of scope.
server/routes/publicBuylist.jsuses separate schemas (publicQuoteSchema,publicOrderCreateSchema) and must not be touched โ unauthenticated traffic must never trigger an outbound fetch. - Nothing new is persisted.
BuylistOrder.lines[]shape is unchanged, so ยง5.2's round-trip-test requirement does not apply. - No plugin code, per-game model, or importer is touched โ ยง5.1 parity is satisfied by declaration, not mirroring. State in the PR: mtg = supported; pokemon/riftbound = explicitly rejected with a clear message (Archidekt is a Magic-only product).
- Server code is CommonJS (
require); test files are ESM (import), matching every neighbouring file. - DI pattern is
_setDeps/_resetDeps/getDeps(), exactly asbuylistQuoteService.jsalready does. - No test performs a live network fetch.
fetchis always dependency-injected. - Coverage โฅ70% on all four metrics for every modified file;
npm run lintclean. - Docs under
docs/are built by VitePress (srcExclude: ['historical/**']only), so this plan file itself must keep angle brackets and braces inside code fences or backticks โ an unescaped generic type broke the docs build once already (PR #390). - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
Task 1: Deck URL identification and the connector registry โ
Files:
- Create:
server/services/deckUrlImportService.js - Test:
server/services/deckUrlImportService.test.js
Interfaces:
Produces:
identifyDeckUrl(rawUrl: string): { connector, deckId: string }โ throwsDeckImportErroron any rejection.DeckImportErrorhas.name === 'DeckImportError'and a.codestring. A connector is{ id, label, enabled, games: string[], hosts: string[], extractId(url: URL), buildApiUrl(deckId), toParsedLines(json) }. Used by Tasks 2 and 3.[ ] Step 1: Write the failing tests
Create server/services/deckUrlImportService.test.js:
javascript
import { describe, it, expect } from 'vitest';
import { identifyDeckUrl, DeckImportError } from './deckUrlImportService.js';
describe('identifyDeckUrl', () => {
it('identifies an Archidekt deck URL and extracts the numeric id', () => {
const { connector, deckId } = identifyDeckUrl('https://archidekt.com/decks/365563');
expect(connector.id).toBe('archidekt');
expect(deckId).toBe('365563');
});
it('accepts the www host and a trailing slug segment', () => {
expect(identifyDeckUrl('https://www.archidekt.com/decks/365563/brago-blink').deckId).toBe('365563');
});
it('ignores query strings and fragments', () => {
expect(identifyDeckUrl('https://archidekt.com/decks/365563?tab=1#notes').deckId).toBe('365563');
});
it('rejects a non-https URL', () => {
expect(() => identifyDeckUrl('http://archidekt.com/decks/365563')).toThrow(DeckImportError);
try { identifyDeckUrl('http://archidekt.com/decks/365563'); }
catch (e) { expect(e.code).toBe('INSECURE_URL'); }
});
it('rejects a lookalike host that merely ends with the allowed host', () => {
try { identifyDeckUrl('https://archidekt.com.evil.com/decks/1'); }
catch (e) { expect(e.code).toBe('UNSUPPORTED_HOST'); }
});
it('rejects a userinfo-prefixed URL whose real host is not allowed', () => {
// new URL() resolves the host to evil.com; only the parsed hostname is
// ever compared, so the archidekt.com@ prefix cannot smuggle anything.
try { identifyDeckUrl('https://archidekt.com@evil.com/decks/1'); }
catch (e) { expect(e.code).toBe('UNSUPPORTED_HOST'); }
});
it('rejects an internal-metadata address', () => {
try { identifyDeckUrl('https://169.254.169.254/decks/1'); }
catch (e) { expect(e.code).toBe('UNSUPPORTED_HOST'); }
});
it('rejects a malformed URL', () => {
try { identifyDeckUrl('not a url'); }
catch (e) { expect(e.code).toBe('INVALID_URL'); }
});
it('rejects an Archidekt URL that is not a deck page', () => {
try { identifyDeckUrl('https://archidekt.com/search/decks'); }
catch (e) { expect(e.code).toBe('NO_DECK_ID'); }
});
it('rejects a non-numeric deck id', () => {
try { identifyDeckUrl('https://archidekt.com/decks/abc'); }
catch (e) { expect(e.code).toBe('NO_DECK_ID'); }
});
it('gives Moxfield its own message instead of a generic unsupported-host error', () => {
try { identifyDeckUrl('https://www.moxfield.com/decks/abc123'); }
catch (e) {
expect(e.code).toBe('CONNECTOR_DISABLED');
expect(e.message).toMatch(/Moxfield/);
}
});
});- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- deckUrlImportService.test.js Expected: FAIL โ the module does not exist yet ("Failed to resolve import").
- [ ] Step 3: Create the service with the registry and identification
Create server/services/deckUrlImportService.js:
javascript
/**
* Deck-URL import connectors for Smart Buylist.
*
* Turns a merchant-pasted deck URL into the same ParsedLine shape
* buylistQuoteService's parseCardList/parseManaBoxCsv/parseTcgplayerCsv
* produce, so everything downstream (matchCatalogLine, resolveBasisPrice,
* applyBuylistRate) is untouched and stays plugin-driven.
*
* Vendor grounding, rate-limit etiquette, and the accepted Archidekt
* ToS tension are documented in
* docs/superpowers/specs/2026-07-26-buylist-url-csv-import-design.md.
*/
'use strict';
// Identifies us to Archidekt so they can contact us rather than silently
// block, which is the implicit ask behind their staff's "just know that you
// might hit our rate limiter" grant of API permission.
const USER_AGENT = 'LGS-Forge/1.0 (+https://lgsforge.com; buylist deck import)';
// Matches the MAX_LINES cap the CSV connectors in buylistQuoteService.js
// already enforce, for the same reason: a legitimate buylist is at most a
// few hundred cards.
const MAX_LINES = 500;
class DeckImportError extends Error {
constructor(message, code) {
super(message);
this.name = 'DeckImportError';
this.code = code;
}
}
// Only a digits-only id survives into the URL we construct, which is what
// makes the constructed-URL approach safe rather than merely filtered.
const ARCHIDEKT_DECK_PATH_RE = /^\/decks\/(\d+)(?:\/|$)/;
const MOXFIELD_DECK_PATH_RE = /^\/decks\/([A-Za-z0-9_-]+)(?:\/|$)/;
const CONNECTORS = [
{
id: 'archidekt',
label: 'Archidekt',
enabled: true,
// Archidekt is a Magic-only product. Declared rather than read from
// the response: the sample deck's top-level `game` was null, so it
// cannot be trusted to self-identify (design spec, Field Mapping).
games: ['mtg'],
hosts: ['archidekt.com', 'www.archidekt.com'],
extractId(url) {
const match = ARCHIDEKT_DECK_PATH_RE.exec(url.pathname);
return match ? match[1] : null;
},
buildApiUrl(deckId) {
return `https://archidekt.com/api/decks/${deckId}/`;
},
toParsedLines: null // set in Task 2
},
{
// Registered but disabled: Moxfield publishes no public API, and
// Cloudflare rejects unallowlisted clients (a direct fetch during
// research returned HTTP 403). Present so a pasted Moxfield link
// gets an honest, specific message instead of "unsupported site",
// and so enabling it later is a flag flip plus a mapper.
id: 'moxfield',
label: 'Moxfield',
enabled: false,
games: ['mtg'],
hosts: ['moxfield.com', 'www.moxfield.com'],
extractId(url) {
const match = MOXFIELD_DECK_PATH_RE.exec(url.pathname);
return match ? match[1] : null;
},
buildApiUrl: null,
toParsedLines: null
}
];
function enabledLabels() {
return CONNECTORS.filter((c) => c.enabled).map((c) => c.label).join(', ');
}
/**
* Parse-and-match only โ performs no network I/O. Any rejection here means
* nothing was ever fetched.
*
* Note the port and userinfo components of the input are discarded entirely:
* only url.hostname is compared, and only the extracted id reaches the URL
* we build in buildApiUrl.
*/
function identifyDeckUrl(rawUrl) {
let url;
try {
url = new URL(String(rawUrl));
} catch {
throw new DeckImportError('That does not look like a valid URL.', 'INVALID_URL');
}
if (url.protocol !== 'https:') {
throw new DeckImportError('Deck URLs must start with https://.', 'INSECURE_URL');
}
// Exact hostname equality, never endsWith โ 'archidekt.com.evil.com'
// must not match 'archidekt.com'.
const hostname = url.hostname.toLowerCase();
const connector = CONNECTORS.find((c) => c.hosts.includes(hostname));
if (!connector) {
throw new DeckImportError(
`That deck site is not supported. Supported sites: ${enabledLabels()}.`,
'UNSUPPORTED_HOST'
);
}
if (!connector.enabled) {
throw new DeckImportError(
`${connector.label} imports are not available yet because ${connector.label} does not offer public API access. Export your list and use the CSV or paste options instead.`,
'CONNECTOR_DISABLED'
);
}
const deckId = connector.extractId(url);
if (!deckId) {
throw new DeckImportError(
`That ${connector.label} link does not point at a deck. Copy the URL from the deck page itself.`,
'NO_DECK_ID'
);
}
return { connector, deckId };
}
module.exports = {
identifyDeckUrl,
DeckImportError,
CONNECTORS,
MAX_LINES
};- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- deckUrlImportService.test.js Expected: PASS โ 11 tests.
- [ ] Step 5: Commit
bash
git add server/services/deckUrlImportService.js server/services/deckUrlImportService.test.js
git commit -m "Add deck URL identification for buylist imports"Task 2: Map an Archidekt response to ParsedLine โ
Files:
- Create:
server/services/__fixtures__/archidektDeck.json - Modify:
server/services/deckUrlImportService.js(addarchidektToParsedLines, wire it into the Archidekt connector'stoParsedLines, export it) - Test:
server/services/deckUrlImportService.test.js(newdescribeblock)
Interfaces:
Consumes:
MAX_LINES(Task 1).Produces:
archidektToParsedLines(deck: object): ParsedLine[]whereParsedLine = { rawLine, quantity, name, setCode, collectorNumber, condition }โ the exact shapeparseCardListproduces andmatchCatalogLine/priceAndRateLineconsume. Used by Task 3.[ ] Step 1: Create the fixture
Create server/services/__fixtures__/archidektDeck.json. Every value is real, trimmed from the captured response (design spec, Field Mapping) โ three cards: one ordinary, one in a Sideboard category (includedInDeck: true, must be kept), one in Maybeboard (includedInDeck: false, must be dropped):
json
{
"id": 365563,
"name": "Brago Blink",
"game": null,
"categories": [
{ "id": 3159872, "name": "Interaction", "isPremier": false, "includedInDeck": true, "includedInPrice": true },
{ "id": 6793307, "name": "Sideboard", "isPremier": false, "includedInDeck": true, "includedInPrice": true },
{ "id": 3159868, "name": "Maybeboard", "isPremier": false, "includedInDeck": false, "includedInPrice": false }
],
"cards": [
{
"id": 861241711,
"categories": ["Interaction"],
"modifier": "Normal",
"quantity": 1,
"card": {
"id": 33710,
"collectorNumber": "68",
"edition": { "editioncode": "a25", "editionname": "Masters 25" },
"oracleCard": { "name": "Pact of Negation" }
}
},
{
"id": 861241712,
"categories": ["Sideboard"],
"modifier": "Foil",
"quantity": 2,
"card": {
"id": 33711,
"collectorNumber": "65",
"edition": { "editioncode": "ima", "editionname": "Iconic Masters" },
"oracleCard": { "name": "Mana Drain" }
}
},
{
"id": 861241713,
"categories": ["Maybeboard"],
"modifier": "Normal",
"quantity": 4,
"card": {
"id": 33712,
"collectorNumber": "8",
"edition": { "editioncode": "c17", "editionname": "Commander 2017" },
"oracleCard": { "name": "Teferi's Protection" }
}
}
]
}- [ ] Step 2: Write the failing tests
Add to the import line at the top of server/services/deckUrlImportService.test.js so it reads:
javascript
import { identifyDeckUrl, archidektToParsedLines, DeckImportError } from './deckUrlImportService.js';
import deckFixture from './__fixtures__/archidektDeck.json';Add this describe block after the existing one:
javascript
describe('archidektToParsedLines', () => {
it('maps a card to the ParsedLine shape parseCardList produces', () => {
const [line] = archidektToParsedLines(deckFixture);
expect(line).toEqual({
rawLine: '1x Pact of Negation [A25] 68',
quantity: 1,
name: 'Pact of Negation',
setCode: 'A25',
collectorNumber: '68',
condition: 'nm'
});
});
it('uppercases the lowercase editioncode the API returns', () => {
// Real response has "a25"; the catalog keys on uppercase set codes.
expect(archidektToParsedLines(deckFixture)[0].setCode).toBe('A25');
});
it('reads collectorNumber from card, not card.oracleCard', () => {
expect(archidektToParsedLines(deckFixture)[0].collectorNumber).toBe('68');
});
it('excludes cards in a category flagged includedInDeck: false', () => {
const names = archidektToParsedLines(deckFixture).map((l) => l.name);
expect(names).not.toContain("Teferi's Protection");
});
it('keeps cards in a Sideboard category that is flagged includedInDeck: true', () => {
const names = archidektToParsedLines(deckFixture).map((l) => l.name);
expect(names).toContain('Mana Drain');
});
it('reads the exclusion set from the flag rather than the literal name Maybeboard', () => {
// Categories are user-defined per deck, so a renamed maybeboard must
// still be excluded and a category merely NAMED Maybeboard but flagged
// included must still be kept.
const renamed = {
categories: [{ name: 'Trade Binder', includedInDeck: false }],
cards: [{
categories: ['Trade Binder'],
quantity: 1,
card: { collectorNumber: '1', edition: { editioncode: 'znr' }, oracleCard: { name: 'Ignored Card' } }
}]
};
expect(archidektToParsedLines(renamed)).toEqual([]);
});
it('carries quantity through', () => {
const manaDrain = archidektToParsedLines(deckFixture).find((l) => l.name === 'Mana Drain');
expect(manaDrain.quantity).toBe(2);
});
it('drops the finish modifier, matching the CSV connectors', () => {
// Archidekt says "Foil" for Mana Drain; finish is re-derived downstream
// from the matched catalog doc, exactly as for every other parser.
const manaDrain = archidektToParsedLines(deckFixture).find((l) => l.name === 'Mana Drain');
expect(manaDrain).not.toHaveProperty('finish');
});
it('caps the number of lines at MAX_LINES', () => {
const many = {
categories: [],
cards: Array.from({ length: 600 }, (_, i) => ({
categories: [],
quantity: 1,
card: { collectorNumber: String(i), edition: { editioncode: 'znr' }, oracleCard: { name: `Card ${i}` } }
}))
};
expect(archidektToParsedLines(many)).toHaveLength(500);
});
it('skips entries with no card or no name rather than throwing', () => {
const messy = {
categories: [],
cards: [
null,
{ categories: [], quantity: 1, card: null },
{ categories: [], quantity: 1, card: { oracleCard: { name: ' ' }, edition: {} } }
]
};
expect(archidektToParsedLines(messy)).toEqual([]);
});
it('returns an empty array for a response with no cards', () => {
expect(archidektToParsedLines({})).toEqual([]);
});
});- [ ] Step 3: Run the tests to verify they fail
Run: npm test -- deckUrlImportService.test.js -t archidektToParsedLines Expected: FAIL โ archidektToParsedLines is not a function.
- [ ] Step 4: Implement the mapper
In server/services/deckUrlImportService.js, insert this function immediately before the const CONNECTORS = [ declaration (it must exist before the array references it):
javascript
/**
* Archidekt deck response -> ParsedLine[].
*
* Field paths verified against a live response captured 2026-07-26 from
* https://archidekt.com/api/decks/365563/ (see the design spec). Two are
* counter-intuitive and are the reason this mapping is not guessable:
* - collectorNumber lives on `card`, NOT on `card.oracleCard` (where name is)
* - edition.editioncode is lowercase ("a25") and must be uppercased
*
* `modifier` ("Normal"/"Foil") is deliberately dropped: parseCardList and both
* CSV connectors also accept no explicit finish, and finish is re-derived
* downstream from the matched catalog doc. Note the vocabulary is "Normal",
* not "nonfoil" โ the exact literal trap CLAUDE.md ยง5.4 warns about.
*/
function archidektToParsedLines(deck) {
const cards = Array.isArray(deck && deck.cards) ? deck.cards : [];
const deckCategories = Array.isArray(deck && deck.categories) ? deck.categories : [];
// Categories are user-defined per deck (the sample contained custom names
// like "Fetches" and "Wincon"), so the exclusion set is built from the
// includedInDeck flag. Hardcoding "Maybeboard" would quote a merchant for
// cards the customer never offered.
const excludedCategories = new Set(
deckCategories.filter((c) => c && c.includedInDeck === false).map((c) => c.name)
);
const lines = [];
for (const entry of cards) {
if (lines.length >= MAX_LINES) break;
if (!entry || !entry.card) continue;
const entryCategories = Array.isArray(entry.categories) ? entry.categories : [];
if (entryCategories.some((name) => excludedCategories.has(name))) continue;
const name = String((entry.card.oracleCard && entry.card.oracleCard.name) || '').trim();
if (!name) continue;
const quantity = Number.isFinite(entry.quantity) && entry.quantity > 0 ? entry.quantity : 1;
const editionCode = String((entry.card.edition && entry.card.edition.editioncode) || '').trim();
const setCode = editionCode ? editionCode.toUpperCase() : null;
const collectorNumber = String(entry.card.collectorNumber || '').trim() || null;
lines.push({
rawLine: `${quantity}x ${name}${setCode ? ` [${setCode}]` : ''}${collectorNumber ? ` ${collectorNumber}` : ''}`,
quantity,
name,
setCode,
collectorNumber,
// Decklists carry no condition. 'nm' is the same default
// normalizeCondition already applies everywhere else.
condition: 'nm'
});
}
return lines;
}Then in the Archidekt connector object, replace:
javascript
toParsedLines: null // set in Task 2with:
javascript
toParsedLines: archidektToParsedLinesAnd add archidektToParsedLines to module.exports:
javascript
module.exports = {
identifyDeckUrl,
archidektToParsedLines,
DeckImportError,
CONNECTORS,
MAX_LINES
};- [ ] Step 5: Run the tests to verify they pass
Run: npm test -- deckUrlImportService.test.js Expected: PASS โ all tests from Tasks 1 and 2.
- [ ] Step 6: Commit
bash
git add server/services/deckUrlImportService.js server/services/deckUrlImportService.test.js server/services/__fixtures__/archidektDeck.json
git commit -m "Map Archidekt deck responses to buylist quote lines"Task 3: Fetch the deck over the network โ
Files:
- Modify:
server/services/deckUrlImportService.js(add requires, DI block,fetchDeckJson,importDeckUrl; extend exports) - Test:
server/services/deckUrlImportService.test.js(newdescribeblock)
Interfaces:
Consumes:
identifyDeckUrl(Task 1),archidektToParsedLines(Task 2).Produces:
importDeckUrl(sourceUrl: string, game: string): Promise<ParsedLine[]>, plus_setDeps({ fetch })/_resetDeps(). Used by Task 5.[ ] Step 1: Write the failing tests
Update the import line at the top of server/services/deckUrlImportService.test.js to:
javascript
import { identifyDeckUrl, archidektToParsedLines, importDeckUrl, DeckImportError, _setDeps, _resetDeps } from './deckUrlImportService.js';and add vi, afterEach to the vitest import so line 1 reads:
javascript
import { describe, it, expect, vi, afterEach } from 'vitest';Add this describe block at the end of the file:
javascript
describe('importDeckUrl', () => {
afterEach(() => _resetDeps());
const okResponse = (body) => ({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(body))
});
it('fetches a URL we construct, never the one the merchant pasted', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse(deckFixture));
_setDeps({ fetch: fetchMock });
await importDeckUrl('https://www.archidekt.com/decks/365563/brago-blink?tab=1', 'mtg');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('https://archidekt.com/api/decks/365563/');
});
it('never follows redirects and identifies itself', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse(deckFixture));
_setDeps({ fetch: fetchMock });
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
const options = fetchMock.mock.calls[0][1];
expect(options.redirect).toBe('error');
expect(options.headers['User-Agent']).toMatch(/LGS-Forge/);
});
it('returns mapped ParsedLines', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue(okResponse(deckFixture)) });
const lines = await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
expect(lines.map((l) => l.name)).toEqual(['Pact of Negation', 'Mana Drain']);
});
it('rejects a game the connector does not support, without fetching', async () => {
const fetchMock = vi.fn();
_setDeps({ fetch: fetchMock });
await expect(importDeckUrl('https://archidekt.com/decks/365563', 'pokemon')).rejects.toThrow(DeckImportError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps a 404 to a private-or-deleted message', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue({ ok: false, status: 404 }) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('NOT_FOUND'); }
});
it('maps a 429 to its own rate-limit message', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue({ ok: false, status: 429 }) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) {
expect(e.code).toBe('RATE_LIMITED');
expect(e.message).toMatch(/rate-limiting/i);
}
});
it('maps any other non-ok status to an upstream error', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue({ ok: false, status: 500 }) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('UPSTREAM_ERROR'); }
});
it('maps an aborted request to a timeout message', async () => {
const abortErr = new Error('aborted');
abortErr.name = 'AbortError';
_setDeps({ fetch: vi.fn().mockRejectedValue(abortErr) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('TIMEOUT'); }
});
it('maps a refused redirect (or any transport failure) to a network error', async () => {
_setDeps({ fetch: vi.fn().mockRejectedValue(new Error('redirect not allowed')) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('NETWORK'); }
});
it('rejects an oversized response body', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve('x'.repeat(5 * 1024 * 1024 + 1))
}) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('TOO_LARGE'); }
});
it('rejects a non-JSON response', async () => {
_setDeps({ fetch: vi.fn().mockResolvedValue({
ok: true, status: 200, text: () => Promise.resolve('<html>nope</html>')
}) });
try {
await importDeckUrl('https://archidekt.com/decks/365563', 'mtg');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('BAD_JSON'); }
});
});- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- deckUrlImportService.test.js -t importDeckUrl Expected: FAIL โ importDeckUrl is not a function.
- [ ] Step 3: Implement the network layer
In server/services/deckUrlImportService.js, add these requires immediately after 'use strict';:
javascript
const defaultFetch = require('node-fetch');
const { createAbortController } = require('../utils/abortController');Add these constants next to MAX_LINES:
javascript
const FETCH_TIMEOUT_MS = 10000;
// A real 109-card deck response measured ~331 KB, so 5 MB is generous
// headroom while still bounding memory if an upstream response goes wrong.
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;Add the DI block and the two functions at the end of the file, before module.exports:
javascript
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
return _deps || { fetch: defaultFetch };
}
async function fetchDeckJson(apiUrl, connector, deps) {
const controller = createAbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response;
try {
response = await deps.fetch(apiUrl, {
method: 'GET',
// Never follow a redirect: the whole point of constructing our own
// URL is defeated if the response can bounce us somewhere else.
redirect: 'error',
signal: controller.signal,
size: MAX_RESPONSE_BYTES,
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT }
});
} catch (error) {
if (error && error.name === 'AbortError') {
throw new DeckImportError(`${connector.label} took too long to respond. Try again.`, 'TIMEOUT');
}
throw new DeckImportError(`Could not reach ${connector.label}. Try again shortly.`, 'NETWORK');
} finally {
clearTimeout(timer);
}
if (response.status === 404) {
throw new DeckImportError(`That ${connector.label} deck was not found. It may be private or deleted.`, 'NOT_FOUND');
}
if (response.status === 429) {
throw new DeckImportError(`${connector.label} is rate-limiting requests. Wait a moment and try again.`, 'RATE_LIMITED');
}
if (!response.ok) {
throw new DeckImportError(`${connector.label} returned an error (${response.status}).`, 'UPSTREAM_ERROR');
}
let text;
try {
// node-fetch's `size` option surfaces here, not at fetch() time.
text = await response.text();
} catch {
throw new DeckImportError(`That ${connector.label} deck is too large to import.`, 'TOO_LARGE');
}
if (text.length > MAX_RESPONSE_BYTES) {
throw new DeckImportError(`That ${connector.label} deck is too large to import.`, 'TOO_LARGE');
}
try {
return JSON.parse(text);
} catch {
throw new DeckImportError(`${connector.label} returned an unexpected response.`, 'BAD_JSON');
}
}
/**
* Merchant-pasted URL -> ParsedLine[]. The pasted URL is only ever parsed;
* the URL actually fetched is built by connector.buildApiUrl from a
* validated id against a hardcoded host.
*/
async function importDeckUrl(sourceUrl, game, deps = getDeps()) {
const { connector, deckId } = identifyDeckUrl(sourceUrl);
if (!connector.games.includes(game)) {
throw new DeckImportError(
`${connector.label} decks are Magic only. Switch the game selector to Magic to import this list.`,
'GAME_NOT_SUPPORTED'
);
}
const json = await fetchDeckJson(connector.buildApiUrl(deckId), connector, deps);
return connector.toParsedLines(json);
}Extend module.exports:
javascript
module.exports = {
identifyDeckUrl,
archidektToParsedLines,
importDeckUrl,
DeckImportError,
CONNECTORS,
MAX_LINES,
_setDeps,
_resetDeps
};- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- deckUrlImportService.test.js Expected: PASS โ all three describe blocks.
- [ ] Step 5: Commit
bash
git add server/services/deckUrlImportService.js server/services/deckUrlImportService.test.js
git commit -m "Fetch Archidekt decks for buylist import over a constructed URL"Task 4: Detect the CSV format from its header row โ
Files:
- Modify:
server/services/buylistQuoteService.js(adddetectCsvFormatafterparseTcgplayerCsv, which currently ends at line 310; add togetDeps()andmodule.exports) - Test:
server/services/buylistQuoteService.test.js(newdescribeblock)
Interfaces:
Produces:
detectCsvFormat(csvText: string): 'manabox' | 'tcgplayer'โ throws anErrorwith.code === 'UNKNOWN_CSV_FORMAT'when neither matches. Used by Task 5.[ ] Step 1: Write the failing tests
Add detectCsvFormat to the existing named-import list at the top of server/services/buylistQuoteService.test.js.
Add this describe block after the existing describe('parseTcgplayerCsv', ...) block:
javascript
describe('detectCsvFormat', () => {
const MANABOX_HEADER = 'Name,Set Code,Set Name,Collector Number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase Price,Misprint,Altered,Condition,Language,Purchase Price Currency';
const TCG_HEADER = 'TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low w/ Shipping,TCG Low Price,Total Quantity,Pending Quantity,Photo URL,Add to Quantity,TCG Marketplace Price,My Store Reserve Quantity,My Store Price';
it('detects a ManaBox export from its header row', () => {
expect(detectCsvFormat(`${MANABOX_HEADER}\nLightning Bolt,CLB,Baldur's Gate,187,false,common,2,mb-1,sf-1,0.25,false,false,Near Mint,en,USD\n`)).toBe('manabox');
});
it('detects a TCGplayer export from its header row', () => {
expect(detectCsvFormat(`${TCG_HEADER}\n1,Magic,Test Set,Test Card,,1,Common,Near Mint,1,1,1,1,2,0,,0,1,,\n`)).toBe('tcgplayer');
});
it('handles CRLF line endings', () => {
expect(detectCsvFormat(`${MANABOX_HEADER}\r\nrow\r\n`)).toBe('manabox');
});
it('throws a typed error naming the supported formats for an unknown header', () => {
try {
detectCsvFormat('Card,Qty\nLightning Bolt,2\n');
throw new Error('should have thrown');
} catch (e) {
expect(e.code).toBe('UNKNOWN_CSV_FORMAT');
expect(e.message).toMatch(/ManaBox/);
expect(e.message).toMatch(/TCGplayer/);
}
});
it('throws for empty input rather than guessing', () => {
try {
detectCsvFormat('');
throw new Error('should have thrown');
} catch (e) { expect(e.code).toBe('UNKNOWN_CSV_FORMAT'); }
});
});- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- buylistQuoteService.test.js -t detectCsvFormat Expected: FAIL โ detectCsvFormat is not a function.
- [ ] Step 3: Implement detection
In server/services/buylistQuoteService.js, add this immediately after parseTcgplayerCsv ends (currently line 310) and before const defaultPlugins = require('../plugins');:
javascript
// Header tokens unique to each vendor export, taken from the same header
// rows parseManaBoxCsv/parseTcgplayerCsv above are written against:
// ManaBox's includes 'ManaBox ID', TCGplayer's includes 'TCGplayer Id'.
// Neither token appears in the other layout, so a containment check on the
// first line is unambiguous. Detection lives server-side so the merchant
// cannot pick the wrong one and get a silent column mismatch (rule 7).
function detectCsvFormat(csvText) {
const header = String(csvText || '').split(/\r?\n/, 1)[0] || '';
if (header.includes('ManaBox ID')) return 'manabox';
if (header.includes('TCGplayer Id')) return 'tcgplayer';
const error = new Error('That file is not a recognized export. Upload a ManaBox or TCGplayer collection CSV.');
error.code = 'UNKNOWN_CSV_FORMAT';
throw error;
}Add detectCsvFormat, to the getDeps() default object, immediately after the existing parseTcgplayerCsv, line:
javascript
parseCardList,
parseManaBoxCsv,
parseTcgplayerCsv,
detectCsvFormat,
matchCatalogLine,Add detectCsvFormat, to module.exports after parseTcgplayerCsv,.
- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- buylistQuoteService.test.js Expected: PASS โ full file green, including all pre-existing parser tests.
- [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Detect ManaBox and TCGplayer CSV exports from their header row"Task 5: Dispatch sourceUrl and format: 'auto' in generateQuote โ
Files:
- Modify:
server/services/buylistQuoteService.jsโgenerateQuote(currently line 577) and thegetDeps()default object (currently around lines 319-344) - Test:
server/services/buylistQuoteService.test.js(extend the existingdescribe('generateQuote', ...)block)
Interfaces:
Consumes:
importDeckUrl(Task 3),detectCsvFormat(Task 4).Produces:
generateQuote({ store, game, rawText, sourceUrl, format }, deps)โsourceUrlis optional and mutually exclusive withrawText(enforced by the schema in Task 6);formatstill defaults to'plain'.[ ] Step 1: Write the failing tests
Add these tests inside the existing describe('generateQuote', ...) block in server/services/buylistQuoteService.test.js:
javascript
it('dispatches to importDeckUrl when sourceUrl is provided', async () => {
const fakeParsedLine = { rawLine: '1x Fake Card [ABC] 1', quantity: 1, name: 'Fake Card', setCode: 'ABC', collectorNumber: '1', condition: 'nm' };
const importDeckUrlMock = vi.fn().mockResolvedValue([fakeParsedLine]);
_setDeps({
getGameBuylistConfig: () => ({ priceBasis: 'market', conditionMultipliers: {}, defaultRate: 0.5, storeCreditBonus: 0.1 }),
importDeckUrl: importDeckUrlMock,
parseCardList: vi.fn(),
parseManaBoxCsv: vi.fn(),
parseTcgplayerCsv: vi.fn(),
detectCsvFormat: vi.fn(),
matchCatalogLine: vi.fn().mockResolvedValue({ matchStatus: 'matched', cardUuid: 'uuid-1', cardName: 'Fake Card', setCode: 'ABC', collectorNumber: '1', rarity: 'common', finish: 'Nonfoil' }),
resolveBasisPrice: vi.fn().mockResolvedValue(10),
applyBuylistRate: vi.fn().mockReturnValue({ offerPrice: 5, ruleApplied: 'default' })
});
const result = await generateQuote({ store: {}, game: 'mtg', sourceUrl: 'https://archidekt.com/decks/365563' });
expect(importDeckUrlMock).toHaveBeenCalledWith('https://archidekt.com/decks/365563', 'mtg');
expect(result.lines).toHaveLength(1);
});
it('resolves format auto through detectCsvFormat before parsing', async () => {
const detectCsvFormatMock = vi.fn().mockReturnValue('manabox');
const parseManaBoxCsvMock = vi.fn().mockResolvedValue([]);
_setDeps({
getGameBuylistConfig: () => ({ priceBasis: 'market', conditionMultipliers: {}, defaultRate: 0.5, storeCreditBonus: 0.1 }),
importDeckUrl: vi.fn(),
parseCardList: vi.fn(),
parseManaBoxCsv: parseManaBoxCsvMock,
parseTcgplayerCsv: vi.fn(),
detectCsvFormat: detectCsvFormatMock,
matchCatalogLine: vi.fn(),
resolveBasisPrice: vi.fn(),
applyBuylistRate: vi.fn()
});
await generateQuote({ store: {}, game: 'mtg', rawText: 'Name,ManaBox ID\nBolt,1\n', format: 'auto' });
expect(detectCsvFormatMock).toHaveBeenCalledWith('Name,ManaBox ID\nBolt,1\n');
expect(parseManaBoxCsvMock).toHaveBeenCalled();
});
it('still defaults to parseCardList when neither sourceUrl nor a CSV format is given', async () => {
const parseCardListMock = vi.fn().mockReturnValue([]);
_setDeps({
getGameBuylistConfig: () => ({ priceBasis: 'market', conditionMultipliers: {}, defaultRate: 0.5, storeCreditBonus: 0.1 }),
importDeckUrl: vi.fn(),
parseCardList: parseCardListMock,
parseManaBoxCsv: vi.fn(),
parseTcgplayerCsv: vi.fn(),
detectCsvFormat: vi.fn(),
matchCatalogLine: vi.fn(),
resolveBasisPrice: vi.fn(),
applyBuylistRate: vi.fn()
});
await generateQuote({ store: {}, game: 'mtg', rawText: '1 Lightning Bolt [CLB] 187' });
expect(parseCardListMock).toHaveBeenCalledWith('1 Lightning Bolt [CLB] 187');
});- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- buylistQuoteService.test.js -t generateQuote Expected: FAIL โ the first two tests fail because sourceUrl and 'auto' are ignored, so parseCardList is called instead.
- [ ] Step 3: Implement the dispatch
In server/services/buylistQuoteService.js, add importDeckUrl to the getDeps() default object, immediately after the detectCsvFormat, line added in Task 4:
javascript
detectCsvFormat,
importDeckUrl: require('./deckUrlImportService').importDeckUrl,Then replace generateQuote's opening (currently lines 577-583):
javascript
async function generateQuote({ store, game, rawText, format = 'plain' }, deps = getDeps()) {
const buylistConfig = deps.getGameBuylistConfig(store, game);
const parsedLines = await (
format === 'manabox' ? deps.parseManaBoxCsv(rawText)
: format === 'tcgplayer' ? deps.parseTcgplayerCsv(rawText)
: deps.parseCardList(rawText)
);with:
javascript
async function generateQuote({ store, game, rawText, sourceUrl, format = 'plain' }, deps = getDeps()) {
const buylistConfig = deps.getGameBuylistConfig(store, game);
const parsedLines = await resolveParsedLines({ rawText, sourceUrl, format, game }, deps);And add this helper immediately before generateQuote:
javascript
// One input, one parser. sourceUrl wins because the schema guarantees it is
// mutually exclusive with rawText; 'auto' resolves through detectCsvFormat
// (which throws UNKNOWN_CSV_FORMAT rather than falling back to the
// plain-text parser, so an unrecognized upload is a clear error instead of
// several hundred unmatched lines).
async function resolveParsedLines({ rawText, sourceUrl, format, game }, deps) {
if (sourceUrl) return deps.importDeckUrl(sourceUrl, game);
const resolvedFormat = format === 'auto' ? deps.detectCsvFormat(rawText) : format;
if (resolvedFormat === 'manabox') return deps.parseManaBoxCsv(rawText);
if (resolvedFormat === 'tcgplayer') return deps.parseTcgplayerCsv(rawText);
return deps.parseCardList(rawText);
}- [ ] Step 4: Run the tests to verify they pass
Run: npm test -- buylistQuoteService.test.js Expected: PASS โ full file green, including every pre-existing generateQuote test (all omit sourceUrl and pass format values that behave exactly as before).
- [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Let buy order quotes come from a deck URL or an auto-detected CSV"Task 6: Schema, route wiring, and a URL-import rate limit โ
Files:
- Modify:
server/schemas/buylistOrder.js(createBuylistOrderSchema, currently lines 47-73) - Modify:
server/middleware/rateLimiter.js(adddeckImportLimiter, extendmodule.exportson line 73) - Modify:
server/routes/api.js:2190-2252(POST /api/buylist/orders) - Test:
server/schemas/schemas.test.js(extend the existingcreateBuylistOrderSchematests)
Interfaces:
Consumes:
generateQuote'ssourceUrlandformat: 'auto'(Task 5).Produces: the endpoint accepts
sourceUrl; deck-import and CSV-detection failures surface as HTTP 400 with the service's merchant-readable message.[ ] Step 1: Write the failing tests
In server/schemas/schemas.test.js, find the existing createBuylistOrderSchema tests and add:
javascript
it('accepts a sourceUrl with a game', () => {
const result = createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
game: 'mtg',
sourceUrl: 'https://archidekt.com/decks/365563'
});
expect(result.sourceUrl).toBe('https://archidekt.com/decks/365563');
});
it('accepts format auto', () => {
const result = createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
game: 'mtg',
rawText: 'Name,ManaBox ID\nBolt,1\n',
format: 'auto'
});
expect(result.format).toBe('auto');
});
it('rejects sourceUrl together with rawText', () => {
expect(() => createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
game: 'mtg',
rawText: '1 Lightning Bolt [CLB] 187',
sourceUrl: 'https://archidekt.com/decks/365563'
})).toThrow();
});
it('rejects sourceUrl together with lines', () => {
expect(() => createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
sourceUrl: 'https://archidekt.com/decks/365563',
lines: [{ cardUuid: 'u1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 }]
})).toThrow();
});
it('requires game alongside sourceUrl', () => {
expect(() => createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
sourceUrl: 'https://archidekt.com/decks/365563'
})).toThrow();
});
it('rejects a sourceUrl that is not a URL', () => {
expect(() => createBuylistOrderSchema.parse({
customer: { email: 'a@b.com' },
game: 'mtg',
sourceUrl: 'not a url'
})).toThrow();
});
it('still requires exactly one input source', () => {
expect(() => createBuylistOrderSchema.parse({ customer: { email: 'a@b.com' } })).toThrow();
});- [ ] Step 2: Run the tests to verify they fail
Run: npm test -- schemas.test.js -t sourceUrl Expected: FAIL โ createBuylistOrderSchema is .strict(), so sourceUrl is rejected as an unknown key.
- [ ] Step 3: Extend the schema
In server/schemas/buylistOrder.js, replace the createBuylistOrderSchema declaration (currently lines 47-73) with:
javascript
const createBuylistOrderSchema = z.object({
customer: z.object({
email: z.string().email('customer.email must be a valid email'),
name: z.string().optional()
}),
// Required alongside rawText or sourceUrl (one game per pasted or
// imported batch). Forbidden alongside lines, where each line carries
// its own.
game: z.string().min(1).optional(),
rawText: z.string().trim().min(1, 'rawText must not be blank').max(65536, 'rawText is too long').optional(),
// A deck URL to import (deckUrlImportService.js). The URL is never
// fetched as given โ it is parsed for a deck id, and the request URL is
// constructed against a hardcoded allowlisted host.
sourceUrl: z.string().trim().url('sourceUrl must be a valid URL').max(2048, 'sourceUrl is too long').optional(),
// 'plain' = free-text paste (parseCardList); 'manabox'/'tcgplayer' = the
// matching CSV export connector; 'auto' = detect from the header row
// (detectCsvFormat). Only meaningful alongside rawText.
format: z.enum(['plain', 'manabox', 'tcgplayer', 'auto']).optional().default('plain'),
lines: z.array(selectedCardLineSchema).min(1, 'lines must not be empty').max(500, 'too many lines').optional(),
payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict().refine(
(data) => [data.rawText, data.sourceUrl, data.lines].filter((v) => v != null).length === 1,
{ message: 'Provide exactly one of rawText, sourceUrl, or lines' }
).refine(
(data) => (data.rawText == null && data.sourceUrl == null) || data.game != null,
{ message: 'game is required when rawText or sourceUrl is provided' }
).refine(
(data) => data.lines == null || data.game == null,
{ message: 'game must not be provided alongside lines โ each line carries its own' }
);- [ ] Step 4: Add the rate limiter
In server/middleware/rateLimiter.js, add before module.exports:
javascript
/**
* Rate limiter for buy-order creation that imports from a deck URL.
* `skip` means only requests actually carrying a sourceUrl count, so normal
* paste/upload/selector order creation is unaffected by this cap.
*
* This protects Archidekt as much as us: their staff granted API permission
* with the caveat "you might hit our rate limiter if you hit it too hard",
* and a merchant account should not be able to turn into a hammer. Same
* in-memory-store limitation the other limiters already accept.
*/
const deckImportLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 20, // 20 deck imports per window
message: { error: 'Too many deck imports, please try again in a few minutes' },
standardHeaders: true,
legacyHeaders: false,
skip: (req) => !req.body || !req.body.sourceUrl
});and change the exports line to:
javascript
module.exports = { authLimiter, apiLimiter, publicBuylistLimiter, publicPriceLimiter, deckImportLimiter };- [ ] Step 5: Wire the route
In server/routes/api.js, change the route declaration on line 2190 from:
javascript
router.post('/buylist/orders', async (req, res) => {to:
javascript
const { deckImportLimiter } = require('../middleware/rateLimiter');
router.post('/buylist/orders', deckImportLimiter, async (req, res) => {Then replace the rawText branch (currently lines 2208-2218):
javascript
if (parsed.data.rawText) {
const game = parsed.data.game;
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
const buylistConfig = getGameBuylistConfig(req.store, game);
const disabledError = assertBuylistEnabled(buylistConfig);
if (disabledError) return res.status(400).json({ error: disabledError });
quote = await generateQuote({ store: req.store, game, rawText: parsed.data.rawText, format: parsed.data.format });
buylistConfigsByGame = { [game]: buylistConfig };
} else {with:
javascript
if (parsed.data.rawText || parsed.data.sourceUrl) {
const game = parsed.data.game;
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
const buylistConfig = getGameBuylistConfig(req.store, game);
const disabledError = assertBuylistEnabled(buylistConfig);
if (disabledError) return res.status(400).json({ error: disabledError });
try {
quote = await generateQuote({
store: req.store,
game,
rawText: parsed.data.rawText,
sourceUrl: parsed.data.sourceUrl,
format: parsed.data.format
});
} catch (importError) {
// Deck-import and CSV-detection failures are the merchant's
// input being wrong (bad link, private deck, unrecognized
// file), not a server fault โ surface the specific message
// instead of the catch-all 500 below.
if (importError.name === 'DeckImportError' || importError.code === 'UNKNOWN_CSV_FORMAT') {
return res.status(400).json({ error: importError.message });
}
throw importError;
}
buylistConfigsByGame = { [game]: buylistConfig };
} else {- [ ] Step 6: Run the tests to verify they pass
Run: npm test -- schemas.test.js Expected: PASS โ full schema file green.
Then run the whole server suite, since this task touches a schema, a middleware, and a route:
Run: npm test Expected: PASS, 0 failures.
- [ ] Step 7: Commit
bash
git add server/schemas/buylistOrder.js server/middleware/rateLimiter.js server/routes/api.js server/schemas/schemas.test.js
git commit -m "Accept a deck URL when creating a buy order"Task 7: Four entry points in the Buy Order tab โ
Files:
- Modify:
client/src/pages/buylist/NewBuyInTab.jsx - Modify:
client/src/pages/buylist/NewBuyInTab.test.jsx
Interfaces:
Consumes:
POST /buylist/ordersacceptingsourceUrlandformat: 'auto'(Task 6).Produces: no new exports โ leaf page component.
[ ] Step 1: Update the existing tests and write the new ones
In client/src/pages/buylist/NewBuyInTab.test.jsx, delete the two tests that drive the retired dropdown โ 'lets the merchant choose the ManaBox CSV format' (lines 49-61) and 'populates the card list textarea from an uploaded file' (lines 63-73) โ and replace them with:
javascript
it('uploads a CSV and submits it for server-side format detection', async () => {
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /upload csv/i }));
const csvContent = 'Name,Set Code,Set Name,Collector Number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase Price,Misprint,Altered,Condition,Language,Purchase Price Currency\nLightning Bolt,CLB,Baldur\'s Gate,187,false,common,2,mb-1,sf-1,0.25,false,false,Near Mint,en,USD\n';
const file = new File([csvContent], 'export.csv', { type: 'text/csv' });
fireEvent.change(screen.getByLabelText(/csv file/i), { target: { files: [file] } });
await waitFor(() => expect(screen.getByText(/export\.csv/)).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{ customer: { email: 'jamie@example.com' }, game: 'mtg', rawText: csvContent, format: 'auto' }
));
});
it('submits a deck URL as sourceUrl', async () => {
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /from url/i }));
fireEvent.change(screen.getByLabelText(/deck url/i), { target: { value: 'https://archidekt.com/decks/365563' } });
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{ customer: { email: 'jamie@example.com' }, game: 'mtg', sourceUrl: 'https://archidekt.com/decks/365563' }
));
});
it('names Archidekt as the supported deck site', async () => {
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
await screen.findByLabelText(/customer email/i);
fireEvent.click(screen.getByRole('button', { name: /from url/i }));
expect(screen.getByText(/archidekt/i)).toBeInTheDocument();
});
it('surfaces the server error when a deck URL cannot be imported', async () => {
api.post.mockRejectedValue({ response: { data: { error: 'That Archidekt deck was not found. It may be private or deleted.' } } });
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /from url/i }));
fireEvent.change(screen.getByLabelText(/deck url/i), { target: { value: 'https://archidekt.com/decks/1' } });
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
expect(await screen.findByText(/was not found/i)).toBeInTheDocument();
});
it('keeps the plain-text paste path sending format plain', async () => {
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /paste list/i }));
fireEvent.change(screen.getByLabelText(/card list/i), { target: { value: '1 Lightning Bolt [CLB] 187' } });
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{ customer: { email: 'jamie@example.com' }, game: 'mtg', rawText: '1 Lightning Bolt [CLB] 187', format: 'plain' }
));
});- [ ] Step 2: Run the tests to verify they fail
Run: npm run test:client -- NewBuyInTab Expected: FAIL โ no Upload CSV or From URL buttons exist.
- [ ] Step 3: Implement the four entry points
In client/src/pages/buylist/NewBuyInTab.jsx:
(a) Replace the mode and format state declarations (currently lines 27 and 29):
javascript
const [mode, setMode] = useState('select'); // 'select' | 'paste'
const [pickerType, setPickerType] = useState('single'); // 'single' | 'sealed'
const [format, setFormat] = useState('plain'); // 'plain' | 'manabox' | 'tcgplayer'with:
javascript
const [mode, setMode] = useState('select'); // 'select' | 'paste' | 'csv' | 'url'
const [pickerType, setPickerType] = useState('single'); // 'single' | 'sealed'
const [sourceUrl, setSourceUrl] = useState('');
const [uploadedFileName, setUploadedFileName] = useState('');(b) Replace handleFileUpload (currently lines 104-110):
javascript
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setRawText(String(reader.result || ''));
reader.readAsText(file);
};with:
javascript
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setRawText(String(reader.result || ''));
setUploadedFileName(file.name);
};
reader.readAsText(file);
};(c) Replace the body construction in submit (currently lines 117-124):
javascript
const body = mode === 'paste'
? { customer, game, rawText, format }
: {
customer,
lines: selectedCards.map((c) => (c.lineType === 'sealed'
? { lineType: 'sealed', sealedUuid: c.sealedUuid, game: c.game, quantity: Number(c.quantity) }
: { cardUuid: c.uuid, game: c.game, finish: c.finish, condition: c.condition, quantity: Number(c.quantity) }))
};with:
javascript
// The server decides the CSV layout from the header row (detectCsvFormat
// in buylistQuoteService.js) โ the client never guesses it.
const body = mode === 'paste' ? { customer, game, rawText, format: 'plain' }
: mode === 'csv' ? { customer, game, rawText, format: 'auto' }
: mode === 'url' ? { customer, game, sourceUrl }
: {
customer,
lines: selectedCards.map((c) => (c.lineType === 'sealed'
? { lineType: 'sealed', sealedUuid: c.sealedUuid, game: c.game, quantity: Number(c.quantity) }
: { cardUuid: c.uuid, game: c.game, finish: c.finish, condition: c.condition, quantity: Number(c.quantity) }))
};(d) Replace the intro paragraph (currently lines 137-141):
jsx
<p className="text-muted-foreground">
{mode === 'paste'
? "Paste the customer's card list to generate an offer."
: 'Search for cards and build a list to generate an offer.'}
</p>with:
jsx
<p className="text-muted-foreground">
{mode === 'paste' ? "Paste the customer's card list to generate an offer."
: mode === 'csv' ? "Upload the customer's ManaBox or TCGplayer collection export."
: mode === 'url' ? "Paste a link to the customer's deck list."
: 'Search for cards and build a list to generate an offer.'}
</p>(e) Replace the two-button mode row (currently lines 166-173):
jsx
<div className="flex gap-2">
<Button variant={mode === 'select' ? 'default' : 'outline'} size="sm" onClick={() => setMode('select')}>
Search & Select
</Button>
<Button variant={mode === 'paste' ? 'default' : 'outline'} size="sm" onClick={() => setMode('paste')}>
Paste List
</Button>
</div>with:
jsx
<div className="flex gap-2 flex-wrap">
<Button variant={mode === 'select' ? 'default' : 'outline'} size="sm" onClick={() => setMode('select')}>
Search & Select
</Button>
<Button variant={mode === 'paste' ? 'default' : 'outline'} size="sm" onClick={() => setMode('paste')}>
Paste List
</Button>
<Button variant={mode === 'csv' ? 'default' : 'outline'} size="sm" onClick={() => setMode('csv')}>
Upload CSV
</Button>
<Button variant={mode === 'url' ? 'default' : 'outline'} size="sm" onClick={() => setMode('url')}>
From URL
</Button>
</div>(f) Replace the whole paste-mode block (the ) : ( branch currently spanning lines 279-317, from <div className="space-y-2"> through its closing )}) with a four-way branch. The mode === 'select' block above it is unchanged:
jsx
) : mode === 'csv' ? (
<div className="space-y-2">
<label htmlFor="csv-file" className="font-semibold text-sm block mb-1">CSV file</label>
<input
id="csv-file"
type="file"
accept=".csv,text/csv"
aria-label="CSV file"
className="block w-full border-2 border-black rounded px-2 py-2 text-xs bg-card text-foreground"
onChange={handleFileUpload}
/>
<p className="text-xs text-muted-foreground">
ManaBox and TCGplayer collection exports are recognized automatically.
</p>
{uploadedFileName && (
<p className="text-xs">
Loaded <span className="font-semibold">{uploadedFileName}</span>
</p>
)}
</div>
) : mode === 'url' ? (
<div className="space-y-2">
<label htmlFor="deck-url" className="font-semibold text-sm block mb-1">Deck URL</label>
<Input
id="deck-url"
aria-label="Deck URL"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
placeholder="https://archidekt.com/decks/365563"
/>
<p className="text-xs text-muted-foreground">
Archidekt deck links are supported. Cards in a maybeboard are skipped.
</p>
</div>
) : (
<div className="space-y-2">
<label htmlFor="card-list" className="font-semibold text-sm block mb-1">Card list</label>
<Textarea
id="card-list"
aria-label="Card list"
rows={10}
value={rawText}
onChange={(e) => setRawText(e.target.value)}
placeholder={'1 Ragavan, Nimble Pilferer [MH2] 138\n4 Orcish Bowmasters [LTR] 103'}
/>
</div>
)}(g) Replace the submit button's disabled expression (currently line 319):
jsx
<Button onClick={submit} disabled={submitting || !email || (mode === 'paste' ? !rawText : selectedCards.length === 0)}>with:
jsx
<Button
onClick={submit}
disabled={submitting || !email || (
mode === 'select' ? selectedCards.length === 0
: mode === 'url' ? !sourceUrl
: !rawText
)}
>- [ ] Step 4: Run the tests to verify they pass
Run: npm run test:client -- NewBuyInTab Expected: PASS โ all describe blocks green, including the untouched search & select and sealed picker suites.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Add CSV upload and deck URL entry points to the Buy Order tab"Task 8: Quality bars and final verification โ
Files: none (verification only).
- [ ] Step 1: Full server suite
Run: npm test Expected: PASS, 0 failures.
- [ ] Step 2: Coverage on touched files
Run: npm run test:coverage Expected: server/services/deckUrlImportService.js, server/services/buylistQuoteService.js, server/schemas/buylistOrder.js, server/middleware/rateLimiter.js, and server/routes/api.js each report โฅ70% on all four metrics.
Note: the flat Vitest coverage threshold keys in this repo do not fail the run, so read the reported numbers rather than trusting the exit code.
- [ ] Step 3: Full client suite
Run: npm run test:client Expected: PASS, 0 failures.
- [ ] Step 4: Lint and build
Run: npm run lint Expected: exits 0, no new warnings.
Run: npm run build Expected: exits 0 (client was touched).
Run: npm run docs:build Expected: exits 0 (this plan and the design spec are built by VitePress).
- [ ] Step 5: Grep the ยง5 checks
Run: git diff main --name-only | xargs grep -n "|| 'mtg'\|?? 'mtg'\|= 'mtg'" Expected: no identity-default matches (ยง5.5). A useState('mtg') for the client's game selector is pre-existing and unchanged.
Run: git diff main --name-only | xargs grep -n "myshopify.com" Expected: no matches โ this diff makes no Shopify calls.
Run: grep -rn "endsWith" server/services/deckUrlImportService.js Expected: no matches โ host matching must be exact equality.
- [ ] Step 6: Manual smoke test
With npm run dev running, open the Buy Order tab and verify each entry point:
- From URL โ paste
https://archidekt.com/decks/365563, submit. Expect a generated offer whose lines match real cards, and confirm noTeferi's Protection-style maybeboard entry appears if the deck has one. - From URL, wrong game โ switch the game selector to Pokemon, paste the same URL. Expect a 400 with the Magic-only message, not a list of unmatched lines.
- From URL, Moxfield โ paste any
https://www.moxfield.com/decks/...link. Expect the specific "not available yet" message. - Upload CSV โ upload a ManaBox export. Expect the filename to appear and the offer to generate without any format being chosen.
- Paste List โ paste
1 Lightning Bolt [CLB] 187. Expect unchanged behavior.
- [ ] Step 7: Open the PR
bash
git push -u origin HEADbash
gh pr create --title "Add deck URL and CSV import entry points to the Buy Order tab" --body "$(cat <<'EOF'
## Summary
Closes #402. Adds two first-class entry points next to manual entry on the Buy Order tab: **Upload CSV** (ManaBox/TCGplayer, format detected server-side from the header row) and **From URL** (Archidekt deck links). Both feed the existing match/price/rate pipeline unchanged.
The plan the issue pointed at had already shipped in #376, so this covers what actually remained: URL import (new) and making CSV a discoverable entry point rather than a dropdown nested inside Paste List.
## Vendor grounding
Only sanctioned routes are used โ no scraping.
- **Archidekt**: staff explicitly grant API use ("You're more than welcome to use our API for whatever you want (just know that you might hit our rate limiter if you hit it too hard)"). Every field path is verified against a live response captured 2026-07-26; two are counter-intuitive (`collectorNumber` is on `card`, not `oracleCard`; `editioncode` is lowercase and needs uppercasing).
- **Moxfield**: registered but **disabled** โ no public API, and Cloudflare rejects unallowlisted clients (a research fetch returned 403). Pasting a Moxfield link gives a specific message. Enabling it later needs a support-granted user-agent, then a flag flip plus a mapper.
- **ManaBox / TCGplayer**: no obtainable API (TCGplayer closed new applications in late 2024); their CSV exports already cover the need.
## Security
The pasted URL is never fetched. It is parsed, a digits-only deck id is extracted, and the request URL is constructed against a hardcoded host โ so lookalike hosts, userinfo tricks, and internal addresses fail at validation rather than at the socket. Plus `redirect: 'error'`, a 10s timeout, a 5 MB response cap, an identifying User-Agent, and a dedicated rate limiter that counts only URL-import requests. The public portal uses separate schemas and is untouched: unauthenticated traffic cannot trigger an outbound fetch.
## Game parity (ยง5.1)
No plugin code, per-game model, or importer touched. **mtg** = supported. **pokemon** and **riftbound** = explicitly rejected with a clear message, since Archidekt is a Magic-only product โ declared rather than inferred, because the response's top-level `game` was `null` and cannot be trusted to self-identify.
## Test plan
- [ ] `npm test` and `npm run test:coverage` pass, touched files โฅ70%
- [ ] `npm run test:client` passes
- [ ] `npm run lint` and `npm run build` clean
- [ ] `npm run docs:build` clean
- [ ] Manual: Archidekt import, wrong-game rejection, Moxfield message, CSV auto-detect, unchanged paste path
๐ค Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Self-review (done at plan time) โ
- Spec coverage: Vendor research โ Tasks 1 and 3 (registry, Moxfield disabled, sanctioned Archidekt endpoint). Security model โ Task 1 (identification) and Task 3 (transport) plus Task 6's limiter. Field mapping โ Task 2, with both trap fields under explicit test. Maybeboard exclusion โ Task 2, flag-driven with a renamed-category test. Game restriction โ Task 3. CSV auto-detection โ Tasks 4-5. Client four entry points โ Task 7. Portal-untouched constraint โ asserted in Global Constraints and never contradicted by a task. No spec section is unimplemented.
- Placeholder scan: no TBD/TODO/"similar to Task N"/"add error handling" โ every code step carries complete code and every run step an exact command with expected output.
- Type consistency:
ParsedLinefields (rawLine, quantity, name, setCode, collectorNumber, condition) are identical acrossarchidektToParsedLines(Task 2) and the existing parsers, checked againstmatchCatalogLine's andpriceAndRateLine's real consumption.importDeckUrl(sourceUrl, game)'s two-argument signature is consistent between Task 3's implementation, Task 5'sdeps.importDeckUrlcall, and Task 5's mock assertion.detectCsvFormat(csvText)is consistent across Tasks 4, 5, and the client comment.DeckImportError.codevalues (INVALID_URL, INSECURE_URL, UNSUPPORTED_HOST, CONNECTOR_DISABLED, NO_DECK_ID, GAME_NOT_SUPPORTED, TIMEOUT, NETWORK, NOT_FOUND, RATE_LIMITED, UPSTREAM_ERROR, TOO_LARGE, BAD_JSON) are each thrown in exactly one place and asserted with the same spelling. The schema fieldsourceUrlmatches the service parameter, the route pass-through, and the client state name โ no renaming anywhere in the chain. - Ordering note: Task 2 defines
archidektToParsedLinesbefore theCONNECTORSarray because the array references it by value at module-evaluation time; Task 1 deliberately leaves that slotnullso each task's file is internally valid on its own.
