@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.60

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 (147) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  3. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/infinite-scroll.test.ts +143 -0
  6. package/__tests__/numbered-pagination.test.ts +113 -0
  7. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  8. package/__tests__/page-url-sync.test.ts +208 -0
  9. package/__tests__/product-price-sort.ts +313 -0
  10. package/__tests__/search-url-sync.test.ts +7 -3
  11. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  12. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  13. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  14. package/dist/cjs/count-effect.d.ts +42 -0
  15. package/dist/cjs/count-effect.d.ts.map +1 -0
  16. package/dist/cjs/count-effect.js +81 -0
  17. package/dist/cjs/count-effect.js.map +1 -0
  18. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/postgresql.js +2 -1
  20. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  21. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/teleport.js +3 -2
  23. package/dist/cjs/fetchers/teleport.js.map +1 -1
  24. package/dist/cjs/infinite-scroll.d.ts +65 -0
  25. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  26. package/dist/cjs/infinite-scroll.js +190 -0
  27. package/dist/cjs/infinite-scroll.js.map +1 -0
  28. package/dist/cjs/numbered-pagination.d.ts +42 -0
  29. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  30. package/dist/cjs/numbered-pagination.js +167 -0
  31. package/dist/cjs/numbered-pagination.js.map +1 -0
  32. package/dist/cjs/page-reset.d.ts +42 -0
  33. package/dist/cjs/page-reset.d.ts.map +1 -0
  34. package/dist/cjs/page-reset.js +116 -0
  35. package/dist/cjs/page-reset.js.map +1 -0
  36. package/dist/cjs/page-url-sync.d.ts +42 -0
  37. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  38. package/dist/cjs/page-url-sync.js +180 -0
  39. package/dist/cjs/page-url-sync.js.map +1 -0
  40. package/dist/cjs/pagination-controls.d.ts +55 -0
  41. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  42. package/dist/cjs/pagination-controls.js +159 -0
  43. package/dist/cjs/pagination-controls.js.map +1 -0
  44. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  45. package/dist/cjs/pagination-plugin.js +506 -160
  46. package/dist/cjs/pagination-plugin.js.map +1 -1
  47. package/dist/cjs/product-price-sort.d.ts +116 -0
  48. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  49. package/dist/cjs/product-price-sort.js +169 -0
  50. package/dist/cjs/product-price-sort.js.map +1 -0
  51. package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
  52. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  53. package/dist/cjs/transformations/ecommerce-product.js +2 -1
  54. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  55. package/dist/cjs/transformations/index.d.ts +2 -1
  56. package/dist/cjs/transformations/index.d.ts.map +1 -1
  57. package/dist/cjs/transformations/index.js +17 -1
  58. package/dist/cjs/transformations/index.js.map +1 -1
  59. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  60. package/dist/cjs/transformations/shared-utils.js +1 -1
  61. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  62. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  63. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  64. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  65. package/dist/esm/array-mapper-pagination.js.map +1 -1
  66. package/dist/esm/count-effect.d.ts +42 -0
  67. package/dist/esm/count-effect.d.ts.map +1 -0
  68. package/dist/esm/count-effect.js +53 -0
  69. package/dist/esm/count-effect.js.map +1 -0
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +2 -1
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  74. package/dist/esm/fetchers/teleport.js +3 -2
  75. package/dist/esm/fetchers/teleport.js.map +1 -1
  76. package/dist/esm/infinite-scroll.d.ts +65 -0
  77. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  78. package/dist/esm/infinite-scroll.js +159 -0
  79. package/dist/esm/infinite-scroll.js.map +1 -0
  80. package/dist/esm/numbered-pagination.d.ts +42 -0
  81. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  82. package/dist/esm/numbered-pagination.js +138 -0
  83. package/dist/esm/numbered-pagination.js.map +1 -0
  84. package/dist/esm/page-reset.d.ts +42 -0
  85. package/dist/esm/page-reset.d.ts.map +1 -0
  86. package/dist/esm/page-reset.js +87 -0
  87. package/dist/esm/page-reset.js.map +1 -0
  88. package/dist/esm/page-url-sync.d.ts +42 -0
  89. package/dist/esm/page-url-sync.d.ts.map +1 -0
  90. package/dist/esm/page-url-sync.js +152 -0
  91. package/dist/esm/page-url-sync.js.map +1 -0
  92. package/dist/esm/pagination-controls.d.ts +55 -0
  93. package/dist/esm/pagination-controls.d.ts.map +1 -0
  94. package/dist/esm/pagination-controls.js +126 -0
  95. package/dist/esm/pagination-controls.js.map +1 -0
  96. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  97. package/dist/esm/pagination-plugin.js +507 -161
  98. package/dist/esm/pagination-plugin.js.map +1 -1
  99. package/dist/esm/product-price-sort.d.ts +116 -0
  100. package/dist/esm/product-price-sort.d.ts.map +1 -0
  101. package/dist/esm/product-price-sort.js +160 -0
  102. package/dist/esm/product-price-sort.js.map +1 -0
  103. package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
  104. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  105. package/dist/esm/transformations/ecommerce-product.js +3 -2
  106. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  107. package/dist/esm/transformations/index.d.ts +2 -1
  108. package/dist/esm/transformations/index.d.ts.map +1 -1
  109. package/dist/esm/transformations/index.js +17 -1
  110. package/dist/esm/transformations/index.js.map +1 -1
  111. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  112. package/dist/esm/transformations/shared-utils.js +1 -1
  113. package/dist/esm/transformations/shared-utils.js.map +1 -1
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/src/array-mapper-pagination.ts +10 -0
  117. package/src/count-effect.ts +137 -0
  118. package/src/fetchers/postgresql.ts +52 -13
  119. package/src/fetchers/teleport.ts +53 -13
  120. package/src/infinite-scroll.ts +401 -0
  121. package/src/numbered-pagination.ts +292 -0
  122. package/src/page-reset.ts +162 -0
  123. package/src/page-url-sync.ts +241 -0
  124. package/src/pagination-controls.ts +176 -0
  125. package/src/pagination-plugin.ts +755 -346
  126. package/src/product-price-sort.ts +204 -0
  127. package/src/transformations/ecommerce-product.ts +141 -10
  128. package/src/transformations/index.ts +19 -1
  129. package/src/transformations/shared-utils.ts +14 -0
  130. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  131. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  132. package/dist/cjs/array-mapper-registry.js +0 -296
  133. package/dist/cjs/array-mapper-registry.js.map +0 -1
  134. package/dist/cjs/pagination-with-count.d.ts +0 -6
  135. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  136. package/dist/cjs/pagination-with-count.js +0 -63
  137. package/dist/cjs/pagination-with-count.js.map +0 -1
  138. package/dist/esm/array-mapper-registry.d.ts +0 -85
  139. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  140. package/dist/esm/array-mapper-registry.js +0 -292
  141. package/dist/esm/array-mapper-registry.js.map +0 -1
  142. package/dist/esm/pagination-with-count.d.ts +0 -6
  143. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  144. package/dist/esm/pagination-with-count.js +0 -34
  145. package/dist/esm/pagination-with-count.js.map +0 -1
  146. package/src/array-mapper-registry.ts +0 -416
  147. package/src/pagination-with-count.ts +0 -89
