@owlmeans/web-payment 0.1.18-rc.2 → 0.1.18-rc.21
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.
- package/README.md +30 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +287 -20
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/@/components/ui/progress.d.ts +5 -0
- package/build/@/components/ui/progress.d.ts.map +1 -0
- package/build/@/components/ui/progress.js +11 -0
- package/build/@/components/ui/progress.js.map +1 -0
- package/build/@/components/ui/select.d.ts +8 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +9 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/capability-list.d.ts +7 -0
- package/build/capability-list.d.ts.map +1 -0
- package/build/capability-list.js +24 -0
- package/build/capability-list.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/copy.d.ts +14 -0
- package/build/copy.d.ts.map +1 -0
- package/build/copy.js +51 -0
- package/build/copy.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +46 -17
- package/build/dialog.js.map +1 -1
- package/build/entitlement.d.ts +26 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +42 -0
- package/build/entitlement.js.map +1 -0
- package/build/estimate-cache.d.ts +14 -0
- package/build/estimate-cache.d.ts.map +1 -0
- package/build/estimate-cache.js +39 -0
- package/build/estimate-cache.js.map +1 -0
- package/build/estimate-summary.d.ts +64 -0
- package/build/estimate-summary.d.ts.map +1 -0
- package/build/estimate-summary.js +94 -0
- package/build/estimate-summary.js.map +1 -0
- package/build/estimate.d.ts +34 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +92 -0
- package/build/estimate.js.map +1 -0
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/hooks.d.ts +0 -4
- package/build/hooks.d.ts.map +1 -1
- package/build/hooks.js +3 -30
- package/build/hooks.js.map +1 -1
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +145 -0
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +11 -7
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/build/limit-meter.d.ts +7 -0
- package/build/limit-meter.d.ts.map +1 -0
- package/build/limit-meter.js +26 -0
- package/build/limit-meter.js.map +1 -0
- package/build/plan-card.d.ts +7 -0
- package/build/plan-card.d.ts.map +1 -0
- package/build/plan-card.js +29 -0
- package/build/plan-card.js.map +1 -0
- package/build/poll.d.ts +7 -0
- package/build/poll.d.ts.map +1 -0
- package/build/poll.js +28 -0
- package/build/poll.js.map +1 -0
- package/build/selectors.d.ts +18 -0
- package/build/selectors.d.ts.map +1 -0
- package/build/selectors.js +78 -0
- package/build/selectors.js.map +1 -0
- package/build/types.d.ts +120 -2
- package/build/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/@/components/ui/progress.tsx +44 -0
- package/src/@/components/ui/select.tsx +62 -0
- package/src/@/globals.css +10 -0
- package/src/capability-list.tsx +50 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/copy.tsx +62 -0
- package/src/dialog.tsx +58 -25
- package/src/entitlement.ts +56 -0
- package/src/estimate-cache.ts +40 -0
- package/src/estimate-summary.tsx +172 -0
- package/src/estimate.ts +119 -0
- package/src/format.ts +71 -0
- package/src/hooks.ts +3 -35
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +145 -0
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +12 -7
- package/src/index.ts +9 -0
- package/src/limit-meter.tsx +55 -0
- package/src/plan-card.tsx +67 -0
- package/src/poll.ts +27 -0
- package/src/selectors.ts +93 -0
- package/src/types.ts +142 -2
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +9 -0
- package/tests/dialog.spec.ts +49 -0
- package/tests/entitlement.spec.ts +117 -0
- package/tests/estimate-cache.spec.ts +46 -0
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/entitlement.tsx +159 -0
- package/tests/harness/estimate-fixtures.ts +48 -0
- package/tests/harness/mount.tsx +19 -3
- package/tests/i18n.spec.ts +78 -8
- package/tests/pieces.spec.ts +134 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser, mountComponent, type Page } from '@owlmeans/test-ui'
|
|
3
|
+
import { closeHarness, harnessUrl } from './context.js'
|
|
4
|
+
|
|
5
|
+
const TIMEOUT = 60_000
|
|
6
|
+
|
|
7
|
+
afterAll(async () => {
|
|
8
|
+
await closeBrowser()
|
|
9
|
+
await closeHarness()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const open = async (query: string) => {
|
|
13
|
+
const mounted = await mountComponent({ url: `${await harnessUrl()}?${query}` })
|
|
14
|
+
// A case whose only content is a dialog (portalled) leaves an empty, zero-size <main>.
|
|
15
|
+
await mounted.page.locator('main').waitFor({ state: 'attached' })
|
|
16
|
+
return mounted
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const text = async (page: Page, selector: string): Promise<string> =>
|
|
20
|
+
(await page.locator(selector).first().innerText()).replace(/\s+/g, ' ').trim()
|
|
21
|
+
|
|
22
|
+
const attr = async (page: Page, selector: string, name: string) => await page.locator(selector).first().getAttribute(name)
|
|
23
|
+
|
|
24
|
+
const output = async (page: Page, id: string): Promise<string> => (await page.locator(`#${id}`).textContent()) ?? ''
|
|
25
|
+
|
|
26
|
+
/** The honeypot sits off screen and out of the tab order — present for a bot, never for a person. */
|
|
27
|
+
const honeypotHidden = async (page: Page): Promise<boolean> => await page.locator('[data-honeypot] input').evaluate(input => {
|
|
28
|
+
const rect = input.getBoundingClientRect()
|
|
29
|
+
return rect.right <= 0 && input.getAttribute('tabindex') === '-1' && input.closest('[aria-hidden="true"]') != null
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const FORBIDDEN = /non-?refundable|nicht erstattungsf|non rembours|bezzwrotn|no reembolsable|невозвратн|неповоротн|незваротн/i
|
|
33
|
+
|
|
34
|
+
describe('PerformanceConsentDialog — contract language, one unchecked checkbox', () => {
|
|
35
|
+
test('shows the German statement with the purchases, gates confirm on the checkbox, and toggles to English', async () => {
|
|
36
|
+
const { page, close } = await open('case=consent&lng=de&ui=en')
|
|
37
|
+
try {
|
|
38
|
+
const dialog = page.locator('[data-consent-dialog]')
|
|
39
|
+
await dialog.waitFor()
|
|
40
|
+
expect(await dialog.getAttribute('data-language')).toBe('de')
|
|
41
|
+
expect(await dialog.getAttribute('data-contract-language')).toBe('de')
|
|
42
|
+
expect(await text(page, '[data-consent-statement]')).toStartWith('Ich verlange ausdrücklich und stimme ausdrücklich zu')
|
|
43
|
+
expect(await text(page, '[data-consent-statement]')).toContain('Example Trader')
|
|
44
|
+
expect(await page.locator('[data-consent-purchase]').count()).toBe(2)
|
|
45
|
+
expect(await attr(page, '[data-consent-purchase][data-purchase-id="pur_1"]', 'data-deadline')).toBe('2026-10-09T00:00:00.000Z')
|
|
46
|
+
expect(await text(page, '[data-consent-purchase][data-purchase-id="pur_1"]')).toContain('8. Oktober 2026')
|
|
47
|
+
|
|
48
|
+
// Unchecked by default; confirm disabled until it is ticked.
|
|
49
|
+
expect(await attr(page, '[data-consent-checkbox]', 'data-state')).toBe('unchecked')
|
|
50
|
+
expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(true)
|
|
51
|
+
expect(await text(page, '[data-consent-confirm]')).toBe('Jetzt beginnen')
|
|
52
|
+
expect(await text(page, '[data-consent-decline]')).toBe('Nicht jetzt')
|
|
53
|
+
expect(await attr(page, '[data-legal-link="billing-terms"]', 'href')).toBe('https://example.test/de/legal/billing-terms')
|
|
54
|
+
expect(await text(page, '[data-legal-link="withdrawal-function"]')).toBe('Vertrag widerrufen')
|
|
55
|
+
expect(FORBIDDEN.test(await dialog.innerText())).toBe(false)
|
|
56
|
+
|
|
57
|
+
await page.locator('[data-consent-checkbox]').click()
|
|
58
|
+
expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(false)
|
|
59
|
+
|
|
60
|
+
// The toggle is phrased in the interface language, and un-ticks: the text agreed to is the one shown.
|
|
61
|
+
expect(await text(page, '[data-consent-language-toggle]')).toBe('Show in English')
|
|
62
|
+
await page.locator('[data-consent-language-toggle]').click()
|
|
63
|
+
expect(await dialog.getAttribute('data-language')).toBe('en')
|
|
64
|
+
expect(await text(page, '[data-consent-statement]')).toStartWith('I expressly request and agree')
|
|
65
|
+
expect(await attr(page, '[data-legal-link="billing-terms"]', 'href')).toBe('https://example.test/legal/billing-terms')
|
|
66
|
+
expect(await text(page, '[data-legal-link="withdrawal-function"]')).toBe('Withdraw from contract here')
|
|
67
|
+
expect(await text(page, '[data-consent-language-toggle]')).toBe('Show in German')
|
|
68
|
+
expect(await attr(page, '[data-consent-checkbox]', 'data-state')).toBe('unchecked')
|
|
69
|
+
expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(true)
|
|
70
|
+
|
|
71
|
+
await page.locator('[data-consent-checkbox]').click()
|
|
72
|
+
await page.locator('[data-consent-confirm]').click()
|
|
73
|
+
const body = JSON.parse(await output(page, 'consent-result'))
|
|
74
|
+
expect(body).toEqual({
|
|
75
|
+
purchaseIds: ['pur_1', 'pur_2'], textVersion: 'terms-2026-09', language: 'en', acknowledged: true, uiLanguage: 'en',
|
|
76
|
+
})
|
|
77
|
+
} finally { await close() }
|
|
78
|
+
}, TIMEOUT)
|
|
79
|
+
|
|
80
|
+
test('records the contract language when confirmed untoggled, and declines on "Nicht jetzt"', async () => {
|
|
81
|
+
const confirmed = await open('case=consent&lng=de&ui=en')
|
|
82
|
+
try {
|
|
83
|
+
await confirmed.page.locator('[data-consent-checkbox]').click()
|
|
84
|
+
await confirmed.page.locator('[data-consent-confirm]').click()
|
|
85
|
+
expect(JSON.parse(await output(confirmed.page, 'consent-result')).language).toBe('de')
|
|
86
|
+
} finally { await confirmed.close() }
|
|
87
|
+
|
|
88
|
+
const declined = await open('case=consent&lng=de&ui=en')
|
|
89
|
+
try {
|
|
90
|
+
await declined.page.locator('[data-consent-decline]').click()
|
|
91
|
+
expect(await output(declined.page, 'consent-result')).toBe('declined')
|
|
92
|
+
expect(await declined.page.locator('[data-consent-dialog]').count()).toBe(0)
|
|
93
|
+
} finally { await declined.close() }
|
|
94
|
+
}, TIMEOUT)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('SubscriptionStartDialog', () => {
|
|
98
|
+
test('French statement, "Continuer vers le paiement" gated on the checkbox', async () => {
|
|
99
|
+
const { page, close } = await open('case=start&lng=fr')
|
|
100
|
+
try {
|
|
101
|
+
const dialog = page.locator('[data-start-dialog]')
|
|
102
|
+
await dialog.waitFor()
|
|
103
|
+
expect(await dialog.getAttribute('data-language')).toBe('fr')
|
|
104
|
+
expect(await dialog.getAttribute('data-plan-sku')).toBe('pro-monthly')
|
|
105
|
+
expect(await dialog.innerText()).toContain('Commencer les services Pro maintenant')
|
|
106
|
+
expect(await text(page, '[data-start-statement]')).toStartWith('Je demande expressément et j’accepte expressément')
|
|
107
|
+
expect(await text(page, '[data-start-price]')).toBe('€20.00 / month')
|
|
108
|
+
expect(await text(page, '[data-start-confirm]')).toBe('Continuer vers le paiement')
|
|
109
|
+
expect(await attr(page, '[data-start-checkbox]', 'data-state')).toBe('unchecked')
|
|
110
|
+
expect(await page.locator('[data-start-confirm]').isDisabled()).toBe(true)
|
|
111
|
+
expect(await text(page, '[data-start-language-toggle]')).toBe('Show in English')
|
|
112
|
+
|
|
113
|
+
await page.locator('[data-start-checkbox]').click()
|
|
114
|
+
await page.locator('[data-start-confirm]').click()
|
|
115
|
+
expect(JSON.parse(await output(page, 'start-result'))).toEqual({
|
|
116
|
+
planSku: 'pro-monthly', textVersion: 'terms-2026-09', language: 'fr', acknowledged: true,
|
|
117
|
+
})
|
|
118
|
+
} finally { await close() }
|
|
119
|
+
}, TIMEOUT)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('Withdrawal function', () => {
|
|
123
|
+
test('in-app, French: statutory entry and confirm, three steps, receipt with the time of receipt in UTC', async () => {
|
|
124
|
+
const { page, close } = await open('case=withdrawal&lng=fr&ui=en')
|
|
125
|
+
try {
|
|
126
|
+
const entry = page.locator('[data-withdrawal-function]')
|
|
127
|
+
await entry.waitFor()
|
|
128
|
+
expect((await entry.innerText()).trim()).toBe('Renoncer au contrat ici')
|
|
129
|
+
expect(await entry.getAttribute('title')).toBe('Withdraw from contract here')
|
|
130
|
+
expect(await entry.getAttribute('data-language')).toBe('fr')
|
|
131
|
+
|
|
132
|
+
await entry.click()
|
|
133
|
+
const dialog = page.locator('[data-withdrawal-dialog]')
|
|
134
|
+
await dialog.waitFor()
|
|
135
|
+
expect(await dialog.getAttribute('data-language')).toBe('fr')
|
|
136
|
+
expect(await dialog.getAttribute('data-step')).toBe('form')
|
|
137
|
+
await page.locator('[data-withdrawal-step="form"]').waitFor()
|
|
138
|
+
await page.locator('[data-withdrawal-candidate]').first().waitFor()
|
|
139
|
+
expect(await page.locator('[data-withdrawal-candidate]').count()).toBe(2)
|
|
140
|
+
expect(await attr(page, '[data-withdrawal-candidate][data-purchase-id="pur_1"] [data-withdrawal-estimate]', 'data-refund-minor')).toBe('942')
|
|
141
|
+
// Only name, contract and e-mail are asked — prefilled from the list.
|
|
142
|
+
expect(await page.locator('[data-withdrawal-field="name"]').inputValue()).toBe('Marie Curie')
|
|
143
|
+
expect(await page.locator('[data-withdrawal-field="email"]').inputValue()).toBe('marie@example.test')
|
|
144
|
+
|
|
145
|
+
// No contract chosen yet: the form stays.
|
|
146
|
+
await page.locator('[data-withdrawal-continue]').click()
|
|
147
|
+
expect(await attr(page, '[data-withdrawal-form]', 'data-withdrawal-step')).toBe('form')
|
|
148
|
+
|
|
149
|
+
await page.locator('[data-withdrawal-candidate-input="pur_1"]').check()
|
|
150
|
+
await page.locator('[data-withdrawal-continue]').click()
|
|
151
|
+
await page.locator('[data-withdrawal-step="review"]').waitFor()
|
|
152
|
+
expect(await dialog.getAttribute('data-step')).toBe('review')
|
|
153
|
+
expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirmer la rétractation')
|
|
154
|
+
|
|
155
|
+
await page.locator('[data-withdrawal-language-toggle]').click()
|
|
156
|
+
expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirm withdrawal')
|
|
157
|
+
await page.locator('[data-withdrawal-language-toggle]').click()
|
|
158
|
+
expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirmer la rétractation')
|
|
159
|
+
|
|
160
|
+
await page.locator('[data-withdrawal-confirm]').click()
|
|
161
|
+
const receipt = page.locator('[data-withdrawal-receipt]')
|
|
162
|
+
await receipt.waitFor()
|
|
163
|
+
expect(await dialog.getAttribute('data-step')).toBe('receipt')
|
|
164
|
+
expect(await receipt.getAttribute('data-status')).toBe('refunded')
|
|
165
|
+
expect(await receipt.getAttribute('data-declaration-id')).toBe('wd_7Kq2')
|
|
166
|
+
expect(await receipt.getAttribute('data-received-at')).toBe('2026-09-27T14:05:09.000Z')
|
|
167
|
+
expect(await text(page, '[data-withdrawal-received-at]')).toContain('14:05:09')
|
|
168
|
+
expect(await text(page, '[data-withdrawal-received-at]')).toContain('UTC')
|
|
169
|
+
expect(await receipt.innerText()).toContain('Votre rétractation a été reçue le')
|
|
170
|
+
|
|
171
|
+
expect(JSON.parse(await output(page, 'withdrawal-body'))).toEqual({
|
|
172
|
+
purchaseId: 'pur_1', contractRef: 'CR-260923-AB12CD', name: 'Marie Curie', email: 'marie@example.test', language: 'fr',
|
|
173
|
+
})
|
|
174
|
+
} finally { await close() }
|
|
175
|
+
}, TIMEOUT)
|
|
176
|
+
|
|
177
|
+
test('public, German: contract reference, name and e-mail only, a honeypot no one reaches', async () => {
|
|
178
|
+
const { page, close } = await open('case=withdrawal-public&lng=de&ui=de')
|
|
179
|
+
try {
|
|
180
|
+
await page.locator('[data-withdrawal-form]').waitFor()
|
|
181
|
+
// Contract, name, e-mail — and the honeypot, which no person sees.
|
|
182
|
+
expect(await page.locator('[data-withdrawal-form] input').count()).toBe(4)
|
|
183
|
+
expect(await honeypotHidden(page)).toBe(true)
|
|
184
|
+
await page.locator('[data-withdrawal-field="contract"]').fill('CR-260923-AB12CD')
|
|
185
|
+
await page.locator('[data-withdrawal-field="name"]').fill('Max Mustermann')
|
|
186
|
+
await page.locator('[data-withdrawal-field="email"]').fill('not-an-address')
|
|
187
|
+
await page.locator('[data-withdrawal-continue]').click()
|
|
188
|
+
expect(await attr(page, '[data-withdrawal-form]', 'data-step')).toBe('form')
|
|
189
|
+
await page.locator('[data-withdrawal-field="email"]').fill('max@example.test')
|
|
190
|
+
await page.locator('[data-withdrawal-continue]').click()
|
|
191
|
+
expect(await text(page, '[data-withdrawal-confirm]')).toBe('Widerruf bestätigen')
|
|
192
|
+
await page.locator('[data-withdrawal-confirm]').click()
|
|
193
|
+
await page.locator('[data-withdrawal-receipt]').waitFor()
|
|
194
|
+
expect(await attr(page, '[data-withdrawal-receipt]', 'data-status')).toBe('received')
|
|
195
|
+
expect(JSON.parse(await output(page, 'withdrawal-body'))).toEqual({
|
|
196
|
+
contractRef: 'CR-260923-AB12CD', name: 'Max Mustermann', email: 'max@example.test', language: 'de',
|
|
197
|
+
})
|
|
198
|
+
} finally { await close() }
|
|
199
|
+
}, TIMEOUT)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('CancellationForm', () => {
|
|
203
|
+
test('German: kind, reason for cause, summary with "Jetzt kündigen", receipt with the effective date and print', async () => {
|
|
204
|
+
const { page, close } = await open('case=cancellation&lng=de&ui=de')
|
|
205
|
+
try {
|
|
206
|
+
const form = page.locator('[data-cancellation-form]')
|
|
207
|
+
await form.waitFor()
|
|
208
|
+
expect(await form.getAttribute('data-cancellation-step')).toBe('form')
|
|
209
|
+
expect(await form.getAttribute('data-language')).toBe('de')
|
|
210
|
+
expect(await honeypotHidden(page)).toBe(true)
|
|
211
|
+
|
|
212
|
+
await page.locator('[data-cancellation-field="kind"][data-kind="extraordinary"]').check()
|
|
213
|
+
await page.locator('[data-cancellation-field="name"]').fill('Max Mustermann')
|
|
214
|
+
await page.locator('[data-cancellation-field="email"]').fill('max@example.test')
|
|
215
|
+
await page.locator('[data-cancellation-continue]').click()
|
|
216
|
+
// A cancellation for cause needs its reason.
|
|
217
|
+
expect(await form.getAttribute('data-cancellation-step')).toBe('form')
|
|
218
|
+
await page.locator('[data-cancellation-field="kind"][data-kind="ordinary"]').check()
|
|
219
|
+
await page.locator('[data-cancellation-field="contract"]').fill('CR-260925-ZZ99YY')
|
|
220
|
+
await page.locator('[data-cancellation-continue]').click()
|
|
221
|
+
|
|
222
|
+
await page.locator('[data-cancellation-step="review"]').waitFor()
|
|
223
|
+
expect(await text(page, '[data-cancellation-summary] [data-summary="contract"]')).toBe('CR-260925-ZZ99YY')
|
|
224
|
+
expect(await text(page, '[data-cancellation-confirm]')).toBe('Jetzt kündigen')
|
|
225
|
+
await page.locator('[data-cancellation-confirm]').click()
|
|
226
|
+
|
|
227
|
+
const receipt = page.locator('[data-cancellation-receipt]')
|
|
228
|
+
await receipt.waitFor()
|
|
229
|
+
expect(await receipt.getAttribute('data-status')).toBe('scheduled')
|
|
230
|
+
expect(await receipt.getAttribute('data-effective-at')).toBe('2026-10-31T00:00:00.000Z')
|
|
231
|
+
expect(await receipt.getAttribute('data-received-at')).toBe('2026-09-27T16:30:00.000Z')
|
|
232
|
+
expect(await text(page, '[data-cancellation-effective]')).toContain('31. Oktober 2026')
|
|
233
|
+
await page.locator('[data-cancellation-print]').click()
|
|
234
|
+
expect(await output(page, 'printed')).toBe('printed')
|
|
235
|
+
expect(JSON.parse(await output(page, 'cancellation-body'))).toEqual({
|
|
236
|
+
kind: 'ordinary', name: 'Max Mustermann', contractRef: 'CR-260925-ZZ99YY', effective: 'earliest',
|
|
237
|
+
email: 'max@example.test', language: 'de',
|
|
238
|
+
})
|
|
239
|
+
} finally { await close() }
|
|
240
|
+
}, TIMEOUT)
|
|
241
|
+
|
|
242
|
+
test('French and English statutory confirm labels', async () => {
|
|
243
|
+
for (const [lng, label] of [['fr', 'Notification de la résiliation'], ['en', 'Cancel now']] as const) {
|
|
244
|
+
const { page, close } = await open(`case=cancellation&lng=${lng}&ui=${lng}`)
|
|
245
|
+
try {
|
|
246
|
+
await page.locator('[data-cancellation-field="name"]').fill('A Person')
|
|
247
|
+
await page.locator('[data-cancellation-field="email"]').fill('a@example.test')
|
|
248
|
+
await page.locator('[data-cancellation-continue]').click()
|
|
249
|
+
expect(await text(page, '[data-cancellation-confirm]')).toBe(label)
|
|
250
|
+
} finally { await close() }
|
|
251
|
+
}
|
|
252
|
+
}, TIMEOUT)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('Checkout limit', () => {
|
|
256
|
+
test('a per-purchase limit hides presets above it, names its maximum, and refuses more', async () => {
|
|
257
|
+
const { page, close } = await open('case=limit')
|
|
258
|
+
try {
|
|
259
|
+
const note = page.locator('[data-checkout-limit]')
|
|
260
|
+
await note.waitFor()
|
|
261
|
+
expect(await note.getAttribute('data-max-minor')).toBe('2500')
|
|
262
|
+
expect(await note.getAttribute('data-blocked')).toBe('false')
|
|
263
|
+
expect(await note.getAttribute('data-reason')).toBe('per-purchase')
|
|
264
|
+
expect(await note.innerText()).toContain('You can add up to $25.00 right now.')
|
|
265
|
+
expect(await page.locator('[data-amount-preset]').evaluateAll(items => items.map(item => item.getAttribute('data-amount-preset'))))
|
|
266
|
+
.toEqual(['1000', '2000'])
|
|
267
|
+
expect(await page.locator('[data-harness-legal-note]').count()).toBe(1)
|
|
268
|
+
|
|
269
|
+
const input = page.getByLabel('Custom amount')
|
|
270
|
+
await input.fill('30')
|
|
271
|
+
expect(await page.getByText('The most you can add right now is $25.00.').count()).toBe(1)
|
|
272
|
+
expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(true)
|
|
273
|
+
await input.fill('25')
|
|
274
|
+
expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(false)
|
|
275
|
+
await page.locator('[data-amount-confirm]').click()
|
|
276
|
+
expect(await output(page, 'confirmed')).toBe('2500')
|
|
277
|
+
} finally { await close() }
|
|
278
|
+
}, TIMEOUT)
|
|
279
|
+
|
|
280
|
+
test('a blocked limit disables the amount and the confirm button', async () => {
|
|
281
|
+
const { page, close } = await open('case=limit&blocked=true')
|
|
282
|
+
try {
|
|
283
|
+
const note = page.locator('[data-checkout-limit]')
|
|
284
|
+
await note.waitFor()
|
|
285
|
+
expect(await note.getAttribute('data-blocked')).toBe('true')
|
|
286
|
+
expect(await note.getAttribute('data-reason')).toBe('window')
|
|
287
|
+
expect(await note.innerText()).toContain('You cannot add credits right now.')
|
|
288
|
+
expect(await page.getByLabel('Custom amount').isDisabled()).toBe(true)
|
|
289
|
+
expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(true)
|
|
290
|
+
expect(await page.locator('[data-amount-preset]').count()).toBe(0)
|
|
291
|
+
} finally { await close() }
|
|
292
|
+
}, TIMEOUT)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('Locked billing country', () => {
|
|
296
|
+
test('the estimate adopts the locked country and the picker is disabled with its note', async () => {
|
|
297
|
+
const { page, close } = await open('case=country-locked')
|
|
298
|
+
try {
|
|
299
|
+
await page.locator('[data-country-select][data-locked="true"]').waitFor()
|
|
300
|
+
await page.locator('#locked:text("true")').waitFor()
|
|
301
|
+
expect(await attr(page, '[data-country-select]', 'data-country')).toBe('DE')
|
|
302
|
+
expect(await page.getByLabel('Billing country').isDisabled()).toBe(true)
|
|
303
|
+
expect(await text(page, '[data-country-note]')).toBe('Your billing country was set by your first purchase and cannot be changed here.')
|
|
304
|
+
// The first request named no country; the locked answer's country is the one asked next.
|
|
305
|
+
expect(JSON.parse(await output(page, 'requested'))).toEqual(['', 'DE'])
|
|
306
|
+
} finally { await close() }
|
|
307
|
+
}, TIMEOUT)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('PerformanceConsentProvider + useConsentGate', () => {
|
|
311
|
+
test('a bare 428 opens the one dialog, and after consent the action is retried exactly once', async () => {
|
|
312
|
+
const { page, close } = await open('case=consent-gate&lng=pl&ui=en')
|
|
313
|
+
try {
|
|
314
|
+
await page.locator('#gate-run').click()
|
|
315
|
+
const dialog = page.locator('[data-consent-dialog]')
|
|
316
|
+
await dialog.waitFor()
|
|
317
|
+
expect(await dialog.getAttribute('data-language')).toBe('pl')
|
|
318
|
+
await page.locator('[data-consent-checkbox]').click()
|
|
319
|
+
await page.locator('[data-consent-confirm]').click()
|
|
320
|
+
await page.locator('#gate-result:text("ok:2")').waitFor()
|
|
321
|
+
expect(JSON.parse(await output(page, 'recorded'))).toEqual(expect.objectContaining({
|
|
322
|
+
purchaseIds: ['pur_1', 'pur_2'], language: 'pl', acknowledged: true,
|
|
323
|
+
}))
|
|
324
|
+
await dialog.waitFor({ state: 'detached' })
|
|
325
|
+
|
|
326
|
+
// Recorded: ensure() now answers true with no dialog.
|
|
327
|
+
await page.locator('#gate-ensure').click()
|
|
328
|
+
await page.locator('#gate-result:text("ensure:true")').waitFor()
|
|
329
|
+
expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
|
|
330
|
+
} finally { await close() }
|
|
331
|
+
}, TIMEOUT)
|
|
332
|
+
|
|
333
|
+
test('a decline throws ConsentDeclined and the action is not retried', async () => {
|
|
334
|
+
const { page, close } = await open('case=consent-gate&lng=pl&ui=en')
|
|
335
|
+
try {
|
|
336
|
+
await page.locator('#gate-run').click()
|
|
337
|
+
await page.locator('[data-consent-dialog]').waitFor()
|
|
338
|
+
await page.locator('[data-consent-decline]').click()
|
|
339
|
+
await page.locator('#gate-result:text("declined:1")').waitFor()
|
|
340
|
+
expect(await output(page, 'gate-calls')).toBe('1')
|
|
341
|
+
} finally { await close() }
|
|
342
|
+
}, TIMEOUT)
|
|
343
|
+
})
|
package/tests/context.ts
CHANGED
|
@@ -17,6 +17,15 @@ export const harnessUrl = async (): Promise<string> => {
|
|
|
17
17
|
alias: { '@': resolve(here, '../src/@') },
|
|
18
18
|
dedupe: ['react', 'react-dom'],
|
|
19
19
|
},
|
|
20
|
+
// Pre-bundle every runtime dependency the mounted pieces reach. One discovered mid-navigation
|
|
21
|
+
// makes Vite re-optimize under a new hash, and the page briefly runs two copies of React.
|
|
22
|
+
optimizeDeps: {
|
|
23
|
+
include: [
|
|
24
|
+
'react', 'react-dom/client', '@radix-ui/react-checkbox', '@radix-ui/react-dialog', '@radix-ui/react-label',
|
|
25
|
+
'@radix-ui/react-progress', '@radix-ui/react-select', '@radix-ui/react-slot',
|
|
26
|
+
'class-variance-authority', 'clsx', 'tailwind-merge',
|
|
27
|
+
],
|
|
28
|
+
},
|
|
20
29
|
server: { port: 0 },
|
|
21
30
|
logLevel: 'warn',
|
|
22
31
|
})
|
package/tests/dialog.spec.ts
CHANGED
|
@@ -63,4 +63,53 @@ describe('AmountCheckoutDialog', () => {
|
|
|
63
63
|
expect(await readyMount.page.locator('#dialog-state').textContent()).toBe('closed')
|
|
64
64
|
} finally { await readyMount.close() }
|
|
65
65
|
}, TIMEOUT)
|
|
66
|
+
|
|
67
|
+
test('a Stripe Tax estimate replaces the plain tax note with the country and the rate, converted to the local currency', async () => {
|
|
68
|
+
const { page, close } = await mountComponent({ url: `${await harnessUrl()}?estimate=pl` })
|
|
69
|
+
try {
|
|
70
|
+
const dialog = page.getByRole('dialog')
|
|
71
|
+
await dialog.waitFor()
|
|
72
|
+
const text = await dialog.innerText()
|
|
73
|
+
expect(text).not.toContain('Stripe calculates any applicable tax')
|
|
74
|
+
expect(text).toContain('Poland')
|
|
75
|
+
expect(text).toContain('VAT (23%)')
|
|
76
|
+
// PLN replaces USD outright once a local currency is known — never both figures at once.
|
|
77
|
+
// Intl.NumberFormat separates an ISO-code currency (no locale symbol for PLN in "en") from
|
|
78
|
+
// the amount with a NO-BREAK SPACE (U+00A0), not a plain space.
|
|
79
|
+
expect(text).not.toContain('$2.35')
|
|
80
|
+
expect(text).not.toContain('$12.56')
|
|
81
|
+
expect(text).toContain('≈ PLN 9.40')
|
|
82
|
+
expect(text).toContain('≈ PLN 50.24')
|
|
83
|
+
expect(text).toContain("Converted at Stripe's current exchange rate")
|
|
84
|
+
expect(text).toContain('Tax is included in the estimated total below.')
|
|
85
|
+
} finally { await close() }
|
|
86
|
+
}, TIMEOUT)
|
|
87
|
+
|
|
88
|
+
test('shows the reverse-charge sentence and zero added tax for a valid cross-border VAT id', async () => {
|
|
89
|
+
const { page, close } = await mountComponent({ url: `${await harnessUrl()}?estimate=reverse` })
|
|
90
|
+
try {
|
|
91
|
+
const text = await page.getByRole('dialog').innerText()
|
|
92
|
+
expect(text).toContain('Germany')
|
|
93
|
+
expect(text).toContain('$10.21')
|
|
94
|
+
expect(text).toContain('your VAT ID means you account for this tax yourself')
|
|
95
|
+
} finally { await close() }
|
|
96
|
+
}, TIMEOUT)
|
|
97
|
+
|
|
98
|
+
test('falls back to "computed at checkout" wording with no numeric tax row when Stripe cannot resolve a rate', async () => {
|
|
99
|
+
const { page, close } = await mountComponent({ url: `${await harnessUrl()}?estimate=at-checkout` })
|
|
100
|
+
try {
|
|
101
|
+
const text = await page.getByRole('dialog').innerText()
|
|
102
|
+
expect(text).toContain('The exact tax is calculated at checkout.')
|
|
103
|
+
expect(text).not.toContain('Estimated total')
|
|
104
|
+
} finally { await close() }
|
|
105
|
+
}, TIMEOUT)
|
|
106
|
+
|
|
107
|
+
test('asks for a country before showing any estimate', async () => {
|
|
108
|
+
const { page, close } = await mountComponent({ url: `${await harnessUrl()}?estimate=location-required` })
|
|
109
|
+
try {
|
|
110
|
+
const text = await page.getByRole('dialog').innerText()
|
|
111
|
+
expect(text).toContain('Choose your billing country to see an estimate.')
|
|
112
|
+
expect(text).not.toContain('Estimated total')
|
|
113
|
+
} finally { await close() }
|
|
114
|
+
}, TIMEOUT)
|
|
66
115
|
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
import type { RegisteredEntrypoint } from '@owlmeans/entrypoint'
|
|
5
|
+
import {
|
|
6
|
+
LimitKind, LimitWindow, PortalFlow, SubscriptionStatus, entitlementViewOf,
|
|
7
|
+
type EntitlementPlanView, type EntitlementView, type LimitView, type PortalLinkBody,
|
|
8
|
+
type PortalLinkResponse, type ProductPlan,
|
|
9
|
+
} from '@owlmeans/payment'
|
|
10
|
+
import { useCapability, useLimit, usePortal } from '../src/entitlement.js'
|
|
11
|
+
import { limitStatusOf, planStatusLineOf } from '../src/selectors.js'
|
|
12
|
+
|
|
13
|
+
const AT = new Date('2026-09-16T12:00:00Z')
|
|
14
|
+
|
|
15
|
+
const planView: EntitlementPlanView = {
|
|
16
|
+
sku: 'pro-monthly', productSku: 'pro', title: 'Pro', rank: 10, free: false,
|
|
17
|
+
status: SubscriptionStatus.Active, paygate: 'stripe', subscribedAt: new Date('2026-01-01T00:00:00Z'),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const view: EntitlementView = entitlementViewOf({
|
|
21
|
+
capabilities: [
|
|
22
|
+
{ scope: 'feature', permissions: { whitelabel: true } },
|
|
23
|
+
{ scope: 'feature', permissions: { beta: true }, promo: { until: new Date('2026-09-01T00:00:00Z') } },
|
|
24
|
+
],
|
|
25
|
+
limits: { seats: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 5 } },
|
|
26
|
+
} as unknown as ProductPlan, planView, [{ key: 'seats', window: '2026-09', used: 3 }], AT)
|
|
27
|
+
|
|
28
|
+
/** Render hooks once on the server and hand back what they answered. */
|
|
29
|
+
const answer = <T>(hook: () => T): T => {
|
|
30
|
+
let value: T | undefined
|
|
31
|
+
const Probe = () => { value = hook(); return null }
|
|
32
|
+
renderToStaticMarkup(createElement(Probe))
|
|
33
|
+
return value as T
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const row = (limit: number, used: number): LimitView => ({
|
|
37
|
+
key: 'seats', param: 'limit:seats', kind: LimitKind.Lifetime, limit, used, remaining: Math.max(0, limit - used),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('entitlement hooks — null means unknown', () => {
|
|
41
|
+
test('answer null for a view that is not known yet, and the view otherwise', () => {
|
|
42
|
+
expect(answer(() => useCapability(null, 'feature:whitelabel'))).toBeNull()
|
|
43
|
+
expect(answer(() => useLimit(undefined, 'seats'))).toBeNull()
|
|
44
|
+
expect(answer(() => useCapability(view, 'feature:whitelabel'))).toBe(true)
|
|
45
|
+
// A lapsed promo is listed but not granted; an undeclared key has no row.
|
|
46
|
+
expect(answer(() => useCapability(view, 'feature:beta'))).toBe(false)
|
|
47
|
+
expect(answer(() => useLimit(view, 'unknown'))).toBeNull()
|
|
48
|
+
expect(answer(() => useLimit(view, 'seats'))).toMatchObject({ used: 3, limit: 5, remaining: 2, exhausted: false })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('limitStatusOf', () => {
|
|
53
|
+
test('clamps the ratio to [0, 1] and flags exhaustion', () => {
|
|
54
|
+
expect(limitStatusOf(row(5, 3))).toMatchObject({ ratio: 0.6, exhausted: false })
|
|
55
|
+
expect(limitStatusOf(row(5, 5))).toMatchObject({ ratio: 1, exhausted: true })
|
|
56
|
+
expect(limitStatusOf({ ...row(2, 3), remaining: 0 })).toMatchObject({ ratio: 1, exhausted: true })
|
|
57
|
+
expect(limitStatusOf({ ...row(5, -1), remaining: 5 })).toMatchObject({ ratio: 0, exhausted: false })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('reads a limit that is not included as empty and exhausted', () => {
|
|
61
|
+
expect(limitStatusOf(row(0, 0))).toMatchObject({ ratio: 0, exhausted: true })
|
|
62
|
+
expect(limitStatusOf(row(0, 1))).toMatchObject({ ratio: 1, exhausted: true })
|
|
63
|
+
expect(limitStatusOf(null)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('planStatusLineOf', () => {
|
|
68
|
+
test('picks the first matching line and the date it names', () => {
|
|
69
|
+
const periodEnd = new Date('2026-10-16T00:00:00Z')
|
|
70
|
+
expect(planStatusLineOf({ ...planView, periodEnd })).toEqual({ kind: 'renews', tone: 'ok', date: periodEnd })
|
|
71
|
+
expect(planStatusLineOf({ ...planView, periodEnd, cancelAtPeriodEnd: true }).kind).toBe('cancel-scheduled')
|
|
72
|
+
expect(planStatusLineOf({ ...planView, status: SubscriptionStatus.PastDue, pastDue: true, cancelAtPeriodEnd: true }).kind)
|
|
73
|
+
.toBe('past-due')
|
|
74
|
+
expect(planStatusLineOf({ ...planView, status: SubscriptionStatus.Suspended }).kind).toBe('suspended')
|
|
75
|
+
// A wire view whose dates were never revived still names its date.
|
|
76
|
+
expect(planStatusLineOf({ ...planView, status: SubscriptionStatus.Suspended, pausedAt: '2026-09-01T00:00:00Z' as never }))
|
|
77
|
+
.toEqual({ kind: 'paused', tone: 'warning', date: new Date('2026-09-01T00:00:00Z') })
|
|
78
|
+
expect(planStatusLineOf({ ...planView, free: true, periodEnd }).kind).toBe('free')
|
|
79
|
+
expect(planStatusLineOf({ ...planView, status: SubscriptionStatus.Canceled, periodEnd }).kind).toBe('canceled')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('usePortal', () => {
|
|
84
|
+
const entry = (calls: unknown[], url = 'https://billing.example/session') => ({
|
|
85
|
+
call: async (request: unknown) => { calls.push(request); return { url } },
|
|
86
|
+
}) as unknown as RegisteredEntrypoint<{ body: PortalLinkBody }, PortalLinkResponse>
|
|
87
|
+
|
|
88
|
+
test('calls the protocol with its flow and is inert without a DOM', async () => {
|
|
89
|
+
expect(typeof window).toBe('undefined')
|
|
90
|
+
const calls: unknown[] = []
|
|
91
|
+
const { portal, pending } = answer(() => usePortal(PortalFlow.Change))
|
|
92
|
+
expect(pending).toBe(false)
|
|
93
|
+
const result = await portal(entry(calls), { body: { planSku: 'pro-yearly' } })
|
|
94
|
+
expect(result).toEqual({ url: 'https://billing.example/session' })
|
|
95
|
+
expect(calls).toEqual([{ body: { planSku: 'pro-yearly', flow: PortalFlow.Change } }])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('sends the browser to the portal in the same window by default', async () => {
|
|
99
|
+
const assigned: string[] = []
|
|
100
|
+
const opened: unknown[][] = []
|
|
101
|
+
const previous = globalThis.window
|
|
102
|
+
Object.defineProperty(globalThis, 'window', {
|
|
103
|
+
configurable: true,
|
|
104
|
+
value: { location: { assign: (url: string) => assigned.push(url) }, open: (...args: unknown[]) => opened.push(args) },
|
|
105
|
+
})
|
|
106
|
+
try {
|
|
107
|
+
const calls: unknown[] = []
|
|
108
|
+
await answer(() => usePortal(PortalFlow.Manage)).portal(entry(calls))
|
|
109
|
+
await answer(() => usePortal(PortalFlow.Cancel, '_blank')).portal(entry(calls, 'https://billing.example/cancel'))
|
|
110
|
+
expect(calls).toEqual([{ body: { flow: PortalFlow.Manage } }, { body: { flow: PortalFlow.Cancel } }])
|
|
111
|
+
expect(assigned).toEqual(['https://billing.example/session'])
|
|
112
|
+
expect(opened).toEqual([['https://billing.example/cancel', '_blank', 'noopener,noreferrer']])
|
|
113
|
+
} finally {
|
|
114
|
+
Object.defineProperty(globalThis, 'window', { configurable: true, value: previous })
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { EstimateCache } from '../src/estimate-cache.js'
|
|
3
|
+
|
|
4
|
+
describe('EstimateCache', () => {
|
|
5
|
+
test('caches a resolved value for its TTL, then refetches', async () => {
|
|
6
|
+
const cache = new EstimateCache<number>()
|
|
7
|
+
let calls = 0
|
|
8
|
+
const factory = async () => { calls++; return calls }
|
|
9
|
+
expect(await cache.load('k', 50, factory)).toBe(1)
|
|
10
|
+
expect(await cache.load('k', 50, factory)).toBe(1)
|
|
11
|
+
expect(calls).toBe(1)
|
|
12
|
+
await new Promise(resolve => setTimeout(resolve, 60))
|
|
13
|
+
expect(await cache.load('k', 50, factory)).toBe(2)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('shares one in-flight request between concurrent callers', async () => {
|
|
17
|
+
const cache = new EstimateCache<number>()
|
|
18
|
+
let calls = 0
|
|
19
|
+
const factory = async () => { calls++; await new Promise(resolve => setTimeout(resolve, 20)); return 42 }
|
|
20
|
+
const [a, b] = await Promise.all([cache.load('k', 1_000, factory), cache.load('k', 1_000, factory)])
|
|
21
|
+
expect(a).toBe(42)
|
|
22
|
+
expect(b).toBe(42)
|
|
23
|
+
expect(calls).toBe(1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('never caches a rejected factory — the next call tries again', async () => {
|
|
27
|
+
const cache = new EstimateCache<number>()
|
|
28
|
+
let attempt = 0
|
|
29
|
+
const factory = async () => {
|
|
30
|
+
attempt++
|
|
31
|
+
if (attempt === 1) throw new Error('network')
|
|
32
|
+
return attempt
|
|
33
|
+
}
|
|
34
|
+
await expect(cache.load('k', 1_000, factory)).rejects.toThrow('network')
|
|
35
|
+
expect(await cache.load('k', 1_000, factory)).toBe(2)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('keys are independent', async () => {
|
|
39
|
+
const cache = new EstimateCache<string>()
|
|
40
|
+
expect(await cache.load('a', 1_000, async () => 'A')).toBe('A')
|
|
41
|
+
expect(await cache.load('b', 1_000, async () => 'B')).toBe('B')
|
|
42
|
+
expect(cache.get('a')).toBe('A')
|
|
43
|
+
expect(cache.get('b')).toBe('B')
|
|
44
|
+
expect(cache.get('c')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|