@thorprovider/adapters 5.0.10 → 5.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +1 -1
  2. package/dist/index.d.mts +376 -16
  3. package/dist/index.d.ts +376 -16
  4. package/dist/index.js +1687 -528
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1680 -530
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/core/errors.ts +31 -0
  10. package/src/factory/index.ts +16 -6
  11. package/src/factory/provider-types.test.ts +32 -0
  12. package/src/factory/provider-types.ts +176 -0
  13. package/src/index.ts +6 -4
  14. package/src/providers/index.ts +1 -1
  15. package/src/providers/medusa/auth.test.ts +148 -25
  16. package/src/providers/medusa/auth.ts +129 -16
  17. package/src/providers/medusa/available-filters.test.ts +280 -0
  18. package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
  19. package/src/providers/medusa/catalog-query.test.ts +422 -0
  20. package/src/providers/medusa/catalog-sort.test.ts +175 -0
  21. package/src/providers/medusa/customer-orders.test.ts +142 -0
  22. package/src/providers/medusa/index.test.ts +480 -0
  23. package/src/providers/medusa/index.ts +1592 -538
  24. package/src/providers/medusa/live-smoke.test.ts +68 -0
  25. package/src/providers/medusa/transforms.test.ts +222 -0
  26. package/src/providers/medusa/transforms.ts +152 -66
  27. package/src/providers/mock/catalog-query.test.ts +491 -0
  28. package/src/providers/mock/index.test.ts +66 -0
  29. package/src/providers/mock/index.ts +43 -1
  30. package/src/providers/mock/mock.ts +513 -3
  31. package/src/types/completion.ts +57 -0
  32. package/src/types/index.ts +7 -0
  33. package/.turbo/turbo-build.log +0 -18
  34. package/.turbo/turbo-test.log +0 -372
  35. package/.turbo/turbo-type-check.log +0 -0
  36. package/dist/.tsbuildinfo +0 -1
  37. package/publish.log +0 -93
@@ -18,10 +18,25 @@ import type {
18
18
  StockStatus,
19
19
  StockValidation,
20
20
  } from '@thorprovider/types';
21
+ import type {
22
+ CatalogAppliedFilters,
23
+ CatalogFacetId,
24
+ CatalogQuery,
25
+ CatalogQueryOutcome,
26
+ CatalogSortKey,
27
+ FacetSupport,
28
+ } from '@thorprovider/types';
21
29
 
22
30
  const FIXTURE_TIMESTAMP = '2026-01-01T00:00:00.000Z';
23
31
  const DEFAULT_SITE_URL = 'https://mock.thorcommerce.local';
24
32
 
