@thorprovider/adapters 5.0.9 → 5.0.11

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.
@@ -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 {
@@ -160,6 +183,7 @@ const productFixtures: 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',
@@ -425,7 +452,7 @@ function buildProducts(currencyCode: string): Product[] {
425
452
  productType: product.productType,
426
453
  vendor: product.vendor,
427
454
  tags: product.tags,
428
- createdAt: FIXTURE_TIMESTAMP,
455
+ createdAt: product.createdAt,
429
456
  updatedAt: FIXTURE_TIMESTAMP,
430
457
  metadata: {
431
458
  categoryHandle: product.categoryHandle,
@@ -517,6 +544,167 @@ function paginateProducts(products: Product[], skip?: number, first?: number): P
517
544
  return products.slice(start, end);
518
545
  }
519
546
 
547
+ /**
548
+ * Canonical sort keys the mock catalog can truthfully apply (E2 task 2.5).
549
+ *
550
+ * Mirrors the Medusa advertisement: `featured` (stable fixture order, the
551
+ * canonical default), `name` (title ascending), and `newest` (fixture-supplied
552
+ * `createdAt` descending). No global price or rating ordering is advertised.
553
+ */
554
+ export const MOCK_CATALOG_SORT_KEYS: readonly CatalogSortKey[] = [
555
+ 'featured',
556
+ 'name',
557
+ 'newest',
558
+ ];
559
+
560
+ /**
561
+ * The mock catalog's demonstration endpoint identity.
562
+ *
563
+ * Explicitly mock-scoped so a support descriptor can never be mistaken for a
564
+ * live Medusa endpoint. The mock is the basic fixture listing; advanced facets
565
+ * are held.
566
+ */
567
+ const MOCK_CATALOG_ENDPOINT = 'mock://catalog/products';
568
+
569
+ /** Canonical default page size, matching the Medusa configured bound. */
570
+ const MOCK_CATALOG_PAGE_SIZE = 50;
571
+
572
+ /**
573
+ * Effective category scope for a canonical query.
574
+ *
575
+ * Mirrors the Medusa contract: the route `categoryScope` is always retained and
576
+ * the `categories` filter narrows within it. Returns `undefined` when no
577
+ * category filter is requested, `[]` for an explicit empty intersection (a valid
578
+ * zero-result query), or the handles to resolve against the fixtures.
579
+ */
580
+ function resolveEffectiveCategoryHandles(
581
+ query: CatalogQuery
582
+ ): string[] | undefined {
583
+ const { categoryScope, categories } = query;
584
+ if (categoryScope === undefined) return categories;
585
+ if (categories === undefined) return [categoryScope];
586
+ return categories.filter((handle) => handle === categoryScope);
587
+ }
588
+
589
+ /** Canonical query fields that require advanced filtering the mock does not implement. */
590
+ function collectRequestedAdvancedFields(query: CatalogQuery): string[] {
591
+ const fields: string[] = [];
592
+ if (query.priceMin !== undefined) fields.push('priceMin');
593
+ if (query.priceMax !== undefined) fields.push('priceMax');
594
+ if (query.inStock !== undefined) fields.push('inStock');
595
+ for (const name of Object.keys(query.attributes ?? {})) {
596
+ fields.push(`attr.${name}`);
597
+ }
598
+ return fields;
599
+ }
600
+
601
+ /** Map a requested advanced field name to the canonical facet id it needs. */
602
+ function advancedFieldToFacet(field: string): CatalogFacetId {
603
+ if (field === 'priceMin' || field === 'priceMax') return 'price';
604
+ return field as CatalogFacetId;
605
+ }
606
+
607
+ /**
608
+ * Filters a successful (or explicitly-empty) canonical result reports as
609
+ * applied. The mock implements only the basic filters (text/categories/
610
+ * collections); requested advanced filters never reach a success path.
611
+ */
612
+ function buildAppliedCatalogFilters(
613
+ query: CatalogQuery,
614
+ effectiveCategoryHandles: string[] | undefined
615
+ ): CatalogAppliedFilters {
616
+ return {
617
+ ...(query.text ? { text: query.text } : {}),
618
+ ...(effectiveCategoryHandles !== undefined
619
+ ? { categories: effectiveCategoryHandles }
620
+ : {}),
621
+ ...(query.collections !== undefined
622
+ ? { collections: query.collections }
623
+ : {}),
624
+ };
625
+ }
626
+
627
+ /** Basic fields the mock listing actually accepts, recorded on the demonstration. */
628
+ function buildBasicDemonstratedQuery(query: CatalogQuery): CatalogQuery {
629
+ return {
630
+ ...(query.text !== undefined ? { text: query.text } : {}),
631
+ ...(query.categoryScope !== undefined
632
+ ? { categoryScope: query.categoryScope }
633
+ : {}),
634
+ ...(query.categories !== undefined ? { categories: query.categories } : {}),
635
+ ...(query.collections !== undefined
636
+ ? { collections: query.collections }
637
+ : {}),
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Truthful facet-support descriptor for the mock.
643
+ *
644
+ * `categories`/`collections` are offered with the mock listing as their
645
+ * demonstration source; `price`/`inStock` are held with a real reason because
646
+ * the mock does not implement advanced filtering. No count or range is
647
+ * synthesized and the endpoint is explicitly mock-scoped.
648
+ */
649
+ function buildMockFacetSupport(query: CatalogQuery): FacetSupport {
650
+ const appliedQuery = buildBasicDemonstratedQuery(query);
651
+ const scopeField =
652
+ query.categoryScope !== undefined ? { scope: query.categoryScope } : {};
653
+
654
+ return {
655
+ offered: ['categories', 'collections'],
656
+ held: ['price', 'inStock'],
657
+ demonstrations: {
658
+ categories: {
659
+ endpoint: MOCK_CATALOG_ENDPOINT,
660
+ appliedQuery,
661
+ ...scopeField,
662
+ },
663
+ collections: {
664
+ endpoint: MOCK_CATALOG_ENDPOINT,
665
+ appliedQuery,
666
+ ...scopeField,
667
+ },
668
+ },
669
+ heldReasons: {
670
+ price: `${MOCK_CATALOG_ENDPOINT} is the mock basic listing; price filtering is not implemented in mock mode`,
671
+ inStock: `${MOCK_CATALOG_ENDPOINT} is the mock basic listing; stock filtering is not implemented in mock mode`,
672
+ },
673
+ };
674
+ }
675
+
676
+ /** Build a typed `unsupported-query` outcome naming the offending fields. */
677
+ function unsupportedMockCatalogOutcome(fields: string[]): CatalogQueryOutcome {
678
+ return {
679
+ ok: false,
680
+ error: {
681
+ code: 'unsupported-query',
682
+ fields,
683
+ message: `Unsupported catalog query in mock mode: ${fields.join(', ')}`,
684
+ repair: { remove: fields },
685
+ },
686
+ };
687
+ }
688
+
689
+ /**
690
+ * Apply the canonical ordering globally, before pagination.
691
+ *
692
+ * `featured` preserves the stable fixture order; `name` sorts by title
693
+ * ascending; `newest` sorts by the fixture-supplied `createdAt` descending.
694
+ */
695
+ function sortMockCatalogProducts(
696
+ products: Product[],
697
+ sortKey?: CatalogSortKey
698
+ ): Product[] {
699
+ const sorted = [...products];
700
+ if (sortKey === 'name') {
701
+ sorted.sort((left, right) => left.title.localeCompare(right.title));
702
+ } else if (sortKey === 'newest') {
703
+ sorted.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
704
+ }
705
+ return sorted;
706
+ }
707
+
520
708
  function resolveVariant(currencyCode: string, merchandiseId: string): {
521
709
  product: Product;
522
710
  variant: Product['variants'][number];
@@ -595,11 +783,22 @@ function buildExternalCart(state: MockCartState, currencyCode: string, siteUrl:
595
783
  checkoutUrl: `${siteUrl}/checkout?cart=${state.id}`,
596
784
  cost: {
597
785
  subtotalAmount: createMoney(subtotal, regionCurrency),
598
- totalAmount: createMoney(total, regionCurrency),
786
+ discountAmount: createMoney(discount, regionCurrency),
599
787
  totalTaxAmount: createMoney(totalTax, regionCurrency),
788
+ shippingAmount: createMoney(shipping, regionCurrency),
789
+ totalAmount: createMoney(total, regionCurrency),
600
790
  },
601
791
  lines,
602
792
  totalQuantity: lines.reduce((sum, line) => sum + line.quantity, 0),
793
+ salesChannelId: MOCK_SALES_CHANNEL_ID,
794
+ regionId: state.regionId,
795
+ currencyCode: regionCurrency.toUpperCase(),
796
+ selectedShippingMethodId: state.shippingMethodId,
797
+ // The mock method id IS the shipping option id (the value returned by
798
+ // `getMockShippingMethods` and accepted by `addShippingMethod`), so the
799
+ // canonical option identity mirrors the real adapter's `so_` field and the
800
+ // checkout UI can highlight the selected option.
801
+ selectedShippingOptionId: state.shippingMethodId,
603
802
  completedAt: state.completedAt,
604
803
  };
605
804
  }
@@ -674,6 +873,118 @@ export function getMockProducts(options?: {
674
873
  return paginateProducts(sorted, options?.skip, options?.first);
675
874
  }
676
875
 
876
+ /**
877
+ * Canonical catalog query for the mock provider — E2 `storefront-catalog-search-filters`
878
+ * task 2.5.
879
+ *
880
+ * A faithful stand-in for the Medusa canonical contract so mock mode exercises
881
+ * the same behavior:
882
+ *
883
+ * - The route `categoryScope` is always retained and intersected with the
884
+ * `categories` filter; an explicit empty intersection (or a requested
885
+ * category/collection handle that matches no fixture) is a valid zero-result
886
+ * query, never an unfiltered request.
887
+ * - Requested category/collection handles resolve against the fixtures by id or
888
+ * handle; text matching is case-insensitive over title, description, and tags.
889
+ * - Ordering is global, before pagination: `featured` preserves the stable
890
+ * fixture order, `name` sorts by title ascending, and `newest` sorts by the
891
+ * fixture-supplied `createdAt` descending. Only the advertised
892
+ * `MOCK_CATALOG_SORT_KEYS` are accepted; any other sort is a typed
893
+ * `unsupported-query`.
894
+ * - `total` is the full matching fixture count (never the page length) and is
895
+ * present whenever a listing is returned.
896
+ * - Advanced filters (price/stock/attributes) are held and rejected as a typed
897
+ * `unsupported-query` **before** any zero-result short-circuit, so a requested
898
+ * filter is never silently dropped and a partial result is never labelled
899
+ * success. The attached `facetSupport` marks its demonstration endpoint as
900
+ * mock-scoped and never claims Medusa support.
901
+ */
902
+ export function getMockCatalogProducts(
903
+ query: CatalogQuery,
904
+ options: { currencyCode?: string; regionId?: string } = {}
905
+ ): CatalogQueryOutcome {
906
+ if (
907
+ query.sort !== undefined &&
908
+ !MOCK_CATALOG_SORT_KEYS.includes(query.sort)
909
+ ) {
910
+ return unsupportedMockCatalogOutcome(['sort']);
911
+ }
912
+
913
+ const region = getRegionFixture(options.regionId);
914
+ const currencyCode = options.currencyCode ?? region.currencyCode;
915
+ const allProducts = buildProducts(currencyCode);
916
+ const facetSupport = buildMockFacetSupport(query);
917
+
918
+ // Reject every requested advanced field before the zero-result short-circuit,
919
+ // so an unsupported filter is never hidden behind an empty intersection.
920
+ const requestedAdvanced = collectRequestedAdvancedFields(query);
921
+ const heldAdvanced = requestedAdvanced.filter(
922
+ (field) => !facetSupport.offered.includes(advancedFieldToFacet(field))
923
+ );
924
+ if (heldAdvanced.length > 0) {
925
+ return unsupportedMockCatalogOutcome(heldAdvanced);
926
+ }
927
+
928
+ const effectiveCategoryHandles = resolveEffectiveCategoryHandles(query);
929
+ const emptyIntersection =
930
+ effectiveCategoryHandles !== undefined &&
931
+ effectiveCategoryHandles.length === 0;
932
+ const categoryFilterEmpty =
933
+ effectiveCategoryHandles !== undefined &&
934
+ !effectiveCategoryHandles.some((handle) =>
935
+ categoryFixtures.some(
936
+ (category) => category.handle === handle || category.id === handle
937
+ )
938
+ );
939
+ const collectionFilterEmpty =
940
+ query.collections !== undefined &&
941
+ !query.collections.some((handle) =>
942
+ collectionFixtures.some(
943
+ (collection) =>
944
+ collection.handle === handle || collection.id === handle
945
+ )
946
+ );
947
+
948
+ if (emptyIntersection || categoryFilterEmpty || collectionFilterEmpty) {
949
+ return {
950
+ ok: true,
951
+ results: {
952
+ products: [],
953
+ appliedFilters: buildAppliedCatalogFilters(
954
+ query,
955
+ effectiveCategoryHandles
956
+ ),
957
+ facetSupport,
958
+ },
959
+ };
960
+ }
961
+
962
+ const filtered = filterProducts(allProducts, {
963
+ query: query.text,
964
+ categoryIds: effectiveCategoryHandles,
965
+ collectionIds: query.collections,
966
+ });
967
+ const sorted = sortMockCatalogProducts(filtered, query.sort);
968
+
969
+ const pageSize = query.pageSize ?? MOCK_CATALOG_PAGE_SIZE;
970
+ const page = query.page ?? 1;
971
+ const offset = (page - 1) * pageSize;
972
+
973
+ return {
974
+ ok: true,
975
+ results: {
976
+ products: sorted.slice(offset, offset + pageSize),
977
+ // The full matching fixture count, never the page length.
978
+ total: filtered.length,
979
+ appliedFilters: buildAppliedCatalogFilters(
980
+ query,
981
+ effectiveCategoryHandles
982
+ ),
983
+ facetSupport,
984
+ },
985
+ };
986
+ }
987
+
677
988
  export function getMockProduct(handle: string, regionId?: string): Product | undefined {
678
989
  const region = getRegionFixture(regionId);
679
990
  return buildProducts(region.currencyCode).find(
@@ -813,6 +1124,17 @@ export function getMockPaymentMethods(currencyCode: string): PaymentMethod[] {
813
1124
  requiresSetup: false,
814
1125
  description: 'Mock cash payment option for checkout prototypes.',
815
1126
  },
1127
+ {
1128
+ // The single approved manual TEST provider used by the real Medusa
1129
+ // backend, mirrored in mock mode so the guest TEST purchase path is
1130
+ // exercisable without a backend.
1131
+ id: 'pp_system_default',
1132
+ name: 'Manual TEST payment',
1133
+ type: 'cash_on_delivery',
1134
+ isEnabled: true,
1135
+ requiresSetup: false,
1136
+ description: 'Manual system TEST provider (bookkeeping only; not external funds).',
1137
+ },
816
1138
  ];
817
1139
  }
818
1140
 
@@ -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';