@thorprovider/adapters 5.0.10 → 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.
@@ -174,6 +174,140 @@ describe('MedusaProvider — Products', () => {
174
174
  })
175
175
  })
176
176
 
177
+ // ---------------------------------------------------------------------------
178
+ // Catalog pagination bound + directed lookup (change B, group 4)
179
+ // ---------------------------------------------------------------------------
180
+ describe('MedusaProvider — Catalog pagination and directed lookup', () => {
181
+ function captureListUrl(): { urls: string[] } {
182
+ const urls: string[] = []
183
+ server.use(
184
+ http.get(`${BASE}/store/products`, ({ request }) => {
185
+ urls.push(request.url)
186
+ const url = new URL(request.url)
187
+ if (url.searchParams.get('handle') || url.searchParams.get('id')) {
188
+ return HttpResponse.json({
189
+ products: [MEDUSA_PRODUCT],
190
+ count: 1,
191
+ offset: 0,
192
+ limit: 1,
193
+ })
194
+ }
195
+ return HttpResponse.json({ products: [], count: 0, offset: 0, limit: 50 })
196
+ }),
197
+ )
198
+ return { urls }
199
+ }
200
+
201
+ it('getProducts uses the configured default bound (50), not an unbounded scan', async () => {
202
+ const { urls } = captureListUrl()
203
+ const provider = makeProvider()
204
+
205
+ await provider.getProducts()
206
+
207
+ expect(urls).toHaveLength(1)
208
+ expect(new URL(urls[0]).searchParams.get('limit')).toBe('50')
209
+ })
210
+
211
+ it('getProducts honors an explicit configured bound', async () => {
212
+ const { urls } = captureListUrl()
213
+ const provider = new MedusaProvider({
214
+ baseUrl: BASE,
215
+ publishableKey: PUB_KEY,
216
+ currencyCode: CURRENCY,
217
+ catalogPageSize: 25,
218
+ debug: false,
219
+ })
220
+
221
+ await provider.getProducts()
222
+
223
+ expect(new URL(urls[0]).searchParams.get('limit')).toBe('25')
224
+ })
225
+
226
+ it('getProducts paginates per request with first + after (count/offset)', async () => {
227
+ const { urls } = captureListUrl()
228
+ const provider = makeProvider()
229
+
230
+ await provider.getProducts({ first: 10, after: '20' })
231
+
232
+ const params = new URL(urls[0]).searchParams
233
+ expect(params.get('limit')).toBe('10')
234
+ expect(params.get('offset')).toBe('20')
235
+ })
236
+
237
+ it('getProduct resolves by handle with a single directed query (limit 1), never a scan', async () => {
238
+ const { urls } = captureListUrl()
239
+ const provider = makeProvider()
240
+
241
+ const product = await provider.getProduct('test-product')
242
+
243
+ expect(product?.handle).toBe('test-product')
244
+ expect(urls).toHaveLength(1)
245
+ const params = new URL(urls[0]).searchParams
246
+ expect(params.get('handle')).toBe('test-product')
247
+ expect(params.get('limit')).toBe('1')
248
+ })
249
+
250
+ it('getProductById resolves by id with a directed query, independent of the first page', async () => {
251
+ const { urls } = captureListUrl()
252
+ const provider = makeProvider()
253
+
254
+ const product = await provider.getProductById('prod_01')
255
+
256
+ expect(product?.id).toBe('prod_01')
257
+ expect(urls).toHaveLength(1)
258
+ const params = new URL(urls[0]).searchParams
259
+ expect(params.get('id')).toBe('prod_01')
260
+ expect(params.get('limit')).toBe('1')
261
+ })
262
+
263
+ it('a product beyond a >200 dataset resolves by handle without scanning the listing', async () => {
264
+ // 250-product regression dataset: the first bounded page never contains the
265
+ // target. A directed handle lookup still resolves it in one request.
266
+ const urls: string[] = []
267
+ const dataset = Array.from({ length: 250 }, (_, i) => ({
268
+ ...MEDUSA_PRODUCT,
269
+ id: `prod_${i}`,
270
+ handle: `prod-${i}`,
271
+ }))
272
+ const target = { ...MEDUSA_PRODUCT, id: 'prod_249', handle: 'prod-249' }
273
+ server.use(
274
+ http.get(`${BASE}/store/products`, ({ request }) => {
275
+ urls.push(request.url)
276
+ const url = new URL(request.url)
277
+ const handle = url.searchParams.get('handle')
278
+ if (handle) {
279
+ const hit = dataset.find((p) => p.handle === handle)
280
+ return HttpResponse.json({
281
+ products: hit ? [hit] : [],
282
+ count: hit ? 1 : 0,
283
+ offset: 0,
284
+ limit: 1,
285
+ })
286
+ }
287
+ const limit = Number(url.searchParams.get('limit') ?? 50)
288
+ return HttpResponse.json({
289
+ products: dataset.slice(0, limit),
290
+ count: dataset.length,
291
+ offset: 0,
292
+ limit,
293
+ })
294
+ }),
295
+ )
296
+
297
+ const provider = makeProvider()
298
+ const firstPage = await provider.getProducts()
299
+ expect(firstPage).toHaveLength(50)
300
+ expect(firstPage.some((p) => p.handle === target.handle)).toBe(false)
301
+
302
+ const resolved = await provider.getProduct('prod-249')
303
+ expect(resolved?.handle).toBe('prod-249')
304
+
305
+ const directed = urls.filter((u) => new URL(u).searchParams.get('handle'))
306
+ expect(directed).toHaveLength(1)
307
+ expect(new URL(directed[0]).searchParams.get('limit')).toBe('1')
308
+ })
309
+ })
310
+
177
311
  // ---------------------------------------------------------------------------