@@ -0,0 +1,230 @@
1
+ import * as types from '@babel/types'
2
+ import { ChunkType, FileType, ChunkDefinition } from '@teleporthq/teleport-types'
3
+ import { PAGINATION_CONTROL_ATTR } from '../../src/pagination-controls'
4
+
5
+ /**
6
+ * A component shell carrying a `DataProvider` and the pagination controls a
7
+ * builder would have authored, so the wiring steps of the plugin have something
8
+ * real to find.
9
+ *
10
+ * `__tests__/_helpers` is excluded from the jest test match, so this file is a
11
+ * fixture rather than a suite.
12
+ */
13
+
14
+ // tslint:disable:no-any
15
+
16
+ interface ControlOptions {
17
+ /** Which `data-tq-pagination-control` markers to author. */
18
+ controls: string[]
19
+ /** Author Previous/Next with only the legacy class names, no markers. */
20
+ legacyOnly?: boolean
21
+ /** Number of DataProvider + widget pairs to emit (order-based zip coverage). */
22
+ mappers?: number
23
+ }
24
+
25
+ const control = (marker: string, children: types.JSXElement['children'] = []): types.JSXElement =>
26
+ types.jsxElement(
27
+ types.jsxOpeningElement(
28
+ types.jsxIdentifier('div'),
29
+ [
30
+ types.jsxAttribute(
31
+ types.jsxIdentifier('className'),
32
+ types.stringLiteral(`thq-${marker}-elm`)
33
+ ),
34
+ types.jsxAttribute(
35
+ types.jsxIdentifier(PAGINATION_CONTROL_ATTR),
36
+ types.stringLiteral(marker)
37
+ ),
38
+ ],
39
+ false
40
+ ),
41
+ types.jsxClosingElement(types.jsxIdentifier('div')),
42
+ children,
43
+ false
44
+ )
45
+
46
+ const legacyControl = (direction: 'previous' | 'next'): types.JSXElement =>
47
+ types.jsxElement(
48
+ types.jsxOpeningElement(
49
+ types.jsxIdentifier('div'),
50
+ [
51
+ types.jsxAttribute(
52
+ types.jsxIdentifier('className'),
53
+ types.stringLiteral(`products-list-thq-${direction}-elm`)
54
+ ),
55
+ ],
56
+ false
57
+ ),
58
+ types.jsxClosingElement(types.jsxIdentifier('div')),
59
+ [types.jsxText(direction === 'previous' ? 'Previous' : 'Next')],
60
+ false
61
+ )
62
+
63
+ const buildWidget = (options: ControlOptions): types.JSXElement => {
64
+ const children: types.JSXElement[] = []
65
+ for (const marker of options.controls) {
66
+ if (marker === 'pages') {
67
+ // The container holds ONE template button, which the generator repeats.
68
+ children.push(control('pages', [control('page', [types.jsxText('1')])]))
69
+ continue
70
+ }
71
+ if (options.legacyOnly && (marker === 'previous' || marker === 'next')) {
72
+ children.push(legacyControl(marker))
73
+ continue
74
+ }
75
+ children.push(control(marker))
76
+ }
77
+
78
+ return types.jsxElement(
79
+ types.jsxOpeningElement(
80
+ types.jsxIdentifier('div'),
81
+ [
82
+ types.jsxAttribute(
83
+ types.jsxIdentifier('className'),
84
+ types.stringLiteral('products-list-cms-pagination-node')
85
+ ),
86
+ ],
87
+ false
88
+ ),
89
+ types.jsxClosingElement(types.jsxIdentifier('div')),
90
+ children,
91
+ false
92
+ )
93
+ }
94
+
95
+ // Mirrors the real emitted shape: a self-closing `<DataProvider>` whose
96
+ // `renderSuccess` returns a `<Repeater renderItem={row => …}>`. The plugin only
97
+ // treats a provider as an array mapper when it can read that `renderItem`
98
+ // parameter name, so the fixture must carry one.
99
+ const buildDataProvider = (name: string, rowIdentifier: string): types.JSXElement =>
100
+ types.jsxElement(
101
+ types.jsxOpeningElement(
102
+ types.jsxIdentifier('DataProvider'),
103
+ [
104
+ types.jsxAttribute(
105
+ types.jsxIdentifier('name'),
106
+ types.jsxExpressionContainer(types.stringLiteral(name))
107
+ ),
108
+ types.jsxAttribute(
109
+ types.jsxIdentifier('renderSuccess'),
110
+ types.jsxExpressionContainer(
111
+ types.arrowFunctionExpression(
112
+ [types.identifier('items')],
113
+ types.jsxElement(
114
+ types.jsxOpeningElement(
115
+ types.jsxIdentifier('Repeater'),
116
+ [
117
+ types.jsxAttribute(
118
+ types.jsxIdentifier('renderItem'),
119
+ types.jsxExpressionContainer(
120
+ types.arrowFunctionExpression(
121
+ [types.identifier(rowIdentifier)],
122
+ types.jsxElement(
123
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
124
+ null,
125
+ [],
126
+ true
127
+ )
128
+ )
129
+ )
130
+ ),
131
+ ],
132
+ true
133
+ ),
134
+ null,
135
+ [],
136
+ true
137
+ )
138
+ )
139
+ )
140
+ ),
141
+ ],
142
+ true
143
+ ),
144
+ null,
145
+ [],
146
+ true
147
+ )
148
+
149
+ export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefinition => {
150
+ const sections: types.JSXElement[] = []
151
+ const mappers = options.mappers ?? 1
152
+ for (let i = 0; i < mappers; i += 1) {
153
+ const identifier = `items${i === 0 ? '' : i}`
154
+ sections.push(buildDataProvider(identifier, `${identifier}_row`))
155
+ sections.push(buildWidget(options))
156
+ }
157
+
158
+ const root = types.jsxElement(
159
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], false),
160
+ types.jsxClosingElement(types.jsxIdentifier('div')),
161
+ sections,
162
+ false
163
+ )
164
+
165
+ return {
166
+ name: 'jsx-component',
167
+ type: ChunkType.AST,
168
+ fileType: FileType.JS,
169
+ linkAfter: [],
170
+ content: types.variableDeclaration('const', [
171
+ types.variableDeclarator(
172
+ types.identifier('TestComponent'),
173
+ types.arrowFunctionExpression(
174
+ [types.identifier('props')],
175
+ types.blockStatement([types.returnStatement(root)])
176
+ )
177
+ ),
178
+ ]),
179
+ meta: {},
180
+ }
181
+ }
182
+
183
+ export interface RepeaterOptions {
184
+ paginated?: boolean
185
+ searchEnabled?: boolean
186
+ paginationMode?: 'buttons' | 'numbered'
187
+ infiniteScroll?: boolean
188
+ infiniteScrollLoadMore?: boolean
189
+ perPage?: number
190
+ }
191
+
192
+ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
193
+ type: 'data-source-list',
194
+ content: {
195
+ renderPropIdentifier: identifier,
196
+ resourceDefinition: {
197
+ dataSourceId: 'ds1',
198
+ tableName: 'products',
199
+ dataSourceType: 'postgresql',
200
+ },
201
+ resource: { params: { queryColumns: { content: ['name'] } } },
202
+ nodes: {
203
+ success: {
204
+ type: 'cms-list-repeater',
205
+ content: {
206
+ renderPropIdentifier: `${identifier}_row`,
207
+ paginated: options.paginated !== false,
208
+ perPage: options.perPage ?? 20,
209
+ searchEnabled: !!options.searchEnabled,
210
+ searchDebounce: 300,
211
+ ...(options.paginationMode ? { paginationMode: options.paginationMode } : {}),
212
+ ...(options.infiniteScroll ? { infiniteScroll: true } : {}),
213
+ ...(options.infiniteScrollLoadMore ? { infiniteScrollLoadMore: true } : {}),
214
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
215
+ },
216
+ },
217
+ },
218
+ },
219
+ })
220
+
221
+ /** Wraps several data-source-lists in one element node, as a page would. */
222
+ export const makeMultiMapperUidl = (repeaters: RepeaterOptions[]): any => ({
223
+ type: 'element',
224
+ content: {
225
+ elementType: 'container',
226
+ children: repeaters.map((options, index) =>
227
+ makeUidlNode(options, `items${index === 0 ? '' : index}`)
228
+ ),
229
+ },
230
+ })
@@ -0,0 +1,156 @@
1
+ import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+ import { buildProductTransformOptions } from '../src/transformations'
4
+
5
+ /**
6
+ * With backorders allowed (or stock management disabled) the merchant sells
7
+ * past zero, so NOTHING in the storefront may present a product or a variant
8
+ * combination as sold out:
9
+ *
10
+ * - `outOfStock` stays 'false' even at quantity <= 0;
11
+ * - a zero-stock combination is NOT dead, IS default-selectable, and keeps
12
+ * `hasPurchasableVariant: 'true'` / a non-empty `defaultVariantId`;
13
+ * - a combination that does not exist at all is STILL unavailable — there
14
+ * is no variant row to backorder.
15
+ *
16
+ * The flag is baked at export time via
17
+ * `EcommerceProductTransformOptions.allowBackorders`, resolved in ONE place
18
+ * (`buildProductTransformOptions`) as: stock management off OR
19
+ * `stockManagementConfig.allowBackorders` on.
20
+ */
21
+
22
+ const evalBuildProduct = (
23
+ allowBackorders: boolean
24
+ ): ((r: unknown, o?: unknown) => Record<string, unknown>) => {
25
+ const code =
26
+ generateSharedTransformationCode() +
27
+ '\n' +
28
+ generateEcommerceProductTransformationCode({ allowBackorders })
29
+ return new Function(code + '\nreturn buildEcommerceProduct;')() as (
30
+ r: unknown,
31
+ o?: unknown
32
+ ) => Record<string, unknown>
33
+ }
34
+
35
+ type Axis = { key: string; values: Array<Record<string, unknown>> }
36
+
37
+ const SIZE_AXIS = JSON.stringify([
38
+ {
39
+ key: 'size',
40
+ name: 'Size',
41
+ type: 'text',
42
+ values: [
43
+ { value: 'xs', label: 'XS' },
44
+ { value: 's', label: 'S' },
45
+ ],
46
+ },
47
+ ])
48
+
49
+ const variantProduct = (overrides: Record<string, unknown> = {}) => ({
50
+ id: 'p1',
51
+ name: 'Tee',
52
+ slug: 'tee',
53
+ price: 20,
54
+ currency: 'USD',
55
+ variant_options: SIZE_AXIS,
56
+ ...overrides,
57
+ })
58
+
59
+ const values = (product: Record<string, unknown>): Array<Record<string, unknown>> =>
60
+ (product.variantOptions as Axis[])[0].values
61
+
62
+ describe('ecommerce product transform — allowBackorders', () => {
63
+ const withBackorders = evalBuildProduct(true)
64
+ const withoutBackorders = evalBuildProduct(false)
65
+
66
+ it('a zero-quantity FLAT product is never out of stock with backorders', () => {
67
+ const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 }
68
+ expect(withBackorders(record).outOfStock).toBe('false')
69
+ expect(withoutBackorders(record).outOfStock).toBe('true')
70
+ })
71
+
72
+ it('a NEGATIVE quantity (already oversold) is still not out of stock', () => {
73
+ const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: -3 }
74
+ expect(withBackorders(record).outOfStock).toBe('false')
75
+ })
76
+
77
+ it('every combination sold out: values stay alive and purchasable with backorders', () => {
78
+ const options = {
79
+ variantsByProductId: {
80
+ p1: [
81
+ { id: 'v-xs', options: { size: 'xs' }, quantity: 0 },
82
+ { id: 'v-s', options: { size: 's' }, quantity: 0 },
83
+ ],
84
+ },
85
+ }
86
+ const product = withBackorders(variantProduct(), options)
87
+ expect(values(product).every((v) => v.isDead === 'false')).toBe(true)
88
+ expect(product.hasPurchasableVariant).toBe('true')
89
+ // The first covering combination becomes the default selection again.
90
+ expect(product.defaultVariantId).toBe('v-xs')
91
+
92
+ // The strict build keeps its sold-out answer (regression guard for the
93
+ // flag's default).
94
+ const strict = withoutBackorders(variantProduct(), options)
95
+ expect(strict.hasPurchasableVariant).toBe('false')
96
+ expect(strict.defaultVariantId).toBe('')
97
+ })
98
+
99
+ it('a value with NO combination behind it stays dead even with backorders', () => {
100
+ const product = withBackorders(variantProduct(), {
101
+ variantsByProductId: { p1: [{ id: 'v-xs', options: { size: 'xs' }, quantity: 0 }] },
102
+ })
103
+ expect(values(product)[0].isDead).toBe('false')
104
+ // 's' has no variant row — nothing exists to backorder.
105
+ expect(values(product)[1].isDead).toBe('true')
106
+ })
107
+
108
+ it('looked up and there are NO combinations at all: still not purchasable', () => {
109
+ const product = withBackorders(variantProduct(), { variantsByProductId: {} })
110
+ expect(values(product).every((v) => v.isDead === 'true')).toBe(true)
111
+ expect(product.hasPurchasableVariant).toBe('false')
112
+ })
113
+
114
+ it('omitting the option keeps the strict pre-flag behaviour', () => {
115
+ const code =
116
+ generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
117
+ const build = new Function(code + '\nreturn buildEcommerceProduct;')() as (
118
+ r: unknown
119
+ ) => Record<string, unknown>
120
+ expect(
121
+ build({ id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 })
122
+ .outOfStock
123
+ ).toBe('true')
124
+ })
125
+ })
126
+
127
+ describe('buildProductTransformOptions — allowBackorders resolution', () => {
128
+ const settings = (stockManagement: boolean, allowBackorders?: boolean) =>
129
+ ({
130
+ stockManagement,
131
+ stockManagementConfig:
132
+ allowBackorders === undefined ? null : { allowBackorders, lowStockThreshold: 5 },
133
+ } as never)
134
+
135
+ it('stock management ON + backorders ON → true', () => {
136
+ expect(
137
+ buildProductTransformOptions({ ecommerceSettings: settings(true, true) }).allowBackorders
138
+ ).toBe(true)
139
+ })
140
+
141
+ it('stock management ON + backorders OFF → false', () => {
142
+ expect(
143
+ buildProductTransformOptions({ ecommerceSettings: settings(true, false) }).allowBackorders
144
+ ).toBe(false)
145
+ })
146
+
147
+ it('stock management OFF → true regardless of the config block', () => {
148
+ expect(
149
+ buildProductTransformOptions({ ecommerceSettings: settings(false) }).allowBackorders
150
+ ).toBe(true)
151
+ })
152
+
153
+ it('no e-commerce settings at all → false (strict default)', () => {
154
+ expect(buildProductTransformOptions({}).allowBackorders).toBe(false)
155
+ })
156
+ })
@@ -0,0 +1,196 @@
1
+ import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+
4
+ /**
5
+ * Per-product discounts in the GENERATED storefront transform — the third
6
+ * mirror of the rule that also lives in teleport-gui as
7
+ * `features/e-commerce/utils/product-discounts.ts` (editor/canvas) and
8
+ * `.../builders/ecommerce/product-discount-script.ts` (the baked add-to-cart
9
+ * script). The GUI's `product-discount-parity.spec.ts` pins those two together;
10
+ * this file is what keeps THIS copy honest.
11
+ *
12
+ * The two invariants under test: a discount comes off the NET price BEFORE tax,
13
+ * and the stored `price` never moves — every write path re-reads the raw row.
14
+ */
15
+
16
+ const evalBuildProduct = (
17
+ storefrontTaxRate = 0
18
+ ): ((record: unknown) => Record<string, unknown>) => {
19
+ const code =
20
+ generateSharedTransformationCode() +
21
+ '\n' +
22
+ generateEcommerceProductTransformationCode({ storefrontTaxRate })
23
+ const fn = new Function(code + '\nreturn buildEcommerceProduct;')
24
+ return fn() as (record: unknown) => Record<string, unknown>
25
+ }
26
+
27
+ const FLAT_PRODUCT = { id: 'p1', name: 'P', slug: 'p', price: '100.00', currency: 'USD' }
28
+
29
+ const discountsColumn = (
30
+ over: Partial<{
31
+ type: string
32
+ value: number
33
+ startsAt: string | null
34
+ endsAt: string | null
35
+ }> = {}
36
+ ): string =>
37
+ JSON.stringify([
38
+ { id: 'd1', type: 'percentage', value: 10, startsAt: null, endsAt: null, ...over },
39
+ ])
40
+
41
+ describe('ecommerce product transform — per-product discounts', () => {
42
+ it('leaves `price` alone and marks the DISPLAY price down', () => {
43
+ const product = evalBuildProduct()({ ...FLAT_PRODUCT, discounts: discountsColumn() })
44
+ expect(product.price).toBe(100)
45
+ expect(product.displayPrice).toBe('90.00')
46
+ expect(product.hasDiscount).toBe('true')
47
+ expect(product.originalDisplayPrice).toBe('100.00')
48
+ })
49
+
50
+ it('takes the discount off the NET price, then adds tax on top of that', () => {
51
+ const product = evalBuildProduct(19)({ ...FLAT_PRODUCT, discounts: discountsColumn() })
52
+ // 100 → 90 net → 90 * 1.19 = 107.10 gross. Taxing first would give 119 − 10%.
53
+ expect(product.displayPrice).toBe('107.10')
54
+ expect(product.originalDisplayPrice).toBe('119.00')
55
+ })
56
+
57
+ it('emits the badge copy for both discount types', () => {
58
+ const percentage = evalBuildProduct()({ ...FLAT_PRODUCT, discounts: discountsColumn() })
59
+ expect(percentage.discountLabel).toBe('10% off')
60
+
61
+ const fixed = evalBuildProduct()({
62
+ ...FLAT_PRODUCT,
63
+ discounts: discountsColumn({ type: 'fixed', value: 15 }),
64
+ })
65
+ expect(fixed.discountLabel).toBe('$15 off')
66
+ expect(fixed.displayPrice).toBe('85.00')
67
+ })
68
+
69
+ it('puts the currency symbol where the currency wants it', () => {
70
+ const product = evalBuildProduct()({
71
+ ...FLAT_PRODUCT,
72
+ currency: 'SEK',
73
+ discounts: discountsColumn({ type: 'fixed', value: 20 }),
74
+ })
75
+ expect(String(product.discountLabel).endsWith(' off')).toBe(true)
76
+ expect(String(product.discountLabel)).toContain('20')
77
+ })
78
+
79
+ it('reports no discount — and no struck price — when nothing is scheduled', () => {
80
+ const product = evalBuildProduct()(FLAT_PRODUCT)
81
+ expect(product.hasDiscount).toBe('false')
82
+ expect(product.discountLabel).toBe('')
83
+ expect(product.originalDisplayPrice).toBe('')
84
+ expect(product.displayPrice).toBe('100.00')
85
+ })
86
+
87
+ it('honours the half-open window: live at the start instant, over at the end', () => {
88
+ const now = Date.now()
89
+ const live = evalBuildProduct()({
90
+ ...FLAT_PRODUCT,
91
+ discounts: JSON.stringify([
92
+ {
93
+ id: 'd1',
94
+ type: 'percentage',
95
+ value: 10,
96
+ startsAt: new Date(now - 1000).toISOString(),
97
+ endsAt: new Date(now + 60000).toISOString(),
98
+ },
99
+ ]),
100
+ })
101
+ expect(live.hasDiscount).toBe('true')
102
+
103
+ const expired = evalBuildProduct()({
104
+ ...FLAT_PRODUCT,
105
+ discounts: JSON.stringify([
106
+ {
107
+ id: 'd1',
108
+ type: 'percentage',
109
+ value: 10,
110
+ startsAt: new Date(now - 60000).toISOString(),
111
+ endsAt: new Date(now - 1000).toISOString(),
112
+ },
113
+ ]),
114
+ })
115
+ expect(expired.hasDiscount).toBe('false')
116
+ expect(expired.displayPrice).toBe('100.00')
117
+
118
+ const scheduled = evalBuildProduct()({
119
+ ...FLAT_PRODUCT,
120
+ discounts: JSON.stringify([
121
+ { id: 'd1', type: 'percentage', value: 10, startsAt: new Date(now + 60000).toISOString() },
122
+ ]),
123
+ })
124
+ expect(scheduled.hasDiscount).toBe('false')
125
+ })
126
+
127
+ it('never goes below zero, and never renders a negative price', () => {
128
+ const product = evalBuildProduct()({
129
+ ...FLAT_PRODUCT,
130
+ discounts: discountsColumn({ type: 'fixed', value: 500 }),
131
+ })
132
+ expect(product.displayPrice).toBe('0.00')
133
+ })
134
+
135
+ it('falls back to the list price when the column is unusable', () => {
136
+ const cases = ['{not json', JSON.stringify({ type: 'percentage' }), '', null]
137
+ cases.forEach((discounts) => {
138
+ const product = evalBuildProduct()({ ...FLAT_PRODUCT, discounts })
139
+ expect(product.hasDiscount).toBe('false')
140
+ expect(product.displayPrice).toBe('100.00')
141
+ })
142
+ })
143
+
144
+ it('discounts each variant from its OWN price, carrying the pre-discount twin', () => {
145
+ const record = {
146
+ ...FLAT_PRODUCT,
147
+ discounts: discountsColumn({ value: 50 }),
148
+ variant_options: JSON.stringify([
149
+ {
150
+ key: 'size',
151
+ name: 'Size',
152
+ type: 'text',
153
+ values: [
154
+ { value: 's', label: 'S' },
155
+ { value: 'l', label: 'L' },
156
+ ],
157
+ },
158
+ ]),
159
+ }
160
+ const build = new Function(
161
+ generateSharedTransformationCode() +
162
+ '\n' +
163
+ generateEcommerceProductTransformationCode({ storefrontTaxRate: 0 }) +
164
+ '\nreturn buildEcommerceProduct;'
165
+ )() as (row: unknown, variants: unknown) => Record<string, unknown>
166
+
167
+ const product = build(record, {
168
+ variantsByProductId: {
169
+ // An overriding combination, and one that INHERITS the product price.
170
+ p1: [
171
+ { id: 'v-s', options: { size: 's' }, price: '40.00', quantity: 5 },
172
+ { id: 'v-l', options: { size: 'l' }, price: null, quantity: 5 },
173
+ ],
174
+ },
175
+ })
176
+
177
+ const variantsDisplay = JSON.parse(String(product.variantsDisplayJson)) as Array<{
178
+ id: string
179
+ price: number
180
+ originalPrice: number | null
181
+ }>
182
+ const small = variantsDisplay.find((entry) => entry.id === 'v-s')
183
+ const large = variantsDisplay.find((entry) => entry.id === 'v-l')
184
+
185
+ expect(small?.price).toBe(20)
186
+ expect(small?.originalPrice).toBe(40)
187
+ // The inheriting combination is discounted from the PRODUCT price, not left
188
+ // at the undiscounted base.
189
+ expect(large?.price).toBe(50)
190
+ expect(large?.originalPrice).toBe(100)
191
+
192
+ // The net twins stay untouched — every write path re-reads them.
193
+ const variantsJson = JSON.parse(String(product.variantsJson)) as Array<{ price: number | null }>
194
+ expect(variantsJson.map((entry) => entry.price)).toEqual([40, null])
195
+ })
196
+ })