@teleporthq/teleport-plugin-next-workflows 0.43.38 → 0.43.40

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 (28) hide show
  1. package/__tests__/ecommerce-customhandler-rewriter.test.ts +65 -32
  2. package/__tests__/payment-charge-user-handler-serialization.test.ts +94 -0
  3. package/__tests__/resolve-handler-entry-name.test.ts +130 -0
  4. package/__tests__/stock-decrement-audit.test.ts +19 -0
  5. package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
  6. package/dist/cjs/ecommerce/stock-decrement.js +45 -36
  7. package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
  8. package/dist/cjs/nodes/payment/payment-charge-user.d.ts.map +1 -1
  9. package/dist/cjs/nodes/payment/payment-charge-user.js +40 -32
  10. package/dist/cjs/nodes/payment/payment-charge-user.js.map +1 -1
  11. package/dist/cjs/nodes/types.d.ts.map +1 -1
  12. package/dist/cjs/nodes/types.js +51 -7
  13. package/dist/cjs/nodes/types.js.map +1 -1
  14. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  15. package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
  16. package/dist/esm/ecommerce/stock-decrement.js +45 -36
  17. package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
  18. package/dist/esm/nodes/payment/payment-charge-user.d.ts.map +1 -1
  19. package/dist/esm/nodes/payment/payment-charge-user.js +40 -32
  20. package/dist/esm/nodes/payment/payment-charge-user.js.map +1 -1
  21. package/dist/esm/nodes/types.d.ts.map +1 -1
  22. package/dist/esm/nodes/types.js +51 -7
  23. package/dist/esm/nodes/types.js.map +1 -1
  24. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +2 -2
  26. package/src/ecommerce/stock-decrement.ts +51 -36
  27. package/src/nodes/payment/payment-charge-user.ts +40 -34
  28. package/src/nodes/types.ts +50 -7
@@ -1,4 +1,5 @@
1
1
  import { rewriteLowStockCustomHandlers, __testables } from '../src/ecommerce-customhandler-rewriter'
2
+ import { STOCK_DECREMENT_MARKER } from '../src/ecommerce/stock-decrement'
2
3
 
3
4
  const {
4
5
  looksLikeLowStockSelectBuilder,
@@ -508,6 +509,22 @@ describe('looksLikeStockDecrementBuilder — pattern detection', () => {
508
509
  const rewritten = buildStockDecrementBuilder(false)
509
510
  expect(looksLikeStockDecrementBuilder(rewritten)).toBe(false)
510
511
  })
512
+
513
+ it('self-heals a legacy rewrite that permanently zeroes NULL (unlimited) stock', () => {
514
+ // A project generated before the NULL-preserving fix already carries
515
+ // the marker, so the marker check alone can't distinguish "already
516
+ // correct" from "already rewritten but still buggy". Detect the old
517
+ // `GREATEST(0, COALESCE(quantity, 0) - …)` shape specifically so the
518
+ // NEXT regeneration upgrades it to the NULL-preserving SQL instead of
519
+ // leaving an existing project's unlimited-stock products stuck at 0
520
+ // forever.
521
+ const legacyRewrite = `${STOCK_DECREMENT_MARKER}
522
+ function customHandler() {
523
+ var setClause = "quantity = GREATEST(0, COALESCE(quantity, 0) - CASE id" + caseExpr + " ELSE 0 END)";
524
+ return { query: "UPDATE teleport_products SET " + setClause, affected: ['a'] };
525
+ }`
526
+ expect(looksLikeStockDecrementBuilder(legacyRewrite)).toBe(true)
527
+ })
511
528
  })
512
529
 
