@teleporthq/teleport-project-generator-next 0.43.65 → 0.43.67

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 (76) hide show
  1. package/__tests__/cart-locale-stamp.test.ts +125 -0
  2. package/__tests__/ecommerce-regional-pricing.test.ts +271 -0
  3. package/__tests__/email-sender-and-low-stock-alert.test.ts +165 -2
  4. package/__tests__/i18n-language-switcher-links.test.ts +98 -0
  5. package/__tests__/order-notification-tax-and-date.test.ts +105 -2
  6. package/__tests__/order-notification-template-rendering.test.ts +8 -4
  7. package/__tests__/state-data-source-locale.test.ts +83 -0
  8. package/__tests__/url-search-params-plugin.ts +41 -0
  9. package/dist/cjs/ecommerce/cart-api-routes-generator.d.ts.map +1 -1
  10. package/dist/cjs/ecommerce/cart-api-routes-generator.js +1 -1
  11. package/dist/cjs/ecommerce/cart-api-routes-generator.js.map +1 -1
  12. package/dist/cjs/ecommerce/ecommerce-api-routes-generator.d.ts.map +1 -1
  13. package/dist/cjs/ecommerce/ecommerce-api-routes-generator.js +14 -8
  14. package/dist/cjs/ecommerce/ecommerce-api-routes-generator.js.map +1 -1
  15. package/dist/cjs/ecommerce/ecommerce-context-generator.d.ts.map +1 -1
  16. package/dist/cjs/ecommerce/ecommerce-context-generator.js +89 -9
  17. package/dist/cjs/ecommerce/ecommerce-context-generator.js.map +1 -1
  18. package/dist/cjs/ecommerce/ecommerce-regional-pricing-code.d.ts +57 -0
  19. package/dist/cjs/ecommerce/ecommerce-regional-pricing-code.d.ts.map +1 -0
  20. package/dist/cjs/ecommerce/ecommerce-regional-pricing-code.js +56 -0
  21. package/dist/cjs/ecommerce/ecommerce-regional-pricing-code.js.map +1 -0
  22. package/dist/cjs/ecommerce/email-sender-generator.d.ts.map +1 -1
  23. package/dist/cjs/ecommerce/email-sender-generator.js +16 -2
  24. package/dist/cjs/ecommerce/email-sender-generator.js.map +1 -1
  25. package/dist/cjs/ecommerce/project-plugin.d.ts.map +1 -1
  26. package/dist/cjs/ecommerce/project-plugin.js +9 -2
  27. package/dist/cjs/ecommerce/project-plugin.js.map +1 -1
  28. package/dist/cjs/internationalization/locale-mapper-component.d.ts.map +1 -1
  29. package/dist/cjs/internationalization/locale-mapper-component.js +17 -6
  30. package/dist/cjs/internationalization/locale-mapper-component.js.map +1 -1
  31. package/dist/cjs/state-data-source-plugin.d.ts.map +1 -1
  32. package/dist/cjs/state-data-source-plugin.js +12 -3
  33. package/dist/cjs/state-data-source-plugin.js.map +1 -1
  34. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  35. package/dist/cjs/url-search-params-plugin.d.ts.map +1 -1
  36. package/dist/cjs/url-search-params-plugin.js +42 -18
  37. package/dist/cjs/url-search-params-plugin.js.map +1 -1
  38. package/dist/esm/ecommerce/cart-api-routes-generator.d.ts.map +1 -1
  39. package/dist/esm/ecommerce/cart-api-routes-generator.js +1 -1
  40. package/dist/esm/ecommerce/cart-api-routes-generator.js.map +1 -1
  41. package/dist/esm/ecommerce/ecommerce-api-routes-generator.d.ts.map +1 -1
  42. package/dist/esm/ecommerce/ecommerce-api-routes-generator.js +14 -8
  43. package/dist/esm/ecommerce/ecommerce-api-routes-generator.js.map +1 -1
  44. package/dist/esm/ecommerce/ecommerce-context-generator.d.ts.map +1 -1
  45. package/dist/esm/ecommerce/ecommerce-context-generator.js +89 -9
  46. package/dist/esm/ecommerce/ecommerce-context-generator.js.map +1 -1
  47. package/dist/esm/ecommerce/ecommerce-regional-pricing-code.d.ts +57 -0
  48. package/dist/esm/ecommerce/ecommerce-regional-pricing-code.d.ts.map +1 -0
  49. package/dist/esm/ecommerce/ecommerce-regional-pricing-code.js +50 -0
  50. package/dist/esm/ecommerce/ecommerce-regional-pricing-code.js.map +1 -0
  51. package/dist/esm/ecommerce/email-sender-generator.d.ts.map +1 -1
  52. package/dist/esm/ecommerce/email-sender-generator.js +16 -2
  53. package/dist/esm/ecommerce/email-sender-generator.js.map +1 -1
  54. package/dist/esm/ecommerce/project-plugin.d.ts.map +1 -1
  55. package/dist/esm/ecommerce/project-plugin.js +9 -2
  56. package/dist/esm/ecommerce/project-plugin.js.map +1 -1
  57. package/dist/esm/internationalization/locale-mapper-component.d.ts.map +1 -1
  58. package/dist/esm/internationalization/locale-mapper-component.js +17 -6
  59. package/dist/esm/internationalization/locale-mapper-component.js.map +1 -1
  60. package/dist/esm/state-data-source-plugin.d.ts.map +1 -1
  61. package/dist/esm/state-data-source-plugin.js +13 -4
  62. package/dist/esm/state-data-source-plugin.js.map +1 -1
  63. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  64. package/dist/esm/url-search-params-plugin.d.ts.map +1 -1
  65. package/dist/esm/url-search-params-plugin.js +42 -18
  66. package/dist/esm/url-search-params-plugin.js.map +1 -1
  67. package/package.json +19 -19
  68. package/src/ecommerce/cart-api-routes-generator.ts +35 -1
  69. package/src/ecommerce/ecommerce-api-routes-generator.ts +85 -25
  70. package/src/ecommerce/ecommerce-context-generator.ts +164 -41
  71. package/src/ecommerce/ecommerce-regional-pricing-code.ts +376 -0
  72. package/src/ecommerce/email-sender-generator.ts +114 -12
  73. package/src/ecommerce/project-plugin.ts +12 -2
  74. package/src/internationalization/locale-mapper-component.ts +12 -0
  75. package/src/state-data-source-plugin.ts +15 -2
  76. package/src/url-search-params-plugin.ts +64 -7
