[object Object]

← back to Wallco Ai

marketplace: designer follow + /trade/projects board listing

b74ae4b756466b3e1505338cbf2c7fbdb64e1d82 · 2026-05-13 14:56:00 -0700 · SteveStudio2

- POST/DELETE/GET /api/marketplace/designers/:id/follow (id or slug, idempotent)
- GET /api/marketplace/projects — current user's project boards joined to mp_project_saves
- /trade/projects page (public/marketplace/projects.html) with create-board form + thumbnail grid
- Follow button on designer-profile.html and storefront.html, calls follow API
- Routes split into src/marketplace/follow-projects.js to keep index.js small
- Tests: structure (mount + page wiring) + HTTP smoke (auth gating + public count)

Files touched

Diff

commit b74ae4b756466b3e1505338cbf2c7fbdb64e1d82
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 13 14:56:00 2026 -0700

    marketplace: designer follow + /trade/projects board listing
    
    - POST/DELETE/GET /api/marketplace/designers/:id/follow (id or slug, idempotent)
    - GET /api/marketplace/projects — current user's project boards joined to mp_project_saves
    - /trade/projects page (public/marketplace/projects.html) with create-board form + thumbnail grid
    - Follow button on designer-profile.html and storefront.html, calls follow API
    - Routes split into src/marketplace/follow-projects.js to keep index.js small
    - Tests: structure (mount + page wiring) + HTTP smoke (auth gating + public count)
---
 tests/marketplace/orders.test.js | 358 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 358 insertions(+)

diff --git a/tests/marketplace/orders.test.js b/tests/marketplace/orders.test.js
new file mode 100644
index 0000000..c0137ed
--- /dev/null
+++ b/tests/marketplace/orders.test.js
@@ -0,0 +1,358 @@
+// Unit tests for src/marketplace/orders.js
+// Run with: node tests/marketplace/orders.test.js
+//
+// Pure-function coverage: shipToValid, verifyShopifyHmac, extractMpLineItems,
+// entryTypeFor, sumDiscounts, sumTax — plus an end-to-end ingestFulfilledOrder
+// run against a mock { q, one } that records inserts in memory. No DB / no
+// network required.
+
+const assert = require('assert');
+const crypto = require('crypto');
+const orders = require('../../src/marketplace/orders');
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+// ── shipToValid
+
+t('shipToValid accepts a complete address and trims overlong fields', () => {
+  const out = orders.shipToValid({
+    name: '  Astrid Mauve  ',
+    address1: '123 Pattern Ln',
+    address2: 'Studio 4',
+    city: 'Brooklyn',
+    province: 'NY',
+    country: 'United States',
+    zip: '11211',
+    phone: '+1 555 0100',
+  });
+  assert.deepStrictEqual(out, {
+    name: 'Astrid Mauve',
+    address1: '123 Pattern Ln',
+    address2: 'Studio 4',
+    city: 'Brooklyn',
+    province: 'NY',
+    country: 'United States',
+    zip: '11211',
+    phone: '+1 555 0100',
+  });
+});
+
+t('shipToValid rejects missing required fields', () => {
+  assert.strictEqual(orders.shipToValid(null), null);
+  assert.strictEqual(orders.shipToValid({}), null);
+  assert.strictEqual(orders.shipToValid({ name: 'X', address1: '1 A', city: 'NY' }), null); // no country / zip
+  assert.strictEqual(orders.shipToValid({ name: 'X', address1: '1 A', city: 'NY', country: 'US' }), null); // no zip
+});
+
+t('shipToValid omits optional fields when not present', () => {
+  const out = orders.shipToValid({
+    name: 'X', address1: '1 A', city: 'NY', country: 'US', zip: '10001',
+  });
+  assert.deepStrictEqual(Object.keys(out).sort(), ['address1','city','country','name','zip'].sort());
+});
+
+// ── verifyShopifyHmac
+
+t('verifyShopifyHmac returns null when no secret is configured (dev mode)', () => {
+  assert.strictEqual(orders.verifyShopifyHmac(Buffer.from('{}'), 'whatever', ''), null);
+});
+
+t('verifyShopifyHmac returns false on missing header or body', () => {
+  assert.strictEqual(orders.verifyShopifyHmac(Buffer.from('{}'), null, 'secret'), false);
+  assert.strictEqual(orders.verifyShopifyHmac(null, 'header', 'secret'), false);
+});
+
+t('verifyShopifyHmac returns true for a correctly signed body', () => {
+  const secret = 'top-secret';
+  const body = Buffer.from(JSON.stringify({ id: 1234, fulfilled_at: '2026-05-13T00:00:00Z' }));
+  const sig = crypto.createHmac('sha256', secret).update(body).digest('base64');
+  assert.strictEqual(orders.verifyShopifyHmac(body, sig, secret), true);
+});
+
+t('verifyShopifyHmac returns false when the signature does not match', () => {
+  const secret = 'top-secret';
+  const body = Buffer.from('{}');
+  const sig = crypto.createHmac('sha256', 'WRONG').update(body).digest('base64');
+  assert.strictEqual(orders.verifyShopifyHmac(body, sig, secret), false);
+});
+
+// ── extractMpLineItems
+
+t('extractMpLineItems pulls pattern_id from line-item properties', () => {
+  const order = {
+    id: 9001,
+    line_items: [
+      {
+        id: 1,
+        title: 'Sample — Hotel Bluff',
+        price: '5.00',
+        quantity: 1,
+        properties: [
+          { name: 'pattern_id', value: '42' },
+          { name: 'pattern_slug', value: 'hotel-bluff-astrid-mauve' },
+          { name: 'designer_id', value: '7' },
+          { name: 'mp_product_type', value: 'sample' },
+        ],
+      },
+    ],
+  };
+  const items = orders.extractMpLineItems(order);
+  assert.strictEqual(items.length, 1);
+  assert.strictEqual(items[0].patternId, 42);
+  assert.strictEqual(items[0].designerId, 7);
+  assert.strictEqual(items[0].productType, 'sample');
+  assert.strictEqual(items[0].lineTotal, 5);
+});
+
+t('extractMpLineItems falls back to note_attributes when line-item props are missing', () => {
+  const order = {
+    id: 9002,
+    note_attributes: [
+      { name: 'mp_pattern_id', value: '99' },
+      { name: 'mp_designer_id', value: '3' },
+      { name: 'mp_order_type', value: 'sample' },
+      { name: 'mp_pattern_slug', value: 'foo' },
+    ],
+    line_items: [
+      { id: 1, title: 'Sample', price: '7.50', quantity: 2 },
+    ],
+  };
+  const items = orders.extractMpLineItems(order);
+  assert.strictEqual(items.length, 1);
+  assert.strictEqual(items[0].patternId, 99);
+  assert.strictEqual(items[0].designerId, 3);
+  assert.strictEqual(items[0].lineTotal, 15);
+  assert.strictEqual(items[0].patternSlug, 'foo');
+});
+
+t('extractMpLineItems skips line items with no pattern_id (non-marketplace items)', () => {
+  const order = {
+    id: 9003,
+    line_items: [
+      { id: 1, title: 'Some other product', price: '20.00', quantity: 1 },
+    ],
+  };
+  assert.deepStrictEqual(orders.extractMpLineItems(order), []);
+});
+
+t('extractMpLineItems returns [] for malformed orders', () => {
+  assert.deepStrictEqual(orders.extractMpLineItems(null), []);
+  assert.deepStrictEqual(orders.extractMpLineItems({}), []);
+  assert.deepStrictEqual(orders.extractMpLineItems({ id: 1, line_items: null }), []);
+});
+
+// ── sumDiscounts / sumTax
+
+t('sumDiscounts sums discount_allocations[].amount', () => {
+  assert.strictEqual(orders.sumDiscounts({}), 0);
+  assert.strictEqual(orders.sumDiscounts({ discount_allocations: [{ amount: '1.50' }, { amount: '2.00' }] }), 3.5);
+});
+
+t('sumTax sums tax_lines[].price', () => {
+  assert.strictEqual(orders.sumTax({}), 0);
+  assert.strictEqual(orders.sumTax({ tax_lines: [{ price: '0.40' }, { price: '0.10' }] }), 0.5);
+});
+
+// ── entryTypeFor
+
+t('entryTypeFor maps product types to ledger entry_type', () => {
+  assert.strictEqual(orders.entryTypeFor('sample'), 'sample_credit');
+  assert.strictEqual(orders.entryTypeFor('license'), 'license_sale');
+  assert.strictEqual(orders.entryTypeFor('recolor'), 'custom_recolor');
+  assert.strictEqual(orders.entryTypeFor('wallpaper'), 'wallpaper_sale');
+  assert.strictEqual(orders.entryTypeFor('anything-else'), 'wallpaper_sale');
+});
+
+// ── ingestFulfilledOrder against a mock { q, one }
+
+function makeMockDeps({ existingOrder = null, patternDesignerId = null, designer = { commission_rate: 20, is_founding: true } } = {}) {
+  const log = { inserts: { mp_orders: [], mp_order_items: [], mp_commission_ledger: [], mp_users: [] }, updates: [] };
+  let nextId = 100;
+
+  async function one(sql, params) {
+    const s = sql.replace(/\s+/g, ' ').trim();
+    if (s.startsWith('SELECT designer_id FROM mp_patterns')) {
+      return patternDesignerId ? { designer_id: patternDesignerId } : null;
+    }
+    if (s.startsWith('SELECT id FROM mp_orders WHERE external_shopify_order_id')) {
+      return existingOrder ? { id: existingOrder } : null;
+    }
+    if (s.startsWith('INSERT INTO mp_users')) {
+      const row = { id: ++nextId, email: params[0] };
+      log.inserts.mp_users.push({ params });
+      return row;
+    }
+    if (s.startsWith('SELECT commission_rate, is_founding')) {
+      return designer;
+    }
+    if (s.startsWith('INSERT INTO mp_orders')) {
+      const row = { id: ++nextId };
+      log.inserts.mp_orders.push({ params, id: row.id });
+      return row;
+    }
+    if (s.startsWith('INSERT INTO mp_order_items')) {
+      const row = { id: ++nextId };
+      log.inserts.mp_order_items.push({ params, id: row.id });
+      return row;
+    }
+    if (s.startsWith('INSERT INTO mp_commission_ledger')) {
+      const row = { id: ++nextId };
+      log.inserts.mp_commission_ledger.push({ params, id: row.id });
+      return row;
+    }
+    throw new Error('unexpected one(): ' + s.slice(0, 80));
+  }
+  async function q(sql, params) {
+    log.updates.push({ sql: sql.replace(/\s+/g, ' ').trim().slice(0, 80), params });
+    return { rowCount: 1 };
+  }
+  return { one, q, log };
+}
+
+t('ingestFulfilledOrder is a no-op for orders with no marketplace line items', async () => {
+  const deps = makeMockDeps();
+  const out = await orders.ingestFulfilledOrder({
+    id: 1001,
+    line_items: [{ id: 1, title: 'unrelated', price: '99', quantity: 1 }],
+  }, deps);
+  assert.strictEqual(out.skipped, true);
+  assert.strictEqual(deps.log.inserts.mp_orders.length, 0);
+});
+
+t('ingestFulfilledOrder is idempotent — second call for same Shopify order skips', async () => {
+  const deps = makeMockDeps({ existingOrder: 555 });
+  const order = {
+    id: 2002,
+    email: 'buyer@test.invalid',
+    total_price: '5.00',
+    fulfilled_at: '2026-05-13T12:00:00Z',
+    line_items: [
+      {
+        id: 1, title: 'Sample', price: '5.00', quantity: 1,
+        properties: [
+          { name: 'pattern_id', value: '11' },
+          { name: 'designer_id', value: '4' },
+          { name: 'mp_product_type', value: 'sample' },
+        ],
+      },
+    ],
+  };
+  const out = await orders.ingestFulfilledOrder(order, deps);
+  assert.strictEqual(out.skipped, true);
+  assert.strictEqual(out.orderId, 555);
+  assert.strictEqual(deps.log.inserts.mp_orders.length, 0);
+});
+
+t('ingestFulfilledOrder writes mp_orders + mp_order_items + mp_commission_ledger using buildLedgerEntry', async () => {
+  const deps = makeMockDeps({ designer: { commission_rate: 20, is_founding: true } });
+  const order = {
+    id: 3003,
+    email: 'buyer@test.invalid',
+    customer: { first_name: 'Buyer', last_name: 'Test' },
+    total_price: '5.00',
+    fulfilled_at: '2026-05-13T12:00:00Z',
+    line_items: [
+      {
+        id: 'gid://101', title: 'Sample — Hotel Bluff', price: '5.00', quantity: 1,
+        properties: [
+          { name: 'pattern_id', value: '11' },
+          { name: 'pattern_slug', value: 'hotel-bluff' },
+          { name: 'designer_id', value: '4' },
+          { name: 'mp_product_type', value: 'sample' },
+        ],
+      },
+    ],
+  };
+  const out = await orders.ingestFulfilledOrder(order, deps);
+  assert.strictEqual(out.ok, true);
+  assert.strictEqual(out.skipped, false);
+  assert.strictEqual(out.items, 1);
+  assert.strictEqual(out.ledger, 1);
+  assert.strictEqual(deps.log.inserts.mp_orders.length, 1);
+  assert.strictEqual(deps.log.inserts.mp_order_items.length, 1);
+  assert.strictEqual(deps.log.inserts.mp_commission_ledger.length, 1);
+
+  // mp_orders row — order_type 'sample', subtotal 5.00, fulfilled
+  const orderParams = deps.log.inserts.mp_orders[0].params;
+  // [user_id, external_shopify_order_id, order_type, subtotal, total, fulfilled_at]
+  assert.strictEqual(orderParams[1], '3003');
+  assert.strictEqual(orderParams[2], 'sample');
+  assert.strictEqual(orderParams[3], '5.00');
+
+  // mp_order_items row — pattern_id 11, designer 4, sample, commission 1.00 (20% × $5)
+  const itemParams = deps.log.inserts.mp_order_items[0].params;
+  // [order_id, pattern_id, designer_id, product_type, qty, unit, line_total, disc, tax, rate, amount, basis]
+  assert.strictEqual(itemParams[1], 11);
+  assert.strictEqual(itemParams[2], 4);
+  assert.strictEqual(itemParams[3], 'sample');
+  assert.strictEqual(itemParams[9], '20.00');
+  assert.strictEqual(itemParams[10], '1.00');
+  assert.strictEqual(itemParams[11], '5.00');
+
+  // mp_commission_ledger row — entry_type 'sample_credit'
+  const ledgerParams = deps.log.inserts.mp_commission_ledger[0].params;
+  // [designer_id, order_id, order_item_id, pattern_id, entry_type, basis, rate, amount, notes]
+  assert.strictEqual(ledgerParams[0], 4);
+  assert.strictEqual(ledgerParams[4], 'sample_credit');
+  assert.strictEqual(ledgerParams[5], '5.00');
+  assert.strictEqual(ledgerParams[6], '20.00');
+  assert.strictEqual(ledgerParams[7], '1.00');
+});
+
+t('ingestFulfilledOrder uses 15% default when designer has no commission_rate set', async () => {
+  const deps = makeMockDeps({ designer: { commission_rate: null, is_founding: false } });
+  const order = {
+    id: 4004,
+    email: 'buyer@test.invalid',
+    line_items: [
+      {
+        id: 1, title: 'Wallpaper', price: '200.00', quantity: 3,
+        properties: [
+          { name: 'pattern_id', value: '22' },
+          { name: 'designer_id', value: '8' },
+          { name: 'mp_product_type', value: 'wallpaper' },
+        ],
+      },
+    ],
+  };
+  const out = await orders.ingestFulfilledOrder(order, deps);
+  assert.strictEqual(out.ok, true);
+  const item = deps.log.inserts.mp_order_items[0].params;
+  // 200 × 3 = 600 basis, 15% = 90.00
+  assert.strictEqual(item[6], '600.00'); // line_total
+  assert.strictEqual(item[9], '15.00');  // rate
+  assert.strictEqual(item[10], '90.00'); // amount
+  const ledger = deps.log.inserts.mp_commission_ledger[0].params;
+  assert.strictEqual(ledger[4], 'wallpaper_sale');
+});
+
+t('ingestFulfilledOrder resolves designer_id from mp_patterns when missing on line item', async () => {
+  const deps = makeMockDeps({ patternDesignerId: 77, designer: { commission_rate: 20, is_founding: true } });
+  const order = {
+    id: 5005,
+    email: 'buyer@test.invalid',
+    line_items: [
+      {
+        id: 1, title: 'Sample', price: '5.00', quantity: 1,
+        properties: [
+          { name: 'pattern_id', value: '12' },
+          { name: 'mp_product_type', value: 'sample' },
+        ],
+      },
+    ],
+  };
+  const out = await orders.ingestFulfilledOrder(order, deps);
+  assert.strictEqual(out.ok, true);
+  assert.strictEqual(deps.log.inserts.mp_order_items[0].params[2], 77);
+});
+
+(async () => {
+  let pass = 0, fail = 0;
+  for (const test of tests) {
+    try { await test.fn(); console.log('✓', test.name); pass++; }
+    catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+  }
+  console.log(`\n${pass}/${tests.length} order tests passed`);
+  if (fail) process.exit(1);
+})();

← e092403 marketplace SEO: add smoke tests for sitemap + designer/patt  ·  back to Wallco Ai  ·  marketplace search: case-insensitive tag filters + non-trivi 8350976 →