33
+ /**
34
+ * Stable sales channel the fixture cart is scoped to. Exported so a host can
35
+ * build a matching `StoreContext` for the mock data source; the runtime
36
+ * context check compares against it like a real backend channel.
37
+ */
38
+ export const MOCK_SALES_CHANNEL_ID = 'mock-sales-channel';
39
+
25
40
  interface MockRegionFixture {
26
41
  id: string;
27
42
  name: string;
@@ -69,6 +84,14 @@ interface MockProductFixture {
69
84
  tags: string[];
70
85
  options: Array<{ id: string; name: string; values: string[] }>;
71
86
  variants: MockVariantFixture[];
87
+ /**
88
+ * Fixture-supplied creation timestamp (ISO-8601).
89
+ *
90
+ * Declared explicitly per product rather than derived from the id, so the
91
+ * canonical `newest` ordering is truthful and deterministic. Products keep
92
+ * distinct values so global ordering before pagination is observable.
93
+ */
94
+ createdAt: string;
72
95
  }
73
96
 
74
97
  export interface MockCartState {
@@ -156,10 +179,11 @@ const categoryFixtures: MockCategoryFixture[] = [
156
179
  },
157
180
  ];
158
181
 
159
- const productFixtures: MockProductFixture[] = [
182
+ const heroProductFixtures: MockProductFixture[] = [
160
183
  {
161
184
  id: 'mock-product-aurora-jacket',
162
185
  handle: 'aurora-jacket',
186
+ createdAt: '2026-01-05T00:00:00.000Z',
163
187
  title: 'Aurora Quilted Jacket',
164
188
  description: 'Lightweight quilted outerwear with tonal hardware and a soft structured collar.',
165
189
  imageLabel: 'Aurora Jacket',
@@ -201,6 +225,7 @@ const productFixtures: MockProductFixture[] = [
201
225
  {
202
226
  id: 'mock-product-solis-dress',
203
227
  handle: 'solis-dress',
228
+ createdAt: '2026-01-03T00:00:00.000Z',
204
229
  title: 'Solis Pleated Dress',
205
230
  description: 'A fluid pleated dress with a clean neckline designed for hero storytelling.',
206
231
  imageLabel: 'Solis Dress',
@@ -240,6 +265,7 @@ const productFixtures: MockProductFixture[] = [
240
265
  {
241
266
  id: 'mock-product-helios-lamp',
242
267
  handle: 'helios-lamp',
268
+ createdAt: '2026-01-04T00:00:00.000Z',
243
269
  title: 'Helios Table Lamp',
244
270
  description: 'Warm ambient lamp with sculptural proportions for lifestyle and home grids.',
245
271
  imageLabel: 'Helios Lamp',
@@ -263,6 +289,7 @@ const productFixtures: MockProductFixture[] = [
263
289
  {
264
290
  id: 'mock-product-luna-bag',
265
291
  handle: 'luna-bag',
292
+ createdAt: '2026-01-02T00:00:00.000Z',
266
293
  title: 'Luna Crossbody Bag',
267
294
  description: 'Compact everyday bag for accessory rails, checkout add-ons, and bundle modules.',
268
295
  imageLabel: 'Luna Bag',
@@ -285,6 +312,180 @@ const productFixtures: MockProductFixture[] = [
285
312
  },
286
313
  ];
287
314
 
315
+ /**
316
+ * Deterministic blueprint for a generated demo category.
317
+ *
318
+ * Every field is a fixed literal so the factory below produces the same ids,
319
+ * handles, titles, prices, and ordering on every load (no `Math.random`, no
320
+ * clock reads). `names` are unique within the category and the `noun`
321
+ * distinguishes categories, so `${name} ${noun}` yields globally distinct
322
+ * titles and `${name}-${noun}` yields globally unique handles.
323
+ */
324
+ interface MockProductBlueprint {
325
+ categoryHandle: string;
326
+ noun: string;
327
+ productType: string;
328
+ vendor: string;
329
+ tags: string[];
330
+ variantName: string;
331
+ variantValues: string[];
332
+ names: string[];
333
+ description: string;
334
+ basePrice: number;
335
+ baseHue: number;
336
+ }
337
+
338
+ /**
339
+ * Fixed per-category pools for the generated demo products.
340
+ *
341
+ * Copy deliberately avoids the substrings "light" and "helios" so the existing
342
+ * CU-09 text-search assertions (which pin the only "light"/"HELIOS" matches to
343
+ * the fixture heroes) keep holding.
344
+ */
345
+ const generatedBlueprints: MockProductBlueprint[] = [
346
+ {
347
+ categoryHandle: 'jackets',
348
+ noun: 'Jacket',
349
+ productType: 'outerwear',
350
+ vendor: 'Thor Atelier',
351
+ tags: ['outerwear', 'layer'],
352
+ variantName: 'Size',
353
+ variantValues: ['S', 'M'],
354
+ names: ['Alpine', 'Boreal', 'Cinder', 'Drift', 'Ember', 'Fjord', 'Harbor', 'Meridian', 'Nomad', 'Tundra', 'Vantage'],
355
+ description: 'A considered layering piece in a brushed weave.',
356
+ basePrice: 118,
357
+ baseHue: 210,
358
+ },
359
+ {
360
+ categoryHandle: 'dresses',
361
+ noun: 'Dress',
362
+ productType: 'dress',
363
+ vendor: 'Thor Atelier',
364
+ tags: ['dress', 'editorial'],
365
+ variantName: 'Size',
366
+ variantValues: ['S', 'M'],
367
+ names: ['Amber', 'Camille', 'Dahlia', 'Elara', 'Iris', 'Lumen', 'Nova', 'Rosalind', 'Vera'],
368
+ description: 'Cut from a fluid cloth for editorial styling.',
369
+ basePrice: 142,
370
+ baseHue: 330,
371
+ },
372
+ {
373
+ categoryHandle: 'home',
374
+ noun: 'Lamp',
375
+ productType: 'decor',
376
+ vendor: 'Thor Home',
377
+ tags: ['home', 'decor', 'interior'],
378
+ variantName: 'Finish',
379
+ variantValues: ['Brass'],
380
+ names: ['Atlas', 'Copper', 'Dune', 'Onyx', 'Quill', 'Slate', 'Terra', 'Verdant'],
381
+ description: 'A calm sculptural accent for modern interiors.',
382
+ basePrice: 74,
383
+ baseHue: 40,
384
+ },
385
+ {
386
+ categoryHandle: 'accessories',
387
+ noun: 'Bag',
388
+ productType: 'bag',
389
+ vendor: 'Thor Atelier',
390
+ tags: ['accessory', 'everyday'],
391
+ variantName: 'Color',
392
+ variantValues: ['Juniper'],
393
+ names: ['Cove', 'Fable', 'Juniper', 'Kestrel', 'Marlowe', 'Piper', 'Rune'],
394
+ description: 'An everyday carry with a textured finish.',
395
+ basePrice: 68,
396
+ baseHue: 280,
397
+ },
398
+ ];
399
+
400
+ /** Oldest anchor for generated `createdAt` values; all stay before the heroes. */
401
+ const GENERATED_CREATED_AT_BASE = Date.parse('2025-12-30T00:00:00.000Z');
402
+
403
+ /** Deterministic collection order the generated products are assigned across. */
404
+ const GENERATED_COLLECTION_POOL = ['new-arrivals', 'best-sellers', 'studio-essentials'];
405
+
406
+ /**
407
+ * Build the generated demo products, one pool per seeded category.
408
+ *
409
+ * Deterministic by construction: the trailing `globalIndex` drives collection
410
+ * membership, pricing step, stock, hue, and `createdAt` so the whole seed is a
411
+ * pure function of fixed literals.
412
+ */
413
+ function buildGeneratedProductFixtures(): MockProductFixture[] {
414
+ const fixtures: MockProductFixture[] = [];
415
+ let globalIndex = 0;
416
+
417
+ for (const blueprint of generatedBlueprints) {
418
+ const noun = blueprint.noun.toLowerCase();
419
+
420
+ blueprint.names.forEach((name, localIndex) => {
421
+ const handle = `${name.toLowerCase()}-${noun}`;
422
+ const title = `${name} ${blueprint.noun}`;
423
+ const price = blueprint.basePrice + localIndex * 7 + (localIndex % 3) * 0.5;
424
+ const quantityAvailable = (globalIndex * 5) % 17;
425
+
426
+ const primaryCollection =
427
+ GENERATED_COLLECTION_POOL[globalIndex % GENERATED_COLLECTION_POOL.length];
428
+ const collectionHandles = [primaryCollection];
429
+ if (globalIndex % 5 === 0) {
430
+ const secondary =
431
+ GENERATED_COLLECTION_POOL[(globalIndex + 1) % GENERATED_COLLECTION_POOL.length];
432
+ if (secondary !== primaryCollection) {
433
+ collectionHandles.push(secondary);
434
+ }
435
+ }
436
+
437
+ const createdAt = new Date(GENERATED_CREATED_AT_BASE - globalIndex * 86_400_000).toISOString();
438
+
439
+ const variants: MockVariantFixture[] = blueprint.variantValues.map((value, variantIndex) => ({
440
+ id: `mock-variant-${handle}-${value.toLowerCase()}`,
441
+ title: value,
442
+ price,
443
+ quantityAvailable: Math.max(0, quantityAvailable - variantIndex),
444
+ selectedOptions: [{ name: blueprint.variantName, value }],
445
+ }));
446
+
447
+ fixtures.push({
448
+ id: `mock-product-${handle}`,
449
+ handle,
450
+ title,
451
+ description: `${title} - ${blueprint.description}`,
452
+ imageLabel: title,
453
+ imageHue: (blueprint.baseHue + localIndex * 13) % 360,
454
+ collectionHandles,
455
+ categoryHandle: blueprint.categoryHandle,
456
+ productType: blueprint.productType,
457
+ vendor: blueprint.vendor,
458
+ tags: [...blueprint.tags],
459
+ options: [
460
+ {
461
+ id: `${handle}-${blueprint.variantName.toLowerCase()}`,
462
+ name: blueprint.variantName,
463
+ values: [...blueprint.variantValues],
464
+ },
465
+ ],
466
+ variants,
467
+ createdAt,
468
+ });
469
+
470
+ globalIndex += 1;
471
+ });
472
+ }
473
+
474
+ return fixtures;
475
+ }
476
+
477
+ /**
478
+ * The full mock product seed.
479
+ *
480
+ * The four fixture heroes stay first so the canonical `featured` order and the
481
+ * home hero (`slice(0, 4)`) remain representative; the generated demo products
482
+ * follow and populate every category with a credible multi-item grid.
483
+ */
484
+ const productFixtures: MockProductFixture[] = [
485
+ ...heroProductFixtures,
486
+ ...buildGeneratedProductFixtures(),
487
+ ];
488
+
288
489
  const stockLocationFixture: StockLocation = {
289
490
  id: 'mock-stock-primary',
290
491
  name: 'Mock Main Warehouse',
@@ -422,10 +623,24 @@ function buildProducts(currencyCode: string): Product[] {
422
623
  title: collection.title,
423
624
  handle: collection.handle,
424
625
  })),
626
+ categories: (() => {
627
+ const category = categoryFixtures.find(
628
+ (item) => item.handle === product.categoryHandle,
629
+ );
630
+ if (!category) return undefined;
631
+ return [
632
+ {
633
+ id: category.id,
634
+ name: category.name,
635
+ handle: category.handle,
636
+ parentCategoryId: category.parentCategoryId,
637
+ },
638
+ ];
639
+ })(),
425
640
  productType: product.productType,
426
641
  vendor: product.vendor,
427
642
  tags: product.tags,
428
- createdAt: FIXTURE_TIMESTAMP,
643
+ createdAt: product.createdAt,
429
644
  updatedAt: FIXTURE_TIMESTAMP,
430
645
  metadata: {
431
646
  categoryHandle: product.categoryHandle,
@@ -517,6 +732,167 @@ function paginateProducts(products: Product[], skip?: number, first?: number): P
517
732
  return products.slice(start, end);
518
733
  }
519
734
 
735
+ /**
736
+ * Canonical sort keys the mock catalog can truthfully apply (E2 task 2.5).
737
+ *
738
+ * Mirrors the Medusa advertisement: `featured` (stable fixture order, the
739
+ * canonical default), `name` (title ascending), and `newest` (fixture-supplied
740
+ * `createdAt` descending). No global price or rating ordering is advertised.
741
+ */
742
+ export const MOCK_CATALOG_SORT_KEYS: readonly CatalogSortKey[] = [
743
+ 'featured',
744
+ 'name',
745
+ 'newest',
746
+ ];
747
+
748
+ /**
749
+ * The mock catalog's demonstration endpoint identity.
750
+ *
751
+ * Explicitly mock-scoped so a support descriptor can never be mistaken for a
752
+ * live Medusa endpoint. The mock is the basic fixture listing; advanced facets
753
+ * are held.
754
+ */
755
+ const MOCK_CATALOG_ENDPOINT = 'mock://catalog/products';
756
+
757
+ /** Canonical default page size, matching the Medusa configured bound. */
758
+ const MOCK_CATALOG_PAGE_SIZE = 50;
759
+
760
+ /**
761
+ * Effective category scope for a canonical query.
762
+ *
763
+ * Mirrors the Medusa contract: the route `categoryScope` is always retained and
764
+ * the `categories` filter narrows within it. Returns `undefined` when no
765
+ * category filter is requested, `[]` for an explicit empty intersection (a valid
766
+ * zero-result query), or the handles to resolve against the fixtures.
767
+ */
768
+ function resolveEffectiveCategoryHandles(
769
+ query: CatalogQuery
770
+ ): string[] | undefined {
771
+ const { categoryScope, categories } = query;
772
+ if (categoryScope === undefined) return categories;
773
+ if (categories === undefined) return [categoryScope];
774
+ return categories.filter((handle) => handle === categoryScope);
775
+ }
776
+
777
+ /** Canonical query fields that require advanced filtering the mock does not implement. */
778
+ function collectRequestedAdvancedFields(query: CatalogQuery): string[] {
779
+ const fields: string[] = [];
780
+ if (query.priceMin !== undefined) fields.push('priceMin');
781
+ if (query.priceMax !== undefined) fields.push('priceMax');
782
+ if (query.inStock !== undefined) fields.push('inStock');
783
+ for (const name of Object.keys(query.attributes ?? {})) {
784
+ fields.push(`attr.${name}`);
785
+ }
786
+ return fields;
787
+ }
788
+
789
+ /** Map a requested advanced field name to the canonical facet id it needs. */
790
+ function advancedFieldToFacet(field: string): CatalogFacetId {
791
+ if (field === 'priceMin' || field === 'priceMax') return 'price';
792
+ return field as CatalogFacetId;
793
+ }
794
+
795
+ /**
796
+ * Filters a successful (or explicitly-empty) canonical result reports as
797
+ * applied. The mock implements only the basic filters (text/categories/
798
+ * collections); requested advanced filters never reach a success path.
799
+ */
800
+ function buildAppliedCatalogFilters(
801
+ query: CatalogQuery,
802
+ effectiveCategoryHandles: string[] | undefined
803
+ ): CatalogAppliedFilters {
804
+ return {
805
+ ...(query.text ? { text: query.text } : {}),
806
+ ...(effectiveCategoryHandles !== undefined
807
+ ? { categories: effectiveCategoryHandles }
808
+ : {}),
809
+ ...(query.collections !== undefined
810
+ ? { collections: query.collections }
811
+ : {}),
812
+ };
813
+ }
814
+
815
+ /** Basic fields the mock listing actually accepts, recorded on the demonstration. */
816
+ function buildBasicDemonstratedQuery(query: CatalogQuery): CatalogQuery {
817
+ return {
818
+ ...(query.text !== undefined ? { text: query.text } : {}),
819
+ ...(query.categoryScope !== undefined
820
+ ? { categoryScope: query.categoryScope }
821
+ : {}),
822
+ ...(query.categories !== undefined ? { categories: query.categories } : {}),
823
+ ...(query.collections !== undefined
824
+ ? { collections: query.collections }
825
+ : {}),
826
+ };
827
+ }
828
+
829
+ /**
830
+ * Truthful facet-support descriptor for the mock.
831
+ *
832
+ * `categories`/`collections` are offered with the mock listing as their
833
+ * demonstration source; `price`/`inStock` are held with a real reason because
834
+ * the mock does not implement advanced filtering. No count or range is
835
+ * synthesized and the endpoint is explicitly mock-scoped.
836
+ */
837
+ function buildMockFacetSupport(query: CatalogQuery): FacetSupport {
838
+ const appliedQuery = buildBasicDemonstratedQuery(query);
839
+ const scopeField =
840
+ query.categoryScope !== undefined ? { scope: query.categoryScope } : {};
841
+
842
+ return {
843
+ offered: ['categories', 'collections'],
844
+ held: ['price', 'inStock'],
845
+ demonstrations: {
846
+ categories: {
847
+ endpoint: MOCK_CATALOG_ENDPOINT,
848
+ appliedQuery,
849
+ ...scopeField,
850
+ },
851
+ collections: {
852
+ endpoint: MOCK_CATALOG_ENDPOINT,
853
+ appliedQuery,
854
+ ...scopeField,
855
+ },
856
+ },
857
+ heldReasons: {
858
+ price: `${MOCK_CATALOG_ENDPOINT} is the mock basic listing; price filtering is not implemented in mock mode`,
859
+ inStock: `${MOCK_CATALOG_ENDPOINT} is the mock basic listing; stock filtering is not implemented in mock mode`,
860
+ },
861
+ };
862
+ }
863
+
864
+ /** Build a typed `unsupported-query` outcome naming the offending fields. */
865
+ function unsupportedMockCatalogOutcome(fields: string[]): CatalogQueryOutcome {
866
+ return {
867
+ ok: false,
868
+ error: {
869
+ code: 'unsupported-query',
870
+ fields,
871
+ message: `Unsupported catalog query in mock mode: ${fields.join(', ')}`,
872
+ repair: { remove: fields },
873
+ },
874
+ };
875
+ }
876
+
877
+ /**
878
+ * Apply the canonical ordering globally, before pagination.
879
+ *
880
+ * `featured` preserves the stable fixture order; `name` sorts by title
881
+ * ascending; `newest` sorts by the fixture-supplied `createdAt` descending.
882
+ */
883
+ function sortMockCatalogProducts(
884
+ products: Product[],
885
+ sortKey?: CatalogSortKey
886
+ ): Product[] {
887
+ const sorted = [...products];
888
+ if (sortKey === 'name') {
889
+ sorted.sort((left, right) => left.title.localeCompare(right.title));
890
+ } else if (sortKey === 'newest') {
891
+ sorted.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
892
+ }
893
+ return sorted;
894
+ }
895
+
520
896
  function resolveVariant(currencyCode: string, merchandiseId: string): {
521
897
  product: Product;
522
898
  variant: Product['variants'][number];
@@ -595,11 +971,22 @@ function buildExternalCart(state: MockCartState, currencyCode: string, siteUrl:
595
971
  checkoutUrl: `${siteUrl}/checkout?cart=${state.id}`,
596
972
  cost: {
597
973
  subtotalAmount: createMoney(subtotal, regionCurrency),
598
- totalAmount: createMoney(total, regionCurrency),
974
+ discountAmount: createMoney(discount, regionCurrency),
599
975
  totalTaxAmount: createMoney(totalTax, regionCurrency),
976
+ shippingAmount: createMoney(shipping, regionCurrency),
977
+ totalAmount: createMoney(total, regionCurrency),
600
978
  },
601
979
  lines,
602
980
  totalQuantity: lines.reduce((sum, line) => sum + line.quantity, 0),
981
+ salesChannelId: MOCK_SALES_CHANNEL_ID,
982
+ regionId: state.regionId,
983
+ currencyCode: regionCurrency.toUpperCase(),
984
+ selectedShippingMethodId: state.shippingMethodId,
985
+ // The mock method id IS the shipping option id (the value returned by
986
+ // `getMockShippingMethods` and accepted by `addShippingMethod`), so the
987
+ // canonical option identity mirrors the real adapter's `so_` field and the
988
+ // checkout UI can highlight the selected option.
989
+ selectedShippingOptionId: state.shippingMethodId,
603
990
  completedAt: state.completedAt,
604
991
  };
605
992
  }
@@ -674,6 +1061,118 @@ export function getMockProducts(options?: {
674
1061
  return paginateProducts(sorted, options?.skip, options?.first);
675
1062
  }
676
1063
 
1064
+ /**
1065
+ * Canonical catalog query for the mock provider — E2 `storefront-catalog-search-filters`
1066
+ * task 2.5.
1067
+ *
1068
+ * A faithful stand-in for the Medusa canonical contract so mock mode exercises
1069
+ * the same behavior:
1070
+ *
1071
+ * - The route `categoryScope` is always retained and intersected with the
1072
+ * `categories` filter; an explicit empty intersection (or a requested
1073
+ * category/collection handle that matches no fixture) is a valid zero-result
1074
+ * query, never an unfiltered request.
1075
+ * - Requested category/collection handles resolve against the fixtures by id or
1076
+ * handle; text matching is case-insensitive over title, description, and tags.
1077
+ * - Ordering is global, before pagination: `featured` preserves the stable
1078
+ * fixture order, `name` sorts by title ascending, and `newest` sorts by the
1079
+ * fixture-supplied `createdAt` descending. Only the advertised
1080
+ * `MOCK_CATALOG_SORT_KEYS` are accepted; any other sort is a typed
1081
+ * `unsupported-query`.
1082
+ * - `total` is the full matching fixture count (never the page length) and is
1083
+ * present whenever a listing is returned.
1084
+ * - Advanced filters (price/stock/attributes) are held and rejected as a typed
1085
+ * `unsupported-query` **before** any zero-result short-circuit, so a requested
1086
+ * filter is never silently dropped and a partial result is never labelled
1087
+ * success. The attached `facetSupport` marks its demonstration endpoint as
1088
+ * mock-scoped and never claims Medusa support.
1089
+ */
1090
+ export function getMockCatalogProducts(
1091
+ query: CatalogQuery,
1092
+ options: { currencyCode?: string; regionId?: string } = {}
1093
+ ): CatalogQueryOutcome {
1094
+ if (
1095
+ query.sort !== undefined &&
1096
+ !MOCK_CATALOG_SORT_KEYS.includes(query.sort)
1097
+ ) {
1098
+ return unsupportedMockCatalogOutcome(['sort']);
1099
+ }
1100
+
1101
+ const region = getRegionFixture(options.regionId);
1102
+ const currencyCode = options.currencyCode ?? region.currencyCode;
1103
+ const allProducts = buildProducts(currencyCode);
1104
+ const facetSupport = buildMockFacetSupport(query);
1105
+
1106
+ // Reject every requested advanced field before the zero-result short-circuit,
1107
+ // so an unsupported filter is never hidden behind an empty intersection.
1108
+ const requestedAdvanced = collectRequestedAdvancedFields(query);
1109
+ const heldAdvanced = requestedAdvanced.filter(
1110
+ (field) => !facetSupport.offered.includes(advancedFieldToFacet(field))
1111
+ );
1112
+ if (heldAdvanced.length > 0) {
1113
+ return unsupportedMockCatalogOutcome(heldAdvanced);
1114
+ }
1115
+
1116
+ const effectiveCategoryHandles = resolveEffectiveCategoryHandles(query);
1117
+ const emptyIntersection =
1118
+ effectiveCategoryHandles !== undefined &&
1119
+ effectiveCategoryHandles.length === 0;
1120
+ const categoryFilterEmpty =
1121
+ effectiveCategoryHandles !== undefined &&
1122
+ !effectiveCategoryHandles.some((handle) =>
1123
+ categoryFixtures.some(
1124
+ (category) => category.handle === handle || category.id === handle
1125
+ )
1126
+ );
1127
+ const collectionFilterEmpty =
1128
+ query.collections !== undefined &&
1129
+ !query.collections.some((handle) =>
1130
+ collectionFixtures.some(
1131
+ (collection) =>
1132
+ collection.handle === handle || collection.id === handle
1133
+ )
1134
+ );
1135
+
1136
+ if (emptyIntersection || categoryFilterEmpty || collectionFilterEmpty) {
1137
+ return {
1138
+ ok: true,
1139
+ results: {
1140
+ products: [],
1141
+ appliedFilters: buildAppliedCatalogFilters(
1142
+ query,
1143
+ effectiveCategoryHandles
1144
+ ),
1145
+ facetSupport,
1146
+ },
1147
+ };
1148
+ }
1149
+
1150
+ const filtered = filterProducts(allProducts, {
1151
+ query: query.text,
1152
+ categoryIds: effectiveCategoryHandles,
1153
+ collectionIds: query.collections,
1154
+ });
1155
+ const sorted = sortMockCatalogProducts(filtered, query.sort);
1156
+
1157
+ const pageSize = query.pageSize ?? MOCK_CATALOG_PAGE_SIZE;
1158
+ const page = query.page ?? 1;
1159
+ const offset = (page - 1) * pageSize;
1160
+
1161
+ return {
1162
+ ok: true,
1163
+ results: {
1164
+ products: sorted.slice(offset, offset + pageSize),
1165
+ // The full matching fixture count, never the page length.
1166
+ total: filtered.length,
1167
+ appliedFilters: buildAppliedCatalogFilters(
1168
+ query,
1169
+ effectiveCategoryHandles
1170
+ ),
1171
+ facetSupport,
1172
+ },
1173
+ };
1174
+ }
1175
+
677
1176
  export function getMockProduct(handle: string, regionId?: string): Product | undefined {
678
1177
  const region = getRegionFixture(regionId);
679
1178
  return buildProducts(region.currencyCode).find(
@@ -813,6 +1312,17 @@ export function getMockPaymentMethods(currencyCode: string): PaymentMethod[] {
813
1312
  requiresSetup: false,
814
1313
  description: 'Mock cash payment option for checkout prototypes.',
815
1314
  },
1315
+ {
1316
+ // The single approved manual TEST provider used by the real Medusa
1317
+ // backend, mirrored in mock mode so the guest TEST purchase path is
1318
+ // exercisable without a backend.
1319
+ id: 'pp_system_default',
1320
+ name: 'Manual TEST payment',
1321
+ type: 'cash_on_delivery',
1322
+ isEnabled: true,
1323
+ requiresSetup: false,
1324
+ description: 'Manual system TEST provider (bookkeeping only; not external funds).',
1325
+ },
816
1326
  ];
817
1327
  }
818
1328
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @thorprovider/adapters — Cart completion contract
3
+ *
4
+ * Completion of a backend cart is discriminated (change B, decision 11): a
5
+ * native complete may return an order, a cart carrying a structured error, or
6
+ * an ambiguous/unresolved result whose outcome must be reconciled before any
7
+ * retry. This type is provider-agnostic and lives in adapters (not
8
+ * `@thorprovider/types`, which stays type-only and frozen).
9
+ */
10
+
11
+ import type { Cart, Order } from '@thorprovider/types';
12
+
13
+ /**
14
+ * Structured completion error. `raw` carries the backend's own error object
15
+ * when one was supplied so the discriminator and details are preserved.
16
+ */
17
+ export interface CartCompletionError {
18
+ /** Human-readable message. */
19
+ message: string;
20
+
21
+ /** Stable error code when the backend or transport supplies one. */
22
+ code?: string;
23
+
24
+ /** HTTP/transport status when one is known. */
25
+ status?: number;
26
+
27
+ /** Backend error details, carried through unchanged. */
28
+ raw?: unknown;
29
+ }
30
+
31
+ /**
32
+ * The discriminated result of completing a cart.
33
+ *
34
+ * - `order` — the cart was completed and an order was placed.
35
+ * - `cart` — the backend returned a cart with a structured error (the native
36
+ * HTTP 200 `type: "cart"` discriminator is preserved).
37
+ * - `unresolved` — the mutation's outcome is unknown (timeout/transport/5xx).
38
+ * Never blindly retry: reconcile the committed cart before another attempt.
39
+ */
40
+ export type CartCompletionResult =
41
+ | { type: 'order'; order: Order }
42
+ | { type: 'cart'; cart: Cart; error: CartCompletionError }
43
+ | { type: 'unresolved'; error: CartCompletionError };
44
+
45
+ /**
46
+ * Optional capability a provider may implement to expose the discriminated
47
+ * completion and reconciliation protocol. Providers that do not implement it
48
+ * still satisfy `CommerceProvider`; the runtime falls back to
49
+ * `completeCheckout` for them.
50
+ */
51
+ export interface CartCompletionCapable {
52
+ /**
53
+ * Complete the cart once, preserving the native discriminator. Must not
54
+ * retry an ambiguous mutation.
55
+ */
56
+ completeCart?(cartId: string): Promise<CartCompletionResult>;
57
+ }
@@ -22,3 +22,10 @@ export type {
22
22
  UIDiscountCode,
23
23
  UIPaymentMethod,
24
24
  } from './ui-responses';
25
+
26
+ // Cart completion contract (discriminated native completion)
27
+ export type {
28
+ CartCompletionResult,
29
+ CartCompletionError,
30
+ CartCompletionCapable,
31
+ } from './completion';
@@ -1,18 +0,0 @@
1
- CLI Building entry: {"index":"src/index.ts"}
2
- CLI Using tsconfig: tsconfig.json
3
- CLI tsup v8.5.1
4
- CLI Using tsup config: /home/nelson/dev/active/thorprovider/repos/thorcommerce/packages/adapters/tsup.config.ts
5
- CLI Target: esnext
6
- CLI Cleaning output folder
7
- CJS Build start
8
- ESM Build start
9
- ESM dist/index.mjs 343.86 KB
10
- ESM dist/index.mjs.map 702.37 KB
11
- ESM ⚡️ Build success in 150ms
12
- CJS dist/index.js 348.70 KB
13
- CJS dist/index.js.map 702.75 KB
14
- CJS ⚡️ Build success in 151ms
15
- DTS Build start
16
- DTS ⚡️ Build success in 11687ms
17
- DTS dist/index.d.ts 99.44 KB
18
- DTS dist/index.d.mts 99.44 KB