@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,253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Conflict,
|
|
3
|
+
Invalid,
|
|
4
|
+
Unauthenticated,
|
|
5
|
+
type IdentityDriver,
|
|
6
|
+
type IdentityOrganization,
|
|
7
|
+
type MagicLinkIdentitySurface,
|
|
8
|
+
type MfaChallenge,
|
|
9
|
+
type MfaIdentitySurface,
|
|
10
|
+
type OAuthIdentitySurface,
|
|
11
|
+
type OrganizationIdentitySurface,
|
|
12
|
+
type PasswordIdentitySurface,
|
|
13
|
+
type RequestCookies,
|
|
14
|
+
} from '@avelonjs/core'
|
|
15
|
+
|
|
16
|
+
const capabilities = {
|
|
17
|
+
passwords: true,
|
|
18
|
+
magicLinks: true,
|
|
19
|
+
oauth: true,
|
|
20
|
+
organizations: true,
|
|
21
|
+
mfa: ['totp', 'recovery'] as const,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
type Factor = (typeof capabilities.mfa)[number]
|
|
25
|
+
|
|
26
|
+
/** Actor returned by the in-memory identity implementation. */
|
|
27
|
+
export interface FakeIdentityActor {
|
|
28
|
+
/** Stable actor identifier. */
|
|
29
|
+
id: string
|
|
30
|
+
/** Sign-in address. */
|
|
31
|
+
email: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Session returned by the in-memory identity implementation. */
|
|
35
|
+
export interface FakeIdentitySession {
|
|
36
|
+
/** Stable session identifier. */
|
|
37
|
+
id: string
|
|
38
|
+
/** Authenticated actor identifier. */
|
|
39
|
+
actorId: string
|
|
40
|
+
/** Time after which the session is invalid. */
|
|
41
|
+
expiresAt: Date
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface StoredActor extends FakeIdentityActor {
|
|
45
|
+
password: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StoredSession extends FakeIdentitySession {
|
|
49
|
+
active: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FakeIdentityRaw {
|
|
53
|
+
users: number
|
|
54
|
+
sessions: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sessionCookie = 'avelon_assay_session'
|
|
58
|
+
|
|
59
|
+
function unauthenticated(message: string): Unauthenticated {
|
|
60
|
+
return new Unauthenticated(message, { metadata: { guard: 'identity' } })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function invalid(field: string, message: string): Invalid {
|
|
64
|
+
return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFactor(value: string): value is Factor {
|
|
68
|
+
return capabilities.mfa.some((factor) => factor === value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** In-memory identity reference implementation used by conformance and application tests. */
|
|
72
|
+
export class FakeIdentity
|
|
73
|
+
implements
|
|
74
|
+
IdentityDriver<typeof capabilities, FakeIdentityRaw, FakeIdentityActor, FakeIdentitySession>,
|
|
75
|
+
PasswordIdentitySurface<FakeIdentityActor>,
|
|
76
|
+
MagicLinkIdentitySurface,
|
|
77
|
+
OAuthIdentitySurface,
|
|
78
|
+
OrganizationIdentitySurface,
|
|
79
|
+
MfaIdentitySurface<Factor>
|
|
80
|
+
{
|
|
81
|
+
/** Driver implementation name. */
|
|
82
|
+
readonly name = 'fake'
|
|
83
|
+
|
|
84
|
+
/** Configured identity connection name. */
|
|
85
|
+
readonly instance = 'default'
|
|
86
|
+
|
|
87
|
+
/** Exact optional-feature declaration. */
|
|
88
|
+
readonly capabilities = capabilities
|
|
89
|
+
|
|
90
|
+
readonly #cookies: RequestCookies
|
|
91
|
+
readonly #actors = new Map<string, StoredActor>()
|
|
92
|
+
readonly #sessions = new Map<string, StoredSession>()
|
|
93
|
+
readonly #resetTokens = new Map<string, string>()
|
|
94
|
+
readonly #challenges = new Map<string, { actorId: string; challenge: MfaChallenge<Factor> }>()
|
|
95
|
+
#lastSession: FakeIdentitySession | null = null
|
|
96
|
+
#nextActor = 1
|
|
97
|
+
#nextSession = 1
|
|
98
|
+
#nextChallenge = 1
|
|
99
|
+
|
|
100
|
+
/** Creates an identity driver bound to one request-scoped cookie store. */
|
|
101
|
+
constructor(cookies: RequestCookies) {
|
|
102
|
+
this.#cookies = cookies
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Returns observable in-memory client counts. */
|
|
106
|
+
raw(): FakeIdentityRaw {
|
|
107
|
+
return { users: this.#actors.size, sessions: this.#sessions.size }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Returns the current actor or null for an anonymous request. */
|
|
111
|
+
async user(): Promise<FakeIdentityActor | null> {
|
|
112
|
+
const session = this.#currentSession()
|
|
113
|
+
if (!session) return null
|
|
114
|
+
const actor = [...this.#actors.values()].find((candidate) => candidate.id === session.actorId)
|
|
115
|
+
return actor ? { id: actor.id, email: actor.email } : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Returns the current session or null when it is absent or expired. */
|
|
119
|
+
async session(): Promise<FakeIdentitySession | null> {
|
|
120
|
+
const session = this.#currentSession()
|
|
121
|
+
return session ? this.#copySession(session) : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Ends the current session and deletes its request cookie. */
|
|
125
|
+
async signOut(): Promise<void> {
|
|
126
|
+
const session = this.#requireSession()
|
|
127
|
+
session.active = false
|
|
128
|
+
this.#cookies.delete(sessionCookie, { path: '/' })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Registers an actor without changing the current request session. */
|
|
132
|
+
async register(email: string, password: string): Promise<FakeIdentityActor> {
|
|
133
|
+
if (this.#actors.has(email)) {
|
|
134
|
+
throw new Conflict('An actor with this email already exists.', {
|
|
135
|
+
metadata: { resource: 'actor', key: 'email' },
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
const actor: StoredActor = { id: `actor-${this.#nextActor++}`, email, password }
|
|
139
|
+
this.#actors.set(email, actor)
|
|
140
|
+
return { id: actor.id, email: actor.email }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Authenticates an actor and writes a request-scoped session cookie. */
|
|
144
|
+
async signInWithPassword(email: string, password: string): Promise<FakeIdentityActor> {
|
|
145
|
+
const actor = this.#actors.get(email)
|
|
146
|
+
if (!actor || actor.password !== password) throw unauthenticated('Invalid credentials.')
|
|
147
|
+
|
|
148
|
+
const session: StoredSession = {
|
|
149
|
+
id: `session-${this.#nextSession++}`,
|
|
150
|
+
actorId: actor.id,
|
|
151
|
+
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
152
|
+
active: true,
|
|
153
|
+
}
|
|
154
|
+
this.#sessions.set(session.id, session)
|
|
155
|
+
this.#lastSession = this.#copySession(session)
|
|
156
|
+
this.#cookies.set(sessionCookie, session.id, {
|
|
157
|
+
httpOnly: true,
|
|
158
|
+
maxAge: 60 * 60,
|
|
159
|
+
path: '/',
|
|
160
|
+
sameSite: 'lax',
|
|
161
|
+
secure: true,
|
|
162
|
+
})
|
|
163
|
+
return { id: actor.id, email: actor.email }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Records a deterministic recovery token for a known actor without revealing account absence. */
|
|
167
|
+
async sendPasswordReset(email: string): Promise<void> {
|
|
168
|
+
if (this.#actors.has(email)) this.#resetTokens.set('assay-reset-token', email)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Replaces a password after validating a recovery token. */
|
|
172
|
+
async resetPassword(token: string, password: string): Promise<void> {
|
|
173
|
+
const email = this.#resetTokens.get(token)
|
|
174
|
+
const actor = email ? this.#actors.get(email) : undefined
|
|
175
|
+
if (!email || !actor) throw unauthenticated('Invalid password recovery token.')
|
|
176
|
+
actor.password = password
|
|
177
|
+
this.#resetTokens.delete(token)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Changes the current actor's password. */
|
|
181
|
+
async updatePassword(password: string): Promise<void> {
|
|
182
|
+
const session = this.#requireSession()
|
|
183
|
+
const actor = [...this.#actors.values()].find((candidate) => candidate.id === session.actorId)
|
|
184
|
+
if (!actor) throw unauthenticated('The authenticated actor no longer exists.')
|
|
185
|
+
actor.password = password
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Accepts a magic-link request without revealing account existence. */
|
|
189
|
+
async sendMagicLink(_email: string, _redirectTo?: string): Promise<void> {}
|
|
190
|
+
|
|
191
|
+
/** Links a completed authorization to the authenticated actor. */
|
|
192
|
+
async linkOAuthIdentity(_provider: string, _code: string): Promise<void> {
|
|
193
|
+
this.#requireSession()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Lists organizations available to the authenticated actor. */
|
|
197
|
+
async organizations(): Promise<readonly IdentityOrganization[]> {
|
|
198
|
+
this.#requireSession()
|
|
199
|
+
return [{ id: 'organization-1', name: 'Assay Organization' }]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Selects an organization available to the authenticated actor. */
|
|
203
|
+
async useOrganization(id: string): Promise<void> {
|
|
204
|
+
this.#requireSession()
|
|
205
|
+
if (id !== 'organization-1') throw invalid('organization', 'Unknown organization.')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Begins a challenge for one declared factor. */
|
|
209
|
+
async challengeMfa(factor?: string): Promise<MfaChallenge<Factor>> {
|
|
210
|
+
const session = this.#requireSession()
|
|
211
|
+
const selected = factor ?? capabilities.mfa[0]
|
|
212
|
+
if (!isFactor(selected)) throw invalid('factor', 'Unsupported multi-factor method.')
|
|
213
|
+
const challenge: MfaChallenge<Factor> = {
|
|
214
|
+
id: `challenge-${this.#nextChallenge++}`,
|
|
215
|
+
factor: selected,
|
|
216
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
217
|
+
}
|
|
218
|
+
this.#challenges.set(challenge.id, { actorId: session.actorId, challenge })
|
|
219
|
+
return { ...challenge }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Verifies the deterministic code accepted by the in-memory implementation. */
|
|
223
|
+
async verifyMfa(challengeId: string, code: string): Promise<void> {
|
|
224
|
+
const pending = this.#challenges.get(challengeId)
|
|
225
|
+
if (!pending || pending.challenge.expiresAt.getTime() <= Date.now() || code !== '123456') {
|
|
226
|
+
throw unauthenticated('Invalid multi-factor challenge.')
|
|
227
|
+
}
|
|
228
|
+
this.#challenges.delete(challengeId)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Returns the last issued session for conformance stubs that model stale-session defects. */
|
|
232
|
+
protected lastIssuedSession(): FakeIdentitySession | null {
|
|
233
|
+
return this.#lastSession ? this.#copySession(this.#lastSession) : null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#copySession(session: FakeIdentitySession): FakeIdentitySession {
|
|
237
|
+
return { ...session, expiresAt: new Date(session.expiresAt) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#currentSession(): StoredSession | null {
|
|
241
|
+
const id = this.#cookies.get(sessionCookie)
|
|
242
|
+
if (!id) return null
|
|
243
|
+
const session = this.#sessions.get(id)
|
|
244
|
+
if (!session || !session.active || session.expiresAt.getTime() <= Date.now()) return null
|
|
245
|
+
return session
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#requireSession(): StoredSession {
|
|
249
|
+
const session = this.#currentSession()
|
|
250
|
+
if (!session) throw unauthenticated('An active session is required.')
|
|
251
|
+
return session
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type LogDriver, type LogRecord, type TraceLogSurface, type TraceSpan } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
const capabilities = { traces: true } as const
|
|
4
|
+
|
|
5
|
+
/** Observable trace state retained by the in-memory logging reference implementation. */
|
|
6
|
+
export interface FakeTraceRecord {
|
|
7
|
+
/** Trace operation name. */
|
|
8
|
+
readonly name: string
|
|
9
|
+
/** Stable trace identifier. */
|
|
10
|
+
readonly traceId: string
|
|
11
|
+
/** Stable span identifier. */
|
|
12
|
+
readonly spanId: string
|
|
13
|
+
/** Structured attributes attached to the span. */
|
|
14
|
+
readonly attributes: Record<string, string | number | boolean>
|
|
15
|
+
/** Errors recorded by the span. */
|
|
16
|
+
readonly errors: unknown[]
|
|
17
|
+
/** Whether the span has ended. */
|
|
18
|
+
ended: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FakeLogsRaw {
|
|
22
|
+
readonly records: readonly LogRecord[]
|
|
23
|
+
readonly spans: readonly FakeTraceRecord[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class FakeTraceSpan implements TraceSpan {
|
|
27
|
+
readonly traceId: string
|
|
28
|
+
readonly spanId: string
|
|
29
|
+
readonly #record: FakeTraceRecord
|
|
30
|
+
|
|
31
|
+
constructor(record: FakeTraceRecord) {
|
|
32
|
+
this.#record = record
|
|
33
|
+
this.traceId = record.traceId
|
|
34
|
+
this.spanId = record.spanId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
attribute(name: string, value: string | number | boolean): void {
|
|
38
|
+
this.#record.attributes[name] = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
recordError(error: unknown): void {
|
|
42
|
+
this.#record.errors.push(error)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
end(): void {
|
|
46
|
+
this.#record.ended = true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** In-memory structured logging reference implementation with trace support. */
|
|
51
|
+
export class FakeLogs implements LogDriver<typeof capabilities, FakeLogsRaw>, TraceLogSurface {
|
|
52
|
+
/** Driver implementation name. */
|
|
53
|
+
readonly name = 'fake'
|
|
54
|
+
|
|
55
|
+
/** Configured log connection name. */
|
|
56
|
+
readonly instance: string
|
|
57
|
+
|
|
58
|
+
/** Exact optional-feature declaration. */
|
|
59
|
+
readonly capabilities = capabilities
|
|
60
|
+
|
|
61
|
+
/** Structured records accepted by the fake sink. */
|
|
62
|
+
readonly records: LogRecord[] = []
|
|
63
|
+
|
|
64
|
+
/** Trace records accepted by the fake sink. */
|
|
65
|
+
readonly spans: FakeTraceRecord[] = []
|
|
66
|
+
|
|
67
|
+
#nextSpan = 1
|
|
68
|
+
|
|
69
|
+
/** Creates an isolated log connection. */
|
|
70
|
+
constructor(instance = 'default') {
|
|
71
|
+
this.instance = instance
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Returns the observable fake sink. */
|
|
75
|
+
raw(): FakeLogsRaw {
|
|
76
|
+
return { records: this.records, spans: this.spans }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Writes one structured record and supplies its omitted timestamp. */
|
|
80
|
+
async write(record: LogRecord): Promise<void> {
|
|
81
|
+
this.records.push({ ...record, timestamp: record.timestamp ?? new Date() })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Starts an observable trace span. */
|
|
85
|
+
startSpan(
|
|
86
|
+
name: string,
|
|
87
|
+
attributes: Readonly<Record<string, string | number | boolean>> = {},
|
|
88
|
+
): TraceSpan {
|
|
89
|
+
const sequence = this.#nextSpan++
|
|
90
|
+
const record: FakeTraceRecord = {
|
|
91
|
+
name,
|
|
92
|
+
traceId: `trace-${sequence}`,
|
|
93
|
+
spanId: `span-${sequence}`,
|
|
94
|
+
attributes: { ...attributes },
|
|
95
|
+
errors: [],
|
|
96
|
+
ended: false,
|
|
97
|
+
}
|
|
98
|
+
this.spans.push(record)
|
|
99
|
+
return new FakeTraceSpan(record)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
type MailAttachment,
|
|
4
|
+
type MailDriver,
|
|
5
|
+
type MailMessage,
|
|
6
|
+
type MailReceipt,
|
|
7
|
+
type TemplateMailSurface,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
|
|
10
|
+
const capabilities = {
|
|
11
|
+
templates: true,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
interface FakeMailRaw {
|
|
15
|
+
messages: number
|
|
16
|
+
templates: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TemplateDelivery {
|
|
20
|
+
template: string
|
|
21
|
+
to: string | readonly string[]
|
|
22
|
+
variables: Readonly<Record<string, unknown>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function addressList(value: string | readonly string[] | undefined): readonly string[] {
|
|
26
|
+
if (value === undefined) return []
|
|
27
|
+
return typeof value === 'string' ? [value] : value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validAddress(address: string): boolean {
|
|
31
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateAddresses(message: MailMessage): void {
|
|
35
|
+
const fields: readonly [string, readonly string[]][] = [
|
|
36
|
+
['to', addressList(message.to)],
|
|
37
|
+
['from', addressList(message.from)],
|
|
38
|
+
['replyTo', addressList(message.replyTo)],
|
|
39
|
+
['cc', addressList(message.cc)],
|
|
40
|
+
['bcc', addressList(message.bcc)],
|
|
41
|
+
]
|
|
42
|
+
for (const [field, addresses] of fields) {
|
|
43
|
+
const malformed = addresses.find((address) => !validAddress(address))
|
|
44
|
+
if (malformed) {
|
|
45
|
+
throw new Invalid(`Invalid email address in ${field}.`, {
|
|
46
|
+
metadata: { fields: { [field]: [`${malformed} is not a valid email address.`] } },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function copyAddresses(value: string | readonly string[]): string | readonly string[] {
|
|
53
|
+
return typeof value === 'string' ? value : [...value]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function copyAttachments(
|
|
57
|
+
attachments: readonly MailAttachment[] | undefined,
|
|
58
|
+
): readonly MailAttachment[] | undefined {
|
|
59
|
+
return attachments?.map((attachment) => ({
|
|
60
|
+
filename: attachment.filename,
|
|
61
|
+
content: attachment.content.slice(),
|
|
62
|
+
...(attachment.contentType === undefined ? {} : { contentType: attachment.contentType }),
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function copyMessage(message: MailMessage, defaultFrom: string): MailMessage {
|
|
67
|
+
const attachments = copyAttachments(message.attachments)
|
|
68
|
+
return {
|
|
69
|
+
to: copyAddresses(message.to),
|
|
70
|
+
from: message.from ?? defaultFrom,
|
|
71
|
+
...(message.replyTo === undefined ? {} : { replyTo: copyAddresses(message.replyTo) }),
|
|
72
|
+
...(message.cc === undefined ? {} : { cc: [...message.cc] }),
|
|
73
|
+
...(message.bcc === undefined ? {} : { bcc: [...message.bcc] }),
|
|
74
|
+
subject: message.subject,
|
|
75
|
+
...(message.text === undefined ? {} : { text: message.text }),
|
|
76
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
77
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** In-memory mail reference implementation used by conformance and application tests. */
|
|
82
|
+
export class FakeMail implements MailDriver<typeof capabilities, FakeMailRaw>, TemplateMailSurface {
|
|
83
|
+
/** Driver implementation name. */
|
|
84
|
+
readonly name = 'fake'
|
|
85
|
+
|
|
86
|
+
/** Configured mailer name. */
|
|
87
|
+
readonly instance: string
|
|
88
|
+
|
|
89
|
+
/** Exact optional-feature declaration. */
|
|
90
|
+
readonly capabilities = capabilities
|
|
91
|
+
|
|
92
|
+
readonly #defaultFrom: string
|
|
93
|
+
readonly #sent: MailMessage[] = []
|
|
94
|
+
readonly #templates: TemplateDelivery[] = []
|
|
95
|
+
#nextId = 1
|
|
96
|
+
|
|
97
|
+
/** Creates an isolated mailer with an instance-specific default sender. */
|
|
98
|
+
constructor(instance = 'default', defaultFrom = 'no-reply@example.test') {
|
|
99
|
+
this.instance = instance
|
|
100
|
+
this.#defaultFrom = defaultFrom
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Returns copies of messages accepted by this fake mailer. */
|
|
104
|
+
get sent(): readonly MailMessage[] {
|
|
105
|
+
return this.#sent.map((message) => copyMessage(message, this.#defaultFrom))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Returns observable accepted-message and template counts. */
|
|
109
|
+
raw(): FakeMailRaw {
|
|
110
|
+
return { messages: this.#sent.length, templates: this.#templates.length }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validates and records a transport-neutral message. */
|
|
114
|
+
async send(message: MailMessage): Promise<MailReceipt> {
|
|
115
|
+
const normalized = copyMessage(message, this.#defaultFrom)
|
|
116
|
+
validateAddresses(normalized)
|
|
117
|
+
this.#sent.push(normalized)
|
|
118
|
+
// Every to, cc, and bcc address is an envelope recipient; reply-to is intentionally excluded.
|
|
119
|
+
const accepted = [
|
|
120
|
+
...addressList(normalized.to),
|
|
121
|
+
...addressList(normalized.cc),
|
|
122
|
+
...addressList(normalized.bcc),
|
|
123
|
+
]
|
|
124
|
+
return { id: `message-${this.#nextId++}`, accepted }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Records a hosted-template delivery after validating all recipients. */
|
|
128
|
+
async sendTemplate(
|
|
129
|
+
template: string,
|
|
130
|
+
to: string | readonly string[],
|
|
131
|
+
variables: Readonly<Record<string, unknown>>,
|
|
132
|
+
): Promise<MailReceipt> {
|
|
133
|
+
validateAddresses({ to, subject: template })
|
|
134
|
+
this.#templates.push({ template, to: copyAddresses(to), variables: { ...variables } })
|
|
135
|
+
return {
|
|
136
|
+
id: `message-${this.#nextId++}`,
|
|
137
|
+
accepted: [...addressList(to)],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
type NotificationCapabilities,
|
|
4
|
+
type NotificationChannel,
|
|
5
|
+
type NotificationDriver,
|
|
6
|
+
type NotificationMessage,
|
|
7
|
+
type NotificationReceipt,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
|
|
10
|
+
/** One notification accepted by the in-memory reference implementation. */
|
|
11
|
+
export interface FakeSentNotification {
|
|
12
|
+
/** Delivery channel used for the message. */
|
|
13
|
+
readonly channel: NotificationChannel
|
|
14
|
+
/** Message retained without transport-specific conversion. */
|
|
15
|
+
readonly message: NotificationMessage
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FakeNotificationsRaw {
|
|
19
|
+
readonly sent: readonly FakeSentNotification[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class FakeNotificationsBase<TChannel extends NotificationChannel> implements NotificationDriver<
|
|
23
|
+
NotificationCapabilities<TChannel>,
|
|
24
|
+
FakeNotificationsRaw
|
|
25
|
+
> {
|
|
26
|
+
readonly name = 'fake'
|
|
27
|
+
readonly instance: string
|
|
28
|
+
readonly capabilities: NotificationCapabilities<TChannel>
|
|
29
|
+
readonly sent: FakeSentNotification[] = []
|
|
30
|
+
#nextId = 1
|
|
31
|
+
|
|
32
|
+
constructor(channels: readonly TChannel[], instance = 'default') {
|
|
33
|
+
this.capabilities = { channels }
|
|
34
|
+
this.instance = instance
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
raw(): FakeNotificationsRaw {
|
|
38
|
+
return { sent: this.sent }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async send<TData>(
|
|
42
|
+
channel: NotificationChannel,
|
|
43
|
+
message: NotificationMessage<TData>,
|
|
44
|
+
): Promise<NotificationReceipt> {
|
|
45
|
+
if (!this.capabilities.channels.some((declared) => declared === channel)) {
|
|
46
|
+
throw new Invalid(`Notification channel ${channel} is not configured.`, {
|
|
47
|
+
metadata: { fields: { channel: [`${channel} is not declared by this driver.`] } },
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
this.sent.push({ channel, message })
|
|
51
|
+
return { id: `notification-${this.#nextId++}`, channel }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const allChannels = ['push', 'sms', 'inApp'] as const
|
|
56
|
+
|
|
57
|
+
/** In-memory notification reference implementation supporting every portable channel. */
|
|
58
|
+
export class FakeNotifications extends FakeNotificationsBase<(typeof allChannels)[number]> {
|
|
59
|
+
/** Creates an isolated all-channel notification connection. */
|
|
60
|
+
constructor(instance = 'default') {
|
|
61
|
+
super(allChannels, instance)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const noChannels = [] as const
|
|
66
|
+
|
|
67
|
+
/** In-memory notification reference implementation with every channel disabled. */
|
|
68
|
+
export class FakeNotificationsWithoutChannels extends FakeNotificationsBase<never> {
|
|
69
|
+
/** Creates an isolated notification connection with no configured channels. */
|
|
70
|
+
constructor(instance = 'default') {
|
|
71
|
+
super(noChannels, instance)
|
|
72
|
+
}
|
|
73
|
+
}
|