@teleporthq/teleport-shared 0.43.67 → 0.43.68

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 (87) hide show
  1. package/__tests__/utils/discount-engine.ts +1548 -0
  2. package/__tests__/utils/product-options.ts +2458 -0
  3. package/__tests__/utils/subscription-access.ts +37 -0
  4. package/dist/cjs/index.d.ts +9 -1
  5. package/dist/cjs/index.d.ts.map +1 -1
  6. package/dist/cjs/index.js +17 -1
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  9. package/dist/cjs/utils/cart-currency.d.ts +27 -0
  10. package/dist/cjs/utils/cart-currency.d.ts.map +1 -0
  11. package/dist/cjs/utils/cart-currency.js +31 -0
  12. package/dist/cjs/utils/cart-currency.js.map +1 -0
  13. package/dist/cjs/utils/cart-line-pricing.d.ts +25 -0
  14. package/dist/cjs/utils/cart-line-pricing.d.ts.map +1 -0
  15. package/dist/cjs/utils/cart-line-pricing.js +29 -0
  16. package/dist/cjs/utils/cart-line-pricing.js.map +1 -0
  17. package/dist/cjs/utils/discount-engine.d.ts +74 -0
  18. package/dist/cjs/utils/discount-engine.d.ts.map +1 -0
  19. package/dist/cjs/utils/discount-engine.js +435 -0
  20. package/dist/cjs/utils/discount-engine.js.map +1 -0
  21. package/dist/cjs/utils/product-options.d.ts +25 -0
  22. package/dist/cjs/utils/product-options.d.ts.map +1 -0
  23. package/dist/cjs/utils/product-options.js +1155 -0
  24. package/dist/cjs/utils/product-options.js.map +1 -0
  25. package/dist/cjs/utils/session-cookie-resolver.d.ts +85 -0
  26. package/dist/cjs/utils/session-cookie-resolver.d.ts.map +1 -0
  27. package/dist/cjs/utils/session-cookie-resolver.js +108 -0
  28. package/dist/cjs/utils/session-cookie-resolver.js.map +1 -0
  29. package/dist/cjs/utils/subscription-access.d.ts +36 -0
  30. package/dist/cjs/utils/subscription-access.d.ts.map +1 -0
  31. package/dist/cjs/utils/subscription-access.js +61 -0
  32. package/dist/cjs/utils/subscription-access.js.map +1 -0
  33. package/dist/cjs/utils/table-access.d.ts +114 -0
  34. package/dist/cjs/utils/table-access.d.ts.map +1 -0
  35. package/dist/cjs/utils/table-access.js +238 -0
  36. package/dist/cjs/utils/table-access.js.map +1 -0
  37. package/dist/cjs/utils/web-push.d.ts +43 -0
  38. package/dist/cjs/utils/web-push.d.ts.map +1 -0
  39. package/dist/cjs/utils/web-push.js +62 -0
  40. package/dist/cjs/utils/web-push.js.map +1 -0
  41. package/dist/esm/index.d.ts +9 -1
  42. package/dist/esm/index.d.ts.map +1 -1
  43. package/dist/esm/index.js +9 -1
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  46. package/dist/esm/utils/cart-currency.d.ts +27 -0
  47. package/dist/esm/utils/cart-currency.d.ts.map +1 -0
  48. package/dist/esm/utils/cart-currency.js +27 -0
  49. package/dist/esm/utils/cart-currency.js.map +1 -0
  50. package/dist/esm/utils/cart-line-pricing.d.ts +25 -0
  51. package/dist/esm/utils/cart-line-pricing.d.ts.map +1 -0
  52. package/dist/esm/utils/cart-line-pricing.js +25 -0
  53. package/dist/esm/utils/cart-line-pricing.js.map +1 -0
  54. package/dist/esm/utils/discount-engine.d.ts +74 -0
  55. package/dist/esm/utils/discount-engine.d.ts.map +1 -0
  56. package/dist/esm/utils/discount-engine.js +431 -0
  57. package/dist/esm/utils/discount-engine.js.map +1 -0
  58. package/dist/esm/utils/product-options.d.ts +25 -0
  59. package/dist/esm/utils/product-options.d.ts.map +1 -0
  60. package/dist/esm/utils/product-options.js +1151 -0
  61. package/dist/esm/utils/product-options.js.map +1 -0
  62. package/dist/esm/utils/session-cookie-resolver.d.ts +85 -0
  63. package/dist/esm/utils/session-cookie-resolver.d.ts.map +1 -0
  64. package/dist/esm/utils/session-cookie-resolver.js +103 -0
  65. package/dist/esm/utils/session-cookie-resolver.js.map +1 -0
  66. package/dist/esm/utils/subscription-access.d.ts +36 -0
  67. package/dist/esm/utils/subscription-access.d.ts.map +1 -0
  68. package/dist/esm/utils/subscription-access.js +57 -0
  69. package/dist/esm/utils/subscription-access.js.map +1 -0
  70. package/dist/esm/utils/table-access.d.ts +114 -0
  71. package/dist/esm/utils/table-access.d.ts.map +1 -0
  72. package/dist/esm/utils/table-access.js +233 -0
  73. package/dist/esm/utils/table-access.js.map +1 -0
  74. package/dist/esm/utils/web-push.d.ts +43 -0
  75. package/dist/esm/utils/web-push.d.ts.map +1 -0
  76. package/dist/esm/utils/web-push.js +59 -0
  77. package/dist/esm/utils/web-push.js.map +1 -0
  78. package/package.json +3 -3
  79. package/src/index.ts +16 -0
  80. package/src/utils/cart-currency.ts +76 -0
  81. package/src/utils/cart-line-pricing.ts +151 -0
  82. package/src/utils/discount-engine.ts +437 -0
  83. package/src/utils/product-options.ts +1150 -0
  84. package/src/utils/session-cookie-resolver.ts +305 -0
  85. package/src/utils/subscription-access.ts +59 -0
  86. package/src/utils/table-access.ts +516 -0
  87. package/src/utils/web-push.ts +61 -0