@@ -0,0 +1,125 @@
1
+ import { generateCartApiRoute } from '../src/ecommerce/cart-api-routes-generator'
2
+ import { generateEcommerceContextFileContent } from '../src/ecommerce/ecommerce-context-generator'
3
+
4
+ // The abandoned-cart reminder runs from a cron, with no visitor behind it, so
5
+ // the language it emails a shopper in has to be remembered on the cart itself.
6
+ // The provider sends the page locale with every sync; the route writes it to
7
+ // `teleport_cart.locale` — and ONLY once it has confirmed the column exists,
8
+ // because a store provisioned before the column must keep syncing as before
9
+ // (a failed UPDATE inside the sync transaction would roll the whole cart back).
10
+
11
+ const PG_CFG = { connectionString: 'env:DATABASE_URL' }
12
+
13
+ const extractFunctionSource = (haystack: string, funcDecl: string): string => {
14
+ const startIdx = haystack.indexOf(funcDecl)
15
+ if (startIdx === -1) {
16
+ throw new Error('Helper not found: ' + funcDecl)
17
+ }
18
+ let depth = 0
19
+ let i = haystack.indexOf('{', startIdx)
20
+ for (; i < haystack.length; i++) {
21
+ const ch = haystack.charAt(i)
22
+ if (ch === '{') {
23
+ depth++
24
+ } else if (ch === '}') {
25
+ depth--
26
+ if (depth === 0) {
27
+ return haystack.slice(startIdx, i + 1)
28
+ }
29
+ }
30
+ }
31
+ throw new Error('Unbalanced braces for ' + funcDecl)
32
+ }
33
+
34
+ describe('cart route — storefront language on the cart row', () => {
35
+ const route = generateCartApiRoute('postgresql', PG_CFG) as string
36
+
37
+ it('validates the language through the shared locale module', () => {
38
+ expect(route).toContain("var emailLocale = require('../../../utils/email/email-locale')")
39
+ expect(route).toContain('emailLocale.normalizeEmailLocale(body && body.locale)')
40
+ })
41
+
42
+ it('checks the column once per process before ever writing it', () => {
43
+ expect(route).toContain("table_name = 'teleport_cart' AND column_name = 'locale' LIMIT 1")
44
+ expect(route).toContain('if (cartLocaleColumnPresent !== null) return cartLocaleColumnPresent')
45
+ })
46
+
47
+ it('touches the cart through the locale-aware helper inside the sync transaction', () => {
48
+ expect(route).toContain('await touchCart(client, cartId, body)')
49
+ expect(route).not.toContain(
50
+ "await client.query('UPDATE teleport_cart SET updated_at = NOW() WHERE id = $1', [cartId])\n await client.query('COMMIT')"
51
+ )
52
+ })
53
+
54
+ it('writes the locale only when it is known AND the column exists', async () => {
55
+ const source = extractFunctionSource(route, 'async function touchCart(')
56
+ const queries: Array<{ sql: string; params: unknown[] }> = []
57
+ const client = {
58
+ query: async (sql: string, params: unknown[]) => {
59
+ queries.push({ sql, params })
60
+ return { rows: [] }
61
+ },
62
+ }
63
+ const emailLocale = {
64
+ normalizeEmailLocale: (value: unknown) => (value === 'es' ? 'es' : null),
65
+ }
66
+ const run = async (columnPresent: boolean, body: unknown) => {
67
+ queries.length = 0
68
+ // eslint-disable-next-line no-new-func
69
+ const touchCart = new Function(
70
+ 'emailLocale',
71
+ 'cartHasLocaleColumn',
72
+ `${source}; return touchCart;`
73
+ )(emailLocale, async () => columnPresent)
74
+ await touchCart(client, 'cart-1', body)
75
+ return queries
76
+ }
77
+
78
+ expect(await run(true, { locale: 'es' })).toEqual([
79
+ {
80
+ sql: 'UPDATE teleport_cart SET updated_at = NOW(), locale = $2 WHERE id = $1',
81
+ params: ['cart-1', 'es'],
82
+ },
83
+ ])
84
+ expect(await run(false, { locale: 'es' })).toEqual([
85
+ { sql: 'UPDATE teleport_cart SET updated_at = NOW() WHERE id = $1', params: ['cart-1'] },
86
+ ])
87
+ expect(await run(true, { locale: 'xx' })).toEqual([
88
+ { sql: 'UPDATE teleport_cart SET updated_at = NOW() WHERE id = $1', params: ['cart-1'] },
89
+ ])
90
+ expect(await run(true, undefined)).toEqual([
91
+ { sql: 'UPDATE teleport_cart SET updated_at = NOW() WHERE id = $1', params: ['cart-1'] },
92
+ ])
93
+ })
94
+ })
95
+
96
+ describe('ecommerce provider — cart sync carries the page locale', () => {
97
+ it('reads the live page locale through the shared module and sends it with the synced items', () => {
98
+ const content = generateEcommerceContextFileContent(
99
+ { enabled: true } as any,
100
+ undefined,
101
+ 'ds-1',
102
+ true,
103
+ false
104
+ )
105
+ // Live, not `window.__NEXT_DATA__.locale`: the page data keeps the locale
106
+ // the document was served in after a client-side language switch.
107
+ expect(content).toContain("import emailLocale from './utils/email/email-locale'")
108
+ expect(content).toContain('var pageLocale = emailLocale.getClientLocale()')
109
+ expect(content).not.toContain('__NEXT_DATA__')
110
+ expect(content).toContain(
111
+ 'body: JSON.stringify({ items: payload, sessionId: getOrCreateSessionId(), locale: pageLocale })'
112
+ )
113
+ })
114
+
115
+ it('needs no locale module when the cart is not backed by the database', () => {
116
+ const content = generateEcommerceContextFileContent(
117
+ { enabled: true } as any,
118
+ undefined,
119
+ 'ds-1',
120
+ false,
121
+ false
122
+ )
123
+ expect(content).not.toContain('email-locale')
124
+ })
125
+ })
@@ -0,0 +1,271 @@
1
+ /* tslint:disable:no-eval function-constructor */
2
+ import { parse } from '@babel/parser'
3
+ import { UIDLEcommerceSettings, UIDLInvoiceSettings } from '@teleporthq/teleport-types'
4
+ import { RegionalPricing } from '@teleporthq/teleport-shared'
5
+ import { generateEcommerceContextFileContent } from '../src/ecommerce/ecommerce-context-generator'
6
+
7
+ /**
8
+ * The storefront provider's half of shipping zones + tax jurisdictions.
9
+ *
10
+ * The rule itself is pinned by teleport-shared's parity table. What this pins is
11
+ * the provider around it: a store WITHOUT the feature gets none of its code, a
12
+ * store with it emits a module that parses, and the projections from a quote to
13
+ * what the cart and checkout pages bind are right — run, not pattern-matched.
14
+ */
15
+
16
+ const baseSettings = (overrides: Partial<UIDLEcommerceSettings> = {}): UIDLEcommerceSettings =>
17
+ ({
18
+ cashOnDelivery: true,
19
+ deliveryEnabled: true,
20
+ storePickupEnabled: true,
21
+ guestCheckout: true,
22
+ stockManagement: false,
23
+ orderNotifications: false,
24
+ deliveryConfig: {
25
+ deliveryPrice: 5,
26
+ freeDeliveryEnabled: true,
27
+ freeDeliveryThreshold: 50,
28
+ allowDeliveryNotes: true,
29
+ },
30
+ stockManagementConfig: null,
31
+ orderNotificationConfig: null,
32
+ paymentProviders: [],
33
+ vouchersEnabled: true,
34
+ ...overrides,
35
+ } as UIDLEcommerceSettings)
36
+
37
+ const INVOICE = {
38
+ defaultTaxRate: 19,
39
+ taxIncludedInPrice: false,
40
+ companyDetails: {},
41
+ } as UIDLInvoiceSettings
42
+
43
+ const REGIONAL = {
44
+ enabled: true,
45
+ currency: 'EUR',
46
+ countryCodes: { germany: 'DE', france: 'FR' },
47
+ storeCountryCode: 'DE',
48
+ }
49
+
50
+ const regionalSource = (): string =>
51
+ generateEcommerceContextFileContent(
52
+ baseSettings({ regionalPricing: REGIONAL }),
53
+ INVOICE,
54
+ 'ds-1',
55
+ false,
56
+ false,
57
+ false
58
+ )
59
+
60
+ const grab = (source: string, name: string): string => {
61
+ const start = source.indexOf(`function ${name}(`)
62
+ if (start === -1) {
63
+ throw new Error(`emitted provider is missing function ${name}`)
64
+ }
65
+ const end = source.indexOf('\n}', start)
66
+ return source.slice(start, end + 2)
67
+ }
68
+
69
+ describe('EcommerceProvider — regional pricing is opt-in', () => {
70
+ it('emits none of it for a store without the feature', () => {
71
+ const legacy = generateEcommerceContextFileContent(baseSettings(), INVOICE, 'ds-1')
72
+ expect(legacy).not.toContain('__rpQuote')
73
+ expect(legacy).not.toContain('teleport_shipping_zones')
74
+ expect(legacy).not.toContain('weightUnit')
75
+ // The checkout's regional surfaces still resolve, to "nothing to show".
76
+ expect(legacy).toContain("shippingOptionsVisible: 'false'")
77
+ expect(legacy).toContain("shippingStatus: 'ok'")
78
+ expect(legacy).toContain("codAvailable: 'true'")
79
+ })
80
+
81
+ it('stays off when the flag is present but disabled, or there is no datasource', () => {
82
+ const disabled = generateEcommerceContextFileContent(
83
+ baseSettings({ regionalPricing: { ...REGIONAL, enabled: false } }),
84
+ INVOICE,
85
+ 'ds-1'
86
+ )
87
+ const noDataSource = generateEcommerceContextFileContent(
88
+ baseSettings({ regionalPricing: REGIONAL }),
89
+ INVOICE,
90
+ null
91
+ )
92
+ expect(disabled).not.toContain('__rpQuote')
93
+ expect(noDataSource).not.toContain('__rpQuote')
94
+ })
95
+ })
96
+
97
+ describe('EcommerceProvider — regional pricing emitted module', () => {
98
+ const source = regionalSource()
99
+
100
+ it('parses as the JSX module Next compiles', () => {
101
+ expect(() => parse(source, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
102
+ })
103
+
104
+ it('reads all three tables through the datasource and mirrors the snapshot for checkout', () => {
105
+ for (const table of [
106
+ 'teleport_shipping_zones',
107
+ 'teleport_shipping_rates',
108
+ 'teleport_tax_rates',
109
+ ]) {
110
+ expect(source).toContain(table)
111
+ }
112
+ expect(source).toContain("'/api/data/' + PRODUCTS_DATA_SOURCE_ID + '/select'")
113
+ expect(source).toContain(
114
+ `const SHIPPING_RATE_STORAGE_KEY = '${RegionalPricing.SHIPPING_RATE_STORAGE_KEY}'`
115
+ )
116
+ expect(source).toContain('regional: {')
117
+ })
118
+
119
+ it('bakes the store defaults the quote falls back to', () => {
120
+ expect(source).toContain(
121
+ 'const REGIONAL_STORE = {"deliveryEnabled":true,"storePickupEnabled":true,"deliveryPrice":5,"freeDeliveryEnabled":true,"freeDeliveryThreshold":50,"defaultTaxRate":19,"defaultTaxIncluded":false}'
122
+ )
123
+ })
124
+
125
+ it('stamps weight and categories onto hydrated cart lines', () => {
126
+ expect(source).toContain('weightUnit: product.weight_unit || null')
127
+ expect(source).toContain(
128
+ 'categoryIds: __rpStringArray(product.category_filter_ids || product.category_ids).map(String)'
129
+ )
130
+ })
131
+
132
+ it('prices the voucher against lines already grossed in the destination tax', () => {
133
+ expect(source).toContain('regionalVoucherItems(regionalQuote, cartItems)')
134
+ expect(source).not.toContain(' STOREFRONT_TAX_RATE,\n shippingMeta,')
135
+ })
136
+ })
137
+
138
+ describe('EcommerceProvider — quote projections', () => {
139
+ const source = regionalSource()
140
+ const api = new Function(
141
+ [
142
+ RegionalPricing.generateRegionalPricingHelperCode(),
143
+ 'var STOREFRONT_TAX_RATE = 19;',
144
+ grab(source, 'roundMoney'),
145
+ grab(source, 'applyStorefrontTax'),
146
+ grab(source, 'cartItemDisplayPrice'),
147
+ grab(source, 'cartItemQuantity'),
148
+ grab(source, 'cartItemLineTotal'),
149
+ grab(source, 'cartItemHasDiscount'),
150
+ grab(source, 'cartItemOriginalLineTotal'),
151
+ grab(source, 'formatCartMoney'),
152
+ grab(source, 'regionalLineFor'),
153
+ grab(source, 'regionalLinePricing'),
154
+ grab(source, 'regionalVoucherItems'),
155
+ grab(source, 'regionalShippingMeta'),
156
+ grab(source, 'regionalShippingOptions'),
157
+ grab(source, 'regionalSettingsView'),
158
+ 'return { quote: __rpQuote, normalize: __rpNormalizeConfig, linePricing: regionalLinePricing,' +
159
+ ' voucherItems: regionalVoucherItems, shippingMeta: regionalShippingMeta,' +
160
+ ' options: regionalShippingOptions, settingsView: regionalSettingsView };',
161
+ ].join('\n')
162
+ )()
163
+
164
+ const STORE = {
165
+ deliveryEnabled: true,
166
+ storePickupEnabled: true,
167
+ deliveryPrice: 5,
168
+ freeDeliveryEnabled: true,
169
+ freeDeliveryThreshold: 50,
170
+ defaultTaxRate: 19,
171
+ defaultTaxIncluded: false,
172
+ }
173
+ const CONFIG = api.normalize({
174
+ zones: [{ id: 'z1', name: 'France', countries: 'FR', free_shipping_threshold: '100' }],
175
+ rates: [
176
+ { id: 'r1', zone_id: 'z1', name: 'Standard', price: '4.90' },
177
+ { id: 'r2', zone_id: 'z1', name: 'Express', price: '12', estimated_days: 'Next day' },
178
+ ],
179
+ taxRates: [{ id: 't1', country_code: 'FR', rate: 20, tax_mode: 'added', tax_shipping: true }],
180
+ })
181
+ const CART = [
182
+ { id: 'l1', productId: 'p1', price: 10, quantity: 2, originalPrice: 12.5 },
183
+ { productId: 'p2', price: 5, quantity: 1 },
184
+ ]
185
+ const FRANCE = { countryCode: 'FR', countryName: 'France', region: '' }
186
+ const quoteFor = (selectedRateId: string | null) =>
187
+ api.quote({
188
+ config: CONFIG,
189
+ store: STORE,
190
+ items: CART,
191
+ destination: FRANCE,
192
+ fulfillment: 'delivery',
193
+ selectedRateId,
194
+ currency: 'EUR',
195
+ })
196
+
197
+ it('prices each line in the destination tax, including the struck price and a line with no id', () => {
198
+ const quote = quoteFor(null)
199
+ expect(api.linePricing(quote, CART[0], 0)).toEqual({
200
+ unitPrice: 12,
201
+ lineTotal: 24,
202
+ originalLineTotal: 30,
203
+ })
204
+ expect(api.linePricing(quote, CART[1], 1)).toEqual({
205
+ unitPrice: 6,
206
+ lineTotal: 6,
207
+ originalLineTotal: null,
208
+ })
209
+ })
210
+
211
+ it('hands the voucher rule gross unit prices', () => {
212
+ const items = api.voucherItems(quoteFor(null), CART)
213
+ expect(items.map((item: { price: number }) => item.price)).toEqual([12, 6])
214
+ // The stored cart is never rewritten.
215
+ expect(CART[0].price).toBe(10)
216
+ })
217
+
218
+ it('lists the shipping methods with taxed prices and the selection', () => {
219
+ expect(api.options(quoteFor('r2'))).toEqual([
220
+ {
221
+ id: 'r1',
222
+ name: 'Standard',
223
+ estimatedDays: '',
224
+ price: '5.88',
225
+ isFree: 'false',
226
+ selected: 'false',
227
+ },
228
+ {
229
+ id: 'r2',
230
+ name: 'Express',
231
+ estimatedDays: 'Next day',
232
+ price: '14.40',
233
+ isFree: 'false',
234
+ selected: 'true',
235
+ },
236
+ ])
237
+ })
238
+
239
+ it('feeds the shipping meta from the quote, taxed shipping included', () => {
240
+ expect(api.shippingMeta(quoteFor(null))).toEqual({
241
+ shippingIsFree: false,
242
+ shippingPrice: 5.88,
243
+ totalWithShipping: 35.88,
244
+ freeDeliveryProgress: '30%',
245
+ freeDeliveryRemaining: 70,
246
+ })
247
+ })
248
+
249
+ it("shows the matched zone's fee and threshold through Settings.Delivery", () => {
250
+ const settings = {
251
+ deliveryEnabled: true,
252
+ Delivery: { deliveryPrice: 5, allowDeliveryNotes: true },
253
+ }
254
+ expect(api.settingsView(settings, quoteFor(null)).Delivery).toEqual({
255
+ deliveryPrice: 4.9,
256
+ allowDeliveryNotes: true,
257
+ freeDeliveryEnabled: true,
258
+ freeDeliveryThreshold: 100,
259
+ })
260
+ const pickup = api.quote({
261
+ config: CONFIG,
262
+ store: STORE,
263
+ items: CART,
264
+ destination: FRANCE,
265
+ fulfillment: 'pickup',
266
+ selectedRateId: null,
267
+ currency: 'EUR',
268
+ })
269
+ expect(api.settingsView(settings, pickup)).toBe(settings)
270
+ })
271
+ })
@@ -161,8 +161,8 @@ describe('generateEmailSenderModule — shared dispatcher', () => {
161
161
  it('emits diagnostic console logs on every dispatch (success + failure)', () => {
162
162
  const code = generateEmailSenderModule(baseSettings(), { logTag: 'test-tag' })
163
163
  expect(code).toContain("console.log('[test-tag] dispatching to '")
164
- expect(code).toContain("console.log('[test-tag] sent successfully')")
165
- expect(code).toContain("console.error('[test-tag] dispatch failed:")
164
+ expect(code).toContain("console.log('[test-tag] sent to ' + recipient)")
165
+ expect(code).toContain("console.error('[test-tag] dispatch failed for ' + recipient")
166
166
  expect(code).toContain("console.log('[test-tag] skipped: no recipients configured')")
167
167
  })
168
168
 
@@ -456,3 +456,166 @@ describe('generateEmailSenderModule — list-block expansion (executed, not grep
456
456
  expect(hasOwnItemList('')).toBe(false)
457
457
  })
458
458
  })
459
+
460
+ describe('generateEmailSenderModule — one provider call per recipient (executed)', () => {
461
+ // Postmark refuses a WHOLE call when any address in it is refused (its
462
+ // pending-approval rule: every recipient must share the From domain), which
463
+ // used to fail every recipient because of one. The only honest check is to
464
+ // run the emitted sender against a provider stub and count the calls.
465
+ interface LedgerEntry {
466
+ to: string[]
467
+ status: string
468
+ error: string | null
469
+ providerMessageId: string | null
470
+ }
471
+
472
+ const loadSender = (refuse: (recipient: string) => boolean) => {
473
+ const code = generateEmailSenderModule(baseSettings(), { logTag: 'test' })
474
+ const ledger: LedgerEntry[] = []
475
+ const calls: string[] = []
476
+ const fetchStub = (_url: string, init: { body: string }) => {
477
+ const payload = JSON.parse(init.body) as { To: string }
478
+ calls.push(payload.To)
479
+ if (payload.To.startsWith('throws@')) {
480
+ throw new Error('transport exploded before the request left')
481
+ }
482
+ if (refuse(payload.To)) {
483
+ return Promise.resolve({
484
+ ok: false,
485
+ status: 422,
486
+ text: () => Promise.resolve('{"ErrorCode":412,"Message":"domain not allowed"}'),
487
+ })
488
+ }
489
+ return Promise.resolve({
490
+ ok: true,
491
+ status: 200,
492
+ json: () => Promise.resolve({ MessageID: 'msg-' + payload.To }),
493
+ })
494
+ }
495
+ const requireStub = (specifier: string) => {
496
+ if (specifier === '../email/sent-email-log') {
497
+ return {
498
+ recordSentEmail: (entry: LedgerEntry) => ledger.push(entry),
499
+ settleSentEmailLog: () => Promise.resolve(),
500
+ }
501
+ }
502
+ return {}
503
+ }
504
+ const moduleShim = { exports: {} as Record<string, (...fnArgs: any[]) => any> }
505
+ // The emitted postmark dispatcher calls the free identifier `fetch`; naming
506
+ // it as a parameter shadows Node's global with the stub for this module.
507
+ // eslint-disable-next-line no-new-func
508
+ new Function('module', 'require', 'process', 'fetch', code)(
509
+ moduleShim,
510
+ requireStub,
511
+ { env: { POSTMARK_SERVER_TOKEN: 'token' } },
512
+ fetchStub
513
+ )
514
+ const silence = jest.spyOn(console, 'log').mockImplementation(() => undefined)
515
+ const silenceErr = jest.spyOn(console, 'error').mockImplementation(() => undefined)
516
+ const silenceWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
517
+ const restore = () => {
518
+ silence.mockRestore()
519
+ silenceErr.mockRestore()
520
+ silenceWarn.mockRestore()
521
+ }
522
+ return { send: moduleShim.exports.sendNotificationEmail, ledger, calls, restore }
523
+ }
524
+
525
+ it('delivers to every accepted recipient when another one is refused', async () => {
526
+ const { send, ledger, calls, restore } = loadSender((to) => to === 'bad@other.io')
527
+ try {
528
+ const result = await send(['good@example.com', 'bad@other.io'], 'Subject', '<p>Hi</p>', {
529
+ emailType: 'order-notification',
530
+ })
531
+ expect(calls).toEqual(['good@example.com', 'bad@other.io'])
532
+ expect(result).toEqual({
533
+ sent: true,
534
+ recipients: ['good@example.com', 'bad@other.io'],
535
+ delivered: ['good@example.com'],
536
+ failed: [
537
+ {
538
+ recipient: 'bad@other.io',
539
+ error: 'Postmark API error 422: {"ErrorCode":412,"Message":"domain not allowed"}',
540
+ },
541
+ ],
542
+ })
543
+ // One ledger row per recipient, each with its own verdict.
544
+ expect(ledger.map((e) => [e.to, e.status, e.providerMessageId])).toEqual([
545
+ [['good@example.com'], 'sent', 'msg-good@example.com'],
546
+ [['bad@other.io'], 'failed', null],
547
+ ])
548
+ expect(ledger[1].error).toContain('domain not allowed')
549
+ } finally {
550
+ restore()
551
+ }
552
+ })
553
+
554
+ it('keeps sending after a refused address instead of stopping at it', async () => {
555
+ const { send, calls, restore } = loadSender((to) => to === 'bad@other.io')
556
+ try {
557
+ const result = await send(['bad@other.io', 'good@example.com'], 'S', 'B')
558
+ expect(calls).toEqual(['bad@other.io', 'good@example.com'])
559
+ expect(result.delivered).toEqual(['good@example.com'])
560
+ } finally {
561
+ restore()
562
+ }
563
+ })
564
+
565
+ it("treats a synchronous transport failure as that recipient's failure, not the batch's", async () => {
566
+ const { send, calls, restore } = loadSender(() => false)
567
+ try {
568
+ const result = await send(['throws@example.com', 'good@example.com'], 'S', 'B')
569
+ expect(calls).toEqual(['throws@example.com', 'good@example.com'])
570
+ expect(result.delivered).toEqual(['good@example.com'])
571
+ expect(result.failed).toEqual([
572
+ { recipient: 'throws@example.com', error: 'transport exploded before the request left' },
573
+ ])
574
+ } finally {
575
+ restore()
576
+ }
577
+ })
578
+
579
+ it('rejects only when no recipient accepted the email, naming each refusal', async () => {
580
+ const { send, ledger, calls, restore } = loadSender(() => true)
581
+ try {
582
+ await expect(send(['a@other.io', 'b@other.io'], 'S', 'B')).rejects.toThrow(
583
+ 'no recipient accepted the email — a@other.io: Postmark API error 422: ' +
584
+ '{"ErrorCode":412,"Message":"domain not allowed"}; b@other.io: Postmark API error 422: ' +
585
+ '{"ErrorCode":412,"Message":"domain not allowed"}'
586
+ )
587
+ expect(calls).toHaveLength(2)
588
+ expect(ledger.map((e) => e.status)).toEqual(['failed', 'failed'])
589
+ } finally {
590
+ restore()
591
+ }
592
+ })
593
+
594
+ it('trims, drops blanks and de-duplicates the recipient list before sending', async () => {
595
+ const { send, calls, restore } = loadSender(() => false)
596
+ try {
597
+ const result = await send(
598
+ [' A@example.com ', 'a@example.com', '', null, 42, 'b@example.com'],
599
+ 'S',
600
+ 'B'
601
+ )
602
+ expect(calls).toEqual(['A@example.com', 'b@example.com'])
603
+ expect(result.recipients).toEqual(['A@example.com', 'b@example.com'])
604
+ expect(result.failed).toEqual([])
605
+ } finally {
606
+ restore()
607
+ }
608
+ })
609
+
610
+ it('skips the provider entirely when the list is empty', async () => {
611
+ const { send, calls, ledger, restore } = loadSender(() => false)
612
+ try {
613
+ expect(await send([], 'S', 'B')).toEqual({ sent: false, reason: 'no_recipients' })
614
+ expect(await send(undefined, 'S', 'B')).toEqual({ sent: false, reason: 'no_recipients' })
615
+ expect(calls).toEqual([])
616
+ expect(ledger).toEqual([])
617
+ } finally {
618
+ restore()
619
+ }
620
+ })
621
+ })
@@ -0,0 +1,98 @@
1
+ import { ComponentUIDL, GeneratedFile } from '@teleporthq/teleport-types'
2
+ import { createReactComponentGenerator } from '@teleporthq/teleport-component-generator-react'
3
+ import { createNextInternationalizationPlugin } from '../src/internationalization/locale-mapper-component'
4
+
5
+ /**
6
+ * A language switcher's link resolves to the iterated locale code
7
+ * (`href={<language>?.short}`), which the internationalization plugin rewrites
8
+ * into the Next.js locale switch `<Link href={router.asPath} locale={…}>`. A
9
+ * bare `<a href="es">` would resolve RELATIVE to the current page — "/es/es"
10
+ * from the Spanish home — and land on a 404. The inline switcher renders its
11
+ * links from an array mapper's render prop, so the rewrite has to reach into
12
+ * JSX attributes, not only children.
13
+ */
14
+
15
+ /** The link ability the editor puts on a switcher option: a URL that is the iterated locale code. */
16
+ const localeLink = (url: Record<string, unknown>) => ({
17
+ type: 'url',
18
+ content: { url, newTab: false },
19
+ })
20
+
21
+ const switcherComponent = (link: Record<string, unknown>): ComponentUIDL =>
22
+ ({
23
+ name: 'LanguageSwitcher',
24
+ node: {
25
+ type: 'element',
26
+ content: {
27
+ elementType: 'container',
28
+ attrs: {
29
+ 'data-active-locale': {
30
+ type: 'dynamic',
31
+ content: { referenceType: 'global', id: 'locale', path: ['short'] },
32
+ },
33
+ },
34
+ children: [
35
+ {
36
+ type: 'cms-list-repeater',
37
+ content: {
38
+ elementType: 'Repeater',
39
+ name: 'languages',
40
+ key: 'languages',
41
+ renderPropIdentifier: 'languageOption',
42
+ source: 'locales',
43
+ nodes: {
44
+ list: {
45
+ type: 'element',
46
+ content: {
47
+ elementType: 'container',
48
+ children: [
49
+ {
50
+ type: 'element',
51
+ content: {
52
+ elementType: 'text',
53
+ semanticType: 'span',
54
+ abilities: { link },
55
+ children: [{ type: 'static', content: 'es' }],
56
+ },
57
+ },
58
+ ],
59
+ },
60
+ },
61
+ },
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ } as unknown as ComponentUIDL)
68
+
69
+ const jsFileOf = (files: GeneratedFile[]): string =>
70
+ files.find((file) => file.fileType === 'js')?.content ?? ''
71
+
72
+ const generate = async (uidl: ComponentUIDL): Promise<string> => {
73
+ const generator = createReactComponentGenerator()
74
+ generator.addPlugin(createNextInternationalizationPlugin())
75
+ const { files } = await generator.generateComponent(uidl, { skipValidation: true })
76
+ return jsFileOf(files)
77
+ }
78
+
79
+ describe('language switcher links inside an array mapper render prop', () => {
80
+ it('become Next.js locale switches that keep the visitor on the current page', async () => {
81
+ const code = await generate(
82
+ switcherComponent(localeLink({ type: 'expr', content: 'languageOption?.short' }))
83
+ )
84
+
85
+ expect(code).toContain('<Link href={router.asPath} locale={languageOption?.short}>')
86
+ expect(code).not.toContain('href={languageOption?.short}')
87
+ expect(code).toContain('const router = useRouter()')
88
+ })
89
+
90
+ it('leave any other link alone — only the bare locale code is a locale switch', async () => {
91
+ const code = await generate(
92
+ switcherComponent(localeLink({ type: 'expr', content: "'/' + languageOption?.short" }))
93
+ )
94
+
95
+ expect(code).toContain("<a href={'/' + languageOption?.short}>")
96
+ expect(code).not.toContain('router.asPath')
97
+ })
98
+ })