SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.9 KB · 210 lines typescript
Raw Blame History
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