178
312
  // Cart
179
313
  // ---------------------------------------------------------------------------
@@ -206,6 +340,42 @@ describe('MedusaProvider — Cart', () => {
206
340
  expect(cart.id).toBe('cart_01')
207
341
  })
208
342
 
343
+ it('createCart passes an explicit salesChannelId in the payload', async () => {
344
+ server.use(
345
+ http.post(`${BASE}/store/carts`, async ({ request }) => {
346
+ const body = await request.json() as any
347
+ expect(body.sales_channel_id).toBe('sc_explicit')
348
+ return HttpResponse.json({ cart: MEDUSA_CART })
349
+ }),
350
+ )
351
+
352
+ const provider = makeProvider()
353
+ await provider.createCart({ regionId: 'reg_01', salesChannelId: 'sc_explicit' })
354
+ })
355
+
356
+ it('createCart falls back to MEDUSA_SALES_CHANNEL_ID then the public var', async () => {
357
+ const originalServer = process.env.MEDUSA_SALES_CHANNEL_ID
358
+ const originalPublic = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID
359
+ process.env.MEDUSA_SALES_CHANNEL_ID = 'sc_server'
360
+ process.env.NEXT_PUBLIC_SALES_CHANNEL_ID = 'sc_public'
361
+ try {
362
+ server.use(
363
+ http.post(`${BASE}/store/carts`, async ({ request }) => {
364
+ const body = await request.json() as any
365
+ expect(body.sales_channel_id).toBe('sc_server')
366
+ return HttpResponse.json({ cart: MEDUSA_CART })
367
+ }),
368
+ )
369
+ const provider = makeProvider()
370
+ await provider.createCart({ regionId: 'reg_01' })
371
+ } finally {
372
+ if (originalServer === undefined) delete process.env.MEDUSA_SALES_CHANNEL_ID
373
+ else process.env.MEDUSA_SALES_CHANNEL_ID = originalServer
374
+ if (originalPublic === undefined) delete process.env.NEXT_PUBLIC_SALES_CHANNEL_ID
375
+ else process.env.NEXT_PUBLIC_SALES_CHANNEL_ID = originalPublic
376
+ }
377
+ })
378
+
209
379
  it('getCart returns cart when found', async () => {
210
380
  server.use(
211
381
  http.get(`${BASE}/store/carts/cart_01`, () =>
@@ -235,6 +405,7 @@ describe('MedusaProvider — Cart', () => {
235
405
  thumbnail: 'https://img.example.com/cover.png',
236
406
  quantity: 2,
237
407
  unit_price: 60,
408
+ total: 120,
238
409
  },
239
410
  ],
240
411
  total: 120,
@@ -258,6 +429,93 @@ describe('MedusaProvider — Cart', () => {
258
429
  expect(line?.merchandise.product.title).toBe('Cover Samsung Note 10 Plus')
259
430
  expect(line?.merchandise.product.featuredImage.url).toBe('https://img.example.com/cover.png')
260
431
  expect(line?.cost.totalAmount.amount).toBe('120.00')
432
+ expect(line?.cost.unitPrice?.amount).toBe('60.00')
433
+ })
434
+
435
+ it('getCart does not invent a line total from unit_price × quantity', async () => {
436
+ const cartWithoutLineTotal = {
437
+ ...MEDUSA_CART,
438
+ items: [
439
+ {
440
+ id: 'cali_01',
441
+ variant_id: 'variant_01',
442
+ quantity: 2,
443
+ unit_price: 60,
444
+ },
445
+ ],
446
+ total: 0,
447
+ subtotal: 0,
448
+ }
449
+ server.use(
450
+ http.get(`${BASE}/store/carts/cart_01`, () =>
451
+ HttpResponse.json({ cart: cartWithoutLineTotal }),
452
+ ),
453
+ )
454
+
455
+ const provider = makeProvider()
456
+ const cart = await provider.getCart('cart_01')
457
+
458
+ // No backend line total → empty amount, never a locally multiplied `120`.
459
+ expect(cart?.lines?.[0]?.cost.totalAmount.amount).toBe('')
460
+ })
461
+
462
+ it('getCart carries the backend context, financial fields and item subtotal', async () => {
463
+ let retrieveUrl = ''
464
+ const cartWithTotals = {
465
+ ...MEDUSA_CART,
466
+ sales_channel_id: 'sc_01',
467
+ region_id: 'reg_01',
468
+ currency_code: 'usd',
469
+ item_subtotal: 22.22,
470
+ subtotal: 27.22,
471
+ discount_total: 10,
472
+ tax_total: 5,
473
+ shipping_total: 5,
474
+ total: 27.22,
475
+ items: [
476
+ {
477
+ id: 'cali_01',
478
+ variant_id: 'variant_01',
479
+ product_title: 'B Acceptance Product',
480
+ variant_title: 'Large',
481
+ quantity: 1,
482
+ unit_price: 22.22,
483
+ total: 22.22,
484
+ },
485
+ ],
486
+ shipping_methods: [{ id: 'sm_01', shipping_option_id: 'so_01' }],
487
+ completed_at: null,
488
+ }
489
+ server.use(
490
+ http.get(`${BASE}/store/carts/cart_01`, ({ request }) => {
491
+ retrieveUrl = request.url
492
+ return HttpResponse.json({ cart: cartWithTotals })
493
+ }),
494
+ )
495
+
496
+ const provider = makeProvider()
497
+ const cart = await provider.getCart('cart_01')
498
+
499
+ // The retrieve requests the explicit financial line fields.
500
+ const fields = new URL(retrieveUrl).searchParams.get('fields') ?? ''
501
+ expect(fields).toContain('+items.total')
502
+ expect(fields).toContain('+items.unit_price')
503
+
504
+ expect(cart?.salesChannelId).toBe('sc_01')
505
+ expect(cart?.regionId).toBe('reg_01')
506
+ expect(cart?.currencyCode).toBe('USD')
507
+ expect(cart?.selectedShippingMethodId).toBe('sm_01')
508
+ expect(cart?.selectedShippingOptionId).toBe('so_01')
509
+ expect(cart?.cost).toMatchObject({
510
+ // item_subtotal (lines only), not native subtotal (which includes shipping)
511
+ subtotalAmount: { amount: '22.22', currencyCode: 'USD' },
512
+ discountAmount: { amount: '10.00', currencyCode: 'USD' },
513
+ totalTaxAmount: { amount: '5.00', currencyCode: 'USD' },
514
+ shippingAmount: { amount: '5.00', currencyCode: 'USD' },
515
+ totalAmount: { amount: '27.22', currencyCode: 'USD' },
516
+ })
517
+ expect(cart?.lines[0]?.cost.unitPrice?.amount).toBe('22.22')
518
+ expect(cart?.lines[0]?.cost.totalAmount.amount).toBe('22.22')
261
519
  })
262
520
 
263
521
  it('getCart returns undefined when cart not found (404)', async () => {
@@ -430,3 +688,225 @@ describe('MedusaProvider — Error propagation', () => {
430
688
  await expect(provider.createCart()).rejects.toThrow()
431
689
  })
432
690
  })
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // Completion — discriminated native result, never blindly retried
694
+ // ---------------------------------------------------------------------------
695
+ const MEDUSA_ORDER = {
696
+ id: 'order_01',
697
+ display_id: 1001,
698
+ email: 'buyer@example.com',
699
+ status: 'pending',
700
+ payment_status: 'awaiting',
701
+ fulfillment_status: 'not_fulfilled',
702
+ currency_code: 'usd',
703
+ item_subtotal: 22.22,
704
+ subtotal: 27.22,
705
+ items: [
706
+ {
707
+ id: 'ordli_01',
708
+ title: 'B Acceptance Product',
709
+ product_title: 'B Acceptance Product',
710
+ variant_title: 'Large',
711
+ variant_id: 'variant_01',
712
+ product_id: 'prod_01',
713
+ quantity: 1,
714
+ unit_price: 22.22,
715
+ total: 22.22,
716
+ thumbnail: 'https://example.com/p.png',
717
+ },
718
+ ],
719
+ total: 27.22,
720
+ tax_total: 0,
721
+ shipping_total: 5,
722
+ discount_total: 0,
723
+ created_at: '2026-01-01T00:00:00Z',
724
+ updated_at: '2026-01-01T00:00:00Z',
725
+ }
726
+
727
+ describe('MedusaProvider — Completion', () => {
728
+ it('completeCart requests explicit order item fields and maps populated items', async () => {
729
+ let completeUrl = ''
730
+ server.use(
731
+ http.post(`${BASE}/store/carts/cart_01/complete`, ({ request }) => {
732
+ completeUrl = request.url
733
+ return HttpResponse.json({ type: 'order', order: MEDUSA_ORDER })
734
+ }),
735
+ )
736
+
737
+ const provider = makeProvider()
738
+ const result = await provider.completeCart('cart_01')
739
+
740
+ expect(new URL(completeUrl).searchParams.get('fields')).toBe('*items')
741
+ expect(result.type).toBe('order')
742
+ if (result.type === 'order') {
743
+ expect(result.order.id).toBe('order_01')
744
+ expect(result.order.items).toHaveLength(1)
745
+ expect(result.order.items[0]).toMatchObject({
746
+ id: 'ordli_01',
747
+ title: 'B Acceptance Product',
748
+ variantTitle: 'Large',
749
+ quantity: 1,
750
+ })
751
+ expect(result.order.items[0].price.amount).toBe('22.22')
752
+ expect(result.order.items[0].total.amount).toBe('22.22')
753
+ // Subtotal prefers the backend item_subtotal (lines only), not the
754
+ // native subtotal that folds in shipping.
755
+ expect(result.order.subtotal.amount).toBe('22.22')
756
+ expect(result.order.shipping?.amount).toBe('5.00')
757
+ expect(result.order.total.amount).toBe('27.22')
758
+ }
759
+ })
760
+
761
+ it('completeCart preserves a cart-with-error discriminator', async () => {
762
+ server.use(
763
+ http.post(`${BASE}/store/carts/cart_01/complete`, () =>
764
+ HttpResponse.json({
765
+ type: 'cart',
766
+ cart: MEDUSA_CART,
767
+ error: {
768
+ message: 'Payment not authorized',
769
+ name: 'PaymentError',
770
+ type: 'invalid_data',
771
+ },
772
+ }),
773
+ ),
774
+ )
775
+
776
+ const provider = makeProvider()
777
+ const result = await provider.completeCart('cart_01')
778
+
779
+ expect(result.type).toBe('cart')
780
+ if (result.type === 'cart') {
781
+ expect(result.error.message).toBe('Payment not authorized')
782
+ expect(result.cart.id).toBe('cart_01')
783
+ }
784
+ })
785
+
786
+ it('completeCart returns unresolved and does not retry a failed mutation', async () => {
787
+ let calls = 0
788
+ server.use(
789
+ http.post(`${BASE}/store/carts/cart_01/complete`, () => {
790
+ calls += 1
791
+ return HttpResponse.json({ message: 'Gateway Timeout' }, { status: 504 })
792
+ }),
793
+ )
794
+
795
+ const provider = makeProvider()
796
+ const result = await provider.completeCart('cart_01')
797
+
798
+ expect(result.type).toBe('unresolved')
799
+ expect(calls).toBe(1)
800
+ })
801
+ })
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Payment session compatibility (reuse vs replace)
805
+ // ---------------------------------------------------------------------------
806
+ describe('MedusaProvider — Payment session compatibility', () => {
807
+ const BASE_CART_WITH_PAYMENT = {
808
+ ...MEDUSA_CART,
809
+ items: [{ id: 'li_01', quantity: 1 }],
810
+ email: 'buyer@example.com',
811
+ shipping_address: { country_code: 'gb' },
812
+ shipping_methods: [{ id: 'sm_01' }],
813
+ total: 103,
814
+ }
815
+
816
+ it('reuses a compatible session without creating a new one', async () => {
817
+ let posts = 0
818
+ server.use(
819
+ http.get(`${BASE}/store/carts/cart_01`, () =>
820
+ HttpResponse.json({
821
+ cart: {
822
+ ...BASE_CART_WITH_PAYMENT,
823
+ payment_collection: {
824
+ id: 'paycol_01',
825
+ payment_sessions: [
826
+ { id: 'ps_01', provider_id: 'pp_system_default', amount: 103, status: 'pending' },
827
+ ],
828
+ },
829
+ },
830
+ }),
831
+ ),
832
+ http.post(`${BASE}/store/payment-collections/paycol_01/payment-sessions`, () => {
833
+ posts += 1
834
+ return HttpResponse.json({ payment_collection: {} })
835
+ }),
836
+ )
837
+
838
+ const provider = makeProvider()
839
+ await provider.initiatePaymentSession('cart_01', 'pp_system_default')
840
+
841
+ expect(posts).toBe(0)
842
+ })
843
+
844
+ it('replaces a session whose provider/amount is incompatible', async () => {
845
+ let posts = 0
846
+ server.use(
847
+ http.get(`${BASE}/store/carts/cart_01`, () =>
848
+ HttpResponse.json({
849
+ cart: {
850
+ ...BASE_CART_WITH_PAYMENT,
851
+ payment_collection: {
852
+ id: 'paycol_01',
853
+ payment_sessions: [
854
+ { id: 'ps_old', provider_id: 'pp_stripe_stripe', amount: 50, status: 'pending' },
855
+ ],
856
+ },
857
+ },
858
+ }),
859
+ ),
860
+ http.post(`${BASE}/store/payment-collections/paycol_01/payment-sessions`, () => {
861
+ posts += 1
862
+ return HttpResponse.json({ payment_collection: {} })
863
+ }),
864
+ )
865
+
866
+ const provider = makeProvider()
867
+ await provider.initiatePaymentSession('cart_01', 'pp_system_default')
868
+
869
+ expect(posts).toBe(1)
870
+ })
871
+
872
+ it('rejects payment before a shipping method is selected', async () => {
873
+ server.use(
874
+ http.get(`${BASE}/store/carts/cart_01`, () =>
875
+ HttpResponse.json({
876
+ cart: { ...BASE_CART_WITH_PAYMENT, shipping_methods: [] },
877
+ }),
878
+ ),
879
+ )
880
+
881
+ const provider = makeProvider()
882
+ await expect(
883
+ provider.initiatePaymentSession('cart_01', 'pp_system_default'),
884
+ ).rejects.toThrow(/Shipping method is required/)
885
+ })
886
+ })
887
+
888
+ // ---------------------------------------------------------------------------
889
+ // Shipping truthfulness — no fabricated amount or placeholder ETA
890
+ // ---------------------------------------------------------------------------
891
+ describe('MedusaProvider — Shipping truthfulness', () => {
892
+ it('does not fabricate a zero amount or an "N/A" ETA', async () => {
893
+ server.use(
894
+ http.get(`${BASE}/store/carts/cart_01`, () =>
895
+ HttpResponse.json({ cart: { ...MEDUSA_CART, region_id: 'reg_01' } }),
896
+ ),
897
+ http.get(`${BASE}/store/shipping-options`, () =>
898
+ HttpResponse.json({
899
+ shipping_options: [
900
+ { id: 'so_01', name: 'Standard Shipping', data: {} },
901
+ ],
902
+ }),
903
+ ),
904
+ )
905
+
906
+ const provider = makeProvider()
907
+ const methods = await provider.getShippingMethods('cart_01')
908
+
909
+ expect(methods[0].price.amount).toBe('')
910
+ expect(methods[0].estimatedDays).toBeUndefined()
911
+ })
912
+ })