Appearance
Sealed UPC Ingest (PR 1) 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: Ship real manufacturer UPCs on sealed products by reading the extUPC column the sealed price importer already downloads, and stop sending synthetic LGSF… barcodes for sealed entirely.
Architecture: A new global reference collection sealed_product_barcodes is populated by the existing daily sealed price importer, which already resolves every TCGCSV row to an MTGJSON sealed product uuid. buildSealedSyncPayload reads that collection; when no UPC is known the barcode field is omitted from the Shopify payload rather than filled with a generated value. Singles are untouched.
Tech Stack: Node.js (CommonJS), Mongoose, Vitest (ESM test files), Express.
Spec: docs/superpowers/specs/2026-07-30-sealed-upc-from-tcgcsv-design.md
Global Constraints
- Server code is CommonJS (
require/module.exports); test files are ESM (import). Test files are co-located asx.test.jsnext tox.js. - Never introduce a fallback default for an identity parameter —
game,shop,uuid(CLAUDE.md §5.5). Absent → throw or reject. Grep the diff for|| 'mtg',?? 'mtg',= 'mtg': zero matches. - Every new persisted field is declared field-by-field in the Mongoose schema and ships with a schema-shape round-trip test (CLAUDE.md §5.2).
- Data-matching literals must cite the real document or importer they came from (CLAUDE.md §5.4). The
extUPCcolumn name is verified at https://tcgcsv.com/tcgplayer/1/23874/ProductsAndPrices.csv. - Regex character classes use
[0-9], never\d— inline\dis mangled when commands are pasted through Git Bash, and a silently non-matching pattern is the failure mode. - Husky pre-commit runs ESLint. Fix lint; never use
--no-verify. - All Shopify API calls go through
server/services/shopifyAPI.js(not applicable in this PR — no Shopify calls are added). - Commit messages: one imperative sentence describing the merchant-visible outcome, sentence case, no trailing period.
Parity note (CLAUDE.md §5.1)
- mtg — implemented here.
- pokemon — exempt: no sealed catalog source.
transformSealedToProductexists but nothing enumerates sealed products for it. - riftbound — exempt: same reason.
Verified at server/plugins/mtg/index.js:417, the only plugin reading SetModel.data.sealedProduct. State this in the PR description.
Deviation from the spec (intentional)
The spec's Section 2 says to source game from getPlugin('mtg').gameId. On reading the importer, server/scripts/data-loading/updateSealedProductPrices.js:48 already defines const IMPORTER_GAME = 'mtg'; and buildSealedProductMap already stamps game: IMPORTER_GAME onto every productMap entry (:189). This plan therefore reads product.game off the map entry — same value, no new literal, no new import. The spec's intent (no bare 'mtg' literal in the diff) is satisfied more directly.
File Structure
| File | Responsibility |
|---|---|
server/models/SealedProductBarcode.js | Create. Global reference collection + getBarcode(game, uuid) static. |
server/models/SealedProductBarcode.test.js | Create. Schema-shape round-trip guard (§5.2) + static behavior. |
server/scripts/data-loading/updateSealedProductPrices.js | Modify. Add extractUpc(); ingest UPCs inside processPriceRows before the no-price early-continue. |
server/scripts/data-loading/updateSealedProductPrices.test.js | Modify. Add extractUpc unit tests + the ordering-trap test. |
server/utils/barcodeGenerator.js | Modify. resolveSealedBarcode drops the generated fallback. |
server/utils/barcodeGenerator.test.js | Modify. Replace the two fallback tests with omission tests. |
server/routes/sealedProducts.js | Modify. buildSealedSyncPayload reads the reference collection; export it for tests; fix the stale comment. |
server/routes/sealedProducts.buildSyncPayload.test.js | Create. Payload resolution tests. |
Task 1: SealedProductBarcode model
Files:
- Create:
server/models/SealedProductBarcode.js - Test:
server/models/SealedProductBarcode.test.js
Interfaces:
Consumes: nothing.
Produces:
SealedProductBarcodeMongoose model with fields{ game: String, uuid: String, barcode: String, source: String, tcgplayerProductId: Number }plustimestamps: true, and staticgetBarcode(game: string, uuid: string): Promise<string|null>which throws if either argument is falsy.[ ] Step 1: Write the failing test
Create server/models/SealedProductBarcode.test.js:
javascript
/**
* Unit tests for the SealedProductBarcode reference model.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRequire } from 'node:module';
// The model is CommonJS. Load it through the CJS registry so spies land on the
// same model instance production code requires (an `await import()` compiles a
// second copy and trips Mongoose's OverwriteModelError).
const require = createRequire(import.meta.url);
const SealedProductBarcode = require('./SealedProductBarcode.js');
const validData = {
game: 'mtg',
uuid: 'a1b2c3d4-0000-0000-0000-000000000001',
barcode: '195166278636',
source: 'tcgcsv',
tcgplayerProductId: 610553,
};
afterEach(() => {
vi.restoreAllMocks();
});
describe('SealedProductBarcode schema', () => {
// Schema-shape round-trip guard (CLAUDE.md §5.2): every field here is
// written by the sealed price importer. An undeclared field is silently
// dropped by strict mode, which would blank the barcode with no error.
it('declares and retains every persisted field', () => {
const doc = new SealedProductBarcode(validData);
expect(doc.validateSync()).toBeUndefined();
expect(doc.game).toBe('mtg');
expect(doc.uuid).toBe('a1b2c3d4-0000-0000-0000-000000000001');
expect(doc.barcode).toBe('195166278636');
expect(doc.source).toBe('tcgcsv');
expect(doc.tcgplayerProductId).toBe(610553);
});
it('requires game, uuid, and barcode', () => {
for (const field of ['game', 'uuid', 'barcode']) {
const data = { ...validData };
delete data[field];
const error = new SealedProductBarcode(data).validateSync();
expect(error?.errors?.[field]).toBeDefined();
}
});
it('defaults source to tcgcsv', () => {
const data = { ...validData };
delete data.source;
expect(new SealedProductBarcode(data).source).toBe('tcgcsv');
});
it('declares a unique compound index on game and uuid', () => {
const indexes = SealedProductBarcode.schema.indexes();
const compound = indexes.find(([keys]) => keys.game === 1 && keys.uuid === 1);
expect(compound).toBeDefined();
expect(compound[1].unique).toBe(true);
});
});
describe('SealedProductBarcode.getBarcode', () => {
it('returns the stored barcode', async () => {
vi.spyOn(SealedProductBarcode, 'findOne').mockReturnValue({
lean: () => Promise.resolve({ barcode: '195166278636' }),
});
await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBe('195166278636');
});
it('returns null when no row exists', async () => {
vi.spyOn(SealedProductBarcode, 'findOne').mockReturnValue({
lean: () => Promise.resolve(null),
});
await expect(SealedProductBarcode.getBarcode('mtg', 'uuid-1')).resolves.toBeNull();
});
it('scopes the lookup by game and uuid', async () => {
const findOne = vi.spyOn(SealedProductBarcode, 'findOne').mockReturnValue({
lean: () => Promise.resolve(null),
});
await SealedProductBarcode.getBarcode('mtg', 'uuid-1');
expect(findOne).toHaveBeenCalledWith({ game: 'mtg', uuid: 'uuid-1' });
});
// CLAUDE.md §5.5: identity parameters are never defaulted. A missing game
// must surface as an error, not silently resolve another game's barcode.
it('throws when game is missing', async () => {
await expect(SealedProductBarcode.getBarcode(null, 'uuid-1')).rejects.toThrow(/game is required/);
});
it('throws when uuid is missing', async () => {
await expect(SealedProductBarcode.getBarcode('mtg', null)).rejects.toThrow(/uuid is required/);
});
});- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/models/SealedProductBarcode.test.jsExpected: FAIL — Cannot find module './SealedProductBarcode.js'.
- [ ] Step 3: Write the implementation
Create server/models/SealedProductBarcode.js:
javascript
/**
* Sealed Product Barcode Model
*
* Global reference table mapping a sealed product's catalog uuid to the real
* manufacturer UPC printed on its packaging.
*
* Source: the `extUPC` column of TCGCSV's ProductsAndPrices.csv, which
* server/scripts/data-loading/updateSealedProductPrices.js already downloads
* (verified against https://tcgcsv.com/tcgplayer/1/23874/ProductsAndPrices.csv
* — CLAUDE.md §5.4). Coverage is narrow: roughly the booster pack, display and
* display case per set. Everything else has no UPC and ships no barcode.
*
* Global, not per-store: the data is upstream and authoritative, so no store
* can corrupt it. Same trust model as SealedProductMSRP.
*/
const mongoose = require('mongoose');
const sealedProductBarcodeSchema = new mongoose.Schema({
// Game identifier ('mtg' today). On the key from the start so Pokemon and
// Riftbound need no migration when sealed support lands for them — both
// pull the same TCGCSV columns from the same endpoint.
game: {
type: String,
required: true
},
// Catalog uuid of the sealed product. For MTG this is MTGJSON's
// set.data.sealedProduct[].uuid — the same key buildSealedSyncPayload
// resolves against, so the join needs no translation.
uuid: {
type: String,
required: true
},
// The manufacturer barcode itself: 12 digits (UPC-A) or 13 (EAN-13).
// Validated at ingest by extractUpc() in the importer.
barcode: {
type: String,
required: true
},
// Provenance, so a future non-TCGCSV source is distinguishable.
source: {
type: String,
default: 'tcgcsv'
},
// The TCGCSV row this came from, for tracing a wrong value back to source.
tcgplayerProductId: Number
}, {
timestamps: true
});
// One barcode per sealed product per game. Also the lookup index for
// getBarcode's exact-match query.
sealedProductBarcodeSchema.index({ game: 1, uuid: 1 }, { unique: true });
/**
* Resolve the manufacturer barcode for one sealed product.
*
* @param {string} game - Game identifier, never defaulted (CLAUDE.md §5.5)
* @param {string} uuid - Sealed product catalog uuid
* @returns {Promise<string|null>} The UPC, or null when none is known
*/
sealedProductBarcodeSchema.statics.getBarcode = async function(game, uuid) {
if (!game) {
throw new Error('game is required to resolve a sealed barcode');
}
if (!uuid) {
throw new Error('uuid is required to resolve a sealed barcode');
}
const doc = await this.findOne({ game, uuid }).lean();
return doc ? doc.barcode : null;
};
module.exports = mongoose.model('SealedProductBarcode', sealedProductBarcodeSchema);- [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/models/SealedProductBarcode.test.jsExpected: PASS — 9 tests.
- [ ] Step 5: Lint
bash
npm run lintExpected: exit 0, no new warnings.
- [ ] Step 6: Commit
bash
git add server/models/SealedProductBarcode.js server/models/SealedProductBarcode.test.js
git commit -m "Add a reference table for real sealed-product barcodes"Task 2: extractUpc validation helper
Files:
- Modify:
server/scripts/data-loading/updateSealedProductPrices.js(add helper + named export nearmodule.exportsat:399-402) - Test:
server/scripts/data-loading/updateSealedProductPrices.test.js(append a newdescribe)
Interfaces:
Consumes: nothing from Task 1.
Produces:
extractUpc(row: object): string|null— exported asmodule.exports.extractUpc. Returns the trimmed value when it is 12 or 13 digits, otherwisenull. Task 3 consumes this.[ ] Step 1: Write the failing test
Append to server/scripts/data-loading/updateSealedProductPrices.test.js:
javascript
describe('extractUpc', () => {
// Column name and sample values copied from the real CSV (CLAUDE.md §5.4):
// https://tcgcsv.com/tcgplayer/1/23874/ProductsAndPrices.csv
// Aetherdrift - Play Booster Display -> extUPC 195166278636
it('returns a valid 12-digit UPC-A', () => {
expect(extractUpc({ extUPC: '195166278636' })).toBe('195166278636');
});
it('returns a valid 13-digit EAN-13', () => {
expect(extractUpc({ extUPC: '0195166278636' })).toBe('0195166278636');
});
it('trims surrounding whitespace', () => {
expect(extractUpc({ extUPC: ' 195166278636 ' })).toBe('195166278636');
});
it('returns null when the column is absent', () => {
expect(extractUpc({})).toBeNull();
});
it('returns null for an empty or whitespace-only value', () => {
expect(extractUpc({ extUPC: '' })).toBeNull();
expect(extractUpc({ extUPC: ' ' })).toBeNull();
});
it('rejects non-digit characters rather than passing them through', () => {
expect(extractUpc({ extUPC: '19516-278636' })).toBeNull();
expect(extractUpc({ extUPC: 'N/A' })).toBeNull();
});
it('rejects wrong-length digit strings', () => {
expect(extractUpc({ extUPC: '12345' })).toBeNull();
expect(extractUpc({ extUPC: '12345678901234' })).toBeNull();
});
it('accepts a numeric value from the CSV parser', () => {
expect(extractUpc({ extUPC: 195166278636 })).toBe('195166278636');
});
});Add extractUpc to the existing destructured require near the top of the file (currently line 20):
javascript
const { getMSRPFallback, buildSealedProductMap, extractUpc } = require('./updateSealedProductPrices.js');- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/scripts/data-loading/updateSealedProductPrices.test.jsExpected: FAIL — extractUpc is not a function.
- [ ] Step 3: Write the implementation
In server/scripts/data-loading/updateSealedProductPrices.js, add above processPriceRows (which begins at :243):
javascript
// UPC-A is 12 digits, EAN-13 is 13. Character class is [0-9] rather than \d
// deliberately: an escaped \d is mangled when this file's patterns are pasted
// through Git Bash, and a silently non-matching regex would blank every UPC.
const UPC_PATTERN = /^[0-9]{12,13}$/;
/**
* Pull the manufacturer barcode out of one TCGCSV row.
*
* The `extUPC` column is present on every ProductsAndPrices.csv but populated
* only for sealed products — roughly the booster pack, display and display case
* per set. Verified against the live CSV (CLAUDE.md §5.4):
* https://tcgcsv.com/tcgplayer/1/23874/ProductsAndPrices.csv
*
* @param {Object} row - One parsed CSV row
* @returns {string|null} The validated barcode, or null if absent/malformed
*/
function extractUpc(row) {
if (row.extUPC === undefined || row.extUPC === null) return null;
const trimmed = String(row.extUPC).trim();
if (!trimmed) return null;
return UPC_PATTERN.test(trimmed) ? trimmed : null;
}Then add the named export alongside the existing ones at the bottom of the file (after :402):
javascript
module.exports.extractUpc = extractUpc;- [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/scripts/data-loading/updateSealedProductPrices.test.jsExpected: PASS — the 8 new extractUpc tests plus all pre-existing tests in the file.
- [ ] Step 5: Commit
bash
git add server/scripts/data-loading/updateSealedProductPrices.js server/scripts/data-loading/updateSealedProductPrices.test.js
git commit -m "Validate manufacturer barcodes read from the TCGCSV feed"Task 3: Ingest UPCs in processPriceRows
Files:
- Modify:
server/scripts/data-loading/updateSealedProductPrices.js(processPriceRows,:243-334) - Test:
server/scripts/data-loading/updateSealedProductPrices.test.js
Interfaces:
- Consumes:
SealedProductBarcode(Task 1),extractUpc(Task 2). - Produces:
processPriceRows(rows, productMap)exported asmodule.exports.processPriceRows; its returned summary object gainstotalUpcsandtotalMalformedUpcsalongside the existingtotalProcessed,totalMatched,totalInserted,totalWithMSRP,totalSkipped.
The trap this task exists to avoid: processPriceRows contains if (!finalPrice) continue; at :304. The UPC write must be placed before it. A sealed product with no market price and no MSRP fallback still has a real barcode on its box; putting the write after that line means those products silently never get one.
- [ ] Step 1: Write the failing test
Append to server/scripts/data-loading/updateSealedProductPrices.test.js. Add processPriceRows to the destructured require, and SealedProductBarcode to the model requires at the top:
javascript
const { getMSRPFallback, buildSealedProductMap, extractUpc, processPriceRows } = require('./updateSealedProductPrices.js');
const SealedProductBarcode = require('../../models/SealedProductBarcode.js');
const Price = require('../../models/Price.js');Then append:
javascript
describe('processPriceRows — UPC ingest', () => {
const UUID = 'a1b2c3d4-0000-0000-0000-000000000001';
// buildSealedProductMap stamps `game` onto every entry (see :189), so the
// fixture carries it exactly as the real map would.
function productMapWith(overrides = {}) {
return new Map([['610553', {
uuid: UUID,
name: 'Aetherdrift - Play Booster Display',
category: 'booster_box',
game: 'mtg',
setCode: 'DFT',
setName: 'Aetherdrift',
releaseDate: new Date('2025-02-14'),
...overrides,
}]]);
}
let bulkWrite;
beforeEach(() => {
bulkWrite = vi.spyOn(SealedProductBarcode, 'bulkWrite').mockResolvedValue({});
vi.spyOn(Price, 'insertMany').mockResolvedValue([]);
});
it('upserts the barcode for a row with a valid extUPC', async () => {
await processPriceRows(
[{ productId: '610553', marketPrice: '129.99', extUPC: '195166278636' }],
productMapWith()
);
expect(bulkWrite).toHaveBeenCalledTimes(1);
const [ops] = bulkWrite.mock.calls[0];
expect(ops).toHaveLength(1);
expect(ops[0].updateOne.filter).toEqual({ game: 'mtg', uuid: UUID });
expect(ops[0].updateOne.update.$set).toMatchObject({
barcode: '195166278636',
source: 'tcgcsv',
tcgplayerProductId: 610553,
});
expect(ops[0].updateOne.upsert).toBe(true);
});
// THE ORDERING TRAP. processPriceRows early-continues when no price can be
// resolved. If the UPC write sits after that line, every sealed product
// without a market price or MSRP fallback silently loses its barcode — and
// nothing else in the system would ever write it.
it('upserts the barcode even when the row has no usable price', async () => {
vi.spyOn(SealedProductMSRP, 'getMSRP').mockResolvedValue(null);
const result = await processPriceRows(
[{ productId: '610553', marketPrice: '', extUPC: '195166278636' }],
productMapWith()
);
expect(result.totalInserted).toBe(0);
expect(bulkWrite).toHaveBeenCalledTimes(1);
expect(bulkWrite.mock.calls[0][0][0].updateOne.update.$set.barcode).toBe('195166278636');
});
it('does not upsert when extUPC is absent', async () => {
await processPriceRows(
[{ productId: '610553', marketPrice: '129.99' }],
productMapWith()
);
expect(bulkWrite).not.toHaveBeenCalled();
});
it('counts a malformed extUPC without writing or throwing', async () => {
const result = await processPriceRows(
[{ productId: '610553', marketPrice: '129.99', extUPC: 'N/A' }],
productMapWith()
);
expect(result.totalMalformedUpcs).toBe(1);
expect(result.totalUpcs).toBe(0);
expect(bulkWrite).not.toHaveBeenCalled();
});
it('ignores rows whose product is not in the catalog map', async () => {
await processPriceRows(
[{ productId: '999999', marketPrice: '10.00', extUPC: '195166278636' }],
productMapWith()
);
expect(bulkWrite).not.toHaveBeenCalled();
});
it('reports how many barcodes were written', async () => {
const map = productMapWith();
map.set('610554', { ...map.get('610553'), uuid: 'uuid-2' });
const result = await processPriceRows(
[
{ productId: '610553', marketPrice: '129.99', extUPC: '195166278636' },
{ productId: '610554', marketPrice: '9.99', extUPC: '195166278629' },
],
map
);
expect(result.totalUpcs).toBe(2);
});
});- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/scripts/data-loading/updateSealedProductPrices.test.js -t "UPC ingest"Expected: FAIL — processPriceRows is not a function.
- [ ] Step 3: Write the implementation
3a. Add the model require alongside the existing ones (near :29-31):
javascript
const SealedProductBarcode = require('../../models/SealedProductBarcode');3b. Inside processPriceRows, add to the counter declarations (after let loggedColumns = false;):
javascript
let barcodeOps = [];
let totalUpcs = 0;
let totalMalformedUpcs = 0;
const seenUpcs = new Map();
async function flushBarcodeOps() {
if (barcodeOps.length === 0) return;
await SealedProductBarcode.bulkWrite(barcodeOps, { ordered: false });
barcodeOps = [];
}3c. Insert this block immediately after totalMatched++; and before the let finalPrice = null; line:
javascript
// Ingest the manufacturer barcode BEFORE the `if (!finalPrice) continue`
// below. A sealed product with no market price and no MSRP fallback
// still has a real UPC on the box, and nothing else in the system ever
// writes one — placing this after that line loses them silently.
const rawUpc = String(row.extUPC ?? '').trim();
const upc = extractUpc(row);
if (rawUpc && !upc) {
totalMalformedUpcs++;
} else if (upc) {
// Two uuids can legitimately share a UPC (reprints, repackaging) and
// Shopify permits duplicate barcodes — surface it, don't block it.
const previousUuid = seenUpcs.get(upc);
if (previousUuid && previousUuid !== product.uuid) {
log(` ⚠️ Duplicate UPC ${upc}: ${previousUuid} and ${product.uuid}`);
}
seenUpcs.set(upc, product.uuid);
totalUpcs++;
barcodeOps.push({
updateOne: {
filter: { game: product.game, uuid: product.uuid },
update: {
$set: {
barcode: upc,
source: 'tcgcsv',
tcgplayerProductId: Number(tcgplayerProductId)
}
},
upsert: true
}
});
if (barcodeOps.length >= BATCH_SIZE) {
await flushBarcodeOps();
}
}3d. After the existing trailing if (batchBuffer.length > 0) { ... } flush, add:
javascript
await flushBarcodeOps();3e. Extend the summary log and return value:
javascript
log(` Barcodes recorded: ${totalUpcs}`);
log(` Malformed UPCs skipped: ${totalMalformedUpcs}`);
return { totalProcessed, totalMatched, totalInserted, totalWithMSRP, totalSkipped, totalUpcs, totalMalformedUpcs };3f. Export it for tests, alongside the existing named exports at the bottom:
javascript
module.exports.processPriceRows = processPriceRows;Note on absence-is-not-deletion: the write is $set inside an upsert, never a delete. A row whose extUPC goes blank upstream leaves the stored value untouched, which is required — nothing would ever re-push it.
- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/scripts/data-loading/updateSealedProductPrices.test.jsExpected: PASS — the 6 new ingest tests plus all pre-existing tests.
- [ ] Step 5: Commit
bash
git add server/scripts/data-loading/updateSealedProductPrices.js server/scripts/data-loading/updateSealedProductPrices.test.js
git commit -m "Record real sealed-product barcodes from the daily TCGCSV import"Task 4: resolveSealedBarcode stops generating
Files:
- Modify:
server/utils/barcodeGenerator.js:32-50 - Test:
server/utils/barcodeGenerator.test.js:70-99
Interfaces:
Consumes: nothing.
Produces:
resolveSealedBarcode({ officialBarcode }): string|undefined. ThegameCodeanduuidparameters are removed. Returnsundefinedwhen no official barcode is supplied.generateBarcodeis unchanged and still used by singles.[ ] Step 1: Rewrite the failing tests
In server/utils/barcodeGenerator.test.js, replace the entire describe('resolveSealedBarcode', ...) block (lines 70-99) with:
javascript
describe('resolveSealedBarcode', () => {
it('returns the official barcode when one exists', () => {
expect(resolveSealedBarcode({ officialBarcode: '195166278636' })).toBe('195166278636');
});
it('trims a padded official barcode', () => {
expect(resolveSealedBarcode({ officialBarcode: ' 195166278636 ' })).toBe('195166278636');
});
// A synthetic barcode is worse than none on sealed: it occupies
// Shopify's barcode field, so scanning the real UPC printed on the box
// silently misses. Omitting the field lets the merchant scan it into
// Shopify directly, and nothing re-pushes a sealed barcode afterward.
it('returns undefined when no official barcode is known', () => {
expect(resolveSealedBarcode({})).toBeUndefined();
});
it('treats a blank official barcode as absent', () => {
expect(resolveSealedBarcode({ officialBarcode: ' ' })).toBeUndefined();
});
it('never produces a generated LGSF value', () => {
for (const input of [{}, { officialBarcode: '' }, { officialBarcode: null }]) {
expect(resolveSealedBarcode(input)).not.toMatch(/^LGSF/);
}
});
});- [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/utils/barcodeGenerator.test.jsExpected: FAIL — resolveSealedBarcode({}) throws generateBarcode requires a gameCode.
- [ ] Step 3: Write the implementation
Replace resolveSealedBarcode in server/utils/barcodeGenerator.js (lines 32-50) with:
javascript
/**
* Resolve the barcode to ship for a sealed product.
*
* Sealed products carry a real manufacturer UPC printed on the packaging, so
* there is deliberately NO generated fallback here: a synthetic LGSF… value
* would occupy Shopify's barcode field and make a POS scan of the real code
* silently miss. When the UPC is unknown we send no barcode at all, leaving
* the merchant free to scan it into Shopify directly — nothing re-pushes a
* sealed barcode afterward, so that edit persists.
*
* Singles are the opposite case and still use generateBarcode: a card has no
* manufacturer barcode, so a generated one is the only option.
*
* @param {Object} args
* @param {string} [args.officialBarcode] - Real UPC/EAN if known
* @returns {string|undefined} The barcode to send, or undefined to omit the field
*/
function resolveSealedBarcode({ officialBarcode }) {
const trimmed = typeof officialBarcode === 'string' ? officialBarcode.trim() : officialBarcode;
if (trimmed) {
return String(trimmed);
}
return undefined;
}- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/utils/barcodeGenerator.test.jsExpected: PASS — all tests including the 5 rewritten ones.
- [ ] Step 5: Confirm no caller still passes the removed arguments
bash
grep -rn "resolveSealedBarcode" server/ --include=*.jsExpected: three sites — the definition and export in server/utils/barcodeGenerator.js, the require and call in server/routes/sealedProducts.js (still passing gameCode/uuid; Task 5 fixes it), and the test file. Extra arguments are harmless in JS, so this does not fail the build — Task 5 removes them.
- [ ] Step 6: Commit
bash
git add server/utils/barcodeGenerator.js server/utils/barcodeGenerator.test.js
git commit -m "Stop putting synthetic barcodes on products that have a real one"Task 5: buildSealedSyncPayload reads the reference table
Files:
- Modify:
server/routes/sealedProducts.js(require block near:17,buildSealedSyncPayloadat:267-297, stale comment at:415-417,module.exportsat:1140) - Test:
server/routes/sealedProducts.buildSyncPayload.test.js(create)
Interfaces:
Consumes:
SealedProductBarcode.getBarcode(Task 1),resolveSealedBarcode(Task 4).Produces:
module.exports.buildSealedSyncPayload = buildSealedSyncPayload— signature unchanged:(plugin, transformed, { existing } = {}) => Promise<{ metafields, barcode, msrp, weight, weightUnit }>.barcodeis nowstring|undefinedrather than always a string.[ ] Step 1: Write the failing test
Create server/routes/sealedProducts.buildSyncPayload.test.js:
javascript
/**
* Unit tests for buildSealedSyncPayload's barcode resolution.
*
* Sealed products get the real manufacturer UPC when TCGCSV supplied one and
* no barcode at all otherwise — never a generated LGSF… value.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { buildSealedSyncPayload } = require('./sealedProducts.js');
const SealedProductBarcode = require('../models/SealedProductBarcode.js');
const SealedProductMSRP = require('../models/SealedProductMSRP.js');
const plugin = { gameId: 'mtg', displayName: 'Magic: The Gathering', gameCode: 'MTG' };
const transformed = {
uuid: 'a1b2c3d4-0000-0000-0000-000000000001',
category: 'booster_box',
setCode: 'DFT',
metafields: { card_name: 'Aetherdrift - Play Booster Display' },
};
beforeEach(() => {
vi.spyOn(SealedProductMSRP, 'getMSRP').mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('buildSealedSyncPayload — barcode', () => {
it('ships the real UPC when the reference table has one', async () => {
vi.spyOn(SealedProductBarcode, 'getBarcode').mockResolvedValue('195166278636');
const payload = await buildSealedSyncPayload(plugin, transformed);
expect(payload.barcode).toBe('195166278636');
});
it('looks the barcode up scoped to the plugin game and product uuid', async () => {
const getBarcode = vi.spyOn(SealedProductBarcode, 'getBarcode').mockResolvedValue(null);
await buildSealedSyncPayload(plugin, transformed);
expect(getBarcode).toHaveBeenCalledWith('mtg', transformed.uuid);
});
it('omits the barcode when no UPC is known', async () => {
vi.spyOn(SealedProductBarcode, 'getBarcode').mockResolvedValue(null);
const payload = await buildSealedSyncPayload(plugin, transformed);
expect(payload.barcode).toBeUndefined();
});
// The whole point of the change: a synthetic code would occupy Shopify's
// barcode field and make a scan of the real UPC on the box miss.
it('never falls back to a generated LGSF value', async () => {
vi.spyOn(SealedProductBarcode, 'getBarcode').mockResolvedValue(null);
const payload = await buildSealedSyncPayload(plugin, transformed);
expect(payload.barcode ?? '').not.toMatch(/^LGSF/);
});
it('prefers a value already stored on the product over the reference table', async () => {
vi.spyOn(SealedProductBarcode, 'getBarcode').mockResolvedValue('195166278636');
const payload = await buildSealedSyncPayload(plugin, transformed, {
existing: { barcode: '850001234567' },
});
expect(payload.barcode).toBe('850001234567');
});
});- [ ] Step 2: Run the test to verify it fails
bash
npx vitest run server/routes/sealedProducts.buildSyncPayload.test.jsExpected: FAIL — buildSealedSyncPayload is not a function (the module currently exports only the router).
- [ ] Step 3: Write the implementation
3a. Add the model require alongside the existing ones (after :18):
javascript
const SealedProductBarcode = require('../models/SealedProductBarcode');3b. In buildSealedSyncPayload, replace the const barcode = resolveSealedBarcode({...}) call at :286-290 with:
javascript
// Real manufacturer UPC only — resolveSealedBarcode has no generated
// fallback (see server/utils/barcodeGenerator.js). Unknown means the
// barcode field is omitted from the Shopify payload entirely.
const barcode = resolveSealedBarcode({
officialBarcode: existing?.barcode
|| await SealedProductBarcode.getBarcode(plugin.gameId, transformed.uuid),
});3c. Fix the inaccurate comment at :415-417. Replace:
javascript
// Only the resolved MSRP is real at create time. barcode/weight stay
// unset (no catalog source) so the schema field means "official value",
// not the generated barcode fallback — that is recomputed each sync.with:
javascript
// Only the resolved MSRP is real at create time. barcode/weight stay
// unset here: the barcode's source of truth is the global
// SealedProductBarcode reference table, resolved at sync time. Note
// nothing re-pushes a sealed barcode after creation — createProduct is
// the only writer — so a merchant's own edit in Shopify persists.3d. Add the named export at the bottom of the file, after module.exports = router;:
javascript
// Named export for tests; the default export stays the mounted router.
module.exports.buildSealedSyncPayload = buildSealedSyncPayload;- [ ] Step 4: Run the test to verify it passes
bash
npx vitest run server/routes/sealedProducts.buildSyncPayload.test.jsExpected: PASS — 5 tests.
On the spec's "ships regardless of barcodesEnabled" test: the spec's Section 5 lists one, but it is not constructible as written — buildSealedSyncPayload receives no store and no sync config, so there is no toggle to vary and any such test would be vacuous. The guarantee is structural rather than test-enforced: grep -n barcodesEnabled server/routes/sealedProducts.js returns nothing, and applyBarcodePolicy in server/services/syncService.js:138 only ever touches buildVariantProducts output, which sealed does not use. Note this in the PR description, and treat a future diff that introduces barcodesEnabled into this file as the regression to catch in review — that is exactly what the c644924 cherry-pick would do.
- [ ] Step 5: Verify the payload omits the field end-to-end
server/services/shopifyAPI.js:1837 already guards with ...(mongoProduct.variantbarcode ? { barcode: mongoProduct.variantbarcode } : {}), so an undefined barcode drops out of the GraphQL variables. Confirm that line is unchanged:
bash
grep -n "variantbarcode ? { barcode" server/services/shopifyAPI.jsExpected: one match at :1837. No edit needed.
- [ ] Step 6: Commit
bash
git add server/routes/sealedProducts.js server/routes/sealedProducts.buildSyncPayload.test.js
git commit -m "Send the manufacturer barcode on sealed products when we know it"Task 6: Full verification
Files: none modified — this task only runs and reads.
- [ ] Step 1: Refresh the misleading sealed fixture
server/services/shopifyAPI.test.js:445 uses variantbarcode: 'LGSFMTGABCDEF012345' in its sealed fixture. That test exercises createProduct's pass-through and will still pass unchanged — but the fixture now documents behavior this PR removes, so a future reader would take it as evidence sealed products carry synthetic codes.
Update the fixture value at :445 and the matching assertion at :452 to a real UPC:
javascript
variantbarcode: '195166278636',javascript
expect(updates.barcode).toBe('195166278636');Leave the second test (omits barcode and measurement when not provided) exactly as-is — it already covers the unknown-UPC path this PR makes the common case.
- [ ] Step 2: Full server test suite
bash
npm testExpected: exit 0, zero failures.
- [ ] Step 3: Lint
bash
npm run lintExpected: exit 0, no new warnings versus pre-change output.
- [ ] Step 4: Per-file coverage
bash
npm run test:coverageRead the per-file table by hand for the four modified/created files. Every one must be ≥70% on statements, branches, functions and lines. The configured threshold gate is silently inoperative under Vitest 4 (flat threshold keys are ignored), so this command exits 0 regardless of coverage — the number on screen is the only signal.
- [ ] Step 5: Confirm no identity defaults were introduced (§5.5)
bash
git diff main...HEAD -- server/ | grep -nE "\|\| 'mtg'|\?\? 'mtg'|= 'mtg'"Expected: no output. IMPORTER_GAME is a pre-existing named constant, not a fallback, and is not re-introduced by this diff.
- [ ] Step 6: Confirm no synthetic barcode can reach a sealed product
bash
grep -n "generateBarcode" server/routes/sealedProducts.js server/utils/barcodeGenerator.jsExpected: matches only inside server/utils/barcodeGenerator.js (the definition, and its use by the singles path). Zero matches in server/routes/sealedProducts.js.
- [ ] Step 7: Commit the fixture refresh
bash
git add server/services/shopifyAPI.test.js
git commit -m "Use a real UPC in the sealed product test fixture"PR description checklist
- Parity (§5.1): mtg implemented; pokemon and riftbound exempt — neither has a sealed catalog source (
server/plugins/mtg/index.js:417is the only reader ofSetModel.data.sealedProduct). - New persisted fields (§5.2): all five declared in
SealedProductBarcode.jswith a round-trip test that fails if any schema line is deleted. - Data literals (§5.4):
extUPCcolumn name and sample values cited from https://tcgcsv.com/tcgplayer/1/23874/ProductsAndPrices.csv. - No identity defaults (§5.5): verified by Task 6 Step 4.
- No new aggregations, so §5.6 does not apply.
- No Shopify calls added, so rule 6 is unaffected.
- Note that
syncConfig.barcodesEnabledintentionally does not gate sealed barcodes: a real UPC is the product's factual identity, not a synthetic label the merchant opted into. The toggle now governs generated barcodes on singles only. - Note that UPCs appear only after the next
update-sealed-product-pricesworkflow run (5:00 UTC), and that already-synced sealed products are not repaired by this PR — that is PR 2.
Follow-on work (not this PR)
- PR 2 —
server/scripts/migrations/repairSyntheticSealedBarcodes.js. Must run only after this PR's first importer run has populatedSealedProductBarcode; against an empty table it would clear everyLGSF…code with nothing to refill them. - Open item from the spec — deleting the now-redundant
SealedProduct.barcodefield, contingent on confirming it is empty in production. This PR deliberately keeps theexisting?.barcodeprecedence so the field stays harmless if that check has not run. - Cherry-picking
c644924, whose sealedbarcodesEnabledgate conflicts with this design and must be dropped on the way in.