@@ -0,0 +1,2458 @@
1
+ import {
2
+ generateProductOptionsHelperCode,
3
+ PRODUCT_OPTIONS_HELPERS_CLOSING,
4
+ PRODUCT_OPTIONS_HELPERS_OPENING,
5
+ } from '../../src/utils/product-options'
6
+
7
+ /**
8
+ * Product options — the ES5 copy the published storefront runs (product
9
+ * transform, cart provider, workflow scripts).
10
+ *
11
+ * ⚠️ The fixture table below is SHARED with teleport-gui
12
+ * (`features/e-commerce/utils/__tests__/product-options-parity.spec.ts`), where
13
+ * the same rows run against the TypeScript model and the GUI's copy of this
14
+ * block. Two specs, one table: that is what proves the configurator, the cart,
15
+ * the checkout and the order price the same configuration the same way. Edit
16
+ * both tables or neither.
17
+ */
18
+
19
+ type ProductOptionsApi = Record<string, (...args: unknown[]) => unknown>
20
+
21
+ const loadApi = (): ProductOptionsApi =>
22
+ new Function(
23
+ '"use strict";\n' +
24
+ generateProductOptionsHelperCode() +
25
+ '\nreturn { parseGroups: __poParseGroups, serialize: __poSerialize, validate: __poValidate,' +
26
+ ' parseEntries: __poParseEntries, canonical: __poCanonical, canonicalForKey: __poCanonicalForKey,' +
27
+ ' key: __poKey, defaultSelection: __poDefaultSelection, defaultEntries: __poDefaultEntries,' +
28
+ ' entriesFromSelection: __poEntriesFromSelection, effectiveRule: __poEffectiveRule,' +
29
+ ' effectiveVariant: __poEffectiveVariant, priceVariantKey: __poPriceVariantKey,' +
30
+ ' resolve: __poResolve, resolveForRow: __poResolveForRow, label: __poLabel, details: __poDetails,' +
31
+ ' priceLabel: __poPriceLabel,' +
32
+ ' localize: function (main, clone) { return __poLocalize({ main: main, clone: clone }); },' +
33
+ ' requiresInput: __poRequiresInput, quickAdd: __poQuickAdd, parseSnapshot: __poParseSnapshot,' +
34
+ ' isFileRef: __poIsFileRef, isImage: __poIsImage, mintKey: __poMintKey, round2: __poRound2 };'
35
+ )() as ProductOptionsApi
36
+
37
+ // ── SHARED PARITY TABLE ─────────────────────────────────────────────────────
38
+ // Byte-identical in teleport-gui `product-options-parity.spec.ts` and
39
+ // teleport-code-generators `teleport-shared/__tests__/utils/product-options.ts`.
40
+ // Each row calls one `__po*` entry point (and, in the GUI, its TypeScript twin)
41
+ // with `args` and states the expected result: `resolve` / `resolveForRow` rows
42
+ // state only the fields they are about, every other row the whole result.
43
+ // Groups are stored `option_groups` shapes; configurations are what a cart
44
+ // line carries. Edit both tables or neither.
45
+ type ProductOptionsCall =
46
+ | 'parseGroups'
47
+ | 'serialize'
48
+ | 'validate'
49
+ | 'parseEntries'
50
+ | 'canonical'
51
+ | 'canonicalForKey'
52
+ | 'key'
53
+ | 'defaultSelection'
54
+ | 'defaultEntries'
55
+ | 'entriesFromSelection'
56
+ | 'effectiveRule'
57
+ | 'effectiveVariant'
58
+ | 'priceVariantKey'
59
+ | 'resolve'
60
+ | 'resolveForRow'
61
+ | 'label'
62
+ | 'details'
63
+ | 'priceLabel'
64
+ | 'localize'
65
+ | 'requiresInput'
66
+ | 'quickAdd'
67
+ | 'parseSnapshot'
68
+ | 'isFileRef'
69
+ | 'isImage'
70
+ | 'mintKey'
71
+ | 'round2'
72
+
73
+ interface ProductOptionsFixture {
74
+ name: string
75
+ call: ProductOptionsCall
76
+ args: unknown[]
77
+ expected: unknown
78
+ }
79
+
80
+ const NONE = { mode: 'none' }
81
+
82
+ const GLOSSY = {
83
+ key: 'glossy',
84
+ label: 'Glossy',
85
+ price: { mode: 'fixed', amount: 2 },
86
+ priceWhen: [
87
+ { groupKey: 'size', valueKeys: ['a5'], price: { mode: 'fixed', amount: 3 } },
88
+ { groupKey: 'size', valueKeys: ['a4'], price: { mode: 'fixed', amount: 5 } },
89
+ { groupKey: 'size', valueKeys: ['a3'], price: { mode: 'fixed', amount: 8 } },
90
+ ],
91
+ }
92
+
93
+ // The worked example: a $10 print, Size A5 +0 / A4 +4 / A3 +9 (A2 +15 has no
94
+ // paper override), Glossy priced by Size (A5 +3, A4 +5, A3 +8, otherwise +2),
95
+ // Fine-art +40% of the charged base, and the shopper's photo.
96
+ const PRINT_GROUPS = [
97
+ {
98
+ key: 'size',
99
+ label: 'Size',
100
+ type: 'choice',
101
+ required: true,
102
+ display: 'buttons',
103
+ values: [
104
+ { key: 'a5', label: 'A5', price: NONE },
105
+ { key: 'a4', label: 'A4', price: { mode: 'fixed', amount: 4 } },
106
+ { key: 'a3', label: 'A3', price: { mode: 'fixed', amount: 9 } },
107
+ { key: 'a2', label: 'A2', price: { mode: 'fixed', amount: 15 } },
108
+ ],
109
+ },
110
+ {
111
+ key: 'paper',
112
+ label: 'Paper',
113
+ type: 'choice',
114
+ required: true,
115
+ display: 'cards',
116
+ values: [
117
+ { key: 'matte', label: 'Matte', price: NONE },
118
+ GLOSSY,
119
+ { key: 'fine-art', label: 'Fine-art', price: { mode: 'percent', percent: 40 } },
120
+ ],
121
+ },
122
+ {
123
+ key: 'your-photo',
124
+ label: 'Your photo',
125
+ type: 'upload',
126
+ required: true,
127
+ price: NONE,
128
+ accept: 'image',
129
+ maxFiles: 1,
130
+ },
131
+ ]
132
+
133
+ // A storage-worker file reference: the id carries a digit, an underscore, a
134
+ // dash and a dot, which a `\w`-style class would lose in a quoted-array copy.
135
+ const PHOTO = {
136
+ id: 'a1_b2-c3.d4',
137
+ name: 'IMG_2231.jpg',
138
+ size: 204800,
139
+ url: 'https://files.example.com/uploads/a1_b2-c3.d4.jpg',
140
+ mimeType: 'image/jpeg',
141
+ }
142
+
143
+ const PHOTO_RENAMED = {
144
+ id: 'a1_b2-c3.d4',
145
+ name: 'holiday.jpg',
146
+ size: 204800,
147
+ url: 'https://cdn.example.com/moved/a1_b2-c3.d4.jpg',
148
+ mimeType: 'image/jpeg',
149
+ }
150
+
151
+ const PRINT_A3_GLOSSY = [
152
+ { key: 'size', value: 'a3' },
153
+ { key: 'paper', value: 'glossy' },
154
+ { key: 'your-photo', value: [PHOTO] },
155
+ ]
156
+
157
+ const MACHINE_GROUPS = [
158
+ {
159
+ key: 'material',
160
+ label: 'Material',
161
+ type: 'choice',
162
+ required: true,
163
+ values: [
164
+ { key: 'steel', label: 'Steel', price: NONE },
165
+ { key: 'aluminium', label: 'Aluminium', price: NONE },
166
+ ],
167
+ },
168
+ {
169
+ key: 'extras',
170
+ label: 'Extras',
171
+ type: 'multi-choice',
172
+ required: false,
173
+ values: [
174
+ { key: 'handle', label: 'Handle', price: { mode: 'fixed', amount: 2 } },
175
+ { key: 'wheels', label: 'Wheels', price: { mode: 'fixed', amount: 3.5 } },
176
+ { key: 'lid', label: 'Lid', price: NONE },
177
+ ],
178
+ },
179
+ {
180
+ key: 'finish',
181
+ label: 'Finish',
182
+ type: 'choice',
183
+ required: false,
184
+ values: [
185
+ {
186
+ key: 'lacquer',
187
+ label: 'Lacquer',
188
+ price: { mode: 'fixed', amount: 1 },
189
+ priceWhen: [
190
+ { groupKey: 'extras', valueKeys: ['wheels', 'lid'], price: { mode: 'fixed', amount: 3 } },
191
+ ],
192
+ },
193
+ ],
194
+ },
195
+ {
196
+ key: 'length',
197
+ label: 'Length',
198
+ type: 'number',
199
+ required: true,
200
+ min: 1,
201
+ max: 50,
202
+ step: 0.5,
203
+ unit: 'm',
204
+ price: { mode: 'per-unit', amount: 4 },
205
+ priceWhen: [
206
+ { groupKey: 'material', valueKeys: ['steel'], price: { mode: 'per-unit', amount: 5 } },
207
+ { groupKey: 'material', valueKeys: ['aluminium'], price: { mode: 'per-unit', amount: 3 } },
208
+ ],
209
+ },
210
+ ]
211
+
212
+ const ENGRAVING_GROUPS = [
213
+ {
214
+ key: 'engrave',
215
+ label: 'Add engraving',
216
+ type: 'toggle',
217
+ required: false,
218
+ price: { mode: 'fixed', amount: 4 },
219
+ },
220
+ {
221
+ key: 'engraving-text',
222
+ label: 'Engraving',
223
+ type: 'text',
224
+ required: true,
225
+ maxLength: 30,
226
+ price: NONE,
227
+ priceWhen: [{ groupKey: 'engrave', valueKeys: ['on'], price: { mode: 'fixed', amount: 2.5 } }],
228
+ visibleWhen: [{ groupKey: 'engrave', operator: 'is', valueKeys: ['on'] }],
229
+ },
230
+ ]
231
+
232
+ const DEFAULTS_GROUPS = [
233
+ {
234
+ key: 'colour',
235
+ label: 'Colour',
236
+ type: 'choice',
237
+ required: true,
238
+ display: 'swatches',
239
+ values: [
240
+ { key: 'black', label: 'Black', color: '#111111', price: NONE },
241
+ { key: 'red', label: 'Red', color: '#ef4444', price: NONE, default: true },
242
+ ],
243
+ },
244
+ {
245
+ key: 'extras',
246
+ label: 'Extras',
247
+ type: 'multi-choice',
248
+ required: false,
249
+ values: [
250
+ { key: 'strap', label: 'Strap', price: NONE, default: true },
251
+ { key: 'case', label: 'Case', price: NONE },
252
+ { key: 'charger', label: 'Charger', price: NONE, default: true },
253
+ ],
254
+ },
255
+ {
256
+ key: 'quantity',
257
+ label: 'Names',
258
+ type: 'number',
259
+ required: true,
260
+ min: 1,
261
+ max: 5,
262
+ defaultValue: 2,
263
+ price: NONE,
264
+ },
265
+ { key: 'wrap', label: 'Gift wrap', type: 'toggle', required: false, price: NONE },
266
+ ]
267
+
268
+ const SURCHARGED_DEFAULT_GROUPS = [
269
+ {
270
+ key: 'size',
271
+ label: 'Size',
272
+ type: 'choice',
273
+ required: true,
274
+ values: [
275
+ { key: 's', label: 'S', price: NONE },
276
+ { key: 'l', label: 'L', price: { mode: 'fixed', amount: 6 }, default: true },
277
+ ],
278
+ },
279
+ ]
280
+
281
+ const LONG_TEXT_GROUPS = ['a', 'b', 'c', 'd', 'e'].map((key) => ({
282
+ key: 'note-' + key,
283
+ label: 'Note ' + key,
284
+ type: 'long-text',
285
+ required: false,
286
+ price: NONE,
287
+ }))
288
+
289
+ const PRODUCT_OPTIONS_FIXTURES: ProductOptionsFixture[] = [
290
+ // ── resolve: money ──
291
+ {
292
+ name: 'no groups and no configuration charge nothing and have no key',
293
+ call: 'resolve',
294
+ args: [10, [], '', false],
295
+ expected: {
296
+ delta: 0,
297
+ valid: true,
298
+ canonical: '',
299
+ key: '',
300
+ label: '',
301
+ details: '',
302
+ entries: [],
303
+ errors: [],
304
+ },
305
+ },
306
+ {
307
+ name: 'a fixed surcharge is added to the base',
308
+ call: 'resolve',
309
+ args: [
310
+ 100,
311
+ [
312
+ {
313
+ key: 'engraving',
314
+ label: 'Engraving',
315
+ type: 'text',
316
+ required: false,
317
+ maxLength: 30,
318
+ price: { mode: 'fixed', amount: 5 },
319
+ },
320
+ ],
321
+ [{ key: 'engraving', value: ' For Ana ' }],
322
+ false,
323
+ ],
324
+ expected: {
325
+ delta: 5,
326
+ valid: true,
327
+ canonical: '[{"key":"engraving","value":"For Ana"}]',
328
+ label: 'Engraving: For Ana',
329
+ },
330
+ },
331
+ {
332
+ name: 'a percent option is a share of the discounted base',
333
+ call: 'resolve',
334
+ args: [
335
+ 80,
336
+ [
337
+ {
338
+ key: 'finish',
339
+ label: 'Finish',
340
+ type: 'choice',
341
+ required: true,
342
+ values: [{ key: 'gold', label: 'Gold', price: { mode: 'percent', percent: 10 } }],
343
+ },
344
+ ],
345
+ [{ key: 'finish', value: 'gold' }],
346
+ false,
347
+ ],
348
+ expected: { delta: 8, valid: true },
349
+ },
350
+ {
351
+ name: '15% of 19.99 rounds to 3.00',
352
+ call: 'resolve',
353
+ args: [
354
+ 19.99,
355
+ [
356
+ {
357
+ key: 'finish',
358
+ label: 'Finish',
359
+ type: 'choice',
360
+ required: true,
361
+ values: [{ key: 'gold', label: 'Gold', price: { mode: 'percent', percent: 15 } }],
362
+ },
363
+ ],
364
+ [{ key: 'finish', value: 'gold' }],
365
+ false,
366
+ ],
367
+ expected: { delta: 3, valid: true },
368
+ },
369
+ {
370
+ name: 'two 33.3% entries round each, then sum (6.66, not 6.67)',
371
+ call: 'resolve',
372
+ args: [
373
+ 10,
374
+ [
375
+ {
376
+ key: 'wrap',
377
+ label: 'Wrap',
378
+ type: 'toggle',
379
+ required: false,
380
+ price: { mode: 'percent', percent: 33.3 },
381
+ },
382
+ {
383
+ key: 'card',
384
+ label: 'Card',
385
+ type: 'toggle',
386
+ required: false,
387
+ price: { mode: 'percent', percent: 33.3 },
388
+ },
389
+ ],
390
+ [
391
+ { key: 'wrap', value: true },
392
+ { key: 'card', value: true },
393
+ ],
394
+ false,
395
+ ],
396
+ expected: {
397
+ delta: 6.66,
398
+ valid: true,
399
+ label: 'Wrap: Yes · Card: Yes',
400
+ entries: [
401
+ {
402
+ key: 'wrap',
403
+ label: 'Wrap',
404
+ type: 'toggle',
405
+ value: true,
406
+ valueLabel: 'Yes',
407
+ priceDelta: 3.33,
408
+ },
409
+ {
410
+ key: 'card',
411
+ label: 'Card',
412
+ type: 'toggle',
413
+ value: true,
414
+ valueLabel: 'Yes',
415
+ priceDelta: 3.33,
416
+ },
417
+ ],
418
+ },
419
+ },
420
+ {
421
+ name: 'per-unit 0.35 × 12 is 4.20',
422
+ call: 'resolve',
423
+ args: [
424
+ 20,
425
+ [
426
+ {
427
+ key: 'length',
428
+ label: 'Length',
429
+ type: 'number',
430
+ required: true,
431
+ min: 1,
432
+ max: 100,
433
+ unit: 'm',
434
+ price: { mode: 'per-unit', amount: 0.35 },
435
+ },
436
+ ],
437
+ [{ key: 'length', value: '12' }],
438
+ false,
439
+ ],
440
+ expected: { delta: 4.2, canonical: '[{"key":"length","value":12}]', label: 'Length: 12 m' },
441
+ },
442
+ {
443
+ name: 'a multi-choice sums its values',
444
+ call: 'resolve',
445
+ args: [
446
+ 50,
447
+ MACHINE_GROUPS,
448
+ [
449
+ { key: 'material', value: 'steel' },
450
+ { key: 'extras', value: ['wheels', 'handle'] },
451
+ { key: 'length', value: 1 },
452
+ ],
453
+ false,
454
+ ],
455
+ expected: { delta: 10.5, label: 'Material: Steel · Extras: Handle, Wheels · Length: 1 m' },
456
+ },
457
+ {
458
+ name: 'a toggle switched on is charged',
459
+ call: 'resolve',
460
+ args: [
461
+ 30,
462
+ ENGRAVING_GROUPS,
463
+ [
464
+ { key: 'engrave', value: true },
465
+ { key: 'engraving-text', value: 'To M' },
466
+ ],
467
+ false,
468
+ ],
469
+ expected: {
470
+ delta: 6.5,
471
+ valid: true,
472
+ canonical: '[{"key":"engrave","value":true},{"key":"engraving-text","value":"To M"}]',
473
+ },
474
+ },
475
+ {
476
+ name: 'a toggle switched off is absent, and hides what depends on it',
477
+ call: 'resolve',
478
+ args: [
479
+ 30,
480
+ ENGRAVING_GROUPS,
481
+ [
482
+ { key: 'engrave', value: false },
483
+ { key: 'engraving-text', value: 'To M' },
484
+ ],
485
+ false,
486
+ ],
487
+ expected: { delta: 0, valid: true, canonical: '', key: '', visibleKeys: ['engrave'] },
488
+ },
489
+ {
490
+ name: 'whitespace-only text is no answer: a required text is missing',
491
+ call: 'resolve',
492
+ args: [
493
+ 30,
494
+ ENGRAVING_GROUPS,
495
+ [
496
+ { key: 'engrave', value: 'on' },
497
+ { key: 'engraving-text', value: ' ' },
498
+ ],
499
+ false,
500
+ ],
501
+ expected: {
502
+ valid: false,
503
+ missingKeys: ['engraving-text'],
504
+ missingLabels: ['Engraving'],
505
+ canonical: '[{"key":"engrave","value":true}]',
506
+ },
507
+ },
508
+ {
509
+ name: 'whitespace-only text on an optional text leaves nothing in the configuration',
510
+ call: 'resolve',
511
+ args: [
512
+ 5,
513
+ [{ key: 'note', label: 'Note', type: 'text', required: false, price: NONE }],
514
+ [{ key: 'note', value: ' \n ' }],
515
+ false,
516
+ ],
517
+ expected: { valid: true, canonical: '', key: '', entries: [] },
518
+ },
519
+ // ── resolve: price by an earlier choice ──
520
+ {
521
+ name: 'worked example: A3 + Glossy on a $10 print is $27',
522
+ call: 'resolve',
523
+ args: [10, PRINT_GROUPS, PRINT_A3_GLOSSY, false],
524
+ expected: {
525
+ delta: 17,
526
+ valid: true,
527
+ canonical:
528
+ '[{"key":"paper","value":"glossy"},{"key":"size","value":"a3"},{"key":"your-photo","value":[{"id":"a1_b2-c3.d4","name":"IMG_2231.jpg","size":204800,"url":"https://files.example.com/uploads/a1_b2-c3.d4.jpg","mimeType":"image/jpeg"}]}]',
529
+ key: 'e1dca133dbeaeda7',
530
+ label: 'Size: A3 · Paper: Glossy · Your photo: IMG_2231.jpg',
531
+ details: 'Size: A3\nPaper: Glossy\nYour photo: IMG_2231.jpg',
532
+ entries: [
533
+ {
534
+ key: 'size',
535
+ label: 'Size',
536
+ type: 'choice',
537
+ value: 'a3',
538
+ valueLabel: 'A3',
539
+ priceDelta: 9,
540
+ },
541
+ {
542
+ key: 'paper',
543
+ label: 'Paper',
544
+ type: 'choice',
545
+ value: 'glossy',
546
+ valueLabel: 'Glossy',
547
+ priceDelta: 8,
548
+ },
549
+ {
550
+ key: 'your-photo',
551
+ label: 'Your photo',
552
+ type: 'upload',
553
+ value: ['a1_b2-c3.d4'],
554
+ valueLabel: 'IMG_2231.jpg',
555
+ priceDelta: 0,
556
+ files: [
557
+ {
558
+ id: 'a1_b2-c3.d4',
559
+ name: 'IMG_2231.jpg',
560
+ size: 204800,
561
+ url: 'https://files.example.com/uploads/a1_b2-c3.d4.jpg',
562
+ mimeType: 'image/jpeg',
563
+ },
564
+ ],
565
+ },
566
+ ],
567
+ errors: [],
568
+ missingKeys: [],
569
+ missingLabels: [],
570
+ visibleKeys: ['size', 'paper', 'your-photo'],
571
+ },
572
+ },
573
+ {
574
+ name: 'a renamed upload keeps the line key (the key hashes the file id only)',
575
+ call: 'resolve',
576
+ args: [
577
+ 10,
578
+ PRINT_GROUPS,
579
+ [
580
+ { key: 'your-photo', value: [PHOTO_RENAMED] },
581
+ { key: 'paper', value: 'glossy' },
582
+ { key: 'size', value: 'a3' },
583
+ ],
584
+ false,
585
+ ],
586
+ expected: {
587
+ delta: 17,
588
+ key: 'e1dca133dbeaeda7',
589
+ label: 'Size: A3 · Paper: Glossy · Your photo: holiday.jpg',
590
+ },
591
+ },
592
+ {
593
+ name: 'no override for the chosen size falls back to the default price',
594
+ call: 'resolve',
595
+ args: [
596
+ 10,
597
+ PRINT_GROUPS,
598
+ [
599
+ { key: 'size', value: 'a2' },
600
+ { key: 'paper', value: 'glossy' },
601
+ { key: 'your-photo', value: [PHOTO] },
602
+ ],
603
+ false,
604
+ ],
605
+ expected: { delta: 17, label: 'Size: A2 · Paper: Glossy · Your photo: IMG_2231.jpg' },
606
+ },
607
+ {
608
+ name: 'a percent value is a share of the base, not of the running total',
609
+ call: 'resolve',
610
+ args: [
611
+ 10,
612
+ PRINT_GROUPS,
613
+ [
614
+ { key: 'size', value: 'a3' },
615
+ { key: 'paper', value: 'fine-art' },
616
+ { key: 'your-photo', value: [PHOTO] },
617
+ ],
618
+ false,
619
+ ],
620
+ expected: { delta: 13, label: 'Size: A3 · Paper: Fine-art · Your photo: IMG_2231.jpg' },
621
+ },
622
+ {
623
+ name: 'a percent override is a share of the variant base',
624
+ call: 'resolve',
625
+ args: [
626
+ 24.5,
627
+ [
628
+ {
629
+ key: 'size',
630
+ label: 'Size',
631
+ type: 'choice',
632
+ required: true,
633
+ values: [
634
+ { key: 'a4', label: 'A4', price: NONE },
635
+ { key: 'a3', label: 'A3', price: NONE },
636
+ ],
637
+ },
638
+ {
639
+ key: 'paper',
640
+ label: 'Paper',
641
+ type: 'choice',
642
+ required: true,
643
+ values: [
644
+ {
645
+ key: 'glossy',
646
+ label: 'Glossy',
647
+ price: { mode: 'percent', percent: 10 },
648
+ priceWhen: [
649
+ { groupKey: 'size', valueKeys: ['a3'], price: { mode: 'percent', percent: 20 } },
650
+ ],
651
+ },
652
+ ],
653
+ },
654
+ ],
655
+ [
656
+ { key: 'size', value: 'a3' },
657
+ { key: 'paper', value: 'glossy' },
658
+ ],
659
+ false,
660
+ ],
661
+ expected: { delta: 4.9, valid: true },
662
+ },
663
+ {
664
+ name: 'a hidden price source leaves the default price',
665
+ call: 'resolve',
666
+ args: [
667
+ 10,
668
+ [
669
+ { key: 'custom', label: 'Custom size', type: 'toggle', required: false, price: NONE },
670
+ {
671
+ key: 'size',
672
+ label: 'Size',
673
+ type: 'choice',
674
+ required: true,
675
+ values: [
676
+ { key: 's', label: 'S', price: NONE },
677
+ { key: 'l', label: 'L', price: NONE },
678
+ ],
679
+ visibleWhen: [{ groupKey: 'custom', operator: 'is-set' }],
680
+ },
681
+ {
682
+ key: 'paper',
683
+ label: 'Paper',
684
+ type: 'choice',
685
+ required: true,
686
+ values: [
687
+ {
688
+ key: 'glossy',
689
+ label: 'Glossy',
690
+ price: { mode: 'fixed', amount: 2 },
691
+ priceWhen: [
692
+ { groupKey: 'size', valueKeys: ['l'], price: { mode: 'fixed', amount: 6 } },
693
+ ],
694
+ },
695
+ ],
696
+ },
697
+ ],
698
+ [
699
+ { key: 'size', value: 'l' },
700
+ { key: 'paper', value: 'glossy' },
701
+ ],
702
+ false,
703
+ ],
704
+ expected: {
705
+ delta: 2,
706
+ valid: true,
707
+ canonical: '[{"key":"paper","value":"glossy"}]',
708
+ visibleKeys: ['custom', 'paper'],
709
+ },
710
+ },
711
+ {
712
+ name: 'a multi-choice source matches when the picks intersect the override',
713
+ call: 'resolve',
714
+ args: [
715
+ 50,
716
+ MACHINE_GROUPS,
717
+ [
718
+ { key: 'material', value: 'steel' },
719
+ { key: 'extras', value: ['handle', 'lid'] },
720
+ { key: 'finish', value: 'lacquer' },
721
+ { key: 'length', value: 1 },
722
+ ],
723
+ false,
724
+ ],
725
+ expected: {
726
+ delta: 10,
727
+ label: 'Material: Steel · Extras: Handle, Lid · Finish: Lacquer · Length: 1 m',
728
+ },
729
+ },
730
+ {
731
+ name: 'a toggle source matches its on value',
732
+ call: 'resolve',
733
+ args: [
734
+ 30,
735
+ ENGRAVING_GROUPS,
736
+ [
737
+ { key: 'engrave', value: 'true' },
738
+ { key: 'engraving-text', value: 'Ana & Tom' },
739
+ ],
740
+ false,
741
+ ],
742
+ expected: { delta: 6.5, label: 'Add engraving: Yes · Engraving: Ana & Tom' },
743
+ },
744
+ {
745
+ name: 'a per-unit override on a number group follows the material',
746
+ call: 'resolve',
747
+ args: [
748
+ 50,
749
+ MACHINE_GROUPS,
750
+ [
751
+ { key: 'material', value: 'aluminium' },
752
+ { key: 'length', value: 12 },
753
+ ],
754
+ false,
755
+ ],
756
+ expected: { delta: 36, label: 'Material: Aluminium · Length: 12 m' },
757
+ },
758
+ {
759
+ name: 'the first matching override wins',
760
+ call: 'resolve',
761
+ args: [
762
+ 10,
763
+ [
764
+ {
765
+ key: 'size',
766
+ label: 'Size',
767
+ type: 'choice',
768
+ required: true,
769
+ values: [
770
+ { key: 'a4', label: 'A4', price: NONE },
771
+ { key: 'a3', label: 'A3', price: NONE },
772
+ ],
773
+ },
774
+ {
775
+ key: 'paper',
776
+ label: 'Paper',
777
+ type: 'choice',
778
+ required: true,
779
+ values: [
780
+ {
781
+ key: 'glossy',
782
+ label: 'Glossy',
783
+ price: NONE,
784
+ priceWhen: [
785
+ { groupKey: 'size', valueKeys: ['a3'], price: { mode: 'fixed', amount: 8 } },
786
+ { groupKey: 'size', valueKeys: ['a3', 'a4'], price: { mode: 'fixed', amount: 20 } },
787
+ ],
788
+ },
789
+ ],
790
+ },
791
+ ],
792
+ [
793
+ { key: 'size', value: 'a3' },
794
+ { key: 'paper', value: 'glossy' },
795
+ ],
796
+ false,
797
+ ],
798
+ expected: { delta: 8 },
799
+ },
800
+ // ── resolve: what makes a configuration invalid ──
801
+ {
802
+ name: 'required options left empty are missing',
803
+ call: 'resolve',
804
+ args: [10, PRINT_GROUPS, [{ key: 'size', value: 'a4' }], false],
805
+ expected: {
806
+ delta: 4,
807
+ valid: false,
808
+ errors: [],
809
+ missingKeys: ['paper', 'your-photo'],
810
+ missingLabels: ['Paper', 'Your photo'],
811
+ },
812
+ },
813
+ {
814
+ name: 'a value removed from the product is invalid and charges nothing',
815
+ call: 'resolve',
816
+ args: [
817
+ 10,
818
+ PRINT_GROUPS,
819
+ [
820
+ { key: 'size', value: 'a0' },
821
+ { key: 'paper', value: 'matte' },
822
+ { key: 'your-photo', value: [PHOTO] },
823
+ ],
824
+ false,
825
+ ],
826
+ expected: { delta: 0, valid: false, errors: ['unknown-value:size'], missingKeys: ['size'] },
827
+ },
828
+ {
829
+ name: 'a number outside its range is invalid',
830
+ call: 'resolve',
831
+ args: [
832
+ 50,
833
+ MACHINE_GROUPS,
834
+ [
835
+ { key: 'material', value: 'steel' },
836
+ { key: 'length', value: 80 },
837
+ ],
838
+ false,
839
+ ],
840
+ expected: { valid: false, errors: ['out-of-range:length'] },
841
+ },
842
+ {
843
+ name: 'an unsafe file is refused',
844
+ call: 'resolve',
845
+ args: [
846
+ 10,
847
+ PRINT_GROUPS,
848
+ [
849
+ { key: 'size', value: 'a5' },
850
+ { key: 'paper', value: 'matte' },
851
+ {
852
+ key: 'your-photo',
853
+ value: [
854
+ {
855
+ id: 'x1',
856
+ name: 'x.jpg',
857
+ size: 10,
858
+ url: 'javascript:alert(1)',
859
+ mimeType: 'image/jpeg',
860
+ },
861
+ ],
862
+ },
863
+ ],
864
+ false,
865
+ ],
866
+ expected: {
867
+ valid: false,
868
+ errors: ['bad-file:your-photo'],
869
+ missingKeys: ['your-photo'],
870
+ canonical: '[{"key":"paper","value":"matte"},{"key":"size","value":"a5"}]',
871
+ },
872
+ },
873
+ {
874
+ name: 'a canonical string over 8000 characters is too long',
875
+ call: 'resolve',
876
+ args: [
877
+ 5,
878
+ LONG_TEXT_GROUPS,
879
+ LONG_TEXT_GROUPS.map((group) => ({ key: group.key, value: 'x'.repeat(1700) })),
880
+ false,
881
+ ],
882
+ expected: { delta: 0, valid: false, errors: ['too-long'] },
883
+ },
884
+ {
885
+ name: 'the same answers in another order give the same canonical string and key',
886
+ call: 'resolve',
887
+ args: [
888
+ 50,
889
+ MACHINE_GROUPS,
890
+ [
891
+ { key: 'length', value: 1 },
892
+ { key: 'extras', value: ['wheels', 'handle'] },
893
+ { key: 'material', value: 'steel' },
894
+ ],
895
+ false,
896
+ ],
897
+ expected: {
898
+ canonical:
899
+ '[{"key":"extras","value":["handle","wheels"]},{"key":"length","value":1},{"key":"material","value":"steel"}]',
900
+ key: 'b5d22cedfbe5c44b',
901
+ },
902
+ },
903
+ {
904
+ name: 'pricesDisabled charges nothing whatever is stored',
905
+ call: 'resolve',
906
+ args: [10, PRINT_GROUPS, PRINT_A3_GLOSSY, true],
907
+ expected: { delta: 0, valid: true, key: 'e1dca133dbeaeda7' },
908
+ },
909
+ // ── resolveForRow: the product row decides ──
910
+ {
911
+ name: 'a gift-card row charges nothing, overrides included',
912
+ call: 'resolveForRow',
913
+ args: [
914
+ { option_groups: JSON.stringify(PRINT_GROUPS), is_gift_card: 't', payment_type: 'one_time' },
915
+ 10,
916
+ PRINT_A3_GLOSSY,
917
+ ],
918
+ expected: {
919
+ delta: 0,
920
+ valid: true,
921
+ label: 'Size: A3 · Paper: Glossy · Your photo: IMG_2231.jpg',
922
+ },
923
+ },
924
+ {
925
+ name: 'a recurring row charges nothing',
926
+ call: 'resolveForRow',
927
+ args: [
928
+ { option_groups: PRINT_GROUPS, is_gift_card: false, payment_type: 'recurring' },
929
+ 10,
930
+ PRINT_A3_GLOSSY,
931
+ ],
932
+ expected: { delta: 0, valid: true },
933
+ },
934
+ {
935
+ name: 'a negative stored amount charges nothing',
936
+ call: 'resolveForRow',
937
+ args: [
938
+ {
939
+ option_groups: JSON.stringify([
940
+ {
941
+ key: 'wrap',
942
+ label: 'Wrap',
943
+ type: 'toggle',
944
+ required: false,
945
+ price: { mode: 'fixed', amount: -5 },
946
+ },
947
+ ]),
948
+ },
949
+ 10,
950
+ JSON.stringify([{ key: 'wrap', value: true }]),
951
+ ],
952
+ expected: { delta: 0, valid: true, canonical: '[{"key":"wrap","value":true}]' },
953
+ },
954
+ {
955
+ name: 'malformed option_groups: no groups, so any configuration is invalid',
956
+ call: 'resolveForRow',
957
+ args: [{ option_groups: '{not json' }, 10, [{ key: 'size', value: 'a3' }]],
958
+ expected: { delta: 0, valid: false, errors: ['unknown-option:size'], canonical: '' },
959
+ },
960
+ {
961
+ name: 'a plain product row stays plain',
962
+ call: 'resolveForRow',
963
+ args: [{ option_groups: null }, 10, ''],
964
+ expected: { delta: 0, valid: true, canonical: '', key: '', label: '', details: '' },
965
+ },
966
+ // ── defaults, requiresInput, quickAdd ──
967
+ {
968
+ name: 'default selection',
969
+ call: 'defaultSelection',
970
+ args: [DEFAULTS_GROUPS],
971
+ expected: { colour: 'red', extras: ['strap', 'charger'], quantity: 2 },
972
+ },
973
+ {
974
+ name: 'default entries',
975
+ call: 'defaultEntries',
976
+ args: [DEFAULTS_GROUPS],
977
+ expected: [
978
+ { key: 'colour', value: 'red' },
979
+ { key: 'extras', value: ['strap', 'charger'] },
980
+ { key: 'quantity', value: 2 },
981
+ ],
982
+ },
983
+ {
984
+ name: 'entries from a selection skip empty answers',
985
+ call: 'entriesFromSelection',
986
+ args: [{ size: 'a3', extras: ['lid'], note: null, wrap: false }],
987
+ expected: [
988
+ { key: 'size', value: 'a3' },
989
+ { key: 'extras', value: ['lid'] },
990
+ { key: 'wrap', value: false },
991
+ ],
992
+ },
993
+ {
994
+ name: 'required options without defaults require input',
995
+ call: 'requiresInput',
996
+ args: [PRINT_GROUPS],
997
+ expected: true,
998
+ },
999
+ {
1000
+ name: 'valid defaults need no input',
1001
+ call: 'requiresInput',
1002
+ args: [DEFAULTS_GROUPS],
1003
+ expected: false,
1004
+ },
1005
+ { name: 'no options need no input', call: 'requiresInput', args: [[]], expected: false },
1006
+ {
1007
+ name: 'valid defaults that cost nothing allow a quick add',
1008
+ call: 'quickAdd',
1009
+ args: [DEFAULTS_GROUPS, 20, false],
1010
+ expected: true,
1011
+ },
1012
+ {
1013
+ name: 'a surcharged default refuses a quick add',
1014
+ call: 'quickAdd',
1015
+ args: [SURCHARGED_DEFAULT_GROUPS, 20, false],
1016
+ expected: false,
1017
+ },
1018
+ {
1019
+ name: 'a surcharged default on a gift card allows a quick add',
1020
+ call: 'quickAdd',
1021
+ args: [SURCHARGED_DEFAULT_GROUPS, 20, true],
1022
+ expected: true,
1023
+ },
1024
+ {
1025
+ name: 'required input refuses a quick add',
1026
+ call: 'quickAdd',
1027
+ args: [PRINT_GROUPS, 10, false],
1028
+ expected: false,
1029
+ },
1030
+ // ── effective rule ──
1031
+ {
1032
+ name: 'effective rule: a shown, matching source picks the override',
1033
+ call: 'effectiveRule',
1034
+ args: [GLOSSY, { size: 'a4' }, ['size']],
1035
+ expected: { mode: 'fixed', amount: 5 },
1036
+ },
1037
+ {
1038
+ name: 'effective rule: a hidden source leaves the default',
1039
+ call: 'effectiveRule',
1040
+ args: [GLOSSY, { size: 'a4' }, []],
1041
+ expected: { mode: 'fixed', amount: 2 },
1042
+ },
1043
+ {
1044
+ name: 'effective rule: a toggle source answers on',
1045
+ call: 'effectiveRule',
1046
+ args: [ENGRAVING_GROUPS[1], { engrave: true }, ['engrave']],
1047
+ expected: { mode: 'fixed', amount: 2.5 },
1048
+ },
1049
+ {
1050
+ name: 'effective rule: nothing priced is none',
1051
+ call: 'effectiveRule',
1052
+ args: [{}, {}, []],
1053
+ expected: { mode: 'none' },
1054
+ },
1055
+ // ── parse / serialize / validate ──
1056
+ {
1057
+ name: 'parse drops rules that point at later or unknown groups and repairs the rest',
1058
+ call: 'parseGroups',
1059
+ args: [
1060
+ JSON.stringify([
1061
+ {
1062
+ key: 'Paper',
1063
+ label: ' Paper ',
1064
+ type: 'dropdown',
1065
+ display: 'carousel',
1066
+ values: [
1067
+ {
1068
+ label: 'Glossy',
1069
+ price: { mode: 'per-unit', amount: 3 },
1070
+ default: 'true',
1071
+ priceWhen: [{ groupKey: 'size', valueKeys: ['a3'], price: 5 }],
1072
+ },
1073
+ {
1074
+ key: 'glossy',
1075
+ label: 'Glossy again',
1076
+ price: { mode: 'fixed', amount: -2 },
1077
+ default: true,
1078
+ },
1079
+ 'junk',
1080
+ ],
1081
+ },
1082
+ {
1083
+ key: 'size',
1084
+ label: 'Size',
1085
+ type: 'choice',
1086
+ required: 't',
1087
+ values: [{ key: 'a3', label: 'A3', price: { mode: 'percent', percent: 5000 } }],
1088
+ visibleWhen: [
1089
+ { groupKey: 'paper', operator: 'is', valueKeys: ['glossy', 'missing'] },
1090
+ { groupKey: 'paper', operator: 'near' },
1091
+ { groupKey: 'later', operator: 'is-set' },
1092
+ ],
1093
+ },
1094
+ { key: 'size', label: 'Size', type: 'toggle', required: true, price: { mode: 'bogus' } },
1095
+ { label: 'Photo', type: 'upload', accept: 'svg' },
1096
+ { label: 'Words', type: 'text', maxLength: 99999 },
1097
+ ]),
1098
+ ],
1099
+ expected: [
1100
+ {
1101
+ key: 'paper',
1102
+ label: 'Paper',
1103
+ type: 'choice',
1104
+ required: false,
1105
+ display: 'buttons',
1106
+ values: [
1107
+ { key: 'glossy', label: 'Glossy', price: { mode: 'fixed', amount: 3 }, default: true },
1108
+ { key: 'glossy-2', label: 'Glossy again', price: { mode: 'fixed', amount: 0 } },
1109
+ ],
1110
+ },
1111
+ {
1112
+ key: 'size',
1113
+ label: 'Size',
1114
+ type: 'choice',
1115
+ required: true,
1116
+ display: 'buttons',
1117
+ values: [{ key: 'a3', label: 'A3', price: { mode: 'percent', percent: 1000 } }],
1118
+ visibleWhen: [{ groupKey: 'paper', operator: 'is', valueKeys: ['glossy'] }],
1119
+ },
1120
+ { key: 'size-2', label: 'Size', type: 'toggle', required: false, price: { mode: 'none' } },
1121
+ {
1122
+ key: 'photo',
1123
+ label: 'Photo',
1124
+ type: 'upload',
1125
+ required: false,
1126
+ price: { mode: 'none' },
1127
+ accept: 'any',
1128
+ maxFiles: 1,
1129
+ },
1130
+ {
1131
+ key: 'words',
1132
+ label: 'Words',
1133
+ type: 'text',
1134
+ required: false,
1135
+ maxLength: 500,
1136
+ price: { mode: 'none' },
1137
+ },
1138
+ ],
1139
+ },
1140
+ {
1141
+ name: 'parse: malformed JSON is no groups',
1142
+ call: 'parseGroups',
1143
+ args: ['{nope'],
1144
+ expected: [],
1145
+ },
1146
+ {
1147
+ name: 'parse: not an array is no groups',
1148
+ call: 'parseGroups',
1149
+ args: [{ key: 'x' }],
1150
+ expected: [],
1151
+ },
1152
+ {
1153
+ name: 'serialize writes one key order and drops unknown fields',
1154
+ call: 'serialize',
1155
+ args: [
1156
+ [
1157
+ {
1158
+ visibleWhen: undefined,
1159
+ price: { amount: 3, mode: 'fixed' },
1160
+ type: 'toggle',
1161
+ label: 'Wrap',
1162
+ key: 'wrap',
1163
+ required: 1,
1164
+ extra: 'x',
1165
+ },
1166
+ ],
1167
+ ],
1168
+ expected:
1169
+ '[{"key":"wrap","label":"Wrap","type":"toggle","required":false,"price":{"mode":"fixed","amount":3}}]',
1170
+ },
1171
+ { name: 'serialize: nothing is null', call: 'serialize', args: [[]], expected: null },
1172
+ {
1173
+ name: 'validate names a price source placed after its option',
1174
+ call: 'validate',
1175
+ args: [
1176
+ [
1177
+ {
1178
+ key: 'paper',
1179
+ label: 'Paper',
1180
+ type: 'choice',
1181
+ required: true,
1182
+ values: [
1183
+ {
1184
+ key: 'glossy',
1185
+ label: 'Glossy',
1186
+ price: NONE,
1187
+ priceWhen: [
1188
+ { groupKey: 'size', valueKeys: ['a3'], price: { mode: 'fixed', amount: 8 } },
1189
+ ],
1190
+ },
1191
+ ],
1192
+ },
1193
+ {
1194
+ key: 'size',
1195
+ label: 'Size',
1196
+ type: 'choice',
1197
+ required: true,
1198
+ values: [{ key: 'a3', label: 'A3', price: NONE }],
1199
+ },
1200
+ ],
1201
+ false,
1202
+ '',
1203
+ ],
1204
+ expected: [
1205
+ {
1206
+ severity: 'error',
1207
+ code: 'PRICE_SOURCE_LATER',
1208
+ message:
1209
+ 'A price on “Paper” depends on “Size”, which comes after it. Move “Size” above “Paper”.',
1210
+ groupIndex: 0,
1211
+ groupKey: 'paper',
1212
+ valueKey: 'glossy',
1213
+ },
1214
+ ],
1215
+ },
1216
+ {
1217
+ name: 'validate: structure problems',
1218
+ call: 'validate',
1219
+ args: [
1220
+ [
1221
+ { key: 'empty', label: '', type: 'choice', required: true, values: [] },
1222
+ {
1223
+ key: 'extras',
1224
+ label: 'Extras',
1225
+ type: 'multi-choice',
1226
+ required: false,
1227
+ minSelected: 3,
1228
+ maxSelected: 2,
1229
+ values: [
1230
+ { key: 'a', label: 'A', price: { mode: 'per-unit', amount: 1 } },
1231
+ { key: 'a', label: '', price: { mode: 'fixed', amount: -1 } },
1232
+ ],
1233
+ },
1234
+ {
1235
+ key: 'len',
1236
+ label: 'Length',
1237
+ type: 'number',
1238
+ required: true,
1239
+ min: 5,
1240
+ max: 1,
1241
+ step: 0,
1242
+ defaultValue: 9,
1243
+ price: { mode: 'percent', percent: 2000 },
1244
+ },
1245
+ {
1246
+ key: 'note',
1247
+ label: 'Note',
1248
+ type: 'text',
1249
+ required: false,
1250
+ maxLength: 900,
1251
+ price: NONE,
1252
+ visibleWhen: [{ groupKey: 'len', operator: 'is-set' }],
1253
+ },
1254
+ {
1255
+ key: 'note',
1256
+ label: 'Again',
1257
+ type: 'toggle',
1258
+ required: false,
1259
+ price: NONE,
1260
+ visibleWhen: [{ groupKey: 'gone', operator: 'is-set' }],
1261
+ },
1262
+ ],
1263
+ false,
1264
+ '',
1265
+ ],
1266
+ expected: [
1267
+ {
1268
+ severity: 'error',
1269
+ code: 'GROUP_LABEL_REQUIRED',
1270
+ message: 'Option 1 needs a name.',
1271
+ groupIndex: 0,
1272
+ groupKey: 'empty',
1273
+ valueKey: null,
1274
+ },
1275
+ {
1276
+ severity: 'error',
1277
+ code: 'CHOICE_NEEDS_VALUE',
1278
+ message: '“Option 1” needs at least one choice.',
1279
+ groupIndex: 0,
1280
+ groupKey: 'empty',
1281
+ valueKey: null,
1282
+ },
1283
+ {
1284
+ severity: 'error',
1285
+ code: 'PRICE_PER_UNIT_NUMBER_ONLY',
1286
+ message: 'Only a number option can be priced per unit, so “Extras” cannot.',
1287
+ groupIndex: 1,
1288
+ groupKey: 'extras',
1289
+ valueKey: 'a',
1290
+ },
1291
+ {
1292
+ severity: 'error',
1293
+ code: 'VALUE_LABEL_REQUIRED',
1294
+ message: 'Every choice of “Extras” needs a name.',
1295
+ groupIndex: 1,
1296
+ groupKey: 'extras',
1297
+ valueKey: 'a',
1298
+ },
1299
+ {
1300
+ severity: 'error',
1301
+ code: 'DUPLICATE_VALUE_KEY',
1302
+ message: 'Two choices of “Extras” use the same internal key.',
1303
+ groupIndex: 1,
1304
+ groupKey: 'extras',
1305
+ valueKey: 'a',
1306
+ },
1307
+ {
1308
+ severity: 'error',
1309
+ code: 'PRICE_AMOUNT_INVALID',
1310
+ message: 'Prices on “Extras” must be between 0 and 1,000,000,000.',
1311
+ groupIndex: 1,
1312
+ groupKey: 'extras',
1313
+ valueKey: 'a',
1314
+ },
1315
+ {
1316
+ severity: 'error',
1317
+ code: 'MULTI_MIN_MAX',
1318
+ message: '“Extras”: the minimum number of picks is more than the maximum.',
1319
+ groupIndex: 1,
1320
+ groupKey: 'extras',
1321
+ valueKey: null,
1322
+ },
1323
+ {
1324
+ severity: 'error',
1325
+ code: 'NUMBER_MIN_MAX',
1326
+ message: '“Length”: the minimum is more than the maximum.',
1327
+ groupIndex: 2,
1328
+ groupKey: 'len',
1329
+ valueKey: null,
1330
+ },
1331
+ {
1332
+ severity: 'error',
1333
+ code: 'NUMBER_STEP',
1334
+ message: '“Length”: the step must be more than 0.',
1335
+ groupIndex: 2,
1336
+ groupKey: 'len',
1337
+ valueKey: null,
1338
+ },
1339
+ {
1340
+ severity: 'error',
1341
+ code: 'NUMBER_DEFAULT_RANGE',
1342
+ message: '“Length”: the starting value must be between the minimum and the maximum.',
1343
+ groupIndex: 2,
1344
+ groupKey: 'len',
1345
+ valueKey: null,
1346
+ },
1347
+ {
1348
+ severity: 'error',
1349
+ code: 'PRICE_PERCENT_RANGE',
1350
+ message: 'A percentage on “Length” must be between 0 and 1000.',
1351
+ groupIndex: 2,
1352
+ groupKey: 'len',
1353
+ valueKey: null,
1354
+ },
1355
+ {
1356
+ severity: 'error',
1357
+ code: 'TEXT_MAX_LENGTH',
1358
+ message: '“Note”: the character limit must be between 1 and 500.',
1359
+ groupIndex: 3,
1360
+ groupKey: 'note',
1361
+ valueKey: null,
1362
+ },
1363
+ {
1364
+ severity: 'error',
1365
+ code: 'VISIBILITY_SOURCE_TYPE',
1366
+ message:
1367
+ '“Note” can only be shown depending on a pick-one, pick-several or yes-or-no option.',
1368
+ groupIndex: 3,
1369
+ groupKey: 'note',
1370
+ valueKey: null,
1371
+ },
1372
+ {
1373
+ severity: 'error',
1374
+ code: 'DUPLICATE_GROUP_KEY',
1375
+ message: '“Again” uses the same internal key as an earlier option.',
1376
+ groupIndex: 4,
1377
+ groupKey: 'note',
1378
+ valueKey: null,
1379
+ },
1380
+ {
1381
+ severity: 'error',
1382
+ code: 'VISIBILITY_SOURCE_MISSING',
1383
+ message: '“Again” is shown depending on an option that no longer exists.',
1384
+ groupIndex: 4,
1385
+ groupKey: 'note',
1386
+ valueKey: null,
1387
+ },
1388
+ ],
1389
+ },
1390
+ {
1391
+ name: 'validate: a gift card may not carry option prices',
1392
+ call: 'validate',
1393
+ args: [PRINT_GROUPS, 'true', 'one_time'],
1394
+ expected: [
1395
+ {
1396
+ severity: 'error',
1397
+ code: 'GIFT_CARD_OPTIONS_PRICE',
1398
+ message:
1399
+ 'Gift cards can have options, but not prices on them. Set every option to no extra charge.',
1400
+ groupIndex: null,
1401
+ groupKey: null,
1402
+ valueKey: null,
1403
+ },
1404
+ ],
1405
+ },
1406
+ {
1407
+ name: 'validate: a subscription may not carry option prices',
1408
+ call: 'validate',
1409
+ args: [PRINT_GROUPS, false, 'recurring'],
1410
+ expected: [
1411
+ {
1412
+ severity: 'error',
1413
+ code: 'RECURRING_OPTIONS_PRICE',
1414
+ message:
1415
+ 'Subscription products can have options, but not prices on them. Set every option to no extra charge.',
1416
+ groupIndex: null,
1417
+ groupKey: null,
1418
+ valueKey: null,
1419
+ },
1420
+ ],
1421
+ },
1422
+ {
1423
+ name: 'validate: long texts that could overflow the cart are a warning',
1424
+ call: 'validate',
1425
+ args: [LONG_TEXT_GROUPS, false, ''],
1426
+ expected: [
1427
+ {
1428
+ severity: 'warning',
1429
+ code: 'CONFIGURATION_TOO_LONG',
1430
+ message:
1431
+ 'Filled in to their limits, these options could exceed 8000 characters and be refused at checkout. Use fewer options, or lower the character limits of the text options.',
1432
+ groupIndex: null,
1433
+ groupKey: null,
1434
+ valueKey: null,
1435
+ },
1436
+ ],
1437
+ },
1438
+ // ── configuration entries and keys ──
1439
+ {
1440
+ name: 'parse entries: string or array, first key wins, junk dropped',
1441
+ call: 'parseEntries',
1442
+ args: [
1443
+ '[{"key":"size","value":"a3"},{"key":"size","value":"a4"},{"value":"x"},{"key":"n","value":7},{"key":"f","value":{"id":"f1","name":"a.pdf","size":"12","url":"https://x.example.com/a.pdf","mimeType":"application/pdf"}},{"key":"bad","value":null}]',
1444
+ ],
1445
+ expected: [
1446
+ { key: 'size', value: 'a3' },
1447
+ { key: 'n', value: 7 },
1448
+ {
1449
+ key: 'f',
1450
+ value: [
1451
+ {
1452
+ id: 'f1',
1453
+ name: 'a.pdf',
1454
+ size: 12,
1455
+ url: 'https://x.example.com/a.pdf',
1456
+ mimeType: 'application/pdf',
1457
+ },
1458
+ ],
1459
+ },
1460
+ ],
1461
+ },
1462
+ {
1463
+ name: 'canonical: sorted keys, trimmed text, sorted picks, toggle off and blanks dropped',
1464
+ call: 'canonical',
1465
+ args: [
1466
+ [
1467
+ { key: 'z', value: ' hi ' },
1468
+ { key: 'm', value: ['b', 'a', 'b'] },
1469
+ { key: 'off', value: false },
1470
+ { key: 'blank', value: ' ' },
1471
+ { key: 'a', value: 3 },
1472
+ ],
1473
+ ],
1474
+ expected: '[{"key":"a","value":3},{"key":"m","value":["a","b"]},{"key":"z","value":"hi"}]',
1475
+ },
1476
+ {
1477
+ name: 'canonical for the key keeps only file ids',
1478
+ call: 'canonicalForKey',
1479
+ args: [
1480
+ [
1481
+ { key: 'your-photo', value: [PHOTO] },
1482
+ { key: 'size', value: 'a3' },
1483
+ ],
1484
+ ],
1485
+ expected: '[{"key":"size","value":"a3"},{"key":"your-photo","value":["a1_b2-c3.d4"]}]',
1486
+ },
1487
+ { name: 'key: nothing answered is no key', call: 'key', args: [[]], expected: '' },
1488
+ {
1489
+ name: 'key: the worked example',
1490
+ call: 'key',
1491
+ args: [PRINT_A3_GLOSSY],
1492
+ expected: 'e1dca133dbeaeda7',
1493
+ },
1494
+ // ── labels ──
1495
+ {
1496
+ name: 'label: one line, values clipped to 80',
1497
+ call: 'label',
1498
+ args: [
1499
+ [
1500
+ { label: 'Size', valueLabel: 'A3' },
1501
+ { label: 'Message', valueLabel: 'Line one\nline two ' + 'y'.repeat(100) },
1502
+ { label: '', valueLabel: 'Loose' },
1503
+ ],
1504
+ ],
1505
+ expected:
1506
+ 'Size: A3 · Message: Line one line two yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy… · Loose',
1507
+ },
1508
+ {
1509
+ name: 'details: full values, one line each',
1510
+ call: 'details',
1511
+ args: [
1512
+ [
1513
+ { label: 'Size', valueLabel: 'A3' },
1514
+ { label: 'Message', valueLabel: 'Line one\nline two' },
1515
+ ],
1516
+ ],
1517
+ expected: 'Size: A3\nMessage: Line one\nline two',
1518
+ },
1519
+ {
1520
+ name: 'price label: fixed, tax added, symbol first',
1521
+ call: 'priceLabel',
1522
+ args: [{ mode: 'fixed', amount: 4 }, '$', 'before', 19, ''],
1523
+ expected: '+$4.76',
1524
+ },
1525
+ {
1526
+ name: 'price label: symbol after',
1527
+ call: 'priceLabel',
1528
+ args: [{ mode: 'fixed', amount: 8 }, 'kr', 'after', 0, ''],
1529
+ expected: '+8.00 kr',
1530
+ },
1531
+ {
1532
+ name: 'price label: a percent reads as a percent',
1533
+ call: 'priceLabel',
1534
+ args: [{ mode: 'percent', percent: 40 }, '$', 'before', 19, ''],
1535
+ expected: '+40%',
1536
+ },
1537
+ {
1538
+ name: 'price label: per unit with a unit',
1539
+ call: 'priceLabel',
1540
+ args: [{ mode: 'per-unit', amount: 5 }, '€', 'before', 0, 'm'],
1541
+ expected: '+€5.00 / m',
1542
+ },
1543
+ {
1544
+ name: 'price label: per unit without a unit',
1545
+ call: 'priceLabel',
1546
+ args: [{ mode: 'per-unit', amount: 0.5 }, '$', 'before', 0, ''],
1547
+ expected: '+$0.50 each',
1548
+ },
1549
+ {
1550
+ name: 'price label: nothing added is empty',
1551
+ call: 'priceLabel',
1552
+ args: [{ mode: 'fixed', amount: 0 }, '$', 'before', 19, ''],
1553
+ expected: '',
1554
+ },
1555
+ // ── localize ──
1556
+ {
1557
+ name: 'localize: strings from the clone by key, structure and prices from main',
1558
+ call: 'localize',
1559
+ args: [
1560
+ PRINT_GROUPS,
1561
+ JSON.stringify([
1562
+ {
1563
+ key: 'paper',
1564
+ label: 'Papier',
1565
+ type: 'choice',
1566
+ required: false,
1567
+ values: [
1568
+ {
1569
+ key: 'glossy',
1570
+ label: 'Brillant',
1571
+ price: { mode: 'fixed', amount: 99 },
1572
+ description: 'Ignored: the main value has none',
1573
+ },
1574
+ { key: 'matte', label: '', price: NONE },
1575
+ ],
1576
+ },
1577
+ {
1578
+ key: 'size',
1579
+ label: 'Format',
1580
+ type: 'text',
1581
+ required: false,
1582
+ price: { mode: 'fixed', amount: 99 },
1583
+ },
1584
+ ]),
1585
+ ],
1586
+ expected: [
1587
+ {
1588
+ key: 'size',
1589
+ label: 'Format',
1590
+ type: 'choice',
1591
+ required: true,
1592
+ display: 'buttons',
1593
+ values: [
1594
+ { key: 'a5', label: 'A5', price: { mode: 'none' } },
1595
+ { key: 'a4', label: 'A4', price: { mode: 'fixed', amount: 4 } },
1596
+ { key: 'a3', label: 'A3', price: { mode: 'fixed', amount: 9 } },
1597
+ { key: 'a2', label: 'A2', price: { mode: 'fixed', amount: 15 } },
1598
+ ],
1599
+ },
1600
+ {
1601
+ key: 'paper',
1602
+ label: 'Papier',
1603
+ type: 'choice',
1604
+ required: true,
1605
+ display: 'cards',
1606
+ values: [
1607
+ { key: 'matte', label: 'Matte', price: { mode: 'none' } },
1608
+ {
1609
+ key: 'glossy',
1610
+ label: 'Brillant',
1611
+ price: { mode: 'fixed', amount: 2 },
1612
+ priceWhen: [
1613
+ { groupKey: 'size', valueKeys: ['a5'], price: { mode: 'fixed', amount: 3 } },
1614
+ { groupKey: 'size', valueKeys: ['a4'], price: { mode: 'fixed', amount: 5 } },
1615
+ { groupKey: 'size', valueKeys: ['a3'], price: { mode: 'fixed', amount: 8 } },
1616
+ ],
1617
+ },
1618
+ { key: 'fine-art', label: 'Fine-art', price: { mode: 'percent', percent: 40 } },
1619
+ ],
1620
+ },
1621
+ {
1622
+ key: 'your-photo',
1623
+ label: 'Your photo',
1624
+ type: 'upload',
1625
+ required: true,
1626
+ price: { mode: 'none' },
1627
+ accept: 'image',
1628
+ maxFiles: 1,
1629
+ },
1630
+ ],
1631
+ },
1632
+ {
1633
+ name: 'localize: a blank string in the clone shows the main text, also above translated choices',
1634
+ call: 'localize',
1635
+ args: [
1636
+ [
1637
+ {
1638
+ key: 'paper',
1639
+ label: 'Paper',
1640
+ type: 'choice',
1641
+ required: true,
1642
+ helpText: 'Pick a finish',
1643
+ values: [
1644
+ { key: 'matte', label: 'Matte', description: 'No glare', price: NONE },
1645
+ { key: 'glossy', label: 'Glossy', description: 'Shiny', price: NONE },
1646
+ ],
1647
+ },
1648
+ { key: 'note', label: 'Note', type: 'text', required: false, placeholder: 'For Ana' },
1649
+ ],
1650
+ JSON.stringify([
1651
+ {
1652
+ key: 'paper',
1653
+ label: '',
1654
+ type: 'choice',
1655
+ required: true,
1656
+ helpText: '',
1657
+ values: [
1658
+ { key: 'matte', label: '', description: 'Sans reflet', price: NONE },
1659
+ { key: 'glossy', label: 'Brillant', description: '', price: NONE },
1660
+ ],
1661
+ },
1662
+ { key: 'note', label: '', type: 'text', required: false, placeholder: '' },
1663
+ ]),
1664
+ ],
1665
+ expected: [
1666
+ {
1667
+ key: 'paper',
1668
+ label: 'Paper',
1669
+ type: 'choice',
1670
+ required: true,
1671
+ helpText: 'Pick a finish',
1672
+ display: 'buttons',
1673
+ values: [
1674
+ { key: 'matte', label: 'Matte', description: 'Sans reflet', price: { mode: 'none' } },
1675
+ { key: 'glossy', label: 'Brillant', description: 'Shiny', price: { mode: 'none' } },
1676
+ ],
1677
+ },
1678
+ {
1679
+ key: 'note',
1680
+ label: 'Note',
1681
+ type: 'text',
1682
+ required: false,
1683
+ placeholder: 'For Ana',
1684
+ price: { mode: 'none' },
1685
+ },
1686
+ ],
1687
+ },
1688
+ // ── order snapshots ──
1689
+ {
1690
+ name: 'snapshot v1: entries read back, unsafe files dropped',
1691
+ call: 'parseSnapshot',
1692
+ args: [
1693
+ JSON.stringify({
1694
+ v: 1,
1695
+ basePrice: 10,
1696
+ priceDelta: 17,
1697
+ entries: [
1698
+ {
1699
+ key: 'size',
1700
+ label: 'Size',
1701
+ type: 'choice',
1702
+ value: 'a3',
1703
+ valueLabel: 'A3',
1704
+ priceDelta: 9,
1705
+ },
1706
+ {
1707
+ key: 'your-photo',
1708
+ label: 'Your photo',
1709
+ type: 'upload',
1710
+ value: ['a1_b2-c3.d4', 'evil'],
1711
+ priceDelta: 0,
1712
+ files: [
1713
+ PHOTO,
1714
+ { id: 'evil', name: 'x', size: 1, url: 'javascript:alert(1)', mimeType: 'image/png' },
1715
+ ],
1716
+ },
1717
+ { label: 'No key' },
1718
+ ],
1719
+ }),
1720
+ ],
1721
+ expected: {
1722
+ version: 1,
1723
+ basePrice: 10,
1724
+ priceDelta: 17,
1725
+ entries: [
1726
+ {
1727
+ key: 'size',
1728
+ label: 'Size',
1729
+ type: 'choice',
1730
+ value: 'a3',
1731
+ valueLabel: 'A3',
1732
+ priceDelta: 9,
1733
+ files: [],
1734
+ },
1735
+ {
1736
+ key: 'your-photo',
1737
+ label: 'Your photo',
1738
+ type: 'upload',
1739
+ value: ['a1_b2-c3.d4', 'evil'],
1740
+ valueLabel: 'IMG_2231.jpg',
1741
+ priceDelta: 0,
1742
+ files: [
1743
+ {
1744
+ id: 'a1_b2-c3.d4',
1745
+ name: 'IMG_2231.jpg',
1746
+ size: 204800,
1747
+ url: 'https://files.example.com/uploads/a1_b2-c3.d4.jpg',
1748
+ mimeType: 'image/jpeg',
1749
+ },
1750
+ ],
1751
+ },
1752
+ ],
1753
+ },
1754
+ },
1755
+ {
1756
+ name: 'snapshot legacy: the compact canonical string',
1757
+ call: 'parseSnapshot',
1758
+ args: [
1759
+ '[{"key":"engraving","value":"For Ana"},{"key":"extras","value":["handle","lid"]},{"key":"wrap","value":true},{"key":"your-photo","value":[{"id":"f1","name":"p.png","size":5,"url":"http://insecure.example.com/p.png","mimeType":"image/png"}]}]',
1760
+ ],
1761
+ expected: {
1762
+ version: 0,
1763
+ basePrice: null,
1764
+ priceDelta: null,
1765
+ entries: [
1766
+ {
1767
+ key: 'engraving',
1768
+ label: 'engraving',
1769
+ type: '',
1770
+ value: 'For Ana',
1771
+ valueLabel: 'For Ana',
1772
+ priceDelta: null,
1773
+ files: [],
1774
+ },
1775
+ {
1776
+ key: 'extras',
1777
+ label: 'extras',
1778
+ type: '',
1779
+ value: ['handle', 'lid'],
1780
+ valueLabel: 'handle, lid',
1781
+ priceDelta: null,
1782
+ files: [],
1783
+ },
1784
+ {
1785
+ key: 'wrap',
1786
+ label: 'wrap',
1787
+ type: '',
1788
+ value: true,
1789
+ valueLabel: 'Yes',
1790
+ priceDelta: null,
1791
+ files: [],
1792
+ },
1793
+ {
1794
+ key: 'your-photo',
1795
+ label: 'your-photo',
1796
+ type: '',
1797
+ value: [],
1798
+ valueLabel: '',
1799
+ priceDelta: null,
1800
+ files: [],
1801
+ },
1802
+ ],
1803
+ },
1804
+ },
1805
+ { name: 'snapshot: nothing is null', call: 'parseSnapshot', args: [''], expected: null },
1806
+ { name: 'snapshot: not JSON is null', call: 'parseSnapshot', args: ['Size: A3'], expected: null },
1807
+ // ── uploaded files ──
1808
+ {
1809
+ name: 'file ref: a storage-worker https file is safe',
1810
+ call: 'isFileRef',
1811
+ args: [PHOTO],
1812
+ expected: true,
1813
+ },
1814
+ {
1815
+ name: 'file ref: http is refused',
1816
+ call: 'isFileRef',
1817
+ args: [{ ...PHOTO, url: 'http://files.example.com/a.jpg' }],
1818
+ expected: false,
1819
+ },
1820
+ {
1821
+ name: 'file ref: javascript: is refused',
1822
+ call: 'isFileRef',
1823
+ args: [{ ...PHOTO, url: 'javascript:alert(document.cookie)' }],
1824
+ expected: false,
1825
+ },
1826
+ {
1827
+ name: 'file ref: a data URL is refused',
1828
+ call: 'isFileRef',
1829
+ args: [{ ...PHOTO, url: 'data:image/png;base64,AAAA' }],
1830
+ expected: false,
1831
+ },
1832
+ {
1833
+ name: 'file ref: an id with a space is refused',
1834
+ call: 'isFileRef',
1835
+ args: [{ ...PHOTO, id: 'a b' }],
1836
+ expected: false,
1837
+ },
1838
+ {
1839
+ name: 'file ref: a negative size is refused',
1840
+ call: 'isFileRef',
1841
+ args: [{ ...PHOTO, size: -1 }],
1842
+ expected: false,
1843
+ },
1844
+ // ── keys and rounding ──
1845
+ {
1846
+ name: 'mint key: slug of the label',
1847
+ call: 'mintKey',
1848
+ args: ['Paper Type', [], 'option'],
1849
+ expected: 'paper-type',
1850
+ },
1851
+ {
1852
+ name: 'mint key: taken keys get a suffix',
1853
+ call: 'mintKey',
1854
+ args: ['Size', ['size', 'size-2'], 'option'],
1855
+ expected: 'size-3',
1856
+ },
1857
+ {
1858
+ name: 'mint key: no slug falls back',
1859
+ call: 'mintKey',
1860
+ args: ['★', ['value'], 'value'],
1861
+ expected: 'value-2',
1862
+ },
1863
+ {
1864
+ name: 'round2 snaps a float tail to the cent',
1865
+ call: 'round2',
1866
+ args: [4.199999999999999],
1867
+ expected: 4.2,
1868
+ },
1869
+ // ── limits: what can never be charged, answered or stored ──
1870
+ {
1871
+ name: 'pricesDisabled given as {pricesDisabled: true} charges nothing too',
1872
+ call: 'resolve',
1873
+ args: [
1874
+ 10,
1875
+ ENGRAVING_GROUPS,
1876
+ [
1877
+ { key: 'engrave', value: true },
1878
+ { key: 'engraving-text', value: 'Ana' },
1879
+ ],
1880
+ { pricesDisabled: true },
1881
+ ],
1882
+ expected: { delta: 0, valid: true },
1883
+ },
1884
+ {
1885
+ name: 'quick add: pricesDisabled given as {pricesDisabled: true}',
1886
+ call: 'quickAdd',
1887
+ args: [SURCHARGED_DEFAULT_GROUPS, 20, { pricesDisabled: true }],
1888
+ expected: true,
1889
+ },
1890
+ {
1891
+ name: 'validate: the product may be given as {isGiftCard, paymentType}',
1892
+ call: 'validate',
1893
+ args: [SURCHARGED_DEFAULT_GROUPS, { isGiftCard: false, paymentType: 'recurring' }, undefined],
1894
+ expected: [
1895
+ {
1896
+ severity: 'error',
1897
+ code: 'RECURRING_OPTIONS_PRICE',
1898
+ message:
1899
+ 'Subscription products can have options, but not prices on them. Set every option to no extra charge.',
1900
+ groupIndex: null,
1901
+ groupKey: null,
1902
+ valueKey: null,
1903
+ },
1904
+ ],
1905
+ },
1906
+ {
1907
+ name: 'price label: the options may be given as one object',
1908
+ call: 'priceLabel',
1909
+ args: [
1910
+ { mode: 'per-unit', amount: 5 },
1911
+ { symbol: '€', position: 'after', taxRate: 20, unit: 'm' },
1912
+ ],
1913
+ expected: '+6.00 € / m',
1914
+ },
1915
+ {
1916
+ name: 'an upload of a type the option does not accept is refused (never SVG)',
1917
+ call: 'resolve',
1918
+ args: [
1919
+ 10,
1920
+ PRINT_GROUPS,
1921
+ [
1922
+ { key: 'size', value: 'a5' },
1923
+ { key: 'paper', value: 'matte' },
1924
+ { key: 'your-photo', value: [{ ...PHOTO, mimeType: 'image/svg+xml' }] },
1925
+ ],
1926
+ false,
1927
+ ],
1928
+ expected: { valid: false, errors: ['bad-file:your-photo'], missingKeys: ['your-photo'] },
1929
+ },
1930
+ {
1931
+ name: 'an upload over 4 MB is refused, and a PDF on a photo option too',
1932
+ call: 'resolve',
1933
+ args: [
1934
+ 10,
1935
+ PRINT_GROUPS,
1936
+ [
1937
+ { key: 'size', value: 'a5' },
1938
+ { key: 'paper', value: 'matte' },
1939
+ {
1940
+ key: 'your-photo',
1941
+ value: [
1942
+ { ...PHOTO, size: 4194305 },
1943
+ { ...PHOTO, id: 'b2', mimeType: 'application/pdf' },
1944
+ ],
1945
+ },
1946
+ ],
1947
+ false,
1948
+ ],
1949
+ expected: { valid: false, errors: ['bad-file:your-photo'], missingKeys: ['your-photo'] },
1950
+ },
1951
+ {
1952
+ name: 'an upload of exactly 4 MB with an upper-case MIME type is accepted',
1953
+ call: 'resolve',
1954
+ args: [
1955
+ 10,
1956
+ PRINT_GROUPS,
1957
+ [
1958
+ { key: 'size', value: 'a5' },
1959
+ { key: 'paper', value: 'matte' },
1960
+ { key: 'your-photo', value: [{ ...PHOTO, size: 4194304, mimeType: 'IMAGE/JPEG' }] },
1961
+ ],
1962
+ false,
1963
+ ],
1964
+ expected: { delta: 0, valid: true, errors: [] },
1965
+ },
1966
+ {
1967
+ name: 'a huge per-unit quantity is out of range, never an infinite price',
1968
+ call: 'resolve',
1969
+ args: [
1970
+ 10,
1971
+ [
1972
+ {
1973
+ key: 'length',
1974
+ label: 'Length',
1975
+ type: 'number',
1976
+ required: true,
1977
+ min: 1,
1978
+ unit: 'm',
1979
+ price: { mode: 'per-unit', amount: 0.35 },
1980
+ },
1981
+ ],
1982
+ [{ key: 'length', value: '1e308' }],
1983
+ false,
1984
+ ],
1985
+ expected: { delta: 0, valid: false, errors: ['out-of-range:length'] },
1986
+ },
1987
+ {
1988
+ name: 'an amount too large to price is out of range',
1989
+ call: 'resolve',
1990
+ args: [
1991
+ 10,
1992
+ [
1993
+ {
1994
+ key: 'x',
1995
+ label: 'X',
1996
+ type: 'choice',
1997
+ required: true,
1998
+ values: [{ key: 'v', label: 'V', price: { mode: 'fixed', amount: 1e307 } }],
1999
+ },
2000
+ ],
2001
+ [{ key: 'x', value: 'v' }],
2002
+ false,
2003
+ ],
2004
+ expected: { delta: 0, valid: false, errors: ['out-of-range:x'] },
2005
+ },
2006
+ {
2007
+ name: 'a negative quantity priced per unit is out of range',
2008
+ call: 'resolve',
2009
+ args: [
2010
+ 10,
2011
+ [
2012
+ {
2013
+ key: 'length',
2014
+ label: 'Length',
2015
+ type: 'number',
2016
+ required: true,
2017
+ price: { mode: 'per-unit', amount: 5 },
2018
+ },
2019
+ ],
2020
+ [{ key: 'length', value: -3 }],
2021
+ false,
2022
+ ],
2023
+ expected: { delta: 0, valid: false, errors: ['out-of-range:length'] },
2024
+ },
2025
+ {
2026
+ name: 'a number between two steps is invalid, counted from the minimum',
2027
+ call: 'resolve',
2028
+ args: [
2029
+ 50,
2030
+ MACHINE_GROUPS,
2031
+ [
2032
+ { key: 'material', value: 'steel' },
2033
+ { key: 'length', value: 2.25 },
2034
+ ],
2035
+ false,
2036
+ ],
2037
+ expected: { valid: false, errors: ['off-step:length'] },
2038
+ },
2039
+ {
2040
+ name: 'a decimal step absorbs float error (0.3 is three steps of 0.1)',
2041
+ call: 'resolve',
2042
+ args: [
2043
+ 0,
2044
+ [
2045
+ {
2046
+ key: 'thickness',
2047
+ label: 'Thickness',
2048
+ type: 'number',
2049
+ required: true,
2050
+ min: 0,
2051
+ step: 0.1,
2052
+ price: NONE,
2053
+ },
2054
+ ],
2055
+ [{ key: 'thickness', value: 0.3 }],
2056
+ false,
2057
+ ],
2058
+ expected: { valid: true, errors: [] },
2059
+ },
2060
+ {
2061
+ name: 'an unanswered option keyed constructor is not set',
2062
+ call: 'resolve',
2063
+ args: [
2064
+ 10,
2065
+ [
2066
+ {
2067
+ key: 'constructor',
2068
+ label: 'Constructor',
2069
+ type: 'choice',
2070
+ required: false,
2071
+ values: [{ key: 'lego', label: 'Lego', price: NONE }],
2072
+ },
2073
+ {
2074
+ key: 'manual',
2075
+ label: 'Printed manual',
2076
+ type: 'toggle',
2077
+ required: false,
2078
+ price: NONE,
2079
+ visibleWhen: [{ groupKey: 'constructor', operator: 'is-set' }],
2080
+ },
2081
+ {
2082
+ key: 'hint',
2083
+ label: 'Hint',
2084
+ type: 'text',
2085
+ required: false,
2086
+ price: NONE,
2087
+ visibleWhen: [{ groupKey: 'constructor', operator: 'is-not-set' }],
2088
+ },
2089
+ ],
2090
+ [],
2091
+ false,
2092
+ ],
2093
+ expected: { visibleKeys: ['constructor', 'hint'], valid: true },
2094
+ },
2095
+ {
2096
+ name: 'validate: a pick-several whose maximum is 0 can never be answered',
2097
+ call: 'validate',
2098
+ args: [
2099
+ [
2100
+ {
2101
+ key: 'extras',
2102
+ label: 'Extras',
2103
+ type: 'multi-choice',
2104
+ required: true,
2105
+ maxSelected: 0,
2106
+ values: [{ key: 'case', label: 'Case', price: NONE }],
2107
+ },
2108
+ ],
2109
+ false,
2110
+ '',
2111
+ ],
2112
+ expected: [
2113
+ {
2114
+ severity: 'error',
2115
+ code: 'MULTI_MAX_TOO_LOW',
2116
+ message: '“Extras”: allow at least one pick, or leave the maximum empty for no limit.',
2117
+ groupIndex: 0,
2118
+ groupKey: 'extras',
2119
+ valueKey: null,
2120
+ },
2121
+ ],
2122
+ },
2123
+ {
2124
+ name: 'validate: a pick-several may not pre-select more choices than its maximum',
2125
+ call: 'validate',
2126
+ args: [
2127
+ [
2128
+ {
2129
+ key: 'extras',
2130
+ label: 'Extras',
2131
+ type: 'multi-choice',
2132
+ required: false,
2133
+ maxSelected: 1,
2134
+ values: [
2135
+ { key: 'case', label: 'Case', price: NONE, default: true },
2136
+ { key: 'strap', label: 'Strap', price: NONE, default: true },
2137
+ ],
2138
+ },
2139
+ {
2140
+ key: 'sauces',
2141
+ label: 'Sauces',
2142
+ type: 'multi-choice',
2143
+ required: false,
2144
+ maxSelected: 2,
2145
+ values: [
2146
+ { key: 'mild', label: 'Mild', price: NONE, default: true },
2147
+ { key: 'hot', label: 'Hot', price: NONE, default: true },
2148
+ { key: 'smoky', label: 'Smoky', price: NONE },
2149
+ ],
2150
+ },
2151
+ {
2152
+ key: 'toppings',
2153
+ label: 'Toppings',
2154
+ type: 'multi-choice',
2155
+ required: false,
2156
+ values: [
2157
+ { key: 'nuts', label: 'Nuts', price: NONE, default: true },
2158
+ { key: 'seeds', label: 'Seeds', price: NONE, default: true },
2159
+ ],
2160
+ },
2161
+ ],
2162
+ false,
2163
+ '',
2164
+ ],
2165
+ expected: [
2166
+ {
2167
+ severity: 'error',
2168
+ code: 'MULTI_DEFAULTS_TOO_MANY',
2169
+ message:
2170
+ '“Extras” pre-selects more choices than shoppers may pick. Pre-select fewer, or raise the maximum.',
2171
+ groupIndex: 0,
2172
+ groupKey: 'extras',
2173
+ valueKey: null,
2174
+ },
2175
+ ],
2176
+ },
2177
+ {
2178
+ name: 'validate: two choices of one option may not share a name, in any case or spacing',
2179
+ call: 'validate',
2180
+ args: [
2181
+ [
2182
+ {
2183
+ key: 'size',
2184
+ label: 'Size',
2185
+ type: 'choice',
2186
+ required: true,
2187
+ values: [
2188
+ { key: 'small', label: 'Small', price: NONE },
2189
+ { key: 'medium', label: ' SMALL ', price: NONE },
2190
+ { key: 'large', label: 'small', price: NONE },
2191
+ ],
2192
+ },
2193
+ {
2194
+ key: 'fit',
2195
+ label: 'Fit',
2196
+ type: 'multi-choice',
2197
+ required: false,
2198
+ values: [{ key: 'small', label: 'Small', price: NONE }],
2199
+ },
2200
+ ],
2201
+ false,
2202
+ '',
2203
+ ],
2204
+ expected: [
2205
+ {
2206
+ severity: 'error',
2207
+ code: 'VALUE_LABEL_DUPLICATE',
2208
+ message: 'Two choices of “Size” have the same name.',
2209
+ groupIndex: 0,
2210
+ groupKey: 'size',
2211
+ valueKey: 'medium',
2212
+ },
2213
+ ],
2214
+ },
2215
+ {
2216
+ name: 'validate: an amount over 1,000,000,000 and a starting value between steps',
2217
+ call: 'validate',
2218
+ args: [
2219
+ [
2220
+ {
2221
+ key: 'len',
2222
+ label: 'Length',
2223
+ type: 'number',
2224
+ required: true,
2225
+ min: 0.5,
2226
+ step: 1,
2227
+ defaultValue: 1,
2228
+ price: { mode: 'per-unit', amount: 1000000001 },
2229
+ },
2230
+ ],
2231
+ false,
2232
+ '',
2233
+ ],
2234
+ expected: [
2235
+ {
2236
+ severity: 'error',
2237
+ code: 'NUMBER_DEFAULT_STEP',
2238
+ message: '“Length”: the starting value must be a whole number of steps from the minimum.',
2239
+ groupIndex: 0,
2240
+ groupKey: 'len',
2241
+ valueKey: null,
2242
+ },
2243
+ {
2244
+ severity: 'error',
2245
+ code: 'PRICE_AMOUNT_INVALID',
2246
+ message: 'Prices on “Length” must be between 0 and 1,000,000,000.',
2247
+ groupIndex: 0,
2248
+ groupKey: 'len',
2249
+ valueKey: null,
2250
+ },
2251
+ ],
2252
+ },
2253
+ {
2254
+ name: 'validate: two long texts at 2000 characters could overflow once escaped',
2255
+ call: 'validate',
2256
+ args: [LONG_TEXT_GROUPS.slice(0, 2), false, ''],
2257
+ expected: [
2258
+ {
2259
+ severity: 'warning',
2260
+ code: 'CONFIGURATION_TOO_LONG',
2261
+ message:
2262
+ 'Filled in to their limits, these options could exceed 8000 characters and be refused at checkout. Use fewer options, or lower the character limits of the text options.',
2263
+ groupIndex: null,
2264
+ groupKey: null,
2265
+ valueKey: null,
2266
+ },
2267
+ ],
2268
+ },
2269
+ {
2270
+ name: 'parse: type synonyms, bare prices, a maximum of 0, capped amounts, clipped labels',
2271
+ call: 'parseGroups',
2272
+ args: [
2273
+ [
2274
+ { label: 'Gift wrap', type: 'checkbox', price: '2.5' },
2275
+ {
2276
+ label: 'Extras',
2277
+ type: 'Multi Choice',
2278
+ maxSelected: 0,
2279
+ values: [{ label: 'Case', price: 2000000000 }],
2280
+ },
2281
+ { label: 'Artwork', type: 'file' },
2282
+ { label: 'Finish', type: 'dropdown', values: [{ label: 'Gold' }] },
2283
+ { label: 'a'.repeat(119) + ' tail', type: 'bogus' },
2284
+ { label: 'b'.repeat(119) + String.fromCharCode(0xd83d, 0xde00), type: 'toggle' },
2285
+ ],
2286
+ ],
2287
+ expected: [
2288
+ {
2289
+ key: 'gift-wrap',
2290
+ label: 'Gift wrap',
2291
+ type: 'toggle',
2292
+ required: false,
2293
+ price: { mode: 'fixed', amount: 2.5 },
2294
+ },
2295
+ {
2296
+ key: 'extras',
2297
+ label: 'Extras',
2298
+ type: 'multi-choice',
2299
+ required: false,
2300
+ display: 'buttons',
2301
+ values: [{ key: 'case', label: 'Case', price: { mode: 'fixed', amount: 1000000000 } }],
2302
+ },
2303
+ {
2304
+ key: 'artwork',
2305
+ label: 'Artwork',
2306
+ type: 'upload',
2307
+ required: false,
2308
+ price: NONE,
2309
+ accept: 'any',
2310
+ maxFiles: 1,
2311
+ },
2312
+ {
2313
+ key: 'finish',
2314
+ label: 'Finish',
2315
+ type: 'choice',
2316
+ required: false,
2317
+ display: 'buttons',
2318
+ values: [{ key: 'gold', label: 'Gold', price: NONE }],
2319
+ },
2320
+ { key: 'a'.repeat(119), label: 'a'.repeat(119), type: 'text', required: false, price: NONE },
2321
+ {
2322
+ key: 'b'.repeat(119),
2323
+ label: 'b'.repeat(119),
2324
+ type: 'toggle',
2325
+ required: false,
2326
+ price: NONE,
2327
+ },
2328
+ ],
2329
+ },
2330
+ {
2331
+ name: 'label: line separators collapse, a clip never splits an emoji, non-text reads as text',
2332
+ call: 'label',
2333
+ args: [
2334
+ [
2335
+ {
2336
+ label: 'Note',
2337
+ valueLabel: 'a' + String.fromCharCode(0x2028) + 'b' + String.fromCharCode(0x85) + 'c',
2338
+ },
2339
+ { label: 'Emoji', valueLabel: 'x'.repeat(78) + String.fromCharCode(0xd83d, 0xde00) + 'y' },
2340
+ { label: ['not', 'text'], valueLabel: 7 },
2341
+ ],
2342
+ ],
2343
+ expected: 'Note: a b c · Emoji: ' + 'x'.repeat(78) + '… · 7',
2344
+ },
2345
+ {
2346
+ name: 'details: labels that are not text never throw',
2347
+ call: 'details',
2348
+ args: [
2349
+ [
2350
+ { label: { toString: 5 }, valueLabel: 'A3' },
2351
+ { label: 'Size', valueLabel: ['x'] },
2352
+ ],
2353
+ ],
2354
+ expected: 'A3\nSize: ',
2355
+ },
2356
+ {
2357
+ name: 'effective variant: the index of the matching override',
2358
+ call: 'effectiveVariant',
2359
+ args: [GLOSSY, { size: 'a4' }, ['size']],
2360
+ expected: 1,
2361
+ },
2362
+ {
2363
+ name: 'effective variant: no match is the default d',
2364
+ call: 'effectiveVariant',
2365
+ args: [GLOSSY, { size: 'a2' }, ['size']],
2366
+ expected: 'd',
2367
+ },
2368
+ {
2369
+ name: 'effective variant: a hidden source is the default d',
2370
+ call: 'effectiveVariant',
2371
+ args: [GLOSSY, { size: 'a3' }, []],
2372
+ expected: 'd',
2373
+ },
2374
+ {
2375
+ name: 'price variant key: a value',
2376
+ call: 'priceVariantKey',
2377
+ args: ['p1', 'paper', 'glossy', 2],
2378
+ expected: 'pv:p1|paper|glossy|2',
2379
+ },
2380
+ {
2381
+ name: 'price variant key: a priced option at its default',
2382
+ call: 'priceVariantKey',
2383
+ args: ['p1', 'engraving-text', null, 'd'],
2384
+ expected: 'pv:p1|engraving-text|d',
2385
+ },
2386
+ { name: 'is image: an image MIME type', call: 'isImage', args: [PHOTO], expected: true },
2387
+ {
2388
+ name: 'is image: a PDF is a link',
2389
+ call: 'isImage',
2390
+ args: [{ ...PHOTO, mimeType: 'application/pdf' }],
2391
+ expected: false,
2392
+ },
2393
+ { name: 'is image: junk is not an image', call: 'isImage', args: [null], expected: false },
2394
+ {
2395
+ name: 'file ref: an id of dots only is refused',
2396
+ call: 'isFileRef',
2397
+ args: [{ ...PHOTO, id: '..' }],
2398
+ expected: false,
2399
+ },
2400
+ {
2401
+ name: 'mint key: a missing taken list and a blank fallback read as empty and option',
2402
+ call: 'mintKey',
2403
+ args: ['', null, ''],
2404
+ expected: 'option',
2405
+ },
2406
+ ]
2407
+ // ── END SHARED PARITY TABLE ─────────────────────────────────────────────────
2408
+
2409
+ const SUBSET_CALLS: ProductOptionsCall[] = ['resolve', 'resolveForRow']
2410
+
2411
+ /** A `resolve` row states only some fields; every other row states the whole result. */
2412
+ const comparable = (call: ProductOptionsCall, result: unknown, expected: unknown): unknown => {
2413
+ if (SUBSET_CALLS.indexOf(call) === -1 || !result || typeof result !== 'object') {
2414
+ return result
2415
+ }
2416
+ const picked: Record<string, unknown> = {}
2417
+ Object.keys(expected as Record<string, unknown>).forEach((field) => {
2418
+ picked[field] = (result as Record<string, unknown>)[field]
2419
+ })
2420
+ return picked
2421
+ }
2422
+
2423
+ describe('product options — storefront copy against the shared parity table', () => {
2424
+ const api = loadApi()
2425
+
2426
+ it.each(PRODUCT_OPTIONS_FIXTURES.map((fixture) => [`${fixture.call}: ${fixture.name}`, fixture]))(
2427
+ '%s',
2428
+ (_name, fixture) => {
2429
+ const row = fixture as ProductOptionsFixture
2430
+ const result = api[row.call](...row.args)
2431
+ expect(comparable(row.call, result, row.expected)).toEqual(row.expected)
2432
+ }
2433
+ )
2434
+
2435
+ it('is one sentinel-wrapped block that follows the splice rules', () => {
2436
+ const source = generateProductOptionsHelperCode()
2437
+ const lines = source.split('\n')
2438
+ expect(lines[0]).toBe(PRODUCT_OPTIONS_HELPERS_OPENING)
2439
+ expect(lines[lines.length - 1]).toBe(PRODUCT_OPTIONS_HELPERS_CLOSING)
2440
+ expect(source).not.toContain('`')
2441
+ expect(source).not.toContain('${')
2442
+ expect(source).not.toContain('params.')
2443
+ expect(source).not.toContain('\\')
2444
+
2445
+ // The handler-entry resolver picks the first declared two-parameter function.
2446
+ const declared = /function\s+([A-Za-z0-9_$]+)\s*\(([^)]*)\)/g
2447
+ let match = declared.exec(source)
2448
+ let count = 0
2449
+ while (match !== null) {
2450
+ const arity = match[2].trim() === '' ? 0 : match[2].split(',').length
2451
+ expect(match[1].indexOf('__po')).toBe(0)
2452
+ expect(arity === 1 || arity >= 3).toBe(true)
2453
+ count += 1
2454
+ match = declared.exec(source)
2455
+ }
2456
+ expect(count).toBeGreaterThan(20)
2457
+ })
2458
+ })