takescake

The Subtle Bug: How prepareItemBase Was Silently Stripping oracle_id from Cart Items

2025-10-06

The Subtle Bug: How prepareItemBase Was Silently Stripping oracle_id

This post documents a critical but subtle bug we encountered while implementing clickable card images in the cart. Understanding this bug will help prevent similar issues in the future.

The Symptom

Users would click a card image in their cart expecting to navigate back to that card's detail page, but would end up on a completely different page (or a 404).

Expected behavior:

  • User on: /cards/27ffdf11-bb7f-40bf-94c4-6bfbc41668e5 (Eye of Nowhere's oracle page)
  • Clicks "Add to Cart"
  • Later clicks card image in cart
  • Returns to: /cards/27ffdf11-bb7f-40bf-94c4-6bfbc41668e5 āœ…

Actual behavior:

  • User on: /cards/27ffdf11-bb7f-40bf-94c4-6bfbc41668e5
  • Clicks "Add to Cart"
  • Later clicks card image in cart
  • Goes to: /cards/b307af89-1890-4fac-a12e-f8a678f1101f āŒ (wrong page!)

The cart was using the print ID instead of the oracle ID for navigation.

Understanding MTG Card IDs

To understand this bug, you need to know that Magic cards have two types of IDs:

Oracle ID (Shared Across Printings)

  • Purpose: Identifies the card concept itself
  • Example: 27ffdf11-bb7f-40bf-94c4-6bfbc41668e5
  • Shared by: All printings of "Eye of Nowhere" (Champions of Kamigawa, Mystery Booster, etc.)
  • Used for: /cards/{oracle_id} URLs - the canonical card page

Print ID (Specific to Each Printing)

  • Purpose: Identifies a specific printing/version
  • Example: b307af89-1890-4fac-a12e-f8a678f1101f
  • Unique to: Champions of Kamigawa foil version of Eye of Nowhere
  • Used for: ?art={print_id} parameter - shows specific artwork

Our cart needs both:

  • oracle_id → Navigate to /cards/{oracle_id} (canonical page)
  • print_id → Pass as ?art={print_id} (show correct artwork)

The Investigation

Step 1: Verify Data Is Passed Correctly

First, I checked the card detail page where users add cards to cart:

// app/cards/[oracle]/page.tsx
const initialCardData = {
  id: main.id,                    // "b307af89..." (print ID) āœ…
  oracle_id: oracle,              // "27ffdf11..." (oracle ID from URL) āœ…
  name: main.name,
  // ... other fields
};

// Debug output showed:
// urlOracle: '27ffdf11-bb7f-40bf-94c4-6bfbc41668e5' āœ…
// mainId: 'b307af89-1890-4fac-a12e-f8a678f1101f' āœ…
// initialCardDataOracleId: '27ffdf11-bb7f-40bf-94c4-6bfbc41668e5' āœ…

Data was correct at the source!

Step 2: Verify AddToCartButton Receives Correct Data

Next, checked the button component that calls addItem():

// components/AddToCartButton.tsx
const handleAdd = (isFoil: boolean) => {
  console.log('AddToCartButton - Adding:', {
    id: card.id,        // "b307af89..." āœ…
    oracle_id: card.oracle_id,  // "27ffdf11..." āœ…
  });
  
  addItem({
    id: card.id,
    oracle_id: card.oracle_id,  // We're passing it! āœ…
    name: card.name,
    // ...
  });
};

Still correct! We were definitely passing oracle_id to the cart.

Step 3: Check What's Stored in Cart

Now checked what the cart sidebar actually had:

// components/ScanCartSidebar.tsx
{items.map((item) => {
  console.log('Cart item:', {
    id: item.id,              // "b307af89..." āœ…
    oracle_id: item.oracle_id, // undefined āŒāŒāŒ
  });
  
  // URL construction
  const cardDetailUrl = item.oracle_id 
    ? `/cards/${item.oracle_id}?art=${item.id}`  // Never executed
    : `/cards/${item.id}`;                        // Always fell through to this

FOUND IT! The oracle_id was undefined in the cart, even though we passed it in!

Step 4: Find Where It's Being Stripped

Searched for where cart items are processed. Found the culprit in ScanCartContext.tsx:

// components/ScanCartContext.tsx
function prepareItemBase(raw: Omit<ScanCartItem, 'quantity'> | any): Omit<ScanCartItem, 'quantity'> {
  return {
    id: typeof raw?.id === 'string' ? raw.id : '',
    name: typeof raw?.name === 'string' ? raw.name : '',
    set_name: typeof raw?.set_name === 'string' ? raw.set_name : undefined,
    image: typeof raw?.image === 'string' ? raw.image : null,
    localImage: typeof raw?.localImage === 'string' ? raw.localImage : null,
    tcgplayer_id: typeof raw?.tcgplayer_id === 'number' ? raw.tcgplayer_id : undefined,
    tcgcsv: normalizeTcgcsv(raw?.tcgcsv),
    pricing: normalizePricing(raw?.pricing),
    isFoil: typeof raw?.isFoil === 'boolean' ? raw.isFoil : false,
    // āŒ oracle_id is MISSING! It gets stripped out here!
  };
}

This function is called every time an item is added to the cart. It sanitizes/validates the data... but it forgot to include oracle_id!

The Fix

One line addition:

function prepareItemBase(raw: Omit<ScanCartItem, 'quantity'> | any): Omit<ScanCartItem, 'quantity'> {
  return {
    id: typeof raw?.id === 'string' ? raw.id : '',
    oracle_id: typeof raw?.oracle_id === 'string' ? raw.oracle_id : undefined, // āœ… ADDED!
    name: typeof raw?.name === 'string' ? raw.name : '',
    set_name: typeof raw?.set_name === 'string' ? raw.set_name : undefined,
    image: typeof raw?.image === 'string' ? raw.image : null,
    localImage: typeof raw?.localImage === 'string' ? raw.localImage : null,
    tcgplayer_id: typeof raw?.tcgplayer_id === 'number' ? raw.tcgplayer_id : undefined,
    tcgcsv: normalizeTcgcsv(raw?.tcgcsv),
    pricing: normalizePricing(raw?.pricing),
    isFoil: typeof raw?.isFoil === 'boolean' ? raw.isFoil : false,
  };
}

Now the cart properly stores both IDs, and the sidebar can build correct URLs:

const cardDetailUrl = `/cards/${item.oracle_id}?art=${item.id}`;
// → "/cards/27ffdf11.../art=b307af89..." āœ…

Why This Bug Was So Subtle

1. Silent Failure

No errors were thrown. No warnings in console. The function just quietly dropped the field and continued. From a user's perspective, the cart "worked" - they could add items, see them, remove them. Only the image links were broken.

2. Type Safety Didn't Catch It

TypeScript's interface had oracle_id as optional:

export interface ScanCartItem {
  id: string;
  oracle_id?: string;  // Optional field
  // ...
}

So undefined was a perfectly valid value! TypeScript was happy with:

const item: ScanCartItem = {
  id: "abc123",
  oracle_id: undefined,  // Valid!
  // ...
};

3. Hidden in Helper Function

The bug wasn't at the point of use (AddToCartButton), but in a utility function deep in the cart context. You had to trace through:

  1. AddToCartButton calls addItem()
  2. addItem() calls prepareItemBase()
  3. prepareItemBase() strips the field
  4. Sanitized item is stored
  5. Sidebar retrieves item without oracle_id

4. Feature Worked From Scanner

Cards added via the scanner had oracle_id in the API response, so we thought it was working. We didn't realize the field was being stripped because we hadn't implemented clickable images yet. Once we added the links, the bug became apparent.

Lessons Learned

1. Explicit Field Whitelisting Can Hide Bugs

The prepareItemBase function uses an explicit whitelist pattern:

return {
  field1: validate(raw.field1),
  field2: validate(raw.field2),
  // If you forget field3, it silently disappears!
};

Pros:

  • Type safety - you control exactly what goes in
  • Validation - sanitize each field individually
  • No unexpected data sneaking through

Cons:

  • Maintenance burden - must update when adding fields
  • Easy to forget fields (like we did!)
  • Silent failures when fields are omitted

Alternative pattern (spread-then-override):

return {
  ...raw,  // Include all fields by default
  id: typeof raw?.id === 'string' ? raw.id : '',  // Override with validation
  name: typeof raw?.name === 'string' ? raw.name : '',
};

Pros:

  • No fields forgotten
  • Easier to maintain

Cons:

  • Unexpected fields could sneak through
  • Less type safety
  • Harder to validate each field individually

Best solution (strongly typed result):

function prepareItemBase(raw: any): Omit<ScanCartItem, 'quantity'> {
  // Explicitly type the result object
  const result: Omit<ScanCartItem, 'quantity'> = {
    id: typeof raw?.id === 'string' ? raw.id : '',
    oracle_id: typeof raw?.oracle_id === 'string' ? raw.oracle_id : undefined,
    name: typeof raw?.name === 'string' ? raw.name : '',
    // TypeScript will ERROR if we forget any required fields!
  };
  return result;
}

This way, if you add a required field to ScanCartItem, TypeScript forces you to handle it in prepareItemBase.

2. Test Data Flow End-to-End

We tested:

  • āœ… Data passed to AddToCartButton (step 1)
  • āœ… Cart sidebar URL construction (step 3)
  • āŒ What was actually stored in cart (step 2 - the missing piece!)

Debugging technique that found it: Console logging at multiple points:

// At entry point (AddToCartButton)
console.log('Adding to cart:', item);

// At storage point (ScanCartContext)
console.log('Storing in cart:', preparedItem);

// At retrieval point (ScanCartSidebar)
console.log('Retrieved from cart:', item);

This revealed the oracle_id disappeared between step 1 and step 2.

3. Document Intent in Comments

The prepareItemBase function needed clearer documentation:

/**
 * Sanitizes and validates a cart item before storage.
 * 
 * CRITICAL: This function uses an explicit whitelist pattern.
 * When adding new fields to ScanCartItem, you MUST add them here!
 * 
 * Each field is individually validated and coerced to the correct type.
 * Any fields not explicitly listed will be silently dropped.
 * 
 * @param raw - Raw cart item data (may be from user input, API, or localStorage)
 * @returns Validated cart item ready for storage (without quantity field)
 */
function prepareItemBase(raw: any): Omit<ScanCartItem, 'quantity'> {
  // ...
}

4. Integration Tests for Critical Paths

This would have been caught by a test:

describe('Cart context', () => {
  it('preserves oracle_id when adding card to cart', () => {
    const cartItem = {
      id: 'print-id-123',
      oracle_id: 'oracle-id-456',
      name: 'Test Card',
      // ... other fields
    };
    
    addItem(cartItem);
    
    const stored = getCartItems()[0];
    expect(stored.oracle_id).toBe('oracle-id-456'); // Would FAIL before fix!
  });

  it('builds correct URL with oracle_id and print_id', () => {
    const cartItem = {
      id: 'print-id-123',
      oracle_id: 'oracle-id-456',
      name: 'Test Card',
    };
    
    addItem(cartItem);
    
    const url = buildCartItemUrl(getCartItems()[0]);
    expect(url).toBe('/cards/oracle-id-456?art=print-id-123');
  });
});

How to Avoid This in Future

When Adding New Optional Fields to ScanCartItem

Follow this checklist:

  1. āœ… Add field to interface in lib/scan-cart-types.ts

    export interface ScanCartItem {
      id: string;
      oracle_id?: string;
      new_field?: string;  // Add here
      // ...
    }
    
  2. āœ… Update prepareItemBase in components/ScanCartContext.tsx

    function prepareItemBase(raw: any): Omit<ScanCartItem, 'quantity'> {
      return {
        id: typeof raw?.id === 'string' ? raw.id : '',
        oracle_id: typeof raw?.oracle_id === 'string' ? raw.oracle_id : undefined,
        new_field: typeof raw?.new_field === 'string' ? raw.new_field : undefined,  // Add here
        // ...
      };
    }
    
  3. āœ… Update all entry points

    • components/CardScanner.tsx - scanner adds to cart
    • components/QuickCardSearch.tsx - search adds to cart
    • components/AddToCartButton.tsx - card detail page adds to cart
  4. āœ… Test field persists through full cycle

    • Add item to cart
    • Check localStorage (DevTools → Application → Local Storage)
    • Reload page
    • Verify field still present

Checklist for Cart Field Additions

  • Added to ScanCartItem interface (lib/scan-cart-types.ts)
  • Added to prepareItemBase function with type validation (components/ScanCartContext.tsx)
  • Added to all addItem() call sites:
    • CardScanner.tsx
    • QuickCardSearch.tsx
    • AddToCartButton.tsx
  • Tested field appears in cart after adding
  • Tested field survives localStorage round-trip
  • Tested field persists after page reload
  • Updated any components that read the field

Technical Details

The prepareItemBase Function

This function serves as a data sanitizer for cart items. It's called in three places:

  1. When adding items - via addItem() in the cart context
  2. When loading from localStorage - via loadInitialCart() on mount
  3. When updating items - via increment/decrement/setFoil operations

Its job is to:

  • Validate types - ensure each field has the correct type
  • Provide defaults - fill in missing optional fields with sensible defaults
  • Normalize data - convert legacy/API formats to current schema
  • Prevent injection - strip unexpected fields that could break the app

Why Not Just Spread the Object?

You might think: why not just do return { ...raw } and be done with it?

Security/Safety concerns:

// If raw comes from localStorage (user-editable!)
const raw = JSON.parse(localStorage.getItem('cart'));
// raw could contain: { __proto__: { isAdmin: true }, ... }
// Prototype pollution attack!

// Or: { quantity: "lots" } - breaks price calculations
// Or: { pricing: "free!" } - breaks display logic

Type safety concerns:

// API might return:
{ price_usd: "1.50", price_usd_foil: "3.00" }

// But we need:
{ pricing: { normal: { market: 1.50 }, foil: { market: 3.00 } } }

// prepareItemBase transforms between formats

Evolution concerns:

// Old localStorage format:
{ id: "123", price: 1.50 }

// New format:
{ id: "123", pricing: { normal: { market: 1.50 } } }

// prepareItemBase handles migration

Conclusion

This bug taught us the importance of:

  1. Explicit validation - Don't assume data will flow through untouched
  2. End-to-end testing - Test the full path, not just entry and exit
  3. Documentation - Comment critical functions with maintenance notes
  4. Type safety - Make TypeScript work for you with strong typing
  5. Debug logging - Add strategic logs to trace data flow

The fix was one line, but understanding the bug prevented future similar issues. When working with cart functionality, always remember: if you add a field to ScanCartItem, add it to prepareItemBase too!

Related Posts

Related Posts