import { adapters, type ConnectorMeta, type RareIndexConnector } from '@rareindex/connectors'; import { listFixtures, loadFixture, runFixtureSuite } from '@rareindex/connectors/testing'; import { SUPPORTED_CURRENCIES, type NormalizedListing } from '@rareindex/shared'; type It = (name: string, fn: () => Promise | void) => void; // vitest's `expect` is typed loosely here so the helper does not depend on vitest at type level. type Expect = (v: unknown) => any; /** * Shared vitest suite for the g4-shops-intl storefront connectors (metadata-only Shopify/WooCommerce * stores). Runs the framework fixture suite plus the invariants every store connector must honour: * native currency = config.currency, every category is one the connector declares, listings only * (asking prices are never sales, SPEC §111), seller label present, stable external ids, no zero prices. */ export function storeSuite(meta: ConnectorMeta, connector: RareIndexConnector, it: It, expect: Expect): void { const cfg = adapters.StorefrontConfigSchema.parse(meta.config); runFixtureSuite(connector, it, expect); it('declares a supported native currency and complete compliance metadata', () => { expect(SUPPORTED_CURRENCIES as readonly string[]).toContain(cfg.currency); expect(meta.currency).toContain(cfg.currency); expect(Boolean(meta.accessNotes && meta.accessNotes.length > 120 && !/TODO/.test(meta.accessNotes))).toBe(true); expect(meta.supportsListings).toBe(true); expect(meta.supportsSold).toBe(false); expect(meta.refreshFrequencyMinutes).toBeGreaterThanOrEqual(360); expect(cfg.collections.length + (meta.config.wholeShop ? 1 : 0)).toBeGreaterThan(0); for (const c of cfg.collections) expect(/^[a-z0-9-]+$/.test(c.handle)).toBe(true); expect(new Set(cfg.collections.map((c) => c.handle)).size).toBe(cfg.collections.length); }); it('every collection and rule maps onto a category the connector declares', () => { const declared = new Set(meta.categories); for (const c of cfg.collections) if (c.categorySlug) expect(declared.has(c.categorySlug)).toBe(true); for (const r of cfg.rules) { expect(declared.has(r.categorySlug)).toBe(true); expect(() => new RegExp(r.match, 'i')).not.toThrow(); } expect(() => new RegExp(cfg.exclude, 'i')).not.toThrow(); if (cfg.titlePattern) expect(() => new RegExp(cfg.titlePattern!, 'i')).not.toThrow(); }); it('normalises every fixture into listings in the shop currency with stable ids', async () => { for (const name of listFixtures(meta.id)) { const fx = loadFixture(meta.id, name); const out = await connector.normalize(fx.raw); expect(out.length).toBeGreaterThan(0); const ids = new Set(); for (const r of out) { expect(r.kind).toBe('listing'); if (r.kind !== 'listing') continue; expect(r.currency).toBe(cfg.currency); expect(r.price).toBeGreaterThan(0); expect(r.listingType).toBe('fixed_price'); expect(r.seller).toBe(cfg.seller ?? null); expect(meta.categories).toContain(r.attributes.categorySlug); expect(r.sourceUrl.startsWith(meta.sourceUrl)).toBe(true); expect(Boolean(r.externalId && r.externalId.length > 0)).toBe(true); expect(ids.has(r.externalId!)).toBe(false); ids.add(r.externalId!); expect(r.confidence).toBeLessThan(1); expect(['available', 'ended', 'unknown']).toContain(r.availability); } } }); } export interface MappingCase { title: string; /** collection handle (Shopify) / category slug (WooCommerce) the product was listed under */ collection: string | null; type?: string | null; tags?: string[]; vendor?: string | null; /** optional single variant title (e.g. "Near Mint Foil", "UK 9") */ variant?: string | null; /** expected taxonomy slug, or null when the product must be skipped (accessory, unmapped…) */ expect: string | null; set?: string | null; number?: string | null; name?: string; grader?: string | null; grade?: string | null; brand?: string | null; series?: string | null; franchise?: string | null; conditionRaw?: string | null; year?: number | null; } /** * Parser unit test on synthetic storefront payloads: checks the collection → rule → exclude chain * and titlePattern extraction for representative titles seen live. Complements the real fixtures. */ export async function expectMapping(meta: ConnectorMeta, connector: RareIndexConnector, expect: Expect, cases: MappingCase[]): Promise { const woo = /woocommerce/i.test(meta.acquisitionMethod ?? ''); const cfg = adapters.StorefrontConfigSchema.parse(meta.config); const site = meta.sourceUrl.replace(/\/+$/, ''); let n = 1; for (const c of cases) { const id = 900000 + n++; const payload = woo ? { 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 } } : { 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: [] } }; 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[]; const label = `"${c.title}" [${c.collection ?? '-'}]`; if (c.expect === null) { expect({ label, count: out.length }).toEqual({ label, count: 0 }); continue; } expect({ label, count: out.length }).toEqual({ label, count: 1 }); const r = out[0]!; expect({ label, slug: r.attributes.categorySlug }).toEqual({ label, slug: c.expect }); if (c.set !== undefined) expect({ label, set: r.attributes.set }).toEqual({ label, set: c.set }); if (c.number !== undefined) expect({ label, number: r.attributes.number }).toEqual({ label, number: c.number }); if (c.name !== undefined) expect({ label, name: r.attributes.name }).toEqual({ label, name: c.name }); if (c.grader !== undefined) expect({ label, grader: r.grade.grader }).toEqual({ label, grader: c.grader }); if (c.grade !== undefined) expect({ label, grade: r.grade.grade }).toEqual({ label, grade: c.grade }); if (c.brand !== undefined) expect({ label, brand: r.attributes.brand }).toEqual({ label, brand: c.brand }); if (c.series !== undefined) expect({ label, series: r.attributes.series }).toEqual({ label, series: c.series }); if (c.franchise !== undefined) expect({ label, franchise: r.attributes.franchise }).toEqual({ label, franchise: c.franchise }); if (c.conditionRaw !== undefined) expect({ label, conditionRaw: r.condition.conditionRaw }).toEqual({ label, conditionRaw: c.conditionRaw }); if (c.year !== undefined) expect({ label, year: r.attributes.year }).toEqual({ label, year: c.year }); } }