TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { readFileSync } from 'node:fs';2import path from 'node:path';3import { fileURLToPath } from 'node:url';4import { adapters, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors';5import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';6import { localMeta } from '../_lib/local-meta.js';78/**9 * Shared vitest suite for the g3-shops-na Shopify storefront connectors.10 * A store connector has no code of its own, so the tests exercise the metadata: every configured11 * collection/rule points at an existing taxonomy slug, the collection → category mapping resolves through12 * the SDK's `mapProduct`, rules/exclusions compile and behave on sample titles, and the live-captured13 * fixtures normalise into listings in the store's native currency (runFixtureSuite invariants).14 */15type Vitest = {16 describe: (name: string, fn: () => void) => void;17 it: (name: string, fn: () => Promise<void> | void) => void;18 expect: (v: unknown) => any;19};2021export interface StoreCase {22 /** product title as the store would publish it */23 title: string;24 productType?: string | null;25 tags?: string[];26 collection: string | null;27 /** expected taxonomy slug, or null when the product must be skipped (supplies, apparel…) */28 categorySlug: string | null;29 brand?: string | null;30 franchise?: string | null;31}3233export interface StoreSuiteOptions {34 /** product_type used for the neutral probe product (stores whose exclude regex keys on the type) */35 probeType?: string;36 /** extra rule/exclusion expectations */37 cases?: StoreCase[];38}3940const here = path.dirname(fileURLToPath(import.meta.url));41const TAXONOMY = path.resolve(here, '../../../data/taxonomy/categories.json');4243export function taxonomySlugs(): Set<string> {44 const doc = JSON.parse(readFileSync(TAXONOMY, 'utf8')) as { nodes: Array<{ slug: string }> };45 return new Set(doc.nodes.map((n) => n.slug));46}4748function neutralProduct(handle: string | null, title: string, productType: string | null, tags: string[] = []): adapters.StorefrontProduct {49 return {50 id: 'probe',51 title,52 url: 'https://example.com/products/probe',53 description: null,54 vendor: null,55 productType,56 tags,57 collection: handle,58 images: [],59 publishedAt: null,60 updatedAt: null,61 variants: [{ id: 'v', title: null, sku: null, barcode: null, price: 10, compareAtPrice: null, available: true, quantity: null, image: null }],62 };63}6465export function describeShopifyStore(rawMeta: unknown, create: (meta: ConnectorMeta) => RareIndexConnector, v: Vitest, opts: StoreSuiteOptions = {}): void {66 const meta = localMeta(rawMeta);67 const connector = create(meta);68 const cfg = adapters.ShopifyConfigSchema.parse(meta.config);69 const site = meta.sourceUrl.replace(/\/+$/, '');7071 v.describe(meta.id, () => {72 runFixtureSuite(connector, v.it, v.expect);7374 v.it('declares a coherent Shopify store connector (metadata, refresh class, currency)', async () => {75 v.expect(meta.acquisitionMethod).toMatch(/shopify/i);76 v.expect(meta.enginePriority).toEqual(['api']);77 v.expect(meta.supportsListings).toBe(true);78 v.expect(meta.supportsSold).toBe(false);79 v.expect(meta.currency).toEqual([cfg.currency]);80 v.expect([720, 1440]).toContain(meta.refreshFrequencyMinutes);81 v.expect(meta.accessNotes).toMatch(/robots/i);82 v.expect(meta.accessNotes).toMatch(/products\.json/);83 v.expect(cfg.collections.length).toBeGreaterThan(0);84 v.expect(cfg.seller).toBeTruthy();85 // market pinning (Shopify Markets geo-pricing guard): the shop's home market must match its region/currency86 const market = (connector as { market?: string }).market;87 v.expect(market).toBe(meta.regions[0]);88 v.expect(meta.config.market).toBe(market);89 v.expect({ CA: 'CAD', US: 'USD' }[market as 'CA' | 'US']).toBe(cfg.currency);90 v.expect(meta.accessNotes).toMatch(/localization=/);91 });9293 v.it('pins the shop market on every request (localization cookie + matching Accept-Language)', async () => {94 const seen: Array<Record<string, string> | undefined> = [];95 const ctrl = new AbortController(); // one request is enough: abort so the crawl does not throttle through every collection96 const fake = {97 meta,98 options: { mode: 'probe', limit: 1 },99 signal: ctrl.signal,100 engineStats: {},101 anomalies: [] as string[],102 log: { info() {}, warn() {}, error() {}, debug() {} },103 anomaly() {},104 async setCursor() {},105 async progress() {},106 async fetch(_url: string, opts?: { headers?: Record<string, string> }) {107 seen.push(opts?.headers);108 ctrl.abort();109 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() };110 },111 } as unknown as Parameters<typeof connector.crawl>[0];112 for await (const _ of connector.crawl(fake)) void _;113 v.expect(seen.length).toBeGreaterThan(0);114 for (const h of seen) {115 v.expect(h?.cookie).toBe(`localization=${meta.regions[0]}`);116 v.expect(h?.['accept-language']).toMatch(new RegExp(`^en-${meta.regions[0]}`));117 }118 });119120 v.it('maps every configured collection and rule onto an existing taxonomy slug', async () => {121 const slugs = taxonomySlugs();122 const handles = new Set<string>();123 for (const c of cfg.collections) {124 v.expect(handles.has(c.handle)).toBe(false);125 handles.add(c.handle);126 if (c.categorySlug) v.expect(slugs.has(c.categorySlug)).toBe(true);127 }128 for (const r of cfg.rules) {129 v.expect(slugs.has(r.categorySlug)).toBe(true);130 v.expect(() => new RegExp(r.match, 'i')).not.toThrow();131 }132 if (cfg.defaultCategory) v.expect(slugs.has(cfg.defaultCategory)).toBe(true);133 v.expect(() => new RegExp(cfg.exclude, 'i')).not.toThrow();134 if (cfg.titlePattern) v.expect(() => new RegExp(cfg.titlePattern!, 'i')).not.toThrow();135 // meta.categories must list what the mapping can emit136 const emitted = new Set([...cfg.collections.map((c) => c.categorySlug).filter(Boolean), ...cfg.rules.map((r) => r.categorySlug)]);137 for (const s of emitted) v.expect(meta.categories).toContain(s);138 });139140 v.it('resolves collection → category through the SDK mapProduct', async () => {141 for (const c of cfg.collections) {142 if (!c.categorySlug) continue;143 const m = adapters.mapProduct(cfg, neutralProduct(c.handle, 'Probe item', opts.probeType ?? null));144 v.expect(m).not.toBeNull();145 v.expect(m!.categorySlug).toBe(c.categorySlug);146 if (c.brand) v.expect(m!.brand).toBe(c.brand);147 if (c.franchise) v.expect(m!.franchise).toBe(c.franchise);148 }149 });150151 v.it('applies title/type rules and exclusions on sample products', async () => {152 for (const cs of opts.cases ?? []) {153 const m = adapters.mapProduct(cfg, neutralProduct(cs.collection, cs.title, cs.productType ?? opts.probeType ?? null, cs.tags ?? []));154 if (cs.categorySlug === null) {155 v.expect(m).toBeNull();156 } else {157 v.expect(m).not.toBeNull();158 v.expect(m!.categorySlug).toBe(cs.categorySlug);159 if (cs.brand !== undefined) v.expect(m!.brand).toBe(cs.brand);160 if (cs.franchise !== undefined) v.expect(m!.franchise).toBe(cs.franchise);161 }162 }163 });164165 v.it('normalises fixtures into listings in the store currency with seller, source URL and variant SKUs', async () => {166 const names = listFixtures(meta.id);167 v.expect(names.length).toBeGreaterThanOrEqual(2);168 const collections = new Set<string>();169 for (const name of names) {170 const fx = loadFixture(meta.id, name);171 const payload = fx.raw.payload as { collection: string | null; product: { variants: Array<{ sku?: string | null }> } };172 if (payload.collection) collections.add(payload.collection);173 const out = await connector.normalize(fx.raw);174 v.expect(out.length).toBeGreaterThan(0);175 for (const r of out) {176 v.expect(r.kind).toBe('listing');177 if (r.kind !== 'listing') continue;178 v.expect(r.currency).toBe(cfg.currency);179 v.expect(r.seller).toBe(cfg.seller);180 v.expect(r.listingType).toBe('fixed_price');181 v.expect(r.sourceUrl.startsWith(`${site}/products/`)).toBe(true);182 v.expect(['available', 'ended', 'unknown']).toContain(r.availability);183 v.expect(r.price).toBeGreaterThan(0);184 v.expect(r.confidence).toBeLessThan(1);185 v.expect(r.attributes.metadata?.collection).toBe(payload.collection);186 }187 const skus = payload.product.variants.filter((x) => x.sku).length;188 if (skus) v.expect(out.some((r) => r.kind === 'listing' && r.attributes.identifiers.sku)).toBe(true);189 }190 // fixtures should span more than one collection when the store has several191 if (cfg.collections.length > 1) v.expect(collections.size).toBeGreaterThanOrEqual(Math.min(2, names.length));192 });193194 v.it('keeps out-of-stock variants as ended listings (or drops them when configured)', async () => {195 let sawEnded = false;196 for (const name of listFixtures(meta.id)) {197 const fx = loadFixture(meta.id, name);198 const payload = fx.raw.payload as { product: { variants: Array<{ available?: boolean | null; price?: string | number | null }> } };199 const oos = payload.product.variants.filter((x) => x.available === false && Number(x.price) > 0).length;200 const out = await connector.normalize(fx.raw);201 const ended = out.filter((r) => r.kind === 'listing' && r.availability === 'ended').length;202 if (cfg.keepOutOfStock) v.expect(ended).toBe(oos);203 else v.expect(ended).toBe(0);204 if (ended) sawEnded = true;205 }206 if (cfg.keepOutOfStock) v.expect(typeof sawEnded).toBe('boolean');207 });208 });209}210