513
530
  describe('buildStockDecrementBuilder — semantic behaviour', () => {
@@ -528,16 +545,19 @@ describe('buildStockDecrementBuilder — semantic behaviour', () => {
528
545
  },
529
546
  ])
530
547
  // Two semantic guards in the SET clause:
531
- // * \`COALESCE(quantity, 0)\` bootstraps NULL-quantity rows from
532
- // 0 so freshly-seeded products in a new project decrement on
533
- // first order (instead of being silently skipped by the
534
- // legacy \`WHERE quantity IS NOT NULL\` filter).
535
- // * \`GREATEST(0, …)\` floors the post-decrement value at 0 so
536
- // we never persist a negative stock value — that would be a
537
- // confusing signal in the admin panel ("we sold more than we
538
- // have?"). Once a row sits at exactly 0 the cart-availability
539
- // pre-flight refuses further orders against it.
540
- expect(r.query).toContain('SET quantity = GREATEST(0, COALESCE(quantity, 0) - CASE id')
548
+ // * \`CASE WHEN quantity IS NULL THEN NULL …\` leaves an
549
+ // unlimited-stock product (quantity never set by the
550
+ // merchant) untouched forever an order must never turn
551
+ // "infinite stock" into a finite, trackable 0.
552
+ // * \`GREATEST(0, …)\` floors a TRACKED row's post-decrement
553
+ // value at 0 so we never persist a negative stock value —
554
+ // that would be a confusing signal in the admin panel ("we
555
+ // sold more than we have?"). Once a row sits at exactly 0
556
+ // the cart-availability pre-flight refuses further orders
557
+ // against it.
558
+ expect(r.query).toContain(
559
+ 'SET quantity = CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity - (CASE id'
560
+ )
541
561
  expect(r.query).toContain("WHEN 'a' THEN 2")
542
562
  expect(r.query).toContain("WHEN 'b' THEN 3")
543
563
  expect(r.query).toContain('ELSE 0')
@@ -589,14 +609,16 @@ describe('buildStockDecrementBuilder — semantic behaviour', () => {
589
609
  const r = fn({}, [{ items: [{ productId: 'a', quantity: 2 }] }])
590
610
  expect(r.query).toContain('RETURNING id')
591
611
  expect(r.query).toContain('quantity AS new_quantity')
592
- // \`old_quantity\` reconstructs an approximate pre-SET value. With
593
- // the GREATEST(0, …) clamp in SET we can't perfectly invert the
594
- // arithmetic (a row that decremented from 1 0 with delta 2 also
595
- // would have shown 0 with delta 1), but for the downstream
612
+ // \`old_quantity\` reconstructs an approximate pre-SET value. A row
613
+ // whose (post-update) \`new_quantity\` is NULL was NULL before too
614
+ // (the SET clause never touches a NULL row); for a tracked row,
615
+ // the GREATEST(0, …) clamp in SET means we can't perfectly invert
616
+ // the arithmetic (a row that decremented from 1 → 0 with delta 2
617
+ // also would have shown 0 with delta 1), but for the downstream
596
618
  // low-stock SELECT only \`new_quantity\` matters, so a conservative
597
619
  // floored approximation is fine.
598
620
  expect(r.query).toMatch(
599
- /GREATEST\(0,\s*COALESCE\(quantity, 0\)\s*\+\s*\(CASE id[\s\S]*?\)\)\s*AS old_quantity/
621
+ /\(CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST\(0,\s*quantity\s*\+\s*\(CASE id[\s\S]*?\)\)\s*END\)\s*AS old_quantity/
600
622
  )
601
623
  })
602
624
 
@@ -629,22 +651,30 @@ describe('buildStockDecrementBuilder — semantic behaviour', () => {
629
651
  expect(/(?<!')';/.test(r.query)).toBe(false)
630
652
  })
631
653
 
632
- it('tolerates NULL quantity rows so freshly-seeded products still decrement', () => {
654
+ it('lets orders proceed against NULL-quantity (unlimited stock) rows WITHOUT ever making them finite', () => {
633
655
  // A new project's products table is seeded with NULL quantity
634
- // everywhere; the cart-availability pre-flight lets those orders
635
- // through as "unlimited", so the UPDATE MUST decrement them too
636
- // otherwise the buyer sees the order placed and the merchant
637
- // sees stock standing still. The "quantity IS NULL OR quantity
638
- // >= delta" guard lets NULL rows through; the SET-clause's
639
- // \`GREATEST(0, COALESCE(quantity, 0) - delta)\` arithmetic
640
- // bootstraps the row from 0 so subsequent reads see an integer,
641
- // floored at 0 (never negative).
656
+ // everywhere, and the cart-availability pre-flight treats NULL as
657
+ // "unlimited stock" so those orders reach this UPDATE. The
658
+ // WHERE guard's "quantity IS NULL OR …" lets the row through, but
659
+ // the SET clause's "CASE WHEN quantity IS NULL THEN NULL …" must
660
+ // leave the value untouched: an unlimited product decrementing on
661
+ // its first order would otherwise floor to 0 and become
662
+ // permanently out-of-stock, even though the merchant never set a
663
+ // finite count. Only rows that already carry a real, finite
664
+ // quantity are actually decremented.
642
665
  const r = fn({}, [{ items: [{ productId: 'a', quantity: 1 }] }])
643
666
  expect(r.query).toContain('quantity IS NULL OR quantity >=')
644
- expect(r.query).toContain('GREATEST(0, COALESCE(quantity, 0) - CASE id')
667
+ expect(r.query).toContain(
668
+ 'CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity - (CASE id'
669
+ )
645
670
  // The legacy "quantity IS NOT NULL" filter that used to skip NULL
646
- // rows must be gone — otherwise the bootstrap can't happen.
671
+ // rows entirely must stay gone — the row still needs to be
672
+ // touched (so its RETURNING row is available to callers), just
673
+ // without changing its value.
647
674
  expect(r.query).not.toContain('quantity IS NOT NULL')
675
+ // The old (buggy) shape unconditionally bootstrapped a NULL row
676
+ // to 0 via COALESCE — that must be gone entirely now.
677
+ expect(r.query).not.toContain('COALESCE(quantity, 0)')
648
678
  })
649
679
 
650
680
  it('accepts a bare cart-items array (legacy callers pass [items] directly)', () => {
@@ -664,13 +694,16 @@ describe('buildStockDecrementBuilder — semantic behaviour', () => {
664
694
  const r = fn({}, [{ items: [{ productId: 'a', quantity: 3 }] }])
665
695
  expect(r.query).not.toContain('quantity >=')
666
696
  // The legacy "quantity IS NOT NULL" filter has been removed; the
667
- // SET clause now uses GREATEST(0, COALESCE(...)) so NULL-quantity
668
- // rows bootstrap from 0 (never negative) instead of being silently
669
- // skipped. Even with backorders allowed we floor at 0 — backorders
670
- // semantically mean "let the order through even if stock is short",
671
- // not "let the stock counter go negative".
697
+ // SET clause now uses "CASE WHEN quantity IS NULL THEN NULL …" so
698
+ // NULL-quantity (unlimited-stock) rows stay untouched even with
699
+ // backorders allowed, an unlimited product is never turned into a
700
+ // finite, trackable one. Tracked rows still floor at 0
701
+ // backorders semantically mean "let the order through even if
702
+ // stock is short", not "let the stock counter go negative".
672
703
  expect(r.query).not.toContain('quantity IS NOT NULL')
673
- expect(r.query).toContain('GREATEST(0, COALESCE(quantity, 0) - CASE id')
704
+ expect(r.query).toContain(
705
+ 'CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity - (CASE id'
706
+ )
674
707
  })
675
708
 
676
709
  it('still uses RETURNING so the low-stock SELECT sees what changed', () => {
@@ -0,0 +1,94 @@
1
+ import { paymentChargeUser } from '../src/nodes/payment/payment-charge-user'
2
+
3
+ // Regression coverage for a production bug: clicking "Pay Now" on a
4
+ // Stripe checkout failed with
5
+ // { "success": false, "error": "Oo is not defined", "provider": "stripe" }
6
+ //
7
+ // Root cause: `generateHandler()` builds the final server-route handler by
8
+ // calling `.toString()` on several TypeScript functions independently and
9
+ // concatenating the source text (see `../src/nodes/types.ts`'s
10
+ // `handlerToString` docs). Two currency-code arrays
11
+ // (STRIPE_ZERO_DECIMAL_CURRENCIES / STRIPE_THREE_DECIMAL_CURRENCIES) used
12
+ // to be declared as top-level consts and referenced from inside
13
+ // `toStripeMinorUnits`. That's fine in an unminified build, but this
14
+ // package is itself bundled + minified by consumers (teleport-gui's
15
+ // browser packer, via webpack/Terser) before `generateHandler()` ever
16
+ // runs — the minifier is free to rename an unused-by-name top-level
17
+ // const (nothing calls it by string, only a runtime `.toString()` read
18
+ // does, which the minifier can't see), and it renamed the declaration
19
+ // away from what the reconstructed `var STRIPE_ZERO_DECIMAL_CURRENCIES =
20
+ // [...]` text in `generateHandler()` assumed. The result: the generated
21
+ // workflow segment file declared the array under the ORIGINAL name but
22
+ // `toStripeMinorUnits`'s serialized body referenced the MINIFIER-RENAMED
23
+ // name (e.g. a mangled `Oo`), which was never declared anywhere in the
24
+ // generated file — a `ReferenceError` the first time a Stripe charge ran
25
+ // with a zero- or three-decimal currency.
26
+ //
27
+ // The fix moves both arrays to be declared LOCAL to `toStripeMinorUnits`
28
+ // so the declaration and every reference to it live in the SAME
29
+ // `.toString()` snapshot — a consistent rename can never separate them.
30
+ describe('payment-charge-user handler serialization survives minification-style renaming', () => {
31
+ const handlerSource = paymentChargeUser.generateHandler()
32
+
33
+ it('does not reference the old top-level currency-array identifiers anywhere', () => {
34
+ // These names no longer exist ANYWHERE in the source — not as a
35
+ // declaration, not as a reference. If a future edit reintroduces a
36
+ // top-level const referenced only from inside a separately
37
+ // `.toString()`'d function, this is the shape of bug that comes back.
38
+ expect(handlerSource).not.toContain('STRIPE_ZERO_DECIMAL_CURRENCIES')
39
+ expect(handlerSource).not.toContain('STRIPE_THREE_DECIMAL_CURRENCIES')
40
+ })
41
+
42
+ it('declares the currency arrays INSIDE toStripeMinorUnits, not reconstructed separately', () => {
43
+ const fnStart = handlerSource.indexOf('function toStripeMinorUnits')
44
+ expect(fnStart).toBeGreaterThan(-1)
45
+ // The next function declaration marks the end of toStripeMinorUnits's
46
+ // body (functions are concatenated back-to-back by generateHandler()).
47
+ const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
48
+ expect(nextFnStart).toBeGreaterThan(fnStart)
49
+ const body = handlerSource.slice(fnStart, nextFnStart)
50
+ expect(body).toContain('JPY')
51
+ expect(body).toContain('BHD')
52
+ })
53
+
54
+ it('produces syntactically valid, runnable JavaScript end-to-end', () => {
55
+ // Evaluating the FULL concatenated handler source (not just one
56
+ // function in isolation) is what actually catches an orphaned
57
+ // free-variable reference — exactly like requiring the generated
58
+ // workflow segment .js file would in production.
59
+ expect(() => new Function(handlerSource + '\nreturn payment_charge_user;')()).not.toThrow()
60
+ })
61
+
62
+ describe('toStripeMinorUnits — extracted from the serialized handler source', () => {
63
+ const toStripeMinorUnits = (() => {
64
+ const fnStart = handlerSource.indexOf('function toStripeMinorUnits')
65
+ const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
66
+ const body = handlerSource.slice(fnStart, nextFnStart)
67
+ return new Function(body + '\nreturn toStripeMinorUnits;')() as (
68
+ major: unknown,
69
+ currency: string
70
+ ) => number
71
+ })()
72
+
73
+ it('converts a standard (2-decimal) currency to minor units', () => {
74
+ expect(toStripeMinorUnits(19.99, 'usd')).toBe(1999)
75
+ })
76
+
77
+ it('passes zero-decimal currencies through rounded, unmultiplied', () => {
78
+ // This is the exact code path that threw "Oo is not defined" in
79
+ // production — a JPY (zero-decimal) charge.
80
+ expect(toStripeMinorUnits(500, 'JPY')).toBe(500)
81
+ expect(toStripeMinorUnits(500.6, 'jpy')).toBe(501)
82
+ })
83
+
84
+ it('multiplies three-decimal currencies by 1000', () => {
85
+ expect(toStripeMinorUnits(1.5, 'BHD')).toBe(1500)
86
+ })
87
+
88
+ it('returns 0 for non-finite or non-positive amounts', () => {
89
+ expect(toStripeMinorUnits(0, 'usd')).toBe(0)
90
+ expect(toStripeMinorUnits(-5, 'usd')).toBe(0)
91
+ expect(toStripeMinorUnits(NaN, 'usd')).toBe(0)
92
+ })
93
+ })
94
+ })
@@ -0,0 +1,130 @@
1
+ import { resolveHandlerEntryName } from '../src/nodes/types'
2
+ import { nodeRegistry } from '../src/nodes'
3
+
4
+ /**
5
+ * Regression coverage for resolveHandlerEntryName, which exists because a
6
+ * handler's declared name at runtime can diverge from the
7
+ * `nodeType.replace(/-/g, '_')` convention name: when this package is bundled
8
+ * and minified by a consumer (e.g. teleport-gui's browser packer worker), the
9
+ * minifier freely renames a `handlerToString(fn)`-embedded function's
10
+ * declaration, since nothing in the bundle calls it by name — only the
11
+ * runtime `.toString()` read does, which is invisible to the minifier.
12
+ *
13
+ * Two real handlers exposed shapes the resolver initially got wrong:
14
+ * - payment-charge-user.ts: entry declared FIRST, its own helpers after
15
+ * (`handlerToString(payment_charge_user) + '\n' + chargeWithStripe.toString() + ...`).
16
+ * - ai-custom-prompt.ts: shared AI-provider utils declared BEFORE the
17
+ * entry, each wrapped in a `wrapWithGuard`-style re-declaration guard
18
+ * (`var X = typeof X !== 'undefined' ? X : function Y() {...};` — a
19
+ * function EXPRESSION embedded in a ternary, not a statement-level
20
+ * declaration) — this crashed a real publish with "Could not resolve the
21
+ * entry function for workflow node type "ai-custom-prompt"".
22
+ * A naive "first" or "last" positional heuristic breaks one of these two.
23
+ */
24
+
25
+ function evalEntryWrapper(source: string, entryName: string): unknown {
26
+ return new Function(`${source}\nreturn ${entryName};`)()
27
+ }
28
+
29
+ describe('resolveHandlerEntryName', () => {
30
+ it('returns the convention name when the source declares it directly', () => {
31
+ const source = `async function my_node_type(config, context) { return {}; }`
32
+ expect(resolveHandlerEntryName(source, 'my-node-type')).toBe('my_node_type')
33
+ })
34
+
35
+ it('falls back to the actual declared name when the entry was renamed (single function)', () => {
36
+ const source = `async function e(config, context) { return {}; }`
37
+ expect(resolveHandlerEntryName(source, 'my-node-type')).toBe('e')
38
+ })
39
+
40
+ it('finds a renamed entry declared FIRST, before its own helpers (payment-charge-user shape)', () => {
41
+ const source = `
42
+ async function zzz(config, context) {
43
+ return helper_one(config) + helper_two(context);
44
+ }
45
+ async function helper_one(config) { return config.x; }
46
+ async function helper_two(context) { return context.y; }
47
+ `
48
+ const resolved = resolveHandlerEntryName(source, 'payment-charge-user')
49
+ expect(resolved).toBe('zzz')
50
+ expect(typeof evalEntryWrapper(source, resolved)).toBe('function')
51
+ })
52
+
53
+ it('finds a renamed entry declared LAST, after ternary-guarded shared utils (ai-custom-prompt shape)', () => {
54
+ const source = `
55
+ var util_one = typeof util_one !== 'undefined' ? util_one : function util_one(val) { return val; };
56
+ var util_two = typeof util_two !== 'undefined' ? util_two : function util_two(val) { return val; };
57
+
58
+ async function zzz(config, context, streamCallback) {
59
+ return util_one(config) + util_two(context);
60
+ }
61
+ `
62
+ const resolved = resolveHandlerEntryName(source, 'ai-custom-prompt')
63
+ expect(resolved).toBe('zzz')
64
+ expect(typeof evalEntryWrapper(source, resolved)).toBe('function')
65
+ })
66
+
67
+ it('prefers the entry-point arity (2-3 params) over a same-position helper with a different arity', () => {
68
+ // A helper positioned BEFORE the (renamed) entry, but with an arity that
69
+ // could never be a real handler entry point (1 param) — must not win.
70
+ const source = `
71
+ function one_param_helper(x) { return x; }
72
+ async function zzz(config, context) { return one_param_helper(config); }
73
+ `
74
+ expect(resolveHandlerEntryName(source, 'my-node-type')).toBe('zzz')
75
+ })
76
+
77
+ it('throws a clear error when no declaration can be found at all', () => {
78
+ expect(() => resolveHandlerEntryName('var x = 1;', 'my-node-type')).toThrow(
79
+ /Could not resolve the entry function/
80
+ )
81
+ })
82
+
83
+ // Full-registry regression: every node type's generateHandler()/
84
+ // generateServerHandler() output must resolve to a real, callable function
85
+ // both normally AND after its entry function's declared name is renamed
86
+ // (simulating what a minifier does) — proves the resolver's fallback logic
87
+ // holds for every handler shape actually registered, not just hand-picked
88
+ // examples.
89
+ describe('every registered node type', () => {
90
+ const nodeTypes = Object.keys(nodeRegistry)
91
+
92
+ it.each(nodeTypes)(
93
+ '%s: generateHandler resolves normally and after entry rename',
94
+ (nodeType) => {
95
+ const gen = nodeRegistry[nodeType]
96
+ const source = gen.generateHandler().trim()
97
+ const conventionName = nodeType.replace(/-/g, '_')
98
+
99
+ const resolvedNormal = resolveHandlerEntryName(source, nodeType)
100
+ expect(typeof evalEntryWrapper(source, resolvedNormal)).toBe('function')
101
+
102
+ if (source.includes(conventionName)) {
103
+ const renamed = source.replace(new RegExp(`\\b${conventionName}\\b`, 'g'), 'zzz_renamed')
104
+ const resolvedRenamed = resolveHandlerEntryName(renamed, nodeType)
105
+ expect(resolvedRenamed).toBe('zzz_renamed')
106
+ expect(typeof evalEntryWrapper(renamed, resolvedRenamed)).toBe('function')
107
+ }
108
+ }
109
+ )
110
+
111
+ it.each(nodeTypes.filter((t) => nodeRegistry[t].generateServerHandler))(
112
+ '%s: generateServerHandler resolves normally and after entry rename',
113
+ (nodeType) => {
114
+ const gen = nodeRegistry[nodeType]
115
+ const source = gen.generateServerHandler!().trim()
116
+ const conventionName = nodeType.replace(/-/g, '_')
117
+
118
+ const resolvedNormal = resolveHandlerEntryName(source, nodeType)
119
+ expect(typeof evalEntryWrapper(source, resolvedNormal)).toBe('function')
120
+
121
+ if (source.includes(conventionName)) {
122
+ const renamed = source.replace(new RegExp(`\\b${conventionName}\\b`, 'g'), 'zzz_renamed')
123
+ const resolvedRenamed = resolveHandlerEntryName(renamed, nodeType)
124
+ expect(resolvedRenamed).toBe('zzz_renamed')
125
+ expect(typeof evalEntryWrapper(renamed, resolvedRenamed)).toBe('function')
126
+ }
127
+ }
128
+ )
129
+ })
130
+ })
@@ -82,6 +82,25 @@ describe('classifyStockWriteSite — single-node categorisation', () => {
82
82
  expect(result.category).toBe('order-decrement')
83
83
  })
84
84
 
85
+ it('categorises the current NULL-preserving rewritten decrement as order-decrement', () => {
86
+ // This is the exact shape buildStockDecrementBuilder emits today —
87
+ // the SET clause guards NULL (unlimited-stock) rows with a CASE
88
+ // instead of the old COALESCE-based bootstrap. The auditor must not
89
+ // regress this to "unknown" and start spamming console.warn on
90
+ // every generation of a correctly-behaving project.
91
+ const currentRewrite = `${STOCK_DECREMENT_MARKER}
92
+ function customHandler() {
93
+ var setClause = "quantity = CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity - (CASE id" + caseExpr + " ELSE 0 END)) END";
94
+ var query = "UPDATE teleport_products SET " + setClause + " WHERE id IN ('a')";
95
+ return { query: query, affected: ['a'] };
96
+ }`
97
+ const result = classifyStockWriteSite('Place Order 1', {
98
+ type: 'general-custom-js',
99
+ config: { code: currentRewrite },
100
+ })
101
+ expect(result.category).toBe('order-decrement')
102
+ })
103
+
85
104
  it('categorises our REWRITTEN decrement EVEN WHEN the SQL is split across string concatenation', () => {
86
105
  // This is the exact shape our buildStockDecrementBuilder emits:
87
106
  // the SQL is built up via "UPDATE teleport_products SET " + setClause
@@ -1 +1 @@
1
- {"version":3,"file":"stock-decrement.d.ts","sourceRoot":"","sources":["../../../src/ecommerce/stock-decrement.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAKxD,eAAO,MAAM,sBAAsB,uCAAuC,CAAA;AAU1E,eAAO,MAAM,8BAA8B,SAAU,MAAM,KAAG,OAiB7D,CAAA;AAsBD,eAAO,MAAM,4BAA4B,q5BAsBrC,CAAA;AA8BJ,eAAO,MAAM,0BAA0B,oBAAqB,OAAO,KAAG,MAiHrE,CAAA;AAsBD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,UAAU,GAAG,YAAY,CAAA;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,QAAQ,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAAA;IAGjD,UAAU,EAAE,MAAM,CAAA;CACnB;AAiED,eAAO,MAAM,sBAAsB,iBACnB,MAAM,QACd;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAClD;IAAE,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAuC5D,CAAA;AAID,eAAO,MAAM,mBAAmB,SAAU,WAAW,KAAG,cAAc,EAwErE,CAAA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,cAAc,EAAE,cAAc,EAAE,CAAA;IAChC,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAID,eAAO,MAAM,oBAAoB,SAAU,WAAW,KAAG,eAQxD,CAAA;AAMD,eAAO,MAAM,qBAAqB,SAAU,WAAW,KAAG,eAmEzD,CAAA;AAuMD,UAAU,YAAY;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,iCAAiC,SAAU,WAAW,KAAG,YA6BrE,CAAA"}
1
+ {"version":3,"file":"stock-decrement.d.ts","sourceRoot":"","sources":["../../../src/ecommerce/stock-decrement.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAKxD,eAAO,MAAM,sBAAsB,uCAAuC,CAAA;AAU1E,eAAO,MAAM,8BAA8B,SAAU,MAAM,KAAG,OAgC7D,CAAA;AAsBD,eAAO,MAAM,4BAA4B,q5BAsBrC,CAAA;AA8BJ,eAAO,MAAM,0BAA0B,oBAAqB,OAAO,KAAG,MAwGrE,CAAA;AAsBD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,UAAU,GAAG,YAAY,CAAA;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,QAAQ,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAAA;IAGjD,UAAU,EAAE,MAAM,CAAA;CACnB;AA0ED,eAAO,MAAM,sBAAsB,iBACnB,MAAM,QACd;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAClD;IAAE,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAuC5D,CAAA;AAID,eAAO,MAAM,mBAAmB,SAAU,WAAW,KAAG,cAAc,EAwErE,CAAA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,cAAc,EAAE,cAAc,EAAE,CAAA;IAChC,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAID,eAAO,MAAM,oBAAoB,SAAU,WAAW,KAAG,eAQxD,CAAA;AAMD,eAAO,MAAM,qBAAqB,SAAU,WAAW,KAAG,eAmEzD,CAAA;AAuMD,UAAU,YAAY;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,iCAAiC,SAAU,WAAW,KAAG,YA6BrE,CAAA"}
@@ -57,6 +57,19 @@ const looksLikeStockDecrementBuilder = (code) => {
57
57
  return false;
58
58
  }
59
59
  if (code.indexOf(exports.STOCK_DECREMENT_MARKER) >= 0) {
60
+ // Already our rewrite. A prior rewriter version (the
61
+ // `GREATEST(0, COALESCE(quantity, 0) - …)` shape below) permanently
62
+ // floored an unlimited-stock (NULL quantity) product to 0 the first
63
+ // time an order touched it — turning "infinite stock" into
64
+ // "out of stock" forever. Re-match that legacy shape so the next
65
+ // regeneration upgrades already-rewritten projects to the current,
66
+ // NULL-preserving SQL (`buildStockDecrementBuilder` below). A rewrite
67
+ // that already carries the NULL guard is up to date — leave it alone.
68
+ if (code.indexOf('teleport_products') >= 0 &&
69
+ /quantity\s*=\s*GREATEST\s*\(\s*\d+\s*,\s*COALESCE\s*\(\s*quantity/i.test(code) &&
70
+ code.indexOf('WHEN quantity IS NULL THEN NULL') < 0) {
71
+ return true;
72
+ }
60
73
  return false;
61
74
  }
62
75
  if (code.indexOf('teleport_products') < 0) {
@@ -184,31 +197,27 @@ ${STOCK_DECREMENT_HELPERS}
184
197
  // the row AFTER the SET clause runs (so bare \`quantity\` would
185
198
  // be the new value, not the old).
186
199
  //
187
- // Two SQL helpers do the heavy lifting:
188
- //
189
- // 1. \`COALESCE(quantity, 0)\` every new project starts with
190
- // \`teleport_products.quantity\` seeded NULL, and the
191
- // cart-availability pre-flight tolerates NULL as "unlimited"
192
- // so those orders reach this UPDATE. Without COALESCE the
193
- // NULL row's arithmetic would be NULL (no decrement) and the
194
- // merchant would see "order placed, stock unchanged" and
195
- // assume the workflow is broken.
196
- // 2. \`GREATEST(0, …)\` clamps the post-update value at zero so
197
- // we never persist a negative quantity. Negative stock is a
198
- // confusing signal in the admin panel ("we sold more than we
199
- // have?"); the same "set your initial stock" feedback is
200
- // achieved by the row sitting at exactly 0 subsequent
201
- // orders are then blocked by the cart-availability check
202
- // (which compares against 0 and finds insufficient stock).
203
- var setClause = "quantity = GREATEST(0, COALESCE(quantity, 0) - CASE id" + caseExpr + " ELSE 0 END), updated_at = NOW()";
204
- // \`old_quantity\` in RETURNING reconstructs the pre-update value.
205
- // After the GREATEST clamp the SET value is no longer
206
- // "old - delta" — it might be 0 because of the floor. So we can't
200
+ // \`teleport_products.quantity\` is NULL for every product the
201
+ // merchant hasn't given a finite count — that NULL means
202
+ // "unlimited stock", not "zero stock waiting to be bootstrapped".
203
+ // The SET clause's \`CASE WHEN quantity IS NULL THEN NULL …\` guard
204
+ // leaves those rows untouched forever: an unlimited product must
205
+ // stay unlimited across every order, not silently become
206
+ // out-of-stock (0) the first time someone buys it. Only a row
207
+ // that already carries a real, finite count is decremented, and
208
+ // \`GREATEST(0, …)\` floors THAT arithmetic at zero so we never
209
+ // persist a negative quantity for a tracked product.
210
+ var setClause = "quantity = CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity - (CASE id" + caseExpr + " ELSE 0 END)) END, updated_at = NOW()";
211
+ // \`old_quantity\` in RETURNING reconstructs the pre-update value. A
212
+ // row whose (post-update) \`new_quantity\` is NULL was NULL before too
213
+ // (the SET clause never changes a NULL row), so \`old_quantity\` is
214
+ // NULL as well. For a tracked row, the GREATEST clamp means the SET
215
+ // value is no longer "old - delta" once it floors at 0, so we can't
207
216
  // just invert the SET expression; instead we expose 0 as a
208
217
  // conservative lower bound for what was there before. Downstream
209
218
  // (low-stock SELECT) only uses \`new_quantity\` for the threshold
210
219
  // comparison, so this approximation is fine.
211
- var returningClause = "RETURNING id, quantity AS new_quantity, GREATEST(0, COALESCE(quantity, 0) + (CASE id" + caseExpr + " ELSE 0 END)) AS old_quantity";
220
+ var returningClause = "RETURNING id, quantity AS new_quantity, (CASE WHEN quantity IS NULL THEN NULL ELSE GREATEST(0, quantity + (CASE id" + caseExpr + " ELSE 0 END)) END) AS old_quantity";
212
221
 
213
222
  // When backorders are DISALLOWED we add a per-row guard:
214
223
  // AND (quantity IS NULL OR quantity >= CASE id ... END)
@@ -219,17 +228,12 @@ ${STOCK_DECREMENT_HELPERS}
219
228
  // would otherwise allow two simultaneous orders to drive stock
220
229
  // below zero.
221
230
  //
222
- // The "quantity IS NULL OR" clause is the new-project bootstrap:
223
- // a freshly-seeded products table has NULL quantity everywhere,
224
- // and the cart-availability pre-flight (which also tolerates NULL
225
- // as "unlimited") lets those orders through. We MUST decrement
226
- // those rows too, otherwise the user sees "order placed but stock
227
- // unchanged" and concludes the workflow is broken. After the first
228
- // decrement the row holds an integer (now floored at 0 by the
229
- // GREATEST clamp in the SET clause) and from then on the normal
230
- // \`quantity >= delta\` guard is enforced — so the merchant sees
231
- // the zero value in the admin panel and gets a clear "set initial
232
- // stock for this product" signal.
231
+ // The "quantity IS NULL OR" clause lets unlimited-stock products
232
+ // through the guard so the row is still touched (its RETURNING row
233
+ // still surfaces to the caller, e.g. for the low-stock SELECT) the
234
+ // SET clause above then leaves the value itself untouched, so an
235
+ // unlimited product stays unlimited no matter how many orders are
236
+ // placed against it, with or without backorders enabled.
233
237
  var whereClause;
234
238
  if (ALLOW_BACKORDERS) {
235
239
  whereClause = "WHERE id IN (" + quotedIds + ")";
@@ -289,10 +293,12 @@ const looksLikeOrderDecrementCustomHandler = (code) => {
289
293
  // Belt-and-braces: even when the marker is present, the code must
290
294
  // actually be a stock-decrement (not, say, an unrelated rewritten
291
295
  // node that happens to live next to teleport_products references).
292
- // Accept BOTH the legacy concat shape (`quantity = quantity - CASE
293
- // id …`) AND the current safety-wrapped shape (`quantity =
294
- // GREATEST(0, COALESCE(quantity, 0) - CASE id …)`) they're both
295
- // emitted by `buildStockDecrementBuilder` across time.
296
+ // Accept every shape `buildStockDecrementBuilder` has emitted over
297
+ // time: the legacy concat shape (`quantity = quantity - CASE id …`),
298
+ // the COALESCE safety-wrapped shape (`quantity = GREATEST(0,
299
+ // COALESCE(quantity, 0) - CASE id …)`), and the current
300
+ // NULL-preserving shape (`quantity = CASE WHEN quantity IS NULL
301
+ // THEN NULL ELSE GREATEST(0, quantity - CASE id …) END`).
296
302
  if (code.indexOf('teleport_products') >= 0) {
297
303
  if (/quantity\s*=\s*quantity\s*-/.test(code)) {
298
304
  return true;
@@ -300,6 +306,9 @@ const looksLikeOrderDecrementCustomHandler = (code) => {
300
306
  if (/quantity\s*=\s*GREATEST\s*\(\s*\d+\s*,\s*COALESCE\s*\(\s*quantity/i.test(code)) {
301
307
  return true;
302
308
  }
309
+ if (/quantity\s*=\s*CASE\s+WHEN\s+quantity\s+IS\s+NULL\s+THEN\s+NULL\s+ELSE\s+GREATEST/i.test(code)) {
310
+ return true;
311
+ }
303
312
  }
304
313
  return false;
305
314
  }