mppx 0.6.19 → 0.6.20
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/CHANGELOG.md +7 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +15 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +190 -40
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- package/src/middlewares/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +23 -1
- package/src/server/Mppx.test.ts +73 -3
- package/src/server/Mppx.ts +291 -58
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- package/src/zod.ts +24 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import * as Store from '../../Store.js'
|
|
4
|
+
import { fromStore } from './Store.js'
|
|
5
|
+
import type { SubscriptionRecord } from './Types.js'
|
|
6
|
+
|
|
7
|
+
const subscriptionId = 'sub_123'
|
|
8
|
+
|
|
9
|
+
function createRecord(overrides: Partial<SubscriptionRecord> = {}): SubscriptionRecord {
|
|
10
|
+
return {
|
|
11
|
+
amount: '10000000',
|
|
12
|
+
billingAnchor: '2025-01-01T00:00:00.000Z',
|
|
13
|
+
chainId: 4217,
|
|
14
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
15
|
+
lastChargedPeriod: 0,
|
|
16
|
+
lookupKey: 'user-1:plan:pro',
|
|
17
|
+
periodCount: '1',
|
|
18
|
+
periodUnit: 'day',
|
|
19
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
20
|
+
reference: `0x${'a'.repeat(64)}`,
|
|
21
|
+
subscriptionExpires: '2026-01-01T00:00:00.000Z',
|
|
22
|
+
subscriptionId,
|
|
23
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('tempo subscription store', () => {
|
|
29
|
+
test('rejects a replayed activation challenge', async () => {
|
|
30
|
+
const store = fromStore(Store.memory())
|
|
31
|
+
|
|
32
|
+
const first = await store.activate({
|
|
33
|
+
challengeId: 'challenge-1',
|
|
34
|
+
create: async () => ({ subscription: createRecord() }),
|
|
35
|
+
lookupKey: 'user-1:plan:pro',
|
|
36
|
+
})
|
|
37
|
+
expect(first.status).toBe('activated')
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
await store.activate({
|
|
41
|
+
challengeId: 'challenge-1',
|
|
42
|
+
create: async () => ({ subscription: createRecord() }),
|
|
43
|
+
lookupKey: 'user-1:plan:pro',
|
|
44
|
+
}),
|
|
45
|
+
).toEqual({ status: 'replayed' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('tracks a resolved lookup key activation until committed', async () => {
|
|
49
|
+
const store = fromStore(Store.memory())
|
|
50
|
+
let finishActivation!: () => void
|
|
51
|
+
const pendingActivation = new Promise<void>((resolve) => {
|
|
52
|
+
finishActivation = resolve
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const first = store.activate({
|
|
56
|
+
challengeId: 'challenge-1',
|
|
57
|
+
create: async () => {
|
|
58
|
+
await pendingActivation
|
|
59
|
+
return { subscription: createRecord() }
|
|
60
|
+
},
|
|
61
|
+
lookupKey: 'user-1:plan:pro',
|
|
62
|
+
})
|
|
63
|
+
expect(
|
|
64
|
+
await store.activate({
|
|
65
|
+
challengeId: 'challenge-2',
|
|
66
|
+
create: async () => ({ subscription: createRecord() }),
|
|
67
|
+
lookupKey: 'user-1:plan:pro',
|
|
68
|
+
}),
|
|
69
|
+
).toEqual({ status: 'inFlight' })
|
|
70
|
+
|
|
71
|
+
finishActivation()
|
|
72
|
+
expect((await first).status).toBe('activated')
|
|
73
|
+
expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('replaces a stale activation marker after the timeout', async () => {
|
|
77
|
+
const store = fromStore(Store.memory(), { activationTimeoutMs: 0 })
|
|
78
|
+
let finishActivation!: () => void
|
|
79
|
+
const pendingActivation = new Promise<void>((resolve) => {
|
|
80
|
+
finishActivation = resolve
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const first = store.activate({
|
|
84
|
+
challengeId: 'challenge-1',
|
|
85
|
+
create: async () => {
|
|
86
|
+
await pendingActivation
|
|
87
|
+
return { subscription: createRecord({ reference: `0x${'b'.repeat(64)}` }) }
|
|
88
|
+
},
|
|
89
|
+
lookupKey: 'user-1:plan:pro',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const second = await store.activate({
|
|
93
|
+
challengeId: 'challenge-2',
|
|
94
|
+
create: async () => ({ subscription: createRecord() }),
|
|
95
|
+
lookupKey: 'user-1:plan:pro',
|
|
96
|
+
})
|
|
97
|
+
expect(second.status).toBe('activated')
|
|
98
|
+
|
|
99
|
+
finishActivation()
|
|
100
|
+
expect(await first).toEqual({ status: 'claimMismatch' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('does not replace an activation marker that is committing', async () => {
|
|
104
|
+
const rawStore = Store.memory()
|
|
105
|
+
let store!: ReturnType<typeof fromStore>
|
|
106
|
+
let nestedStatus: string | undefined
|
|
107
|
+
let sawCommit = false
|
|
108
|
+
const wrapped = {
|
|
109
|
+
...rawStore,
|
|
110
|
+
async update(key, change) {
|
|
111
|
+
const result = await rawStore.update(key, change)
|
|
112
|
+
const marker = await rawStore.get(key)
|
|
113
|
+
if (
|
|
114
|
+
!sawCommit &&
|
|
115
|
+
key === 'tempo:subscription:activation:user-1:plan:pro' &&
|
|
116
|
+
marker &&
|
|
117
|
+
typeof marker === 'object' &&
|
|
118
|
+
'committingAt' in marker
|
|
119
|
+
) {
|
|
120
|
+
sawCommit = true
|
|
121
|
+
const nested = await store.activate({
|
|
122
|
+
challengeId: 'challenge-2',
|
|
123
|
+
create: async () => ({ subscription: createRecord({ subscriptionId: 'sub_2' }) }),
|
|
124
|
+
lookupKey: 'user-1:plan:pro',
|
|
125
|
+
})
|
|
126
|
+
nestedStatus = nested.status
|
|
127
|
+
}
|
|
128
|
+
return result
|
|
129
|
+
},
|
|
130
|
+
} satisfies Store.AtomicStore<Record<string, unknown>>
|
|
131
|
+
store = fromStore(wrapped, { activationTimeoutMs: 0 })
|
|
132
|
+
|
|
133
|
+
const activated = await store.activate({
|
|
134
|
+
challengeId: 'challenge-1',
|
|
135
|
+
create: async () => ({ subscription: createRecord() }),
|
|
136
|
+
lookupKey: 'user-1:plan:pro',
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(activated.status).toBe('activated')
|
|
140
|
+
expect(nestedStatus).toBe('inFlight')
|
|
141
|
+
expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('keeps a superseded activation record for reconciliation', async () => {
|
|
145
|
+
const store = fromStore(Store.memory(), { activationTimeoutMs: 0 })
|
|
146
|
+
let finishActivation!: () => void
|
|
147
|
+
const pendingActivation = new Promise<void>((resolve) => {
|
|
148
|
+
finishActivation = resolve
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const first = store.activate({
|
|
152
|
+
challengeId: 'challenge-1',
|
|
153
|
+
create: async () => {
|
|
154
|
+
await pendingActivation
|
|
155
|
+
return {
|
|
156
|
+
subscription: createRecord({
|
|
157
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
158
|
+
subscriptionId: 'sub_late',
|
|
159
|
+
}),
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
lookupKey: 'user-1:plan:pro',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const second = await store.activate({
|
|
166
|
+
challengeId: 'challenge-2',
|
|
167
|
+
create: async () => ({
|
|
168
|
+
subscription: createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_2' }),
|
|
169
|
+
}),
|
|
170
|
+
lookupKey: 'user-1:plan:pro',
|
|
171
|
+
})
|
|
172
|
+
expect(second.status).toBe('activated')
|
|
173
|
+
|
|
174
|
+
finishActivation()
|
|
175
|
+
expect(await first).toEqual({ status: 'claimMismatch' })
|
|
176
|
+
expect((await store.get('sub_late'))?.canceledAt).toBeTruthy()
|
|
177
|
+
expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_2')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('rechecks the lookup key after claiming activation', async () => {
|
|
181
|
+
const rawStore = Store.memory()
|
|
182
|
+
const seeded = fromStore(rawStore)
|
|
183
|
+
const store = fromStore({
|
|
184
|
+
...rawStore,
|
|
185
|
+
async update(key, change) {
|
|
186
|
+
const result = await rawStore.update(key, change)
|
|
187
|
+
if (key === 'tempo:subscription:activation:user-1:plan:pro') {
|
|
188
|
+
await seeded.put(createRecord())
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
let createCalled = false
|
|
194
|
+
|
|
195
|
+
const activated = await store.activate({
|
|
196
|
+
challengeId: 'challenge-1',
|
|
197
|
+
create: async () => {
|
|
198
|
+
createCalled = true
|
|
199
|
+
return { subscription: createRecord({ subscriptionId: 'sub_new' }) }
|
|
200
|
+
},
|
|
201
|
+
isReusable: () => true,
|
|
202
|
+
lookupKey: 'user-1:plan:pro',
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(activated).toEqual({ status: 'existing', subscription: createRecord() })
|
|
206
|
+
expect(createCalled).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('clears the activation marker when creation fails', async () => {
|
|
210
|
+
const store = fromStore(Store.memory())
|
|
211
|
+
|
|
212
|
+
await expect(
|
|
213
|
+
store.activate({
|
|
214
|
+
challengeId: 'challenge-1',
|
|
215
|
+
create: async () => {
|
|
216
|
+
throw new Error('activation failed')
|
|
217
|
+
},
|
|
218
|
+
lookupKey: 'user-1:plan:pro',
|
|
219
|
+
}),
|
|
220
|
+
).rejects.toThrow('activation failed')
|
|
221
|
+
|
|
222
|
+
const retried = await store.activate({
|
|
223
|
+
challengeId: 'challenge-2',
|
|
224
|
+
create: async () => ({ subscription: createRecord() }),
|
|
225
|
+
lookupKey: 'user-1:plan:pro',
|
|
226
|
+
})
|
|
227
|
+
expect(retried.status).toBe('activated')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('tracks an in-flight renewal and commits it once', async () => {
|
|
231
|
+
const store = fromStore(Store.memory())
|
|
232
|
+
await store.put(createRecord())
|
|
233
|
+
|
|
234
|
+
const renewed = await store.renew({
|
|
235
|
+
inFlightReference: '0xrenewal',
|
|
236
|
+
periodIndex: 1,
|
|
237
|
+
renew: async ({ inFlightReference, subscription }) => {
|
|
238
|
+
expect(inFlightReference).toBe('0xrenewal')
|
|
239
|
+
return {
|
|
240
|
+
subscription: {
|
|
241
|
+
...subscription,
|
|
242
|
+
lastChargedPeriod: 1,
|
|
243
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
subscriptionId,
|
|
248
|
+
})
|
|
249
|
+
expect(renewed.status).toBe('renewed')
|
|
250
|
+
expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined)
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
await store.renew({
|
|
254
|
+
inFlightReference: '0xmissing',
|
|
255
|
+
periodIndex: 2,
|
|
256
|
+
renew: async () => ({ subscription: createRecord() }),
|
|
257
|
+
subscriptionId: 'sub_missing',
|
|
258
|
+
}),
|
|
259
|
+
).toEqual({ status: 'missing' })
|
|
260
|
+
|
|
261
|
+
const committed = await store.get(subscriptionId)
|
|
262
|
+
expect(committed?.lastChargedPeriod).toBe(1)
|
|
263
|
+
expect(committed?.inFlightPeriod).toBe(undefined)
|
|
264
|
+
|
|
265
|
+
const charged = await store.renew({
|
|
266
|
+
inFlightReference: '0xrenewal',
|
|
267
|
+
periodIndex: 1,
|
|
268
|
+
renew: async () => ({ subscription: createRecord() }),
|
|
269
|
+
subscriptionId,
|
|
270
|
+
})
|
|
271
|
+
expect(charged.status).toBe('charged')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('returns in-flight for a duplicate renewal period', async () => {
|
|
275
|
+
const store = fromStore(Store.memory())
|
|
276
|
+
await store.put(createRecord())
|
|
277
|
+
let finishRenewal!: () => void
|
|
278
|
+
const pendingRenewal = new Promise<void>((resolve) => {
|
|
279
|
+
finishRenewal = resolve
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const first = store.renew({
|
|
283
|
+
inFlightReference: '0xrenewal',
|
|
284
|
+
periodIndex: 1,
|
|
285
|
+
renew: async ({ subscription }) => {
|
|
286
|
+
await pendingRenewal
|
|
287
|
+
return { subscription }
|
|
288
|
+
},
|
|
289
|
+
subscriptionId,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const duplicate = await store.renew({
|
|
293
|
+
inFlightReference: '0xrenewal',
|
|
294
|
+
periodIndex: 1,
|
|
295
|
+
renew: async ({ subscription }) => ({ subscription }),
|
|
296
|
+
subscriptionId,
|
|
297
|
+
})
|
|
298
|
+
expect(duplicate.status).toBe('inFlight')
|
|
299
|
+
|
|
300
|
+
finishRenewal()
|
|
301
|
+
expect((await first).status).toBe('renewed')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('returns in-flight when any non-stale renewal is active', async () => {
|
|
305
|
+
const store = fromStore(Store.memory())
|
|
306
|
+
await store.put(
|
|
307
|
+
createRecord({ inFlightPeriod: 1, inFlightStartedAt: new Date().toISOString() }),
|
|
308
|
+
)
|
|
309
|
+
let renewCalled = false
|
|
310
|
+
|
|
311
|
+
const renewal = await store.renew({
|
|
312
|
+
inFlightReference: '0xrenewal',
|
|
313
|
+
periodIndex: 2,
|
|
314
|
+
renew: async ({ subscription }) => {
|
|
315
|
+
renewCalled = true
|
|
316
|
+
return { subscription }
|
|
317
|
+
},
|
|
318
|
+
subscriptionId,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(renewal.status).toBe('inFlight')
|
|
322
|
+
expect(renewCalled).toBe(false)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('replaces a stale in-flight renewal after the timeout', async () => {
|
|
326
|
+
const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
|
|
327
|
+
await store.put(createRecord())
|
|
328
|
+
let finishRenewal!: () => void
|
|
329
|
+
const pendingRenewal = new Promise<void>((resolve) => {
|
|
330
|
+
finishRenewal = resolve
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const first = store.renew({
|
|
334
|
+
inFlightReference: '0xfirst',
|
|
335
|
+
periodIndex: 1,
|
|
336
|
+
renew: async ({ subscription }) => {
|
|
337
|
+
await pendingRenewal
|
|
338
|
+
return { subscription }
|
|
339
|
+
},
|
|
340
|
+
subscriptionId,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const second = await store.renew({
|
|
344
|
+
inFlightReference: '0xsecond',
|
|
345
|
+
periodIndex: 1,
|
|
346
|
+
renew: async ({ subscription }) => ({
|
|
347
|
+
subscription: {
|
|
348
|
+
...subscription,
|
|
349
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
subscriptionId,
|
|
353
|
+
})
|
|
354
|
+
expect(second.status).toBe('renewed')
|
|
355
|
+
|
|
356
|
+
finishRenewal()
|
|
357
|
+
expect(await first).toEqual({ status: 'claimMismatch' })
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('stale renewal cannot commit over a newer in-flight attempt', async () => {
|
|
361
|
+
const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
|
|
362
|
+
await store.put(createRecord())
|
|
363
|
+
let finishFirst!: () => void
|
|
364
|
+
let finishSecond!: () => void
|
|
365
|
+
const firstPending = new Promise<void>((resolve) => {
|
|
366
|
+
finishFirst = resolve
|
|
367
|
+
})
|
|
368
|
+
const secondPending = new Promise<void>((resolve) => {
|
|
369
|
+
finishSecond = resolve
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const first = store.renew({
|
|
373
|
+
inFlightReference: '0xfirst',
|
|
374
|
+
periodIndex: 1,
|
|
375
|
+
renew: async ({ subscription }) => {
|
|
376
|
+
await firstPending
|
|
377
|
+
return {
|
|
378
|
+
subscription: {
|
|
379
|
+
...subscription,
|
|
380
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
subscriptionId,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const second = store.renew({
|
|
388
|
+
inFlightReference: '0xsecond',
|
|
389
|
+
periodIndex: 1,
|
|
390
|
+
renew: async ({ subscription }) => {
|
|
391
|
+
await secondPending
|
|
392
|
+
return {
|
|
393
|
+
subscription: {
|
|
394
|
+
...subscription,
|
|
395
|
+
reference: `0x${'c'.repeat(64)}`,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
subscriptionId,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
finishFirst()
|
|
403
|
+
expect(await first).toEqual({ status: 'claimMismatch' })
|
|
404
|
+
expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond')
|
|
405
|
+
|
|
406
|
+
finishSecond()
|
|
407
|
+
expect((await second).status).toBe('renewed')
|
|
408
|
+
expect((await store.get(subscriptionId))?.reference).toBe(`0x${'c'.repeat(64)}`)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
test('stale renewal failure cannot clear a newer in-flight attempt', async () => {
|
|
412
|
+
const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 })
|
|
413
|
+
await store.put(createRecord())
|
|
414
|
+
let rejectFirst!: () => void
|
|
415
|
+
let finishSecond!: () => void
|
|
416
|
+
const firstPending = new Promise<void>((_resolve, reject) => {
|
|
417
|
+
rejectFirst = () => reject(new Error('first failed'))
|
|
418
|
+
})
|
|
419
|
+
const secondPending = new Promise<void>((resolve) => {
|
|
420
|
+
finishSecond = resolve
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
const first = store.renew({
|
|
424
|
+
inFlightReference: '0xfirst',
|
|
425
|
+
periodIndex: 1,
|
|
426
|
+
renew: async ({ subscription }) => {
|
|
427
|
+
await firstPending
|
|
428
|
+
return { subscription }
|
|
429
|
+
},
|
|
430
|
+
subscriptionId,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const second = store.renew({
|
|
434
|
+
inFlightReference: '0xsecond',
|
|
435
|
+
periodIndex: 1,
|
|
436
|
+
renew: async ({ subscription }) => {
|
|
437
|
+
await secondPending
|
|
438
|
+
return { subscription }
|
|
439
|
+
},
|
|
440
|
+
subscriptionId,
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
rejectFirst()
|
|
444
|
+
await expect(first).rejects.toThrow('first failed')
|
|
445
|
+
expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond')
|
|
446
|
+
|
|
447
|
+
finishSecond()
|
|
448
|
+
expect((await second).status).toBe('renewed')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('clears an in-flight renewal after failure', async () => {
|
|
452
|
+
const store = fromStore(Store.memory())
|
|
453
|
+
await store.put(createRecord())
|
|
454
|
+
|
|
455
|
+
await expect(
|
|
456
|
+
store.renew({
|
|
457
|
+
inFlightReference: '0xrenewal',
|
|
458
|
+
periodIndex: 1,
|
|
459
|
+
renew: async () => {
|
|
460
|
+
throw new Error('renewal failed')
|
|
461
|
+
},
|
|
462
|
+
subscriptionId,
|
|
463
|
+
}),
|
|
464
|
+
).rejects.toThrow('renewal failed')
|
|
465
|
+
|
|
466
|
+
expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined)
|
|
467
|
+
expect(
|
|
468
|
+
(
|
|
469
|
+
await store.renew({
|
|
470
|
+
inFlightReference: '0xrenewal',
|
|
471
|
+
periodIndex: 1,
|
|
472
|
+
renew: async ({ subscription }) => ({ subscription }),
|
|
473
|
+
subscriptionId,
|
|
474
|
+
})
|
|
475
|
+
).status,
|
|
476
|
+
).toBe('renewed')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('preserves cancellation that lands during an in-flight renewal', async () => {
|
|
480
|
+
const store = fromStore(Store.memory())
|
|
481
|
+
await store.put(createRecord())
|
|
482
|
+
|
|
483
|
+
const renewed = await store.renew({
|
|
484
|
+
inFlightReference: '0xrenewal',
|
|
485
|
+
periodIndex: 1,
|
|
486
|
+
renew: async ({ subscription }) => {
|
|
487
|
+
await store.put({
|
|
488
|
+
...subscription,
|
|
489
|
+
canceledAt: '2025-01-02T00:00:00.000Z',
|
|
490
|
+
})
|
|
491
|
+
return {
|
|
492
|
+
subscription: {
|
|
493
|
+
...subscription,
|
|
494
|
+
lastChargedPeriod: 1,
|
|
495
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
subscriptionId,
|
|
500
|
+
})
|
|
501
|
+
expect(renewed.status).toBe('renewed')
|
|
502
|
+
|
|
503
|
+
const committed = await store.get(subscriptionId)
|
|
504
|
+
expect(committed?.canceledAt).toBe('2025-01-02T00:00:00.000Z')
|
|
505
|
+
expect(committed?.lastChargedPeriod).toBe(1)
|
|
506
|
+
expect(committed?.inFlightPeriod).toBe(undefined)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
test('does not renew a superseded subscription record', async () => {
|
|
510
|
+
const store = fromStore(Store.memory())
|
|
511
|
+
await store.put(createRecord({ subscriptionId: 'sub_old' }))
|
|
512
|
+
await store.put(createRecord({ reference: `0x${'b'.repeat(64)}`, subscriptionId: 'sub_new' }))
|
|
513
|
+
let renewCalled = false
|
|
514
|
+
|
|
515
|
+
const renewal = await store.renew({
|
|
516
|
+
inFlightReference: '0xrenewal',
|
|
517
|
+
periodIndex: 1,
|
|
518
|
+
renew: async ({ subscription }) => {
|
|
519
|
+
renewCalled = true
|
|
520
|
+
return { subscription }
|
|
521
|
+
},
|
|
522
|
+
subscriptionId: 'sub_old',
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
expect(renewal.status).toBe('superseded')
|
|
526
|
+
expect(renewCalled).toBe(false)
|
|
527
|
+
expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('does not reclaim lookup ownership when superseded during renewal', async () => {
|
|
531
|
+
const store = fromStore(Store.memory())
|
|
532
|
+
await store.put(createRecord({ subscriptionId: 'sub_old' }))
|
|
533
|
+
|
|
534
|
+
const renewal = await store.renew({
|
|
535
|
+
inFlightReference: '0xrenewal',
|
|
536
|
+
periodIndex: 1,
|
|
537
|
+
renew: async ({ subscription }) => {
|
|
538
|
+
await store.put(
|
|
539
|
+
createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_new' }),
|
|
540
|
+
)
|
|
541
|
+
return {
|
|
542
|
+
subscription: {
|
|
543
|
+
...subscription,
|
|
544
|
+
reference: `0x${'b'.repeat(64)}`,
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
subscriptionId: 'sub_old',
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
expect(renewal.status).toBe('superseded')
|
|
552
|
+
expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new')
|
|
553
|
+
})
|
|
554
|
+
})
|