import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { adapters, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors'; import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; import { localMeta } from '../_lib/local-meta.js'; /** * Shared vitest suite for the g3-shops-na Shopify storefront connectors. * A store connector has no code of its own, so the tests exercise the metadata: every configured * collection/rule points at an existing taxonomy slug, the collection → category mapping resolves through * the SDK's `mapProduct`, rules/exclusions compile and behave on sample titles, and the live-captured * fixtures normalise into listings in the store's native currency (runFixtureSuite invariants). */ type Vitest = { describe: (name: string, fn: () => void) => void; it: (name: string, fn: () => Promise | void) => void; expect: (v: unknown) => any; }; export interface StoreCase { /** product title as the store would publish it */ title: string; productType?: string | null; tags?: string[]; collection: string | null; /** expected taxonomy slug, or null when the product must be skipped (supplies, apparel…) */ categorySlug: string | null; brand?: string | null; franchise?: string | null; } export interface StoreSuiteOptions { /** product_type used for the neutral probe product (stores whose exclude regex keys on the type) */ probeType?: string; /** extra rule/exclusion expectations */ cases?: StoreCase[]; } const here = path.dirname(fileURLToPath(import.meta.url)); const TAXONOMY = path.resolve(here, '../../../data/taxonomy/categories.json'); export function taxonomySlugs(): Set { const doc = JSON.parse(readFileSync(TAXONOMY, 'utf8')) as { nodes: Array<{ slug: string }> }; return new Set(doc.nodes.map((n) => n.slug)); } function neutralProduct(handle: string | null, title: string, productType: string | null, tags: string[] = []): adapters.StorefrontProduct { return { id: 'probe', title, url: 'https://example.com/products/probe', description: null, vendor: null, productType, tags, collection: handle, images: [], publishedAt: null, updatedAt: null, variants: [{ id: 'v', title: null, sku: null, barcode: null, price: 10, compareAtPrice: null, available: true, quantity: null, image: null }], }; } export function describeShopifyStore(rawMeta: unknown, create: (meta: ConnectorMeta) => RareIndexConnector, v: Vitest, opts: StoreSuiteOptions = {}): void { const meta = localMeta(rawMeta); const connector = create(meta); const cfg = adapters.ShopifyConfigSchema.parse(meta.config); const site = meta.sourceUrl.replace(/\/+$/, ''); v.describe(meta.id, () => { runFixtureSuite(connector, v.it, v.expect); v.it('declares a coherent Shopify store connector (metadata, refresh class, currency)', async () => { v.expect(meta.acquisitionMethod).toMatch(/shopify/i); v.expect(meta.enginePriority).toEqual(['api']); v.expect(meta.supportsListings).toBe(true); v.expect(meta.supportsSold).toBe(false); v.expect(meta.currency).toEqual([cfg.currency]); v.expect([720, 1440]).toContain(meta.refreshFrequencyMinutes); v.expect(meta.accessNotes).toMatch(/robots/i); v.expect(meta.accessNotes).toMatch(/products\.json/); v.expect(cfg.collections.length).toBeGreaterThan(0); v.expect(cfg.seller).toBeTruthy(); // market pinning (Shopify Markets geo-pricing guard): the shop's home market must match its region/currency const market = (connector as { market?: string }).market; v.expect(market).toBe(meta.regions[0]); v.expect(meta.config.market).toBe(market); v.expect({ CA: 'CAD', US: 'USD' }[market as 'CA' | 'US']).toBe(cfg.currency); v.expect(meta.accessNotes).toMatch(/localization=/); }); v.it('pins the shop market on every request (localization cookie + matching Accept-Language)', async () => { const seen: Array | undefined> = []; const ctrl = new AbortController(); // one request is enough: abort so the crawl does not throttle through every collection const fake = { meta, options: { mode: 'probe', limit: 1 }, signal: ctrl.signal, engineStats: {}, anomalies: [] as string[], log: { info() {}, warn() {}, error() {}, debug() {} }, anomaly() {}, async setCursor() {}, async progress() {}, async fetch(_url: string, opts?: { headers?: Record }) { seen.push(opts?.headers); ctrl.abort(); return { success: false, engine: 'api', url: _url, finalUrl: _url, httpStatus: 503, html: null, markdown: null, json: null, qualityScore: 0, requiresReview: false, error: 'test', costCredits: 0, durationMs: 0, fetchedAt: new Date() }; }, } as unknown as Parameters[0]; for await (const _ of connector.crawl(fake)) void _; v.expect(seen.length).toBeGreaterThan(0); for (const h of seen) { v.expect(h?.cookie).toBe(`localization=${meta.regions[0]}`); v.expect(h?.['accept-language']).toMatch(new RegExp(`^en-${meta.regions[0]}`)); } }); v.it('maps every configured collection and rule onto an existing taxonomy slug', async () => { const slugs = taxonomySlugs(); const handles = new Set(); for (const c of cfg.collections) { v.expect(handles.has(c.handle)).toBe(false); handles.add(c.handle); if (c.categorySlug) v.expect(slugs.has(c.categorySlug)).toBe(true); } for (const r of cfg.rules) { v.expect(slugs.has(r.categorySlug)).toBe(true); v.expect(() => new RegExp(r.match, 'i')).not.toThrow(); } if (cfg.defaultCategory) v.expect(slugs.has(cfg.defaultCategory)).toBe(true); v.expect(() => new RegExp(cfg.exclude, 'i')).not.toThrow(); if (cfg.titlePattern) v.expect(() => new RegExp(cfg.titlePattern!, 'i')).not.toThrow(); // meta.categories must list what the mapping can emit const emitted = new Set([...cfg.collections.map((c) => c.categorySlug).filter(Boolean), ...cfg.rules.map((r) => r.categorySlug)]); for (const s of emitted) v.expect(meta.categories).toContain(s); }); v.it('resolves collection → category through the SDK mapProduct', async () => { for (const c of cfg.collections) { if (!c.categorySlug) continue; const m = adapters.mapProduct(cfg, neutralProduct(c.handle, 'Probe item', opts.probeType ?? null)); v.expect(m).not.toBeNull(); v.expect(m!.categorySlug).toBe(c.categorySlug); if (c.brand) v.expect(m!.brand).toBe(c.brand); if (c.franchise) v.expect(m!.franchise).toBe(c.franchise); } }); v.it('applies title/type rules and exclusions on sample products', async () => { for (const cs of opts.cases ?? []) { const m = adapters.mapProduct(cfg, neutralProduct(cs.collection, cs.title, cs.productType ?? opts.probeType ?? null, cs.tags ?? [])); if (cs.categorySlug === null) { v.expect(m).toBeNull(); } else { v.expect(m).not.toBeNull(); v.expect(m!.categorySlug).toBe(cs.categorySlug); if (cs.brand !== undefined) v.expect(m!.brand).toBe(cs.brand); if (cs.franchise !== undefined) v.expect(m!.franchise).toBe(cs.franchise); } } }); v.it('normalises fixtures into listings in the store currency with seller, source URL and variant SKUs', async () => { const names = listFixtures(meta.id); v.expect(names.length).toBeGreaterThanOrEqual(2); const collections = new Set(); for (const name of names) { const fx = loadFixture(meta.id, name); const payload = fx.raw.payload as { collection: string | null; product: { variants: Array<{ sku?: string | null }> } }; if (payload.collection) collections.add(payload.collection); const out = await connector.normalize(fx.raw); v.expect(out.length).toBeGreaterThan(0); for (const r of out) { v.expect(r.kind).toBe('listing'); if (r.kind !== 'listing') continue; v.expect(r.currency).toBe(cfg.currency); v.expect(r.seller).toBe(cfg.seller); v.expect(r.listingType).toBe('fixed_price'); v.expect(r.sourceUrl.startsWith(`${site}/products/`)).toBe(true); v.expect(['available', 'ended', 'unknown']).toContain(r.availability); v.expect(r.price).toBeGreaterThan(0); v.expect(r.confidence).toBeLessThan(1); v.expect(r.attributes.metadata?.collection).toBe(payload.collection); } const skus = payload.product.variants.filter((x) => x.sku).length; if (skus) v.expect(out.some((r) => r.kind === 'listing' && r.attributes.identifiers.sku)).toBe(true); } // fixtures should span more than one collection when the store has several if (cfg.collections.length > 1) v.expect(collections.size).toBeGreaterThanOrEqual(Math.min(2, names.length)); }); v.it('keeps out-of-stock variants as ended listings (or drops them when configured)', async () => { let sawEnded = false; for (const name of listFixtures(meta.id)) { const fx = loadFixture(meta.id, name); const payload = fx.raw.payload as { product: { variants: Array<{ available?: boolean | null; price?: string | number | null }> } }; const oos = payload.product.variants.filter((x) => x.available === false && Number(x.price) > 0).length; const out = await connector.normalize(fx.raw); const ended = out.filter((r) => r.kind === 'listing' && r.availability === 'ended').length; if (cfg.keepOutOfStock) v.expect(ended).toBe(oos); else v.expect(ended).toBe(0); if (ended) sawEnded = true; } if (cfg.keepOutOfStock) v.expect(typeof sawEnded).toBe('boolean'); }); }); }