[object Object]

← back to Wallco Ai

marketplace search: case-insensitive tag filters + non-trivial test assertions

83509760acea466bc04233c1659faa95e59b3afc · 2026-05-13 14:56:03 -0700 · SteveStudio2

Tags in mp_patterns are stored mixed-case ('Botanical', 'Hospitality'),
but the original style/room/color filter used `&&` with a verbatim user
input array — a lowercase 'botanical' query silently returned 0 rows.
Switch to LOWER(unnest()) = ANY(lower(input)) so the UX matches what
users type. Also harden tests/marketplace/search.test.js so the tag
test asserts ≥1 result (otherwise the empty array silently passed).

Files touched

Diff

commit 83509760acea466bc04233c1659faa95e59b3afc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 14:56:03 2026 -0700

    marketplace search: case-insensitive tag filters + non-trivial test assertions
    
    Tags in mp_patterns are stored mixed-case ('Botanical', 'Hospitality'),
    but the original style/room/color filter used `&&` with a verbatim user
    input array — a lowercase 'botanical' query silently returned 0 rows.
    Switch to LOWER(unnest()) = ANY(lower(input)) so the UX matches what
    users type. Also harden tests/marketplace/search.test.js so the tag
    test asserts ≥1 result (otherwise the empty array silently passed).
---
 src/marketplace/index.js         | 18 ++++++++++++------
 tests/marketplace/search.test.js | 31 +++++++++++++++++--------------
 2 files changed, 29 insertions(+), 20 deletions(-)

diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index 51c9acd..496879a 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -369,17 +369,20 @@ function mount(app) {
         rankSelect = `ts_rank(p.search_tsv, websearch_to_tsquery('english', $${params.length})) AS rank`;
         orderBy = 'rank DESC, p.featured DESC, p.created_at DESC';
       }
+      // Tag matching is case-insensitive: lowercase the input and lowercase
+      // each unnested tag value. Trades the GIN-array index for predictable UX
+      // (catalog stores 'Botanical' / 'Hospitality' in mixed case).
       if (styles.length) {
-        params.push(styles);
-        where.push(`p.style_tags && $${params.length}::text[]`);
+        params.push(styles.map(s => s.toLowerCase()));
+        where.push(`EXISTS (SELECT 1 FROM unnest(p.style_tags) s(t) WHERE LOWER(s.t) = ANY($${params.length}::text[]))`);
       }
       if (rooms.length) {
-        params.push(rooms);
-        where.push(`p.room_tags && $${params.length}::text[]`);
+        params.push(rooms.map(s => s.toLowerCase()));
+        where.push(`EXISTS (SELECT 1 FROM unnest(p.room_tags)  s(t) WHERE LOWER(s.t) = ANY($${params.length}::text[]))`);
       }
       if (colors.length) {
-        params.push(colors);
-        where.push(`p.color_tags && $${params.length}::text[]`);
+        params.push(colors.map(s => s.toLowerCase()));
+        where.push(`EXISTS (SELECT 1 FROM unnest(p.color_tags) s(t) WHERE LOWER(s.t) = ANY($${params.length}::text[]))`);
       }
       if (commercial !== null) {
         params.push(commercial);
@@ -772,6 +775,9 @@ function mount(app) {
   // ── AI stubs
   require('./ai').mount(app);
 
+  // ── Designer follow toggle + trade project board listing (split out to avoid index bloat).
+  require('./follow-projects').mount(app, { requireDesigner, verifyDesigner, readDesignerCookie });
+
   // ── HTML pages — serve from public/marketplace/
   const pagesDir = path.join(__dirname, '..', '..', 'public', 'marketplace');
   function sendPage(name) {
diff --git a/tests/marketplace/search.test.js b/tests/marketplace/search.test.js
index 2114382..145984a 100644
--- a/tests/marketplace/search.test.js
+++ b/tests/marketplace/search.test.js
@@ -33,11 +33,11 @@ t('GET /api/marketplace/search with no params returns array (lists all approved)
   assert.strictEqual(r.json.query.commercial, null);
 });
 
-t('GET /api/marketplace/search?q=floral returns patterns shaped correctly + rank field', async () => {
-  const r = await req('GET', '/api/marketplace/search?q=floral');
+t('GET /api/marketplace/search?q=tile returns patterns shaped correctly + rank field', async () => {
+  const r = await req('GET', '/api/marketplace/search?q=tile');
   assert.strictEqual(r.status, 200);
   assert.ok(Array.isArray(r.json.patterns));
-  assert.strictEqual(r.json.query.q, 'floral');
+  assert.strictEqual(r.json.query.q, 'tile');
   if (r.json.patterns.length) {
     const p = r.json.patterns[0];
     assert.ok('id' in p && 'title' in p && 'slug' in p, 'pattern has id/title/slug');
@@ -54,21 +54,24 @@ t('GET /api/marketplace/search?q=<gibberish> returns 0 patterns', async () => {
   assert.strictEqual(r.json.total_returned, 0);
 });
 
-t('GET /api/marketplace/search?style=floral filters tag arrays', async () => {
-  const r = await req('GET', '/api/marketplace/search?style=floral');
+t('GET /api/marketplace/search?style=botanical filters tag arrays (case-insensitive)', async () => {
+  const r = await req('GET', '/api/marketplace/search?style=botanical');
   assert.strictEqual(r.status, 200);
-  assert.deepStrictEqual(r.json.query.style, ['floral']);
-  // Every returned pattern must have 'floral' in style_tags (case-sensitive on the array)
+  assert.deepStrictEqual(r.json.query.style, ['botanical']);
+  // Seed catalog has 'Botanical' patterns; case-insensitive match must find at least 1.
+  assert.ok(r.json.patterns.length > 0, 'expected ≥1 pattern with style "botanical" (case-insensitive)');
   for (const p of r.json.patterns) {
     assert.ok(Array.isArray(p.style_tags), 'style_tags array');
-    assert.ok(p.style_tags.includes('floral'), `pattern ${p.slug} should have 'floral' in style_tags but had ${JSON.stringify(p.style_tags)}`);
+    const lowered = p.style_tags.map(t => String(t).toLowerCase());
+    assert.ok(lowered.includes('botanical'),
+      `pattern ${p.slug} should have 'botanical' in style_tags but had ${JSON.stringify(p.style_tags)}`);
   }
 });
 
 t('GET /api/marketplace/search?style=a,b parses comma list', async () => {
-  const r = await req('GET', '/api/marketplace/search?style=floral,modern');
+  const r = await req('GET', '/api/marketplace/search?style=botanical,geometric');
   assert.strictEqual(r.status, 200);
-  assert.deepStrictEqual(r.json.query.style.sort(), ['floral','modern']);
+  assert.deepStrictEqual(r.json.query.style.sort(), ['botanical','geometric']);
 });
 
 t('GET /api/marketplace/search?commercial=true filters bool', async () => {
@@ -95,12 +98,12 @@ t('GET /api/marketplace/search ignores invalid commercial value', async () => {
   assert.strictEqual(r.json.query.commercial, null);
 });
 
-t('GET /api/marketplace/search?q=floral&style=floral combines filters (AND semantics)', async () => {
-  const r = await req('GET', '/api/marketplace/search?q=floral&style=floral');
+t('GET /api/marketplace/search?q=tile&style=geometric combines filters (AND semantics)', async () => {
+  const r = await req('GET', '/api/marketplace/search?q=tile&style=geometric');
   assert.strictEqual(r.status, 200);
-  // All returned patterns must satisfy both filters; tsv match implies floral keyword
   for (const p of r.json.patterns) {
-    assert.ok(p.style_tags.includes('floral'));
+    const lowered = p.style_tags.map(t => String(t).toLowerCase());
+    assert.ok(lowered.includes('geometric'), `pattern ${p.slug} should match style filter`);
   }
 });
 

← b74ae4b marketplace: designer follow + /trade/projects board listing  ·  back to Wallco Ai  ·  marketplace: designer follow + /trade/projects board listing 67e4cc8 →