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%
7.2 KB · 125 lines typescript
Raw Blame History
1import { adapters, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors';2import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing';3import { SUPPORTED_CURRENCIES, type NormalizedListing } from '@rareindex/shared';45type It = (name: string, fn: () => Promise<void> | void) => void;6// vitest's `expect` is typed loosely here so the helper does not depend on vitest at type level.7type Expect = (v: unknown) => any;89/**10 * Shared vitest suite for the g4-shops-intl storefront connectors (metadata-only Shopify/WooCommerce11 * stores). Runs the framework fixture suite plus the invariants every store connector must honour:12 * native currency = config.currency, every category is one the connector declares, listings only13 * (asking prices are never sales, SPEC §111), seller label present, stable external ids, no zero prices.14 */15export function storeSuite(meta: ConnectorMeta, connector: RareIndexConnector, it: It, expect: Expect): void {16  const cfg = adapters.StorefrontConfigSchema.parse(meta.config);17  runFixtureSuite(connector, it, expect);1819  it('declares a supported native currency and complete compliance metadata', () => {20    expect(SUPPORTED_CURRENCIES as readonly string[]).toContain(cfg.currency);21    expect(meta.currency).toContain(cfg.currency);22    expect(Boolean(meta.accessNotes && meta.accessNotes.length > 120 && !/TODO/.test(meta.accessNotes))).toBe(true);23    expect(meta.supportsListings).toBe(true);24    expect(meta.supportsSold).toBe(false);25    expect(meta.refreshFrequencyMinutes).toBeGreaterThanOrEqual(360);26    expect(cfg.collections.length + (meta.config.wholeShop ? 1 : 0)).toBeGreaterThan(0);27    for (const c of cfg.collections) expect(/^[a-z0-9-]+$/.test(c.handle)).toBe(true);28    expect(new Set(cfg.collections.map((c) => c.handle)).size).toBe(cfg.collections.length);29  });3031  it('every collection and rule maps onto a category the connector declares', () => {32    const declared = new Set(meta.categories);33    for (const c of cfg.collections) if (c.categorySlug) expect(declared.has(c.categorySlug)).toBe(true);34    for (const r of cfg.rules) {35      expect(declared.has(r.categorySlug)).toBe(true);36      expect(() => new RegExp(r.match, 'i')).not.toThrow();37    }38    expect(() => new RegExp(cfg.exclude, 'i')).not.toThrow();39    if (cfg.titlePattern) expect(() => new RegExp(cfg.titlePattern!, 'i')).not.toThrow();40  });4142  it('normalises every fixture into listings in the shop currency with stable ids', async () => {43    for (const name of listFixtures(meta.id)) {44      const fx = loadFixture(meta.id, name);45      const out = await connector.normalize(fx.raw);46      expect(out.length).toBeGreaterThan(0);47      const ids = new Set<string>();48      for (const r of out) {49        expect(r.kind).toBe('listing');50        if (r.kind !== 'listing') continue;51        expect(r.currency).toBe(cfg.currency);52        expect(r.price).toBeGreaterThan(0);53        expect(r.listingType).toBe('fixed_price');54        expect(r.seller).toBe(cfg.seller ?? null);55        expect(meta.categories).toContain(r.attributes.categorySlug);56        expect(r.sourceUrl.startsWith(meta.sourceUrl)).toBe(true);57        expect(Boolean(r.externalId && r.externalId.length > 0)).toBe(true);58        expect(ids.has(r.externalId!)).toBe(false);59        ids.add(r.externalId!);60        expect(r.confidence).toBeLessThan(1);61        expect(['available', 'ended', 'unknown']).toContain(r.availability);62      }63    }64  });65}6667export interface MappingCase {68  title: string;69  /** collection handle (Shopify) / category slug (WooCommerce) the product was listed under */70  collection: string | null;71  type?: string | null;72  tags?: string[];73  vendor?: string | null;74  /** optional single variant title (e.g. "Near Mint Foil", "UK 9") */75  variant?: string | null;76  /** expected taxonomy slug, or null when the product must be skipped (accessory, unmapped…) */77  expect: string | null;78  set?: string | null;79  number?: string | null;80  name?: string;81  grader?: string | null;82  grade?: string | null;83  brand?: string | null;84  series?: string | null;85  franchise?: string | null;86  conditionRaw?: string | null;87  year?: number | null;88}8990/**91 * Parser unit test on synthetic storefront payloads: checks the collection → rule → exclude chain92 * and titlePattern extraction for representative titles seen live. Complements the real fixtures.93 */94export async function expectMapping(meta: ConnectorMeta, connector: RareIndexConnector, expect: Expect, cases: MappingCase[]): Promise<void> {95  const woo = /woocommerce/i.test(meta.acquisitionMethod ?? '');96  const cfg = adapters.StorefrontConfigSchema.parse(meta.config);97  const site = meta.sourceUrl.replace(/\/+$/, '');98  let n = 1;99  for (const c of cases) {100    const id = 900000 + n++;101    const payload = woo102      ? { category: c.collection, product: { id, name: c.title, slug: `p-${id}`, permalink: `${site}/product/p-${id}/`, sku: null, prices: { price: '1999', regular_price: '1999', currency_code: cfg.currency, currency_minor_unit: 2 }, images: [], categories: c.type ? c.type.split(' / ').map((name) => ({ name, slug: name.toLowerCase().replace(/\s+/g, '-') })) : [], tags: (c.tags ?? []).map((name) => ({ name })), is_in_stock: true, type: 'simple', brands: c.vendor ? [{ name: c.vendor }] : undefined } }103      : { collection: c.collection, product: { id, title: c.title, handle: `p-${id}`, vendor: c.vendor ?? null, product_type: c.type ?? null, tags: c.tags ?? [], variants: [{ id: id * 10, title: c.variant ?? 'Default Title', sku: null, price: '19.99', available: true }], images: [] } };104    const out = (await connector.normalize({ url: `${site}/products/p-${id}`, externalId: String(id), kind: 'listing', engine: 'api', fetchedAt: new Date('2026-09-08T00:00:00Z'), payload })) as NormalizedListing[];105    const label = `"${c.title}" [${c.collection ?? '-'}]`;106    if (c.expect === null) {107      expect({ label, count: out.length }).toEqual({ label, count: 0 });108      continue;109    }110    expect({ label, count: out.length }).toEqual({ label, count: 1 });111    const r = out[0]!;112    expect({ label, slug: r.attributes.categorySlug }).toEqual({ label, slug: c.expect });113    if (c.set !== undefined) expect({ label, set: r.attributes.set }).toEqual({ label, set: c.set });114    if (c.number !== undefined) expect({ label, number: r.attributes.number }).toEqual({ label, number: c.number });115    if (c.name !== undefined) expect({ label, name: r.attributes.name }).toEqual({ label, name: c.name });116    if (c.grader !== undefined) expect({ label, grader: r.grade.grader }).toEqual({ label, grader: c.grader });117    if (c.grade !== undefined) expect({ label, grade: r.grade.grade }).toEqual({ label, grade: c.grade });118    if (c.brand !== undefined) expect({ label, brand: r.attributes.brand }).toEqual({ label, brand: c.brand });119    if (c.series !== undefined) expect({ label, series: r.attributes.series }).toEqual({ label, series: c.series });120    if (c.franchise !== undefined) expect({ label, franchise: r.attributes.franchise }).toEqual({ label, franchise: c.franchise });121    if (c.conditionRaw !== undefined) expect({ label, conditionRaw: r.condition.conditionRaw }).toEqual({ label, conditionRaw: c.conditionRaw });122    if (c.year !== undefined) expect({ label, year: r.attributes.year }).toEqual({ label, year: c.year });123  }124}125