@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,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Invalid, type FlagCapabilities, type FlagDriver } from '@avelonjs/core'
|
|
3
|
+
import { captureFailure, type SuiteContext } from '../harness'
|
|
4
|
+
|
|
5
|
+
type AssayDriver = FlagDriver<FlagCapabilities>
|
|
6
|
+
|
|
7
|
+
function assay<TDriver extends AssayDriver>(
|
|
8
|
+
context: SuiteContext<TDriver>,
|
|
9
|
+
name: string,
|
|
10
|
+
assertion: (driver: TDriver) => Promise<void> | void,
|
|
11
|
+
): void {
|
|
12
|
+
test(name, async () => {
|
|
13
|
+
const driver = await context.create()
|
|
14
|
+
try {
|
|
15
|
+
await assertion(driver)
|
|
16
|
+
} finally {
|
|
17
|
+
await context.cleanup?.(driver)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
23
|
+
const error = await captureFailure(operation)
|
|
24
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
25
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
26
|
+
expect(error.code).toBe('INVALID')
|
|
27
|
+
return error
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registers the portable feature-flag contract conformance suite.
|
|
32
|
+
*
|
|
33
|
+
* The fixture provides `assay.enabled`, `assay.variant`, and `assay.targeted`; actor
|
|
34
|
+
* `actor-enabled` matches the targeted rule. Targeting narrows the optional context argument rather
|
|
35
|
+
* than adding a method, so both directions are checked behaviorally. An unknown flag returns its
|
|
36
|
+
* supplied default and deliberately produces no taxonomy error.
|
|
37
|
+
*
|
|
38
|
+
* @param context Fresh isolated flag drivers and optional cleanup.
|
|
39
|
+
*/
|
|
40
|
+
export function flagsSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
41
|
+
describe(`flags conformance: ${context.name}`, () => {
|
|
42
|
+
assay(context, 'reports a present and stable resolved flags instance', (driver) => {
|
|
43
|
+
const instance = driver.instance
|
|
44
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
45
|
+
expect(driver.instance).toBe(instance)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
assay(context, 'returns configured flag values with their original types', async (driver) => {
|
|
49
|
+
expect(await driver.evaluate('assay.enabled', false)).toBe(true)
|
|
50
|
+
expect(await driver.evaluate('assay.variant', 'control')).toBe('treatment')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
assay(context, 'returns the supplied default for an unknown flag', async (driver) => {
|
|
54
|
+
const fallback = { enabled: true, allocation: 0.25, nested: { cohort: 'beta' } }
|
|
55
|
+
expect(await driver.evaluate('assay.unknown', fallback)).toEqual(fallback)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
assay(context, 'honors the targeting capability in both directions', async (driver) => {
|
|
59
|
+
if (driver.capabilities.targeting) {
|
|
60
|
+
const context = {
|
|
61
|
+
actorId: 'actor-enabled',
|
|
62
|
+
organizationId: 'organization-1',
|
|
63
|
+
attributes: { region: 'us-east', seats: 12, active: true },
|
|
64
|
+
}
|
|
65
|
+
const first = await driver.evaluate('assay.targeted', false, context)
|
|
66
|
+
const second = await driver.evaluate('assay.targeted', false, context)
|
|
67
|
+
expect(first).toBe(true)
|
|
68
|
+
expect(second).toBe(first)
|
|
69
|
+
expect(await driver.evaluate('assay.targeted', false, { actorId: 'actor-disabled' })).toBe(
|
|
70
|
+
false,
|
|
71
|
+
)
|
|
72
|
+
} else {
|
|
73
|
+
const error = await expectInvalid(() =>
|
|
74
|
+
driver.evaluate('assay.targeted', false, { actorId: 'actor-enabled' }),
|
|
75
|
+
)
|
|
76
|
+
expect(error.metadata.fields?.context).toBeDefined()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
Unauthenticated,
|
|
5
|
+
type IdentityCapabilities,
|
|
6
|
+
type IdentityDriver,
|
|
7
|
+
type MagicLinkIdentitySurface,
|
|
8
|
+
type MfaIdentitySurface,
|
|
9
|
+
type OAuthIdentitySurface,
|
|
10
|
+
type OrganizationIdentitySurface,
|
|
11
|
+
type PasswordIdentitySurface,
|
|
12
|
+
type RequestCookies,
|
|
13
|
+
} from '@avelonjs/core'
|
|
14
|
+
import { assertCapabilitySurface, captureFailure, type SuiteContext } from '../harness'
|
|
15
|
+
|
|
16
|
+
type AssayDriver = IdentityDriver<IdentityCapabilities<string>, unknown, unknown, unknown>
|
|
17
|
+
type IdentityFactory<TDriver extends AssayDriver> = (cookies: RequestCookies) => TDriver
|
|
18
|
+
|
|
19
|
+
class AssayCookies implements RequestCookies {
|
|
20
|
+
readonly #values = new Map<string, string>()
|
|
21
|
+
readonly #expired = new Set<string>()
|
|
22
|
+
|
|
23
|
+
get size(): number {
|
|
24
|
+
return this.#values.size
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(name: string): string | undefined {
|
|
28
|
+
return this.#expired.has(name) ? undefined : this.#values.get(name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set(name: string, value: string): void {
|
|
32
|
+
this.#values.set(name, value)
|
|
33
|
+
this.#expired.delete(name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
delete(name: string): void {
|
|
37
|
+
this.#values.delete(name)
|
|
38
|
+
this.#expired.delete(name)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expireAll(): void {
|
|
42
|
+
for (const name of this.#values.keys()) this.#expired.add(name)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assay<TDriver extends AssayDriver>(
|
|
47
|
+
context: SuiteContext<IdentityFactory<TDriver>>,
|
|
48
|
+
name: string,
|
|
49
|
+
assertion: (driver: TDriver, cookies: AssayCookies) => Promise<void> | void,
|
|
50
|
+
): void {
|
|
51
|
+
test(name, async () => {
|
|
52
|
+
const factory = await context.create()
|
|
53
|
+
const cookies = new AssayCookies()
|
|
54
|
+
// A request-scoped dependency stays explicit and testable. Synchronous construction keeps config
|
|
55
|
+
// resolution deterministic; drivers that need I/O defer it until an operation is called.
|
|
56
|
+
const driver = factory(cookies)
|
|
57
|
+
try {
|
|
58
|
+
await assertion(driver, cookies)
|
|
59
|
+
} finally {
|
|
60
|
+
await context.cleanup?.(factory)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function expectUnauthenticated(operation: () => Promise<unknown>): Promise<Unauthenticated> {
|
|
66
|
+
const error = await captureFailure(operation)
|
|
67
|
+
expect(error).toBeInstanceOf(Unauthenticated)
|
|
68
|
+
if (!(error instanceof Unauthenticated)) {
|
|
69
|
+
throw new Error('Expected Unauthenticated after taxonomy assertion.')
|
|
70
|
+
}
|
|
71
|
+
expect(error.code).toBe('UNAUTHENTICATED')
|
|
72
|
+
return error
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
76
|
+
const error = await captureFailure(operation)
|
|
77
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
78
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
79
|
+
expect(error.code).toBe('INVALID')
|
|
80
|
+
return error
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasPasswords(driver: AssayDriver): driver is AssayDriver & PasswordIdentitySurface {
|
|
84
|
+
return driver.capabilities.passwords
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasMagicLinks(driver: AssayDriver): driver is AssayDriver & MagicLinkIdentitySurface {
|
|
88
|
+
return driver.capabilities.magicLinks
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasOAuth(driver: AssayDriver): driver is AssayDriver & OAuthIdentitySurface {
|
|
92
|
+
return driver.capabilities.oauth
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasOrganizations(
|
|
96
|
+
driver: AssayDriver,
|
|
97
|
+
): driver is AssayDriver & OrganizationIdentitySurface {
|
|
98
|
+
return driver.capabilities.organizations
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasMfa(driver: AssayDriver): driver is AssayDriver & MfaIdentitySurface<string> {
|
|
102
|
+
return driver.capabilities.mfa.length > 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Registers the portable identity contract conformance suite.
|
|
107
|
+
*
|
|
108
|
+
* `context.create()` returns the config-time factory, whose pinned signature is
|
|
109
|
+
* `(cookies: RequestCookies) => TDriver`. Each call to that factory receives one request-scoped
|
|
110
|
+
* cookie store. Recovery-token and MFA-code providers may supply vendor-issued fixture values;
|
|
111
|
+
* otherwise password-capable fixtures accept `assay-reset-token` and MFA fixtures accept `123456`.
|
|
112
|
+
*
|
|
113
|
+
* @param context Fresh identity factories and optional cleanup.
|
|
114
|
+
*/
|
|
115
|
+
export function identitySuite<TDriver extends AssayDriver>(
|
|
116
|
+
context: SuiteContext<IdentityFactory<TDriver>>,
|
|
117
|
+
): void {
|
|
118
|
+
describe(`identity conformance: ${context.name}`, () => {
|
|
119
|
+
assay(context, 'enforces every identity capability surface in both directions', (driver) => {
|
|
120
|
+
const surfaces: readonly [string, boolean, readonly string[]][] = [
|
|
121
|
+
[
|
|
122
|
+
'passwords',
|
|
123
|
+
driver.capabilities.passwords,
|
|
124
|
+
[
|
|
125
|
+
'register',
|
|
126
|
+
'signInWithPassword',
|
|
127
|
+
'sendPasswordReset',
|
|
128
|
+
'resetPassword',
|
|
129
|
+
'updatePassword',
|
|
130
|
+
],
|
|
131
|
+
],
|
|
132
|
+
['magicLinks', driver.capabilities.magicLinks, ['sendMagicLink']],
|
|
133
|
+
['oauth', driver.capabilities.oauth, ['linkOAuthIdentity']],
|
|
134
|
+
['organizations', driver.capabilities.organizations, ['organizations', 'useOrganization']],
|
|
135
|
+
['mfa', driver.capabilities.mfa.length > 0, ['challengeMfa', 'verifyMfa']],
|
|
136
|
+
]
|
|
137
|
+
for (const [capability, declared, methods] of surfaces) {
|
|
138
|
+
assertCapabilitySurface(driver, capability, declared, methods)
|
|
139
|
+
const unavailable = {
|
|
140
|
+
capabilities: { [capability]: capability === 'mfa' ? ([] as const) : false },
|
|
141
|
+
}
|
|
142
|
+
assertCapabilitySurface(unavailable, capability, false, methods)
|
|
143
|
+
expect(() =>
|
|
144
|
+
assertCapabilitySurface(
|
|
145
|
+
{ ...unavailable, [methods[0] ?? 'missing']: () => undefined },
|
|
146
|
+
capability,
|
|
147
|
+
false,
|
|
148
|
+
methods,
|
|
149
|
+
),
|
|
150
|
+
).toThrow()
|
|
151
|
+
expect(() =>
|
|
152
|
+
assertCapabilitySurface(
|
|
153
|
+
{
|
|
154
|
+
capabilities: {
|
|
155
|
+
[capability]: capability === 'mfa' ? (['assay-factor'] as const) : true,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
capability,
|
|
159
|
+
true,
|
|
160
|
+
methods,
|
|
161
|
+
),
|
|
162
|
+
).toThrow()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
assay(
|
|
167
|
+
context,
|
|
168
|
+
'returns no current actor or session for an anonymous request',
|
|
169
|
+
async (driver) => {
|
|
170
|
+
expect(await driver.user()).toBeNull()
|
|
171
|
+
expect(await driver.session()).toBeNull()
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
assay(
|
|
176
|
+
context,
|
|
177
|
+
'registers and signs in with a password while normalizing bad credentials',
|
|
178
|
+
async (driver, cookies) => {
|
|
179
|
+
if (!hasPasswords(driver)) return
|
|
180
|
+
const registered = await driver.register('actor@example.test', 'initial-password')
|
|
181
|
+
expect(registered).not.toBeNull()
|
|
182
|
+
await expectUnauthenticated(() =>
|
|
183
|
+
driver.signInWithPassword('actor@example.test', 'wrong-password'),
|
|
184
|
+
)
|
|
185
|
+
const signedIn = await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
186
|
+
// Actor payloads are opaque, so structured equality pins consistency across identity reads
|
|
187
|
+
// without prescribing fields that real drivers may not share.
|
|
188
|
+
expect(await driver.user()).toEqual(signedIn)
|
|
189
|
+
expect(await driver.session()).not.toBeNull()
|
|
190
|
+
expect(cookies.size).toBeGreaterThan(0)
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
assay(context, 'signs out the current request and clears its session', async (driver) => {
|
|
195
|
+
if (!hasPasswords(driver)) return
|
|
196
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
197
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
198
|
+
await driver.signOut()
|
|
199
|
+
expect(await driver.user()).toBeNull()
|
|
200
|
+
expect(await driver.session()).toBeNull()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
assay(
|
|
204
|
+
context,
|
|
205
|
+
'rejects operations requiring an absent or expired session as Unauthenticated',
|
|
206
|
+
async (driver, cookies) => {
|
|
207
|
+
await expectUnauthenticated(() => driver.signOut())
|
|
208
|
+
if (!hasPasswords(driver)) return
|
|
209
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
210
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
211
|
+
cookies.expireAll()
|
|
212
|
+
expect(await driver.user()).toBeNull()
|
|
213
|
+
expect(await driver.session()).toBeNull()
|
|
214
|
+
await expectUnauthenticated(() => driver.updatePassword('replacement-password'))
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assay(context, 'updates the authenticated actor password', async (driver) => {
|
|
219
|
+
if (!hasPasswords(driver)) return
|
|
220
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
221
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
222
|
+
await driver.updatePassword('updated-password')
|
|
223
|
+
await driver.signOut()
|
|
224
|
+
await expectUnauthenticated(() =>
|
|
225
|
+
driver.signInWithPassword('actor@example.test', 'initial-password'),
|
|
226
|
+
)
|
|
227
|
+
expect(
|
|
228
|
+
await driver.signInWithPassword('actor@example.test', 'updated-password'),
|
|
229
|
+
).not.toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
assay(context, 'resets a password with a vendor recovery token', async (driver) => {
|
|
233
|
+
if (!hasPasswords(driver)) return
|
|
234
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
235
|
+
await driver.sendPasswordReset('actor@example.test')
|
|
236
|
+
await driver.sendPasswordReset('missing@example.test')
|
|
237
|
+
const recoveryToken = context.recoveryToken
|
|
238
|
+
? await context.recoveryToken('actor@example.test')
|
|
239
|
+
: 'assay-reset-token'
|
|
240
|
+
await driver.resetPassword(recoveryToken, 'reset-password')
|
|
241
|
+
await expectUnauthenticated(() =>
|
|
242
|
+
driver.signInWithPassword('actor@example.test', 'initial-password'),
|
|
243
|
+
)
|
|
244
|
+
expect(await driver.signInWithPassword('actor@example.test', 'reset-password')).not.toBeNull()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
assay(context, 'sends passwordless magic links when declared', async (driver) => {
|
|
248
|
+
if (!hasMagicLinks(driver)) return
|
|
249
|
+
await expect(
|
|
250
|
+
driver.sendMagicLink('actor@example.test', 'https://app.example.test/complete'),
|
|
251
|
+
).resolves.toBeUndefined()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
assay(context, 'links a completed OAuth identity when declared', async (driver) => {
|
|
255
|
+
if (!hasPasswords(driver) || !hasOAuth(driver)) return
|
|
256
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
257
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
258
|
+
await expect(
|
|
259
|
+
driver.linkOAuthIdentity('primary', 'authorization-code'),
|
|
260
|
+
).resolves.toBeUndefined()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
assay(context, 'lists and selects organizations when declared', async (driver) => {
|
|
264
|
+
if (!hasPasswords(driver) || !hasOrganizations(driver)) return
|
|
265
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
266
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
267
|
+
const organizations = await driver.organizations()
|
|
268
|
+
expect(organizations.length).toBeGreaterThan(0)
|
|
269
|
+
const organization = organizations[0]
|
|
270
|
+
expect(organization).toBeDefined()
|
|
271
|
+
if (!organization) throw new Error('Expected the organization fixture after assertion.')
|
|
272
|
+
await expect(driver.useOrganization(organization.id)).resolves.toBeUndefined()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
assay(
|
|
276
|
+
context,
|
|
277
|
+
'challenges only declared MFA factors and verifies their responses',
|
|
278
|
+
async (driver) => {
|
|
279
|
+
if (!hasMfa(driver) || !hasPasswords(driver)) return
|
|
280
|
+
await driver.register('actor@example.test', 'initial-password')
|
|
281
|
+
await driver.signInWithPassword('actor@example.test', 'initial-password')
|
|
282
|
+
for (const factor of driver.capabilities.mfa) {
|
|
283
|
+
const challenge = await driver.challengeMfa(factor)
|
|
284
|
+
expect(challenge.factor).toBe(factor)
|
|
285
|
+
expect(challenge.expiresAt.getTime()).toBeGreaterThan(Date.now())
|
|
286
|
+
const code = context.mfaCode ? await context.mfaCode(challenge.id) : '123456'
|
|
287
|
+
await expect(driver.verifyMfa(challenge.id, code)).resolves.toBeUndefined()
|
|
288
|
+
}
|
|
289
|
+
await expectInvalid(() => driver.challengeMfa('undeclared-factor'))
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { aiSuite } from './ai'
|
|
2
|
+
export { cacheSuite } from './cache'
|
|
3
|
+
export { databaseSuite } from './database'
|
|
4
|
+
export { flagsSuite } from './flags'
|
|
5
|
+
export { identitySuite } from './identity'
|
|
6
|
+
export { logsSuite } from './logs'
|
|
7
|
+
export { mailSuite } from './mail'
|
|
8
|
+
export { notificationsSuite } from './notifications'
|
|
9
|
+
export { paymentsSuite } from './payments'
|
|
10
|
+
export { queueSuite } from './queue'
|
|
11
|
+
export { ratelimitSuite } from './ratelimit'
|
|
12
|
+
export { realtimeSuite } from './realtime'
|
|
13
|
+
export { searchSuite } from './search'
|
|
14
|
+
export { socialSuite } from './social'
|
|
15
|
+
export { storageSuite } from './storage'
|
|
16
|
+
export { tokensSuite } from './tokens'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type LogCapabilities,
|
|
4
|
+
type LogDriver,
|
|
5
|
+
type LogLevel,
|
|
6
|
+
type LogRecord,
|
|
7
|
+
type TraceLogSurface,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
import { assertCapabilitySurface, type SuiteContext } from '../harness'
|
|
10
|
+
|
|
11
|
+
interface RecordInspectable {
|
|
12
|
+
readonly records: readonly LogRecord[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type AssayDriver = LogDriver<LogCapabilities> & Partial<TraceLogSurface> & RecordInspectable
|
|
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 hasTraces(driver: AssayDriver): driver is AssayDriver & TraceLogSurface {
|
|
33
|
+
return driver.capabilities.traces && typeof driver.startSpan === 'function'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Registers the portable structured logging contract conformance suite.
|
|
38
|
+
*
|
|
39
|
+
* The context exposes records captured by its test sink so the one-way `write` operation can be
|
|
40
|
+
* observed. Filtering policy is not part of `LogDriver` and is deliberately not tested here. The
|
|
41
|
+
* contract also exposes no deterministic failure, so the suite does not invent a taxonomy trigger.
|
|
42
|
+
*
|
|
43
|
+
* @param context Fresh isolated log drivers with an observable test sink and optional cleanup.
|
|
44
|
+
*/
|
|
45
|
+
export function logsSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
46
|
+
describe(`logs conformance: ${context.name}`, () => {
|
|
47
|
+
assay(context, 'reports a present and stable resolved logs instance', (driver) => {
|
|
48
|
+
const instance = driver.instance
|
|
49
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
50
|
+
expect(driver.instance).toBe(instance)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
assay(context, 'enforces the trace capability surface in both directions', (driver) => {
|
|
54
|
+
assertCapabilitySurface(driver, 'traces', driver.capabilities.traces, ['startSpan'])
|
|
55
|
+
assertCapabilitySurface({ capabilities: { traces: false } }, 'traces', false, ['startSpan'])
|
|
56
|
+
expect(() =>
|
|
57
|
+
assertCapabilitySurface(
|
|
58
|
+
{ capabilities: { traces: false }, startSpan: () => undefined },
|
|
59
|
+
'traces',
|
|
60
|
+
false,
|
|
61
|
+
['startSpan'],
|
|
62
|
+
),
|
|
63
|
+
).toThrow()
|
|
64
|
+
expect(() =>
|
|
65
|
+
assertCapabilitySurface({ capabilities: { traces: true } }, 'traces', true, ['startSpan']),
|
|
66
|
+
).toThrow()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
assay(context, 'preserves structured log fields without mangling', async (driver) => {
|
|
70
|
+
const timestamp = new Date('2026-08-03T12:00:00.000Z')
|
|
71
|
+
const context = {
|
|
72
|
+
actor: { id: 'actor-1', roles: ['admin', 'editor'] },
|
|
73
|
+
message: 'Déploiement terminé',
|
|
74
|
+
default: 'reserved-name',
|
|
75
|
+
counts: { attempted: 3, failed: 0 },
|
|
76
|
+
}
|
|
77
|
+
await driver.write({ level: 'info', message: '公開済み', context, timestamp })
|
|
78
|
+
await driver.write({ level: 'debug', message: 'Empty context', context: {} })
|
|
79
|
+
|
|
80
|
+
expect(driver.records[0]).toEqual({
|
|
81
|
+
level: 'info',
|
|
82
|
+
message: '公開済み',
|
|
83
|
+
context,
|
|
84
|
+
timestamp,
|
|
85
|
+
})
|
|
86
|
+
expect(driver.records[1]?.context).toEqual({})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
assay(context, 'retains every declared log level on the record', async (driver) => {
|
|
90
|
+
const levels: readonly LogLevel[] = ['debug', 'info', 'warning', 'error']
|
|
91
|
+
for (const level of levels) await driver.write({ level, message: `${level} message` })
|
|
92
|
+
expect(driver.records.map((record) => record.level)).toEqual([...levels])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
assay(context, 'defaults an omitted record timestamp to the current time', async (driver) => {
|
|
96
|
+
const before = Date.now()
|
|
97
|
+
await driver.write({ level: 'info', message: 'Timestamped by the driver' })
|
|
98
|
+
const after = Date.now()
|
|
99
|
+
const timestamp = driver.records[0]?.timestamp
|
|
100
|
+
expect(timestamp).toBeInstanceOf(Date)
|
|
101
|
+
expect(timestamp?.getTime()).toBeGreaterThanOrEqual(before)
|
|
102
|
+
expect(timestamp?.getTime()).toBeLessThanOrEqual(after)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
assay(context, 'starts and completes trace spans when declared', (driver) => {
|
|
106
|
+
if (!hasTraces(driver)) return
|
|
107
|
+
const span = driver.startSpan('publish-post', { postId: 'post-1', attempts: 2 })
|
|
108
|
+
expect(span.traceId.length).toBeGreaterThan(0)
|
|
109
|
+
expect(span.spanId.length).toBeGreaterThan(0)
|
|
110
|
+
const error = new Error('trace event')
|
|
111
|
+
expect(() => span.attribute('published', true)).not.toThrow()
|
|
112
|
+
expect(() => span.recordError(error)).not.toThrow()
|
|
113
|
+
expect(() => span.end()).not.toThrow()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
type MailCapabilities,
|
|
5
|
+
type MailDriver,
|
|
6
|
+
type MailMessage,
|
|
7
|
+
type TemplateMailSurface,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
import { assertCapabilitySurface, captureFailure, type SuiteContext } from '../harness'
|
|
10
|
+
|
|
11
|
+
type AssayDriver = MailDriver<MailCapabilities> & Partial<TemplateMailSurface>
|
|
12
|
+
|
|
13
|
+
interface SentMailInspectable {
|
|
14
|
+
readonly sent: readonly MailMessage[]
|
|
15
|
+
}
|
|
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 hasTemplates(driver: AssayDriver): driver is AssayDriver & TemplateMailSurface {
|
|
33
|
+
return driver.capabilities.templates && typeof driver.sendTemplate === 'function'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasSentInspection(driver: object): driver is SentMailInspectable {
|
|
37
|
+
return 'sent' in driver && Array.isArray(driver.sent)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function expectInvalid(operation: () => Promise<unknown>): Promise<Invalid> {
|
|
41
|
+
const error = await captureFailure(operation)
|
|
42
|
+
expect(error).toBeInstanceOf(Invalid)
|
|
43
|
+
if (!(error instanceof Invalid)) throw new Error('Expected Invalid after taxonomy assertion.')
|
|
44
|
+
expect(error.code).toBe('INVALID')
|
|
45
|
+
return error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Registers the portable mail contract conformance suite.
|
|
50
|
+
*
|
|
51
|
+
* The context supplies one already-resolved mailer. Named-mailer resolution and unknown-name
|
|
52
|
+
* rejection belong to configuration and the mail facade; this suite verifies only the driver's
|
|
53
|
+
* stable instance identity. The frozen contract defines no deterministic trigger for provider
|
|
54
|
+
* unavailability or rate limiting, so this suite does not invent one.
|
|
55
|
+
*
|
|
56
|
+
* @param context Fresh isolated mail drivers and optional cleanup.
|
|
57
|
+
*/
|
|
58
|
+
export function mailSuite<TDriver extends AssayDriver>(context: SuiteContext<TDriver>): void {
|
|
59
|
+
const sender = context.mailSender ?? 'sender@example.test'
|
|
60
|
+
const recipients = context.mailRecipients ?? {
|
|
61
|
+
to: ['first@example.test', 'second@example.test'],
|
|
62
|
+
replyTo: ['replies@example.test', 'support@example.test'],
|
|
63
|
+
cc: 'copy@example.test',
|
|
64
|
+
bcc: 'audit@example.test',
|
|
65
|
+
configured: 'recipient@example.test',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe(`mail conformance: ${context.name}`, () => {
|
|
69
|
+
assay(context, 'reports a present and stable resolved mailer instance', (driver) => {
|
|
70
|
+
const instance = driver.instance
|
|
71
|
+
expect(instance.length).toBeGreaterThan(0)
|
|
72
|
+
expect(driver.instance).toBe(instance)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
assay(context, 'enforces every mail capability surface in both directions', (driver) => {
|
|
76
|
+
assertCapabilitySurface(driver, 'templates', driver.capabilities.templates, ['sendTemplate'])
|
|
77
|
+
const unavailable = { capabilities: { templates: false } as const }
|
|
78
|
+
assertCapabilitySurface(unavailable, 'templates', false, ['sendTemplate'])
|
|
79
|
+
expect(() =>
|
|
80
|
+
assertCapabilitySurface(
|
|
81
|
+
{ ...unavailable, sendTemplate: () => undefined },
|
|
82
|
+
'templates',
|
|
83
|
+
false,
|
|
84
|
+
['sendTemplate'],
|
|
85
|
+
),
|
|
86
|
+
).toThrow()
|
|
87
|
+
expect(() =>
|
|
88
|
+
assertCapabilitySurface({ capabilities: { templates: true } as const }, 'templates', true, [
|
|
89
|
+
'sendTemplate',
|
|
90
|
+
]),
|
|
91
|
+
).toThrow()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
assay(
|
|
95
|
+
context,
|
|
96
|
+
'sends multiple recipients with reply-to, cc, bcc, and attachments intact',
|
|
97
|
+
async (driver) => {
|
|
98
|
+
const attachment = new Uint8Array([0, 255, 4])
|
|
99
|
+
const receipt = await driver.send({
|
|
100
|
+
to: recipients.to,
|
|
101
|
+
from: sender,
|
|
102
|
+
replyTo: recipients.replyTo,
|
|
103
|
+
cc: [recipients.cc],
|
|
104
|
+
bcc: [recipients.bcc],
|
|
105
|
+
subject: 'Quarterly report',
|
|
106
|
+
text: 'The report is attached.',
|
|
107
|
+
html: '<p>The report is attached.</p>',
|
|
108
|
+
attachments: [
|
|
109
|
+
{
|
|
110
|
+
filename: 'report.bin',
|
|
111
|
+
content: attachment,
|
|
112
|
+
contentType: 'application/octet-stream',
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Accepted means every envelope recipient, including cc and bcc but excluding reply-to.
|
|
118
|
+
expect(receipt.accepted).toEqual([...recipients.to, recipients.cc, recipients.bcc])
|
|
119
|
+
expect(receipt.id.length).toBeGreaterThan(0)
|
|
120
|
+
|
|
121
|
+
if (hasSentInspection(driver)) {
|
|
122
|
+
expect(driver.sent).toHaveLength(1)
|
|
123
|
+
expect(driver.sent[0]).toMatchObject({
|
|
124
|
+
to: recipients.to,
|
|
125
|
+
from: sender,
|
|
126
|
+
replyTo: recipients.replyTo,
|
|
127
|
+
cc: [recipients.cc],
|
|
128
|
+
bcc: [recipients.bcc],
|
|
129
|
+
})
|
|
130
|
+
expect(driver.sent[0]?.attachments?.[0]?.content).toEqual(attachment)
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assay(context, 'uses the configured sender when from is omitted', async (driver) => {
|
|
136
|
+
await driver.send({
|
|
137
|
+
to: recipients.configured,
|
|
138
|
+
subject: 'Configured sender',
|
|
139
|
+
text: 'Sender defaults are instance-owned.',
|
|
140
|
+
})
|
|
141
|
+
if (hasSentInspection(driver)) expect(driver.sent[0]?.from).toBe('mailer@example.test')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
assay(context, 'normalizes malformed recipient addresses to Invalid', async (driver) => {
|
|
145
|
+
const error = await expectInvalid(() =>
|
|
146
|
+
driver.send({ to: 'not-an-address', subject: 'Invalid recipient', text: 'Not delivered.' }),
|
|
147
|
+
)
|
|
148
|
+
expect(error.metadata.fields?.to?.join(' ')).toContain('not-an-address')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
assay(context, 'sends hosted templates only when declared', async (driver) => {
|
|
152
|
+
if (!hasTemplates(driver)) return
|
|
153
|
+
const receipt = await driver.sendTemplate('welcome-message', recipients.to, {
|
|
154
|
+
actorName: 'Morgan',
|
|
155
|
+
})
|
|
156
|
+
expect(receipt.accepted).toEqual(recipients.to)
|
|
157
|
+
expect(receipt.id.length).toBeGreaterThan(0)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|