@avelonjs/conformance 0.1.0
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/LICENSE +21 -0
- package/README.md +155 -0
- package/package.json +50 -0
- package/src/fakes/ai.ts +113 -0
- package/src/fakes/cache.ts +153 -0
- package/src/fakes/database.ts +612 -0
- package/src/fakes/flags.ts +67 -0
- package/src/fakes/identity.ts +253 -0
- package/src/fakes/logs.ts +101 -0
- package/src/fakes/mail.ts +140 -0
- package/src/fakes/notifications.ts +73 -0
- package/src/fakes/payments.ts +188 -0
- package/src/fakes/queue.ts +205 -0
- package/src/fakes/ratelimit.ts +143 -0
- package/src/fakes/realtime.ts +96 -0
- package/src/fakes/search.ts +127 -0
- package/src/fakes/social.ts +83 -0
- package/src/fakes/storage.ts +125 -0
- package/src/fakes/tokens.ts +106 -0
- package/src/harness.ts +103 -0
- package/src/index.ts +21 -0
- package/src/suites/ai.ts +167 -0
- package/src/suites/cache.ts +174 -0
- package/src/suites/database.ts +853 -0
- package/src/suites/flags.ts +80 -0
- package/src/suites/identity.ts +293 -0
- package/src/suites/index.ts +16 -0
- package/src/suites/logs.ts +116 -0
- package/src/suites/mail.ts +160 -0
- package/src/suites/notifications.ts +119 -0
- package/src/suites/payments.ts +200 -0
- package/src/suites/queue.ts +238 -0
- package/src/suites/ratelimit.ts +134 -0
- package/src/suites/realtime.ts +123 -0
- package/src/suites/search.ts +115 -0
- package/src/suites/social.ts +129 -0
- package/src/suites/storage.ts +158 -0
- package/src/suites/tokens.ts +105 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
type NotificationCapabilities,
|
|
5
|
+
type NotificationChannel,
|
|
6
|
+
type NotificationDriver,
|
|
7
|
+
type NotificationMessage,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
import { captureFailure, type SuiteContext } from '../harness'
|
|
10
|
+
|
|
11
|
+
interface SentInspectable {
|
|
12
|
+
readonly sent: readonly {
|
|
13
|
+
readonly channel: NotificationChannel
|
|
14
|
+
readonly message: NotificationMessage
|
|
15
|
+
}[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type AssayDriver = NotificationDriver<NotificationCapabilities> & Partial<SentInspectable>
|
|
19
|
+
|
|
20
|
+
function assay<TDriver extends AssayDriver>(
|
|
21
|
+
context: SuiteContext<TDriver>,
|
|
22
|
+
name: string,
|
|
23
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
24
|
+
): void {
|
|
25
|
+
test(name, async () => {
|
|
26
|
+
const driver = await context.create()
|
|
27
|
+
try {
|
|
28
|
+
await assertion(driver)
|
|
29
|
+
} finally {
|
|
30
|
+
await context.cleanup?.(driver)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hasSentInspection(driver: object): driver is SentInspectable {
|
|
36
|
+
return 'sent' in driver && Array.isArray(driver.sent)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
40
|
+
const error = await captureFailure(operation)
|
|
41
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
42
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
43
|
+
expect(error.code).toBe('INVALID')
|
|
44
|
+
return error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const channels: readonly NotificationChannel[] = ['push', 'sms', 'inApp']
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Registers the portable notifications contract conformance suite.
|
|
51
|
+
*
|
|
52
|
+
* Channels narrow the accepted `send` argument rather than adding methods. The suite therefore
|
|
53
|
+
* sends through every declared channel and requires undeclared channels to normalize to `Invalid`.
|
|
54
|
+
*
|
|
55
|
+
* @param context Fresh isolated notification drivers and optional cleanup.
|
|
56
|
+
*/
|
|
57
|
+
export function notificationsSuite<TDriver extends AssayDriver>(
|
|
58
|
+
context: SuiteContext<TDriver>,
|
|
59
|
+
): void {
|
|
60
|
+
describe(`notifications conformance: ${context.name}`, () => {
|
|
61
|
+
assay(context, 'reports a present and stable resolved notifications instance', (driver) => {
|
|
62
|
+
const instance = driver.instance
|
|
63
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
64
|
+
expect(driver.instance).toBe(instance)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
assay(
|
|
68
|
+
context,
|
|
69
|
+
'honors every declared notification channel in both directions',
|
|
70
|
+
async (driver) => {
|
|
71
|
+
for (const channel of channels) {
|
|
72
|
+
const operation = () =>
|
|
73
|
+
driver.send(channel, {
|
|
74
|
+
recipient: `recipient-${channel}`,
|
|
75
|
+
title: 'Assay notification',
|
|
76
|
+
body: `Sent through ${channel}`,
|
|
77
|
+
})
|
|
78
|
+
if (driver.capabilities.channels.includes(channel)) {
|
|
79
|
+
const receipt = await operation()
|
|
80
|
+
expect(receipt.channel).toBe(channel)
|
|
81
|
+
expect(receipt.id.length).toBeGreaterThan(0)
|
|
82
|
+
} else {
|
|
83
|
+
const error = await expectInvalid(operation)
|
|
84
|
+
expect(error.metadata.fields?.channel).toBeDefined()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assay(context, 'preserves notification content and structured data', async (driver) => {
|
|
91
|
+
const channel = driver.capabilities.channels[0]
|
|
92
|
+
if (!channel) return
|
|
93
|
+
const message = {
|
|
94
|
+
recipient: 'actor-1',
|
|
95
|
+
title: 'Build complete',
|
|
96
|
+
body: 'Your deployment is ready.',
|
|
97
|
+
data: { deploymentId: 'deploy-1', attempts: 2, nested: { region: 'iad1' } },
|
|
98
|
+
}
|
|
99
|
+
await driver.send(channel, message)
|
|
100
|
+
if (hasSentInspection(driver)) {
|
|
101
|
+
expect(driver.sent).toEqual([{ channel, message }])
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
assay(
|
|
106
|
+
context,
|
|
107
|
+
'returns a distinct stable receipt for each accepted message',
|
|
108
|
+
async (driver) => {
|
|
109
|
+
const channel = driver.capabilities.channels[0]
|
|
110
|
+
if (!channel) return
|
|
111
|
+
const first = await driver.send(channel, { recipient: 'actor-1', body: 'First' })
|
|
112
|
+
const second = await driver.send(channel, { recipient: 'actor-1', body: 'Second' })
|
|
113
|
+
expect(first.id).not.toBe(second.id)
|
|
114
|
+
expect(first.channel).toBe(channel)
|
|
115
|
+
expect(second.channel).toBe(channel)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
NotFound,
|
|
5
|
+
Unauthenticated,
|
|
6
|
+
type CheckoutPaymentSurface,
|
|
7
|
+
type PaymentCapabilities,
|
|
8
|
+
type PaymentDriver,
|
|
9
|
+
type SubscriptionPaymentSurface,
|
|
10
|
+
type WebhookPaymentSurface,
|
|
11
|
+
} from '@avelonjs/core'
|
|
12
|
+
import { assertCapabilitySurface, captureFailure, type SuiteContext } from '../harness'
|
|
13
|
+
|
|
14
|
+
type AssayDriver = PaymentDriver<PaymentCapabilities> &
|
|
15
|
+
Partial<SubscriptionPaymentSurface & CheckoutPaymentSurface & WebhookPaymentSurface>
|
|
16
|
+
|
|
17
|
+
function assay<TDriver extends AssayDriver>(
|
|
18
|
+
context: SuiteContext<TDriver>,
|
|
19
|
+
name: string,
|
|
20
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
21
|
+
): void {
|
|
22
|
+
test(name, async () => {
|
|
23
|
+
const driver = await context.create()
|
|
24
|
+
try {
|
|
25
|
+
await assertion(driver)
|
|
26
|
+
} finally {
|
|
27
|
+
await context.cleanup?.(driver)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasSubscriptions(driver: AssayDriver): driver is AssayDriver & SubscriptionPaymentSurface {
|
|
33
|
+
return driver.capabilities.subscriptions && typeof driver.subscribe === 'function'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasCheckout(driver: AssayDriver): driver is AssayDriver & CheckoutPaymentSurface {
|
|
37
|
+
return driver.capabilities.checkout && typeof driver.checkout === 'function'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasWebhooks(driver: AssayDriver): driver is AssayDriver & WebhookPaymentSurface {
|
|
41
|
+
return driver.capabilities.webhooks && typeof driver.webhook === 'function'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function expectError<TError>(
|
|
45
|
+
constructor: abstract new (...arguments_: never[]) => TError,
|
|
46
|
+
operation: () => Promise<unknown>,
|
|
47
|
+
): Promise<TError> {
|
|
48
|
+
const error = await captureFailure(operation)
|
|
49
|
+
expect(error).toBeInstanceOf(constructor)
|
|
50
|
+
if (!(error instanceof constructor)) throw new Error('Expected normalized payment error.')
|
|
51
|
+
return error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Registers the portable payments contract conformance suite.
|
|
56
|
+
*
|
|
57
|
+
* The webhook verifier is configured with the assay signature `assay-signature`. A bad signature is
|
|
58
|
+
* `Unauthenticated`, because the sender was not established; malformed verified bytes are `Invalid`.
|
|
59
|
+
* The stable webhook event ID enables application idempotency, but the contract exposes no event
|
|
60
|
+
* processing operation or idempotency key, so the suite does not invent an idempotency assertion.
|
|
61
|
+
*
|
|
62
|
+
* @param context Fresh isolated payment drivers and optional cleanup.
|
|
63
|
+
*/
|
|
64
|
+
export function paymentsSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
65
|
+
describe(`payments conformance: ${context.name}`, () => {
|
|
66
|
+
assay(context, 'reports a present and stable resolved payments instance', (driver) => {
|
|
67
|
+
const instance = driver.instance
|
|
68
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
69
|
+
expect(driver.instance).toBe(instance)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
assay(context, 'enforces every payments capability surface in both directions', (driver) => {
|
|
73
|
+
const surfaces: readonly [string, boolean, readonly string[]][] = [
|
|
74
|
+
[
|
|
75
|
+
'subscriptions',
|
|
76
|
+
driver.capabilities.subscriptions,
|
|
77
|
+
['subscribe', 'subscription', 'cancelSubscription'],
|
|
78
|
+
],
|
|
79
|
+
['checkout', driver.capabilities.checkout, ['checkout']],
|
|
80
|
+
['webhooks', driver.capabilities.webhooks, ['webhook']],
|
|
81
|
+
]
|
|
82
|
+
for (const [capability, declared, methods] of surfaces) {
|
|
83
|
+
assertCapabilitySurface(driver, capability, declared, methods)
|
|
84
|
+
assertCapabilitySurface(
|
|
85
|
+
{ capabilities: { [capability]: false } },
|
|
86
|
+
capability,
|
|
87
|
+
false,
|
|
88
|
+
methods,
|
|
89
|
+
)
|
|
90
|
+
expect(() =>
|
|
91
|
+
assertCapabilitySurface(
|
|
92
|
+
{ capabilities: { [capability]: false }, [methods[0] ?? 'missing']: () => undefined },
|
|
93
|
+
capability,
|
|
94
|
+
false,
|
|
95
|
+
methods,
|
|
96
|
+
),
|
|
97
|
+
).toThrow()
|
|
98
|
+
expect(() =>
|
|
99
|
+
assertCapabilitySurface(
|
|
100
|
+
{ capabilities: { [capability]: true } },
|
|
101
|
+
capability,
|
|
102
|
+
true,
|
|
103
|
+
methods,
|
|
104
|
+
),
|
|
105
|
+
).toThrow()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
assay(context, 'creates and retrieves customers with metadata intact', async (driver) => {
|
|
110
|
+
const created = await driver.createCustomer({
|
|
111
|
+
email: 'actor@example.test',
|
|
112
|
+
metadata: { actorId: 'actor-1', plan: 'pro' },
|
|
113
|
+
})
|
|
114
|
+
expect(created.id.length).toBeGreaterThan(0)
|
|
115
|
+
expect(await driver.customer(created.id)).toEqual(created)
|
|
116
|
+
expect(created.metadata).toEqual({ actorId: 'actor-1', plan: 'pro' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
assay(context, 'normalizes a missing customer to NotFound', async (driver) => {
|
|
120
|
+
const error = await expectError(NotFound, () => driver.customer('missing-customer'))
|
|
121
|
+
expect(error.code).toBe('NOT_FOUND')
|
|
122
|
+
expect(error.metadata).toEqual({
|
|
123
|
+
resource: 'payment-customer',
|
|
124
|
+
identifier: 'missing-customer',
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
assay(
|
|
129
|
+
context,
|
|
130
|
+
'creates, retrieves, and cancels subscriptions when declared',
|
|
131
|
+
async (driver) => {
|
|
132
|
+
if (!hasSubscriptions(driver)) return
|
|
133
|
+
const customer = await driver.createCustomer({})
|
|
134
|
+
const created = await driver.subscribe(customer.id, 'price-pro')
|
|
135
|
+
expect(created).toMatchObject({
|
|
136
|
+
customerId: customer.id,
|
|
137
|
+
priceId: 'price-pro',
|
|
138
|
+
status: 'active',
|
|
139
|
+
})
|
|
140
|
+
expect(created.currentPeriodEndsAt).toBeInstanceOf(Date)
|
|
141
|
+
expect(await driver.subscription(created.id)).toEqual(created)
|
|
142
|
+
expect(await driver.cancelSubscription(created.id)).toMatchObject({
|
|
143
|
+
id: created.id,
|
|
144
|
+
status: 'canceled',
|
|
145
|
+
})
|
|
146
|
+
expect((await driver.subscription(created.id)).status).toBe('canceled')
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assay(context, 'creates a hosted checkout session when declared', async (driver) => {
|
|
151
|
+
if (!hasCheckout(driver)) return
|
|
152
|
+
const customer = await driver.createCustomer({})
|
|
153
|
+
const checkout = await driver.checkout({
|
|
154
|
+
customerId: customer.id,
|
|
155
|
+
priceId: 'price-team',
|
|
156
|
+
successUrl: 'https://app.example.test/billing/success',
|
|
157
|
+
cancelUrl: 'https://app.example.test/billing/cancel',
|
|
158
|
+
})
|
|
159
|
+
expect(checkout.id.length).toBeGreaterThan(0)
|
|
160
|
+
expect(new URL(checkout.url).protocol).toBe('https:')
|
|
161
|
+
expect(checkout.expiresAt).toBeInstanceOf(Date)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assay(context, 'verifies and normalizes signed webhook bytes when declared', async (driver) => {
|
|
165
|
+
if (!hasWebhooks(driver)) return
|
|
166
|
+
const occurredAt = '2026-08-03T12:00:00.000Z'
|
|
167
|
+
const payload = new TextEncoder().encode(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
id: 'event-1',
|
|
170
|
+
type: 'subscription.updated',
|
|
171
|
+
data: { subscriptionId: 'subscription-1', status: 'active' },
|
|
172
|
+
occurredAt,
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
expect(await driver.webhook(payload, 'assay-signature')).toEqual({
|
|
176
|
+
id: 'event-1',
|
|
177
|
+
type: 'subscription.updated',
|
|
178
|
+
data: { subscriptionId: 'subscription-1', status: 'active' },
|
|
179
|
+
occurredAt: new Date(occurredAt),
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
assay(context, 'rejects an unverified webhook signature as Unauthenticated', async (driver) => {
|
|
184
|
+
if (!hasWebhooks(driver)) return
|
|
185
|
+
const payload = new TextEncoder().encode('{}')
|
|
186
|
+
const error = await expectError(Unauthenticated, () => driver.webhook(payload, 'forged'))
|
|
187
|
+
expect(error.code).toBe('UNAUTHENTICATED')
|
|
188
|
+
expect(error.metadata.guard).toBe('payment-webhook')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
assay(context, 'rejects malformed verified webhook bytes as Invalid', async (driver) => {
|
|
192
|
+
if (!hasWebhooks(driver)) return
|
|
193
|
+
const error = await expectError(Invalid, () =>
|
|
194
|
+
driver.webhook(new TextEncoder().encode('not-json'), 'assay-signature'),
|
|
195
|
+
)
|
|
196
|
+
expect(error.code).toBe('INVALID')
|
|
197
|
+
expect(error.cause).toBeDefined()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type DeadLetterQueueSurface,
|
|
4
|
+
type DelayedQueueSurface,
|
|
5
|
+
type QueueCapabilities,
|
|
6
|
+
type QueueDriver,
|
|
7
|
+
type QueueReceipt,
|
|
8
|
+
type RetryQueueSurface,
|
|
9
|
+
} from '@avelonjs/core'
|
|
10
|
+
import { assertCapabilitySurface, type SuiteContext } from '../harness'
|
|
11
|
+
|
|
12
|
+
type AssayDriver = QueueDriver<QueueCapabilities> &
|
|
13
|
+
Partial<DelayedQueueSurface & RetryQueueSurface & DeadLetterQueueSurface>
|
|
14
|
+
|
|
15
|
+
function assay<TDriver extends AssayDriver>(
|
|
16
|
+
context: SuiteContext<TDriver>,
|
|
17
|
+
name: string,
|
|
18
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
19
|
+
): void {
|
|
20
|
+
test(name, async () => {
|
|
21
|
+
const driver = await context.create()
|
|
22
|
+
try {
|
|
23
|
+
await assertion(driver)
|
|
24
|
+
} finally {
|
|
25
|
+
await context.cleanup?.(driver)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasDelay(driver: AssayDriver): driver is AssayDriver & DelayedQueueSurface {
|
|
31
|
+
return driver.capabilities.delayed && typeof driver.enqueueAt === 'function'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasRetries(driver: AssayDriver): driver is AssayDriver & RetryQueueSurface {
|
|
35
|
+
return driver.capabilities.retries && typeof driver.retry === 'function'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasDeadLetter(driver: AssayDriver): driver is AssayDriver & DeadLetterQueueSurface {
|
|
39
|
+
return driver.capabilities.deadLetter && typeof driver.failed === 'function'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Registers the portable queue contract conformance suite.
|
|
44
|
+
*
|
|
45
|
+
* Dead-letter-capable contexts configure a three-attempt limit. The limit belongs to driver
|
|
46
|
+
* construction rather than `QueueJob`, so the fixture setup owns that value. Replaying a terminal
|
|
47
|
+
* failure starts a fresh attempt budget.
|
|
48
|
+
*
|
|
49
|
+
* @param context Fresh isolated queue drivers and optional cleanup.
|
|
50
|
+
*/
|
|
51
|
+
export function queueSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
52
|
+
const delayMs = context.queueDelayMs ?? (context.live ? 2_000 : 80)
|
|
53
|
+
const settleMs = context.queueSettleMs ?? (context.live ? 3_000 : 120)
|
|
54
|
+
|
|
55
|
+
describe(`queue conformance: ${context.name}`, () => {
|
|
56
|
+
assay(context, 'reports a present and stable resolved queue instance', (driver) => {
|
|
57
|
+
const instance = driver.instance
|
|
58
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
59
|
+
expect(driver.instance).toBe(instance)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
assay(context, 'enforces every queue capability surface in both directions', (driver) => {
|
|
63
|
+
const surfaces: readonly [string, boolean, readonly string[]][] = [
|
|
64
|
+
['delayed', driver.capabilities.delayed, ['enqueueAt']],
|
|
65
|
+
['retries', driver.capabilities.retries, ['retry']],
|
|
66
|
+
['deadLetter', driver.capabilities.deadLetter, ['failed', 'replay', 'forget']],
|
|
67
|
+
]
|
|
68
|
+
for (const [capability, declared, methods] of surfaces) {
|
|
69
|
+
assertCapabilitySurface(driver, capability, declared, methods)
|
|
70
|
+
const unavailable = { capabilities: { [capability]: false } }
|
|
71
|
+
assertCapabilitySurface(unavailable, capability, false, methods)
|
|
72
|
+
expect(() =>
|
|
73
|
+
assertCapabilitySurface(
|
|
74
|
+
{ ...unavailable, [methods[0] ?? 'missing']: () => undefined },
|
|
75
|
+
capability,
|
|
76
|
+
false,
|
|
77
|
+
methods,
|
|
78
|
+
),
|
|
79
|
+
).toThrow()
|
|
80
|
+
expect(() =>
|
|
81
|
+
assertCapabilitySurface(
|
|
82
|
+
{ capabilities: { [capability]: true } },
|
|
83
|
+
capability,
|
|
84
|
+
true,
|
|
85
|
+
methods,
|
|
86
|
+
),
|
|
87
|
+
).toThrow()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
assay(context, 'enqueues and removes a job when its handler resolves', async (driver) => {
|
|
92
|
+
const id = await driver.enqueue({ name: 'GenerateReport', payload: { reportId: 'report-1' } })
|
|
93
|
+
const receipts: QueueReceipt[] = []
|
|
94
|
+
expect(
|
|
95
|
+
await driver.drain(async (receipt) => {
|
|
96
|
+
receipts.push(receipt)
|
|
97
|
+
}),
|
|
98
|
+
).toBe(1)
|
|
99
|
+
expect(receipts).toEqual([
|
|
100
|
+
{
|
|
101
|
+
id,
|
|
102
|
+
job: { name: 'GenerateReport', payload: { reportId: 'report-1' } },
|
|
103
|
+
attempt: 1,
|
|
104
|
+
},
|
|
105
|
+
])
|
|
106
|
+
expect(await driver.drain(async () => undefined)).toBe(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
assay(context, 'honors destination queues and drain limits', async (driver) => {
|
|
110
|
+
await driver.enqueue({ name: 'DefaultJob', payload: {} })
|
|
111
|
+
await driver.enqueue({ name: 'FirstMail', payload: {}, queue: 'mail' })
|
|
112
|
+
await driver.enqueue({ name: 'SecondMail', payload: {}, queue: 'mail' })
|
|
113
|
+
const names: string[] = []
|
|
114
|
+
|
|
115
|
+
expect(
|
|
116
|
+
await driver.drain(
|
|
117
|
+
async (receipt) => {
|
|
118
|
+
names.push(receipt.job.name)
|
|
119
|
+
},
|
|
120
|
+
{ queue: 'mail', limit: 1 },
|
|
121
|
+
),
|
|
122
|
+
).toBe(1)
|
|
123
|
+
expect(names).toEqual(['FirstMail'])
|
|
124
|
+
expect(await driver.drain(async () => undefined)).toBe(2)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
assay(context, 'redelivers thrown jobs only when retries are declared', async (driver) => {
|
|
128
|
+
await driver.enqueue({ name: 'FragileJob', payload: {} })
|
|
129
|
+
let firstAttempt = 0
|
|
130
|
+
expect(
|
|
131
|
+
await driver.drain(async (receipt) => {
|
|
132
|
+
firstAttempt = receipt.attempt
|
|
133
|
+
throw new Error('temporary failure')
|
|
134
|
+
}),
|
|
135
|
+
).toBe(1)
|
|
136
|
+
expect(firstAttempt).toBe(1)
|
|
137
|
+
|
|
138
|
+
if (driver.capabilities.retries) {
|
|
139
|
+
let secondAttempt = 0
|
|
140
|
+
expect(
|
|
141
|
+
await driver.drain(async (receipt) => {
|
|
142
|
+
secondAttempt = receipt.attempt
|
|
143
|
+
}),
|
|
144
|
+
).toBe(1)
|
|
145
|
+
expect(secondAttempt).toBe(2)
|
|
146
|
+
} else {
|
|
147
|
+
// No retry means at-most-once after a handler throw. Infinite redelivery would secretly
|
|
148
|
+
// implement the capability and can make every future drain permanently fail.
|
|
149
|
+
expect(await driver.drain(async () => undefined)).toBe(0)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
assay(context, 'releases a failed job through the retry surface', async (driver) => {
|
|
154
|
+
if (!hasRetries(driver)) return
|
|
155
|
+
const id = await driver.enqueue({ name: 'RetryJob', payload: {} })
|
|
156
|
+
await driver.drain(async () => {
|
|
157
|
+
throw new Error('retry me')
|
|
158
|
+
})
|
|
159
|
+
await driver.retry(id)
|
|
160
|
+
|
|
161
|
+
let attempt = 0
|
|
162
|
+
expect(
|
|
163
|
+
await driver.drain(async (receipt) => {
|
|
164
|
+
attempt = receipt.attempt
|
|
165
|
+
}),
|
|
166
|
+
).toBe(1)
|
|
167
|
+
expect(attempt).toBe(2)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assay(
|
|
171
|
+
context,
|
|
172
|
+
'dead-letters after exhausting tries and supports replay and forget',
|
|
173
|
+
async (driver) => {
|
|
174
|
+
if (!hasDeadLetter(driver)) return
|
|
175
|
+
const id = await driver.enqueue({
|
|
176
|
+
name: 'TerminalJob',
|
|
177
|
+
payload: { accountId: 'account-1' },
|
|
178
|
+
queue: 'critical',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
182
|
+
expect(
|
|
183
|
+
await driver.drain(
|
|
184
|
+
async () => {
|
|
185
|
+
throw new Error('terminal failure')
|
|
186
|
+
},
|
|
187
|
+
{ queue: 'critical' },
|
|
188
|
+
),
|
|
189
|
+
).toBe(1)
|
|
190
|
+
expect((await driver.failed('critical')).length).toBe(attempt === 3 ? 1 : 0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const failures = await driver.failed('critical')
|
|
194
|
+
expect(failures).toHaveLength(1)
|
|
195
|
+
expect(failures[0]).toMatchObject({
|
|
196
|
+
id,
|
|
197
|
+
job: {
|
|
198
|
+
name: 'TerminalJob',
|
|
199
|
+
payload: { accountId: 'account-1' },
|
|
200
|
+
queue: 'critical',
|
|
201
|
+
},
|
|
202
|
+
attempt: 3,
|
|
203
|
+
error: 'terminal failure',
|
|
204
|
+
})
|
|
205
|
+
expect(failures[0]?.failedAt).toBeInstanceOf(Date)
|
|
206
|
+
|
|
207
|
+
await driver.replay(id)
|
|
208
|
+
expect(await driver.failed('critical')).toHaveLength(0)
|
|
209
|
+
let replayAttempt = 0
|
|
210
|
+
await driver.drain(async (receipt) => {
|
|
211
|
+
replayAttempt = receipt.attempt
|
|
212
|
+
})
|
|
213
|
+
expect(replayAttempt).toBe(1)
|
|
214
|
+
|
|
215
|
+
const forgottenId = await driver.enqueue({ name: 'ForgottenJob', payload: {} })
|
|
216
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
217
|
+
await driver.drain(async () => {
|
|
218
|
+
throw new Error('forget me')
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
expect(await driver.failed()).toHaveLength(1)
|
|
222
|
+
await driver.forget(forgottenId)
|
|
223
|
+
expect(await driver.failed()).toHaveLength(0)
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assay(context, 'keeps delayed jobs invisible until their delivery time', async (driver) => {
|
|
228
|
+
if (!hasDelay(driver)) return
|
|
229
|
+
await driver.enqueueAt(
|
|
230
|
+
{ name: 'ScheduledJob', payload: { batch: 4 } },
|
|
231
|
+
new Date(Date.now() + delayMs),
|
|
232
|
+
)
|
|
233
|
+
expect(await driver.drain(async () => undefined)).toBe(0)
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs))
|
|
235
|
+
expect(await driver.drain(async () => undefined)).toBe(1)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
type RateLimitAlgorithm,
|
|
5
|
+
type RateLimitCapabilities,
|
|
6
|
+
type RateLimitDriver,
|
|
7
|
+
type RateLimitPolicy,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
import { captureFailure, type SuiteContext } from '../harness'
|
|
10
|
+
|
|
11
|
+
type AssayDriver = RateLimitDriver<RateLimitCapabilities>
|
|
12
|
+
|
|
13
|
+
function assay<TDriver extends AssayDriver>(
|
|
14
|
+
context: SuiteContext<TDriver>,
|
|
15
|
+
name: string,
|
|
16
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
17
|
+
): void {
|
|
18
|
+
test(name, async () => {
|
|
19
|
+
const driver = await context.create()
|
|
20
|
+
try {
|
|
21
|
+
await assertion(driver)
|
|
22
|
+
} finally {
|
|
23
|
+
await context.cleanup?.(driver)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
29
|
+
const error = await captureFailure(operation)
|
|
30
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
31
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
32
|
+
expect(error.code).toBe('INVALID')
|
|
33
|
+
return error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const algorithms: readonly RateLimitAlgorithm[] = ['tokenBucket', 'slidingWindow']
|
|
37
|
+
|
|
38
|
+
function policy(algorithm: RateLimitAlgorithm, limit = 2): RateLimitPolicy {
|
|
39
|
+
return { algorithm, limit, intervalSeconds: 0.05 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Registers the portable rate-limit contract conformance suite.
|
|
44
|
+
*
|
|
45
|
+
* Algorithms narrow the accepted policy argument rather than adding methods, so both directions
|
|
46
|
+
* are tested behaviorally. The first `limit` units are accepted and the next is denied; after one
|
|
47
|
+
* full interval, capacity is available again. Denial is represented by `RateLimitDecision`, not a
|
|
48
|
+
* `RateLimited` exception, so only malformed policies normalize to `Invalid`.
|
|
49
|
+
*
|
|
50
|
+
* @param context Fresh isolated rate-limit drivers and optional cleanup.
|
|
51
|
+
*/
|
|
52
|
+
export function ratelimitSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
53
|
+
describe(`ratelimit conformance: ${context.name}`, () => {
|
|
54
|
+
assay(context, 'reports a present and stable resolved ratelimit instance', (driver) => {
|
|
55
|
+
const instance = driver.instance
|
|
56
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
57
|
+
expect(driver.instance).toBe(instance)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
assay(
|
|
61
|
+
context,
|
|
62
|
+
'honors every declared rate-limit algorithm in both directions',
|
|
63
|
+
async (driver) => {
|
|
64
|
+
for (const algorithm of algorithms) {
|
|
65
|
+
if (driver.capabilities.algorithms.includes(algorithm)) {
|
|
66
|
+
const decision = await driver.consume(`declared-${algorithm}`, policy(algorithm))
|
|
67
|
+
expect(decision.allowed).toBe(true)
|
|
68
|
+
expect(decision.remaining).toBe(1)
|
|
69
|
+
} else {
|
|
70
|
+
const error = await expectInvalid(() =>
|
|
71
|
+
driver.consume(`undeclared-${algorithm}`, policy(algorithm)),
|
|
72
|
+
)
|
|
73
|
+
expect(error.metadata.fields?.algorithm).toBeDefined()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assay(context, 'blocks at the configured limit boundary', async (driver) => {
|
|
80
|
+
for (const algorithm of driver.capabilities.algorithms) {
|
|
81
|
+
const configured = policy(algorithm)
|
|
82
|
+
const first = await driver.consume(`boundary-${algorithm}`, configured)
|
|
83
|
+
const second = await driver.consume(`boundary-${algorithm}`, configured)
|
|
84
|
+
const blocked = await driver.consume(`boundary-${algorithm}`, configured)
|
|
85
|
+
expect(first).toMatchObject({ allowed: true, remaining: 1 })
|
|
86
|
+
expect(second).toMatchObject({ allowed: true, remaining: 0 })
|
|
87
|
+
expect(blocked).toMatchObject({ allowed: false, remaining: 0 })
|
|
88
|
+
expect(blocked.retryAfterMs).toBeGreaterThan(0)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
assay(context, 'restores capacity after the configured interval', async (driver) => {
|
|
93
|
+
for (const algorithm of driver.capabilities.algorithms) {
|
|
94
|
+
const configured = policy(algorithm, 1)
|
|
95
|
+
expect((await driver.consume(`window-${algorithm}`, configured)).allowed).toBe(true)
|
|
96
|
+
expect((await driver.consume(`window-${algorithm}`, configured)).allowed).toBe(false)
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
98
|
+
const restored = await driver.consume(`window-${algorithm}`, configured)
|
|
99
|
+
expect(restored.allowed).toBe(true)
|
|
100
|
+
expect(restored.remaining).toBe(0)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
assay(context, 'keeps separate keys on separate budgets', async (driver) => {
|
|
105
|
+
for (const algorithm of driver.capabilities.algorithms) {
|
|
106
|
+
const configured = policy(algorithm, 1)
|
|
107
|
+
const first = await driver.consume(`first-${algorithm}`, configured)
|
|
108
|
+
const second = await driver.consume(`second-${algorithm}`, configured)
|
|
109
|
+
expect(first).toMatchObject({ allowed: true, remaining: 0 })
|
|
110
|
+
expect(second).toMatchObject({ allowed: true, remaining: 0 })
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
assay(context, 'clears accumulated state for one key', async (driver) => {
|
|
115
|
+
for (const algorithm of driver.capabilities.algorithms) {
|
|
116
|
+
const key = `reset-${algorithm}`
|
|
117
|
+
const configured = policy(algorithm, 1)
|
|
118
|
+
await driver.consume(key, configured)
|
|
119
|
+
expect((await driver.consume(key, configured)).allowed).toBe(false)
|
|
120
|
+
await driver.reset(key)
|
|
121
|
+
expect((await driver.consume(key, configured)).allowed).toBe(true)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
assay(context, 'normalizes malformed policies to Invalid', async (driver) => {
|
|
126
|
+
const algorithm = driver.capabilities.algorithms[0]
|
|
127
|
+
if (!algorithm) return
|
|
128
|
+
const error = await expectInvalid(() =>
|
|
129
|
+
driver.consume('invalid-policy', { algorithm, limit: 0, intervalSeconds: 1 }),
|
|
130
|
+
)
|
|
131
|
+
expect(Object.keys(error.metadata.fields ?? {})).toContain('limit')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}
|