@yolk-sdk/emulators 0.1.0-canary.96
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 +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
package/src/email.ts
ADDED
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email emulator: a fixture-driven, in-memory fake backend for the generic `EmailClient` port.
|
|
3
|
+
*
|
|
4
|
+
* Yolk never speaks IMAP, POP3, or SMTP; hosts implement `EmailClient`. This emulator stands in for
|
|
5
|
+
* such a host at the port level: `call(method, request)` takes one port call as plain JSON (the
|
|
6
|
+
* credential-free request) and answers `{ response }`, `{ failure }`, or a fail-closed
|
|
7
|
+
* `{ notEmulated }`. There is no socket, TLS, MIME, or mail library here, no `node:` builtin, and
|
|
8
|
+
* no SDK import: it is a plain structural object, and
|
|
9
|
+
* `emailClientLayerFromBackend` in `@yolk-sdk/connectors/email/conformance` turns it into the
|
|
10
|
+
* `EmailClient` layer.
|
|
11
|
+
*
|
|
12
|
+
* Response behaviour comes ONLY from the email conformance fixtures (copied as data in
|
|
13
|
+
* `email-fixtures.ts`). The emulator keeps an in-memory mailbox (folders, messages, flags) seeded
|
|
14
|
+
* with the mailbox the fixtures describe; the state only selects which fixture answers (the first
|
|
15
|
+
* matching fixture consistent with the mailbox) and is updated with what that fixture says
|
|
16
|
+
* happened (flags set, a message moved to the destination id the fixture names, a draft appended,
|
|
17
|
+
* a Sent copy saved). It never invents an id, a flag, or a response.
|
|
18
|
+
*
|
|
19
|
+
* Request-shape latitude (the only one): credential fields are never compared or recorded, and
|
|
20
|
+
* `connection.host` is not compared (the practice host comes from seeds). Every other connection
|
|
21
|
+
* field (`protocol`, `port`, `security`) is compared. Everything else must equal a fixture request
|
|
22
|
+
* exactly. Anything that does not
|
|
23
|
+
* match (an unknown method, no matching fixture, or no fixture consistent with the mailbox state)
|
|
24
|
+
* fails closed with a ledgered `notEmulated` answer, the port analogue of HTTP 400.
|
|
25
|
+
*
|
|
26
|
+
* @experimental
|
|
27
|
+
*/
|
|
28
|
+
import { Data, Equal, Predicate, Result } from 'effect'
|
|
29
|
+
import * as Schema from 'effect/Schema'
|
|
30
|
+
import {
|
|
31
|
+
emailEmulatorFixtures,
|
|
32
|
+
type EmailEmulatorFailure,
|
|
33
|
+
type EmailEmulatorFixture
|
|
34
|
+
} from './email-fixtures.ts'
|
|
35
|
+
import {
|
|
36
|
+
bindRouteHandlers,
|
|
37
|
+
emulatorRouteKey,
|
|
38
|
+
type EmulatorEvidence,
|
|
39
|
+
type EmulatorRouteEvidence
|
|
40
|
+
} from './route-evidence.ts'
|
|
41
|
+
|
|
42
|
+
export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
|
|
43
|
+
|
|
44
|
+
export { emailEmulatorFixtures, type EmailEmulatorFailure, type EmailEmulatorFixture }
|
|
45
|
+
|
|
46
|
+
/** The port every email fixture and manifest route names. */
|
|
47
|
+
export const emailEmulatorPort = 'EmailClient'
|
|
48
|
+
|
|
49
|
+
/** Manifest routes of port emulators use this pseudo method; `path` is `<Port>.<method>`. */
|
|
50
|
+
export const emailEmulatorRouteMethod = 'PORT'
|
|
51
|
+
|
|
52
|
+
const portRoute = (
|
|
53
|
+
method: string,
|
|
54
|
+
write: boolean,
|
|
55
|
+
caseIds: ReadonlyArray<string>
|
|
56
|
+
): EmulatorRouteEvidence => ({
|
|
57
|
+
method: emailEmulatorRouteMethod,
|
|
58
|
+
path: `${emailEmulatorPort}.${method}`,
|
|
59
|
+
kind: 'connector',
|
|
60
|
+
write,
|
|
61
|
+
caseIds,
|
|
62
|
+
evidence: 'unverified',
|
|
63
|
+
observedAt: undefined
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const listAndGet = 'email.imap.list-and-get-headers'
|
|
67
|
+
|
|
68
|
+
const filteredList = 'email.imap.filtered-list-no-fallback'
|
|
69
|
+
|
|
70
|
+
const draftsDiscovery = 'email.imap.draft-drafts-discovery'
|
|
71
|
+
|
|
72
|
+
const setReadAndFlag = 'email.imap.set-read-and-flag'
|
|
73
|
+
|
|
74
|
+
const trashUntrash = 'email.imap.trash-untrash-to-inbox'
|
|
75
|
+
|
|
76
|
+
const moveIds = 'email.imap.move-destination-ids'
|
|
77
|
+
|
|
78
|
+
const pop3Rejections = 'email.pop3.rejects-folders-drafts-mutations'
|
|
79
|
+
|
|
80
|
+
const sentCopyStatuses = 'email.smtp.sent-copy-statuses'
|
|
81
|
+
|
|
82
|
+
const legacySentCopy = 'email.smtp.legacy-host-sent-copy'
|
|
83
|
+
|
|
84
|
+
const acceptanceNotDelivery = 'email.smtp.acceptance-not-delivery'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Route evidence manifest: every emulated `EmailClient` method (`PORT EmailClient.<method>`) and the
|
|
88
|
+
* email conformance cases whose (synthetic, unverified) fixtures it follows. Write methods are
|
|
89
|
+
* unverified connector writes: they need a pending entry in the repo's evidence check until an
|
|
90
|
+
* owner-approved live run against a practice mailbox verifies them.
|
|
91
|
+
*/
|
|
92
|
+
export const emailEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = [
|
|
93
|
+
portRoute('listMessages', false, [listAndGet, pop3Rejections]),
|
|
94
|
+
portRoute('listMessagesFiltered', false, [filteredList]),
|
|
95
|
+
portRoute('getMessage', false, [
|
|
96
|
+
listAndGet,
|
|
97
|
+
draftsDiscovery,
|
|
98
|
+
setReadAndFlag,
|
|
99
|
+
trashUntrash,
|
|
100
|
+
moveIds
|
|
101
|
+
]),
|
|
102
|
+
portRoute('createDraft', true, [draftsDiscovery, trashUntrash, moveIds]),
|
|
103
|
+
portRoute('sendMessage', true, [sentCopyStatuses, legacySentCopy, acceptanceNotDelivery]),
|
|
104
|
+
portRoute('setRead', true, [setReadAndFlag]),
|
|
105
|
+
portRoute('setFlag', true, [setReadAndFlag]),
|
|
106
|
+
portRoute('trash', true, [trashUntrash]),
|
|
107
|
+
portRoute('untrash', true, [trashUntrash]),
|
|
108
|
+
portRoute('move', true, [moveIds]),
|
|
109
|
+
portRoute('deletePermanently', true, [draftsDiscovery, trashUntrash, moveIds])
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const NonEmpty = Schema.String.check(Schema.isNonEmpty())
|
|
113
|
+
|
|
114
|
+
/** SPECIAL-USE attributes (RFC 6154) a seeded folder may advertise. */
|
|
115
|
+
export const EmailEmulatorSpecialUse = Schema.Literals([
|
|
116
|
+
'\\All',
|
|
117
|
+
'\\Archive',
|
|
118
|
+
'\\Drafts',
|
|
119
|
+
'\\Flagged',
|
|
120
|
+
'\\Junk',
|
|
121
|
+
'\\Sent',
|
|
122
|
+
'\\Trash'
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
export type EmailEmulatorSpecialUse = typeof EmailEmulatorSpecialUse.Type
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One message in the emulated mailbox. `id` is the opaque IMAP id (absent for a Sent copy whose
|
|
129
|
+
* id no fixture named), `uidl` the POP3 id of an INBOX message; unknown flags stay absent until a
|
|
130
|
+
* fixture reports them.
|
|
131
|
+
*/
|
|
132
|
+
export const EmailEmulatorMessage = Schema.Struct({
|
|
133
|
+
id: Schema.optionalKey(NonEmpty),
|
|
134
|
+
uidl: Schema.optionalKey(NonEmpty),
|
|
135
|
+
subject: Schema.optionalKey(Schema.String),
|
|
136
|
+
isRead: Schema.optionalKey(Schema.Boolean),
|
|
137
|
+
isFlagged: Schema.optionalKey(Schema.Boolean)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
export type EmailEmulatorMessage = typeof EmailEmulatorMessage.Type
|
|
141
|
+
|
|
142
|
+
export const EmailEmulatorFolder = Schema.Struct({
|
|
143
|
+
name: NonEmpty,
|
|
144
|
+
specialUse: Schema.optionalKey(EmailEmulatorSpecialUse),
|
|
145
|
+
messages: Schema.Array(EmailEmulatorMessage)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
export type EmailEmulatorFolder = typeof EmailEmulatorFolder.Type
|
|
149
|
+
|
|
150
|
+
/** The emulated mailbox: plain JSON, used both as the seed and as the `state()` snapshot. */
|
|
151
|
+
export const EmailEmulatorSeed = Schema.Struct({
|
|
152
|
+
folders: Schema.Array(EmailEmulatorFolder)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export type EmailEmulatorSeed = typeof EmailEmulatorSeed.Type
|
|
156
|
+
|
|
157
|
+
/** The synthetic practice mailbox the email fixtures describe (the default seed). */
|
|
158
|
+
export const emailEmulatorDefaultSeed: EmailEmulatorSeed = {
|
|
159
|
+
folders: [
|
|
160
|
+
{
|
|
161
|
+
name: 'INBOX',
|
|
162
|
+
messages: [
|
|
163
|
+
{
|
|
164
|
+
id: '1700000001:41',
|
|
165
|
+
uidl: 'uidl-synthetic-0041',
|
|
166
|
+
subject: 'Synthetic welcome',
|
|
167
|
+
isRead: false,
|
|
168
|
+
isFlagged: false
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: '1700000001:42',
|
|
172
|
+
uidl: 'uidl-synthetic-0042',
|
|
173
|
+
subject: 'Synthetic weekly summary',
|
|
174
|
+
isRead: true,
|
|
175
|
+
isFlagged: false
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
{ name: 'Saved Drafts', specialUse: '\\Drafts', messages: [] },
|
|
180
|
+
{ name: 'Sent Items', specialUse: '\\Sent', messages: [] },
|
|
181
|
+
{ name: 'Deleted Items', specialUse: '\\Trash', messages: [] },
|
|
182
|
+
{ name: 'Archive', messages: [] }
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const Failure = Schema.Struct({
|
|
187
|
+
kind: Schema.Literals(['expected', 'error']),
|
|
188
|
+
code: NonEmpty,
|
|
189
|
+
message: Schema.String,
|
|
190
|
+
status: Schema.optionalKey(Schema.Int)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A fault: answer the next `count` calls (every one when omitted) of `method` whose credential-free
|
|
195
|
+
* request contains `match` (a deep subset: objects by key, arrays element by element, other values
|
|
196
|
+
* exactly) with `failure` instead of a fixture. Faults change no mailbox state.
|
|
197
|
+
*/
|
|
198
|
+
export const EmailEmulatorFault = Schema.Struct({
|
|
199
|
+
kind: Schema.Literal('failure'),
|
|
200
|
+
method: NonEmpty,
|
|
201
|
+
match: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
|
202
|
+
count: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
|
203
|
+
failure: Failure
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
export type EmailEmulatorFault = typeof EmailEmulatorFault.Type
|
|
207
|
+
|
|
208
|
+
export type EmailEmulatorFaultState = {
|
|
209
|
+
readonly id: number
|
|
210
|
+
readonly fault: EmailEmulatorFault
|
|
211
|
+
/** Remaining matching calls; `undefined` for an unlimited fault. */
|
|
212
|
+
readonly remaining: number | undefined
|
|
213
|
+
readonly applied: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Thrown by `makeEmailEmulator` (invalid seed) and `faults.add` (invalid fault). */
|
|
217
|
+
export class EmailEmulatorInputInvalid extends Data.TaggedError('EmailEmulatorInputInvalid')<{
|
|
218
|
+
readonly input: 'seed' | 'fault'
|
|
219
|
+
readonly reason: string
|
|
220
|
+
}> {
|
|
221
|
+
override get message(): string {
|
|
222
|
+
return `Invalid email emulator ${this.input}: ${this.reason}`
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Why a call failed closed. */
|
|
227
|
+
export type EmailEmulatorNotEmulatedReason =
|
|
228
|
+
| 'unknown-method'
|
|
229
|
+
| 'invalid-request'
|
|
230
|
+
| 'no-matching-fixture'
|
|
231
|
+
| 'state-conflict'
|
|
232
|
+
|
|
233
|
+
const notEmulatedText: Record<EmailEmulatorNotEmulatedReason, string> = {
|
|
234
|
+
'unknown-method': 'unknown-method: the method has no emulated route',
|
|
235
|
+
'invalid-request': 'invalid-request: the request is not a JSON object',
|
|
236
|
+
'no-matching-fixture': 'no-matching-fixture: no fixture matches this request',
|
|
237
|
+
'state-conflict': 'state-conflict: no matching fixture is consistent with the emulated mailbox'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One emulator answer; structurally the reply of a plain-JSON `EmailBackend`. */
|
|
241
|
+
export type EmailEmulatorReply =
|
|
242
|
+
| { readonly response: Schema.Json }
|
|
243
|
+
| { readonly failure: EmailEmulatorFailure }
|
|
244
|
+
| { readonly notEmulated: { readonly reason: string } }
|
|
245
|
+
|
|
246
|
+
export type EmailEmulatorLedgerEntry = {
|
|
247
|
+
readonly seq: number
|
|
248
|
+
readonly method: string
|
|
249
|
+
/** The request as received, without credential fields. */
|
|
250
|
+
readonly request: Schema.Json
|
|
251
|
+
readonly outcome: 'answered' | 'fault' | 'not-emulated'
|
|
252
|
+
/** `unknown-method` for a method outside the manifest. */
|
|
253
|
+
readonly evidence: EmulatorEvidence | 'unknown-method'
|
|
254
|
+
readonly fixtureId?: string
|
|
255
|
+
readonly faultId?: number
|
|
256
|
+
readonly reason?: EmailEmulatorNotEmulatedReason
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type EmailEmulatorRouteCoverage = EmulatorRouteEvidence & {
|
|
260
|
+
/** Ledger calls to this method since the last ledger clear or reset. */
|
|
261
|
+
readonly calls: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type EmailEmulatorCoverage = {
|
|
265
|
+
readonly routes: ReadonlyArray<EmailEmulatorRouteCoverage>
|
|
266
|
+
/** Ledger calls that failed closed. */
|
|
267
|
+
readonly notEmulatedCalls: number
|
|
268
|
+
/** Fixtures that have not answered a call since the emulator was made or last reset. */
|
|
269
|
+
readonly unusedFixtureIds: ReadonlyArray<string>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type EmailEmulatorOptions = {
|
|
273
|
+
/** The initial mailbox. Defaults to `emailEmulatorDefaultSeed`. */
|
|
274
|
+
readonly seed?: EmailEmulatorSeed
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type EmailEmulator = {
|
|
278
|
+
/** Answer one `EmailClient` call; never throws. This makes the emulator an `EmailBackend`. */
|
|
279
|
+
readonly call: (method: string, request: Schema.Json) => EmailEmulatorReply
|
|
280
|
+
/** The current mailbox (a copy). */
|
|
281
|
+
readonly state: () => EmailEmulatorSeed
|
|
282
|
+
/** The seed the emulator started from (a copy). */
|
|
283
|
+
readonly seed: () => EmailEmulatorSeed
|
|
284
|
+
readonly ledger: {
|
|
285
|
+
readonly entries: () => ReadonlyArray<EmailEmulatorLedgerEntry>
|
|
286
|
+
readonly clear: () => void
|
|
287
|
+
}
|
|
288
|
+
/** Clear the ledger and faults, forget fixture use, and restore the seeded mailbox. */
|
|
289
|
+
readonly reset: () => void
|
|
290
|
+
readonly faults: {
|
|
291
|
+
readonly add: (fault: EmailEmulatorFault) => EmailEmulatorFaultState
|
|
292
|
+
readonly list: () => ReadonlyArray<EmailEmulatorFaultState>
|
|
293
|
+
readonly clear: () => void
|
|
294
|
+
}
|
|
295
|
+
readonly coverage: () => EmailEmulatorCoverage
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// JSON helpers.
|
|
299
|
+
|
|
300
|
+
type JsonObject = Schema.JsonObject
|
|
301
|
+
|
|
302
|
+
const isJsonObject = (value: Schema.Json | undefined): value is JsonObject =>
|
|
303
|
+
value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
|
|
304
|
+
|
|
305
|
+
const objectField = (value: Schema.Json | undefined, key: string): JsonObject | undefined => {
|
|
306
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
307
|
+
|
|
308
|
+
return isJsonObject(field) ? field : undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const stringField = (value: Schema.Json | undefined, key: string): string | undefined => {
|
|
312
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
313
|
+
|
|
314
|
+
return Predicate.isString(field) ? field : undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const booleanField = (value: Schema.Json | undefined, key: string): boolean | undefined => {
|
|
318
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
319
|
+
|
|
320
|
+
return Predicate.isBoolean(field) ? field : undefined
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const arrayField = (
|
|
324
|
+
value: Schema.Json | undefined,
|
|
325
|
+
key: string
|
|
326
|
+
): ReadonlyArray<Schema.Json> | undefined => {
|
|
327
|
+
const field = isJsonObject(value) ? value[key] : undefined
|
|
328
|
+
|
|
329
|
+
return Array.isArray(field) ? field : undefined
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The same credential field names the conformance secret scan flags, plus `credential(s)`.
|
|
333
|
+
const credentialKeyPattern =
|
|
334
|
+
/^(?:credentials?|(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|authorization)$/i
|
|
335
|
+
|
|
336
|
+
/** A copy without credential fields; `ignoreConnectionHost` applies the request-shape latitude. */
|
|
337
|
+
const scrub = (value: Schema.Json, ignoreConnectionHost: boolean): Schema.Json => {
|
|
338
|
+
if (Array.isArray(value)) {
|
|
339
|
+
return value.map(item => scrub(item, ignoreConnectionHost))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!isJsonObject(value)) {
|
|
343
|
+
return value
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const copy: Record<string, Schema.Json> = {}
|
|
347
|
+
|
|
348
|
+
for (const [key, item] of Object.entries(value)) {
|
|
349
|
+
if (credentialKeyPattern.test(key)) {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (ignoreConnectionHost && key === 'connection' && isJsonObject(item)) {
|
|
354
|
+
const { host: _host, ...connection } = item
|
|
355
|
+
|
|
356
|
+
copy[key] = scrub(connection, ignoreConnectionHost)
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
copy[key] = scrub(item, ignoreConnectionHost)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return copy
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Deep subset: objects by key, arrays element by element (same length), other values exactly. */
|
|
367
|
+
const containsSubset = (value: Schema.Json | undefined, pattern: Schema.Json): boolean => {
|
|
368
|
+
if (value === undefined) {
|
|
369
|
+
return false
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (Array.isArray(pattern)) {
|
|
373
|
+
return (
|
|
374
|
+
Array.isArray(value) &&
|
|
375
|
+
value.length === pattern.length &&
|
|
376
|
+
pattern.every((item, index) => containsSubset(value[index], item))
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (isJsonObject(pattern)) {
|
|
381
|
+
return (
|
|
382
|
+
isJsonObject(value) &&
|
|
383
|
+
Object.entries(pattern).every(([key, item]) => containsSubset(value[key], item))
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return Equal.equals(value, pattern)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Mailbox state.
|
|
391
|
+
|
|
392
|
+
type MessageState = {
|
|
393
|
+
id?: string
|
|
394
|
+
uidl?: string
|
|
395
|
+
subject?: string
|
|
396
|
+
isRead?: boolean
|
|
397
|
+
isFlagged?: boolean
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
type FolderState = {
|
|
401
|
+
readonly name: string
|
|
402
|
+
readonly specialUse?: EmailEmulatorSpecialUse
|
|
403
|
+
messages: Array<MessageState>
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
type MailboxState = { readonly folders: Array<FolderState> }
|
|
407
|
+
|
|
408
|
+
const copyMessage = (message: EmailEmulatorMessage | MessageState): MessageState => {
|
|
409
|
+
const copy: MessageState = {}
|
|
410
|
+
|
|
411
|
+
if (message.id !== undefined) copy.id = message.id
|
|
412
|
+
|
|
413
|
+
if (message.uidl !== undefined) copy.uidl = message.uidl
|
|
414
|
+
|
|
415
|
+
if (message.subject !== undefined) copy.subject = message.subject
|
|
416
|
+
|
|
417
|
+
if (message.isRead !== undefined) copy.isRead = message.isRead
|
|
418
|
+
|
|
419
|
+
if (message.isFlagged !== undefined) copy.isFlagged = message.isFlagged
|
|
420
|
+
|
|
421
|
+
return copy
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const mailboxFrom = (seed: EmailEmulatorSeed): MailboxState => ({
|
|
425
|
+
folders: seed.folders.map(folder =>
|
|
426
|
+
folder.specialUse === undefined
|
|
427
|
+
? { name: folder.name, messages: folder.messages.map(copyMessage) }
|
|
428
|
+
: {
|
|
429
|
+
name: folder.name,
|
|
430
|
+
specialUse: folder.specialUse,
|
|
431
|
+
messages: folder.messages.map(copyMessage)
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const snapshotOf = (state: MailboxState): EmailEmulatorSeed => ({
|
|
437
|
+
folders: state.folders.map(folder =>
|
|
438
|
+
folder.specialUse === undefined
|
|
439
|
+
? { name: folder.name, messages: folder.messages.map(copyMessage) }
|
|
440
|
+
: {
|
|
441
|
+
name: folder.name,
|
|
442
|
+
specialUse: folder.specialUse,
|
|
443
|
+
messages: folder.messages.map(copyMessage)
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
const folderNamed = (state: MailboxState, name: string | undefined): FolderState | undefined =>
|
|
449
|
+
name === undefined ? undefined : state.folders.find(folder => folder.name === name)
|
|
450
|
+
|
|
451
|
+
const specialUseFolder = (
|
|
452
|
+
state: MailboxState,
|
|
453
|
+
use: EmailEmulatorSpecialUse
|
|
454
|
+
): FolderState | undefined => state.folders.find(folder => folder.specialUse === use)
|
|
455
|
+
|
|
456
|
+
const isPop3 = (request: JsonObject): boolean =>
|
|
457
|
+
stringField(objectField(request, 'connection'), 'protocol') === 'pop3'
|
|
458
|
+
|
|
459
|
+
const messageIn = (
|
|
460
|
+
folder: FolderState | undefined,
|
|
461
|
+
id: string | undefined,
|
|
462
|
+
pop3 = false
|
|
463
|
+
): MessageState | undefined =>
|
|
464
|
+
folder === undefined || id === undefined
|
|
465
|
+
? undefined
|
|
466
|
+
: folder.messages.find(message => (pop3 ? message.uidl : message.id) === id)
|
|
467
|
+
|
|
468
|
+
const flagKeys = ['isRead', 'isFlagged'] as const
|
|
469
|
+
|
|
470
|
+
/** Reported flags and subject agree with what the mailbox knows (unknown state agrees). */
|
|
471
|
+
const agrees = (message: MessageState, reported: Schema.Json | undefined): boolean => {
|
|
472
|
+
const subject = stringField(reported, 'subject')
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
flagKeys.every(key => {
|
|
476
|
+
const value = booleanField(reported, key)
|
|
477
|
+
|
|
478
|
+
return value === undefined || message[key] === undefined || message[key] === value
|
|
479
|
+
}) &&
|
|
480
|
+
(subject === undefined || message.subject === undefined || message.subject === subject)
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Remember flags and subject the mailbox did not know yet. */
|
|
485
|
+
const learn = (message: MessageState, reported: Schema.Json | undefined): void => {
|
|
486
|
+
for (const key of flagKeys) {
|
|
487
|
+
const value = booleanField(reported, key)
|
|
488
|
+
|
|
489
|
+
if (message[key] === undefined && value !== undefined) {
|
|
490
|
+
message[key] = value
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const subject = stringField(reported, 'subject')
|
|
495
|
+
|
|
496
|
+
if (message.subject === undefined && subject !== undefined) {
|
|
497
|
+
message.subject = subject
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A state model for one method: whether a fixture is consistent, and what it changes. */
|
|
502
|
+
type MethodModel = {
|
|
503
|
+
readonly consistent: (
|
|
504
|
+
state: MailboxState,
|
|
505
|
+
request: JsonObject,
|
|
506
|
+
fixture: EmailEmulatorFixture
|
|
507
|
+
) => boolean
|
|
508
|
+
readonly apply: (state: MailboxState, request: JsonObject, fixture: EmailEmulatorFixture) => void
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const requestFolder = (state: MailboxState, request: JsonObject) =>
|
|
512
|
+
folderNamed(state, stringField(request, 'folder') ?? 'INBOX')
|
|
513
|
+
|
|
514
|
+
/** Every known message that certainly passes the request's read/flagged filters. */
|
|
515
|
+
const certainlyListed = (message: MessageState, request: JsonObject): boolean =>
|
|
516
|
+
flagKeys.every(key => {
|
|
517
|
+
const filter = booleanField(request, key)
|
|
518
|
+
|
|
519
|
+
return filter === undefined || message[key] === filter
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const listModel: MethodModel = {
|
|
523
|
+
consistent: (state, request, fixture) => {
|
|
524
|
+
if (fixture.failure !== undefined) {
|
|
525
|
+
return true
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const folder = requestFolder(state, request)
|
|
529
|
+
const listed = arrayField(fixture.response, 'messages')
|
|
530
|
+
const pop3 = isPop3(request)
|
|
531
|
+
|
|
532
|
+
if (folder === undefined || listed === undefined) {
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const everyListedKnown = listed.every(summary => {
|
|
537
|
+
const message = messageIn(folder, stringField(summary, 'id'), pop3)
|
|
538
|
+
|
|
539
|
+
return message !== undefined && agrees(message, summary)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
if (!everyListedKnown) {
|
|
543
|
+
return false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (stringField(fixture.response, 'nextCursor') !== undefined) {
|
|
547
|
+
return true
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const listedIds = new Set(listed.map(summary => stringField(summary, 'id')))
|
|
551
|
+
|
|
552
|
+
return folder.messages.every(message => {
|
|
553
|
+
const id = pop3 ? message.uidl : message.id
|
|
554
|
+
|
|
555
|
+
return id === undefined || !certainlyListed(message, request) || listedIds.has(id)
|
|
556
|
+
})
|
|
557
|
+
},
|
|
558
|
+
apply: (state, request, fixture) => {
|
|
559
|
+
const folder = requestFolder(state, request)
|
|
560
|
+
const pop3 = isPop3(request)
|
|
561
|
+
|
|
562
|
+
for (const summary of arrayField(fixture.response, 'messages') ?? []) {
|
|
563
|
+
const message = messageIn(folder, stringField(summary, 'id'), pop3)
|
|
564
|
+
|
|
565
|
+
if (message !== undefined) {
|
|
566
|
+
learn(message, summary)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const getModel: MethodModel = {
|
|
573
|
+
consistent: (state, request, fixture) => {
|
|
574
|
+
const id = stringField(request, 'messageId')
|
|
575
|
+
const message = messageIn(requestFolder(state, request), id, isPop3(request))
|
|
576
|
+
|
|
577
|
+
if (fixture.failure !== undefined) {
|
|
578
|
+
// A failed get is consistent only where the mailbox has no such message.
|
|
579
|
+
return message === undefined
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const reported = objectField(fixture.response, 'message')
|
|
583
|
+
|
|
584
|
+
return message !== undefined && stringField(reported, 'id') === id && agrees(message, reported)
|
|
585
|
+
},
|
|
586
|
+
apply: (state, request, fixture) => {
|
|
587
|
+
const message = messageIn(
|
|
588
|
+
requestFolder(state, request),
|
|
589
|
+
stringField(request, 'messageId'),
|
|
590
|
+
isPop3(request)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if (message !== undefined && fixture.failure === undefined) {
|
|
594
|
+
learn(message, objectField(fixture.response, 'message'))
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const flagModel = (key: 'isRead' | 'isFlagged'): MethodModel => ({
|
|
600
|
+
consistent: (state, request, fixture) => {
|
|
601
|
+
if (fixture.failure !== undefined) {
|
|
602
|
+
return true
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const id = stringField(request, 'messageId')
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
messageIn(requestFolder(state, request), id) !== undefined &&
|
|
609
|
+
stringField(fixture.response, 'messageId') === id &&
|
|
610
|
+
booleanField(fixture.response, key) === booleanField(request, key)
|
|
611
|
+
)
|
|
612
|
+
},
|
|
613
|
+
apply: (state, request, fixture) => {
|
|
614
|
+
const message = messageIn(requestFolder(state, request), stringField(request, 'messageId'))
|
|
615
|
+
const value = booleanField(request, key)
|
|
616
|
+
|
|
617
|
+
if (fixture.failure === undefined && message !== undefined && value !== undefined) {
|
|
618
|
+
message[key] = value
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
/** A move-shaped method: where the message comes from and which destination is acceptable. */
|
|
624
|
+
const moveModel = (
|
|
625
|
+
source: (state: MailboxState, request: JsonObject) => FolderState | undefined,
|
|
626
|
+
destinationAllowed: (
|
|
627
|
+
state: MailboxState,
|
|
628
|
+
request: JsonObject,
|
|
629
|
+
destination: FolderState
|
|
630
|
+
) => boolean
|
|
631
|
+
): MethodModel => {
|
|
632
|
+
const plan = (state: MailboxState, request: JsonObject, fixture: EmailEmulatorFixture) => {
|
|
633
|
+
const from = source(state, request)
|
|
634
|
+
const message = messageIn(from, stringField(request, 'messageId'))
|
|
635
|
+
const destination = folderNamed(state, stringField(fixture.response, 'folder'))
|
|
636
|
+
const movedId = stringField(fixture.response, 'messageId')
|
|
637
|
+
|
|
638
|
+
return { from, message, destination, movedId }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
consistent: (state, request, fixture) => {
|
|
643
|
+
if (fixture.failure !== undefined) {
|
|
644
|
+
return true
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const { from, message, destination, movedId } = plan(state, request, fixture)
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
booleanField(fixture.response, 'moved') === true &&
|
|
651
|
+
from !== undefined &&
|
|
652
|
+
message !== undefined &&
|
|
653
|
+
destination !== undefined &&
|
|
654
|
+
destination !== from &&
|
|
655
|
+
destinationAllowed(state, request, destination) &&
|
|
656
|
+
messageIn(destination, movedId) === undefined
|
|
657
|
+
)
|
|
658
|
+
},
|
|
659
|
+
apply: (state, request, fixture) => {
|
|
660
|
+
if (fixture.failure !== undefined) {
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const { from, message, destination, movedId } = plan(state, request, fixture)
|
|
665
|
+
|
|
666
|
+
if (from === undefined || message === undefined || destination === undefined) {
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
from.messages = from.messages.filter(candidate => candidate !== message)
|
|
671
|
+
|
|
672
|
+
const moved: MessageState = { ...message }
|
|
673
|
+
|
|
674
|
+
delete moved.uidl
|
|
675
|
+
|
|
676
|
+
if (movedId === undefined) {
|
|
677
|
+
delete moved.id
|
|
678
|
+
} else {
|
|
679
|
+
moved.id = movedId
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
destination.messages.push(moved)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const createDraftModel: MethodModel = {
|
|
688
|
+
consistent: (state, request, fixture) => {
|
|
689
|
+
if (fixture.failure !== undefined) {
|
|
690
|
+
return true
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const destination = folderNamed(state, stringField(fixture.response, 'folder'))
|
|
694
|
+
const requested = stringField(request, 'folder')
|
|
695
|
+
|
|
696
|
+
return (
|
|
697
|
+
booleanField(fixture.response, 'saved') === true &&
|
|
698
|
+
destination !== undefined &&
|
|
699
|
+
(requested === undefined
|
|
700
|
+
? destination.specialUse === '\\Drafts'
|
|
701
|
+
: destination.name === requested) &&
|
|
702
|
+
messageIn(destination, stringField(fixture.response, 'draftId')) === undefined
|
|
703
|
+
)
|
|
704
|
+
},
|
|
705
|
+
apply: (state, request, fixture) => {
|
|
706
|
+
const destination = folderNamed(state, stringField(fixture.response, 'folder'))
|
|
707
|
+
|
|
708
|
+
if (fixture.failure !== undefined || destination === undefined) {
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const draft: MessageState = {}
|
|
713
|
+
const draftId = stringField(fixture.response, 'draftId')
|
|
714
|
+
const subject = stringField(objectField(request, 'message'), 'subject')
|
|
715
|
+
|
|
716
|
+
if (draftId !== undefined) draft.id = draftId
|
|
717
|
+
|
|
718
|
+
if (subject !== undefined) draft.subject = subject
|
|
719
|
+
|
|
720
|
+
destination.messages.push(draft)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const deletePermanentlyModel: MethodModel = {
|
|
725
|
+
consistent: (state, request, fixture) => {
|
|
726
|
+
if (fixture.failure !== undefined) {
|
|
727
|
+
return true
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const folder = requestFolder(state, request)
|
|
731
|
+
const results = arrayField(fixture.response, 'results')
|
|
732
|
+
|
|
733
|
+
return (
|
|
734
|
+
folder !== undefined &&
|
|
735
|
+
results !== undefined &&
|
|
736
|
+
results.every(result => {
|
|
737
|
+
const present = messageIn(folder, stringField(result, 'messageId')) !== undefined
|
|
738
|
+
const status = stringField(result, 'status')
|
|
739
|
+
|
|
740
|
+
return status === 'succeeded'
|
|
741
|
+
? present
|
|
742
|
+
: status !== 'failed' || stringField(result, 'code') !== 'not_found' || !present
|
|
743
|
+
})
|
|
744
|
+
)
|
|
745
|
+
},
|
|
746
|
+
apply: (state, request, fixture) => {
|
|
747
|
+
const folder = requestFolder(state, request)
|
|
748
|
+
|
|
749
|
+
if (fixture.failure !== undefined || folder === undefined) {
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const deleted = new Set(
|
|
754
|
+
(arrayField(fixture.response, 'results') ?? []).flatMap(result =>
|
|
755
|
+
stringField(result, 'status') === 'succeeded' ? [stringField(result, 'messageId')] : []
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
folder.messages = folder.messages.filter(
|
|
760
|
+
message => message.id === undefined || !deleted.has(message.id)
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The mailbox a saved Sent copy lands in, or `undefined` when none is consistent. */
|
|
766
|
+
const savedCopyFolder = (
|
|
767
|
+
state: MailboxState,
|
|
768
|
+
request: JsonObject,
|
|
769
|
+
fixture: EmailEmulatorFixture
|
|
770
|
+
): FolderState | undefined => {
|
|
771
|
+
const requestedCopy = objectField(request, 'sentCopy')
|
|
772
|
+
|
|
773
|
+
if (requestedCopy === undefined) {
|
|
774
|
+
return undefined
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const requestedFolder = stringField(requestedCopy, 'folder')
|
|
778
|
+
const reportedFolder = stringField(objectField(fixture.response, 'sentCopy'), 'folder')
|
|
779
|
+
|
|
780
|
+
const folder =
|
|
781
|
+
requestedFolder === undefined
|
|
782
|
+
? reportedFolder === undefined
|
|
783
|
+
? specialUseFolder(state, '\\Sent')
|
|
784
|
+
: folderNamed(state, reportedFolder)
|
|
785
|
+
: folderNamed(state, requestedFolder)
|
|
786
|
+
|
|
787
|
+
if (folder === undefined || (reportedFolder !== undefined && reportedFolder !== folder.name)) {
|
|
788
|
+
return undefined
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return requestedFolder !== undefined || folder.specialUse === '\\Sent' ? folder : undefined
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const sentCopyStatus = (fixture: EmailEmulatorFixture) =>
|
|
795
|
+
stringField(objectField(fixture.response, 'sentCopy'), 'status')
|
|
796
|
+
|
|
797
|
+
const sendMessageModel: MethodModel = {
|
|
798
|
+
consistent: (state, request, fixture) => {
|
|
799
|
+
if (fixture.failure !== undefined) {
|
|
800
|
+
return true
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const status = sentCopyStatus(fixture)
|
|
804
|
+
const requestedCopy = objectField(request, 'sentCopy')
|
|
805
|
+
const requestedFolder = stringField(requestedCopy, 'folder')
|
|
806
|
+
|
|
807
|
+
// A failed copy needs a requested copy; when the request names a folder, that folder must be
|
|
808
|
+
// missing from the mailbox (the only failure the fixtures record).
|
|
809
|
+
const failedConsistent =
|
|
810
|
+
requestedCopy !== undefined &&
|
|
811
|
+
(requestedFolder === undefined || folderNamed(state, requestedFolder) === undefined)
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
booleanField(fixture.response, 'accepted') === true &&
|
|
815
|
+
(status === 'saved'
|
|
816
|
+
? savedCopyFolder(state, request, fixture) !== undefined
|
|
817
|
+
: status !== 'failed' || failedConsistent)
|
|
818
|
+
)
|
|
819
|
+
},
|
|
820
|
+
apply: (state, request, fixture) => {
|
|
821
|
+
if (fixture.failure !== undefined || sentCopyStatus(fixture) !== 'saved') {
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const folder = savedCopyFolder(state, request, fixture)
|
|
826
|
+
const subject = stringField(objectField(request, 'message'), 'subject')
|
|
827
|
+
|
|
828
|
+
folder?.messages.push(subject === undefined ? {} : { subject })
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const routeKey = (method: string) =>
|
|
833
|
+
emulatorRouteKey(emailEmulatorRouteMethod, `${emailEmulatorPort}.${method}`)
|
|
834
|
+
|
|
835
|
+
const methodModels: ReadonlyMap<string, MethodModel> = new Map([
|
|
836
|
+
[routeKey('listMessages'), listModel],
|
|
837
|
+
[routeKey('listMessagesFiltered'), listModel],
|
|
838
|
+
[routeKey('getMessage'), getModel],
|
|
839
|
+
[routeKey('createDraft'), createDraftModel],
|
|
840
|
+
[routeKey('sendMessage'), sendMessageModel],
|
|
841
|
+
[routeKey('setRead'), flagModel('isRead')],
|
|
842
|
+
[routeKey('setFlag'), flagModel('isFlagged')],
|
|
843
|
+
[
|
|
844
|
+
routeKey('trash'),
|
|
845
|
+
moveModel(
|
|
846
|
+
(state, request) => requestFolder(state, request),
|
|
847
|
+
(_state, request, destination) => {
|
|
848
|
+
const trashFolder = stringField(request, 'trashFolder')
|
|
849
|
+
|
|
850
|
+
return trashFolder === undefined
|
|
851
|
+
? destination.specialUse === '\\Trash'
|
|
852
|
+
: destination.name === trashFolder
|
|
853
|
+
}
|
|
854
|
+
)
|
|
855
|
+
],
|
|
856
|
+
[
|
|
857
|
+
routeKey('untrash'),
|
|
858
|
+
moveModel(
|
|
859
|
+
(state, request) => {
|
|
860
|
+
const folder = stringField(request, 'folder')
|
|
861
|
+
|
|
862
|
+
return folder === undefined
|
|
863
|
+
? specialUseFolder(state, '\\Trash')
|
|
864
|
+
: folderNamed(state, folder)
|
|
865
|
+
},
|
|
866
|
+
(_state, request, destination) =>
|
|
867
|
+
destination.name === stringField(request, 'destinationFolder')
|
|
868
|
+
)
|
|
869
|
+
],
|
|
870
|
+
[
|
|
871
|
+
routeKey('move'),
|
|
872
|
+
moveModel(
|
|
873
|
+
(state, request) => requestFolder(state, request),
|
|
874
|
+
(_state, request, destination) =>
|
|
875
|
+
destination.name === stringField(request, 'destinationFolder')
|
|
876
|
+
)
|
|
877
|
+
],
|
|
878
|
+
[routeKey('deletePermanently'), deletePermanentlyModel]
|
|
879
|
+
])
|
|
880
|
+
|
|
881
|
+
const strict = { onExcessProperty: 'error' } as const
|
|
882
|
+
|
|
883
|
+
const decodeSeed = Schema.decodeUnknownResult(EmailEmulatorSeed, strict)
|
|
884
|
+
|
|
885
|
+
const decodeFault = Schema.decodeUnknownResult(EmailEmulatorFault, strict)
|
|
886
|
+
|
|
887
|
+
const issueText = (error: Schema.SchemaError): string => error.message
|
|
888
|
+
|
|
889
|
+
/** Folder names and message ids (and POP3 ids) must be unique so fixtures address one message. */
|
|
890
|
+
const seedProblem = (seed: EmailEmulatorSeed): string | undefined => {
|
|
891
|
+
const names = seed.folders.map(folder => folder.name)
|
|
892
|
+
|
|
893
|
+
if (new Set(names).size !== names.length) {
|
|
894
|
+
return 'folder names must be unique'
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const folder of seed.folders) {
|
|
898
|
+
const ids = folder.messages.flatMap(message => (message.id === undefined ? [] : [message.id]))
|
|
899
|
+
|
|
900
|
+
const uidls = folder.messages.flatMap(message =>
|
|
901
|
+
message.uidl === undefined ? [] : [message.uidl]
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
if (new Set(ids).size !== ids.length || new Set(uidls).size !== uidls.length) {
|
|
905
|
+
return `message ids in ${folder.name} must be unique`
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return undefined
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Create an email emulator. Each call has its own mailbox, ledger, faults, and fixture use.
|
|
914
|
+
*
|
|
915
|
+
* `call(method, request)` answers in this order: an unknown method or a non-object request fails
|
|
916
|
+
* closed; the first active matching fault answers its failure; otherwise the fixtures whose method
|
|
917
|
+
* and request match (after the request-shape latitude) are checked against the mailbox, and the
|
|
918
|
+
* first consistent one (preferring one not used since the last reset) answers and updates the
|
|
919
|
+
* mailbox; no match (`no-matching-fixture`) or no consistent match (`state-conflict`) fails closed.
|
|
920
|
+
* Every call is written to the ledger without credential fields.
|
|
921
|
+
*
|
|
922
|
+
* Throws `EmailEmulatorInputInvalid` for an invalid seed.
|
|
923
|
+
*/
|
|
924
|
+
export const makeEmailEmulator = (options: EmailEmulatorOptions = {}): EmailEmulator => {
|
|
925
|
+
const seedResult = decodeSeed(options.seed ?? emailEmulatorDefaultSeed)
|
|
926
|
+
|
|
927
|
+
if (Result.isFailure(seedResult)) {
|
|
928
|
+
throw new EmailEmulatorInputInvalid({ input: 'seed', reason: issueText(seedResult.failure) })
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const seed = seedResult.success
|
|
932
|
+
const problem = seedProblem(seed)
|
|
933
|
+
|
|
934
|
+
if (problem !== undefined) {
|
|
935
|
+
throw new EmailEmulatorInputInvalid({ input: 'seed', reason: problem })
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const bound = new Map(
|
|
939
|
+
bindRouteHandlers(emailEmulatorRoutes, methodModels).map(({ route, handler }) => [
|
|
940
|
+
route.path.slice(emailEmulatorPort.length + 1),
|
|
941
|
+
{ route, model: handler }
|
|
942
|
+
])
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
let state = mailboxFrom(seed)
|
|
946
|
+
let entries: Array<EmailEmulatorLedgerEntry> = []
|
|
947
|
+
|
|
948
|
+
let faults: Array<{
|
|
949
|
+
id: number
|
|
950
|
+
fault: EmailEmulatorFault
|
|
951
|
+
remaining: number | undefined
|
|
952
|
+
applied: number
|
|
953
|
+
}> = []
|
|
954
|
+
|
|
955
|
+
let nextFaultId = 1
|
|
956
|
+
let nextSeq = 1
|
|
957
|
+
const used = new Set<string>()
|
|
958
|
+
|
|
959
|
+
const record = (entry: Omit<EmailEmulatorLedgerEntry, 'seq'>) => {
|
|
960
|
+
entries.push({ seq: nextSeq, ...entry })
|
|
961
|
+
nextSeq += 1
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const refuse = (
|
|
965
|
+
method: string,
|
|
966
|
+
request: Schema.Json,
|
|
967
|
+
reason: EmailEmulatorNotEmulatedReason,
|
|
968
|
+
evidence: EmailEmulatorLedgerEntry['evidence']
|
|
969
|
+
): EmailEmulatorReply => {
|
|
970
|
+
record({ method, request, outcome: 'not-emulated', evidence, reason })
|
|
971
|
+
|
|
972
|
+
return { notEmulated: { reason: notEmulatedText[reason] } }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const call = (method: string, rawRequest: Schema.Json): EmailEmulatorReply => {
|
|
976
|
+
const request = scrub(rawRequest, false)
|
|
977
|
+
const target = bound.get(method)
|
|
978
|
+
|
|
979
|
+
if (target === undefined) {
|
|
980
|
+
return refuse(method, request, 'unknown-method', 'unknown-method')
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const evidence = target.route.evidence
|
|
984
|
+
|
|
985
|
+
if (!isJsonObject(request)) {
|
|
986
|
+
return refuse(method, request, 'invalid-request', evidence)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const comparable = scrub(request, true)
|
|
990
|
+
|
|
991
|
+
const matching = emailEmulatorFixtures.filter(
|
|
992
|
+
fixture =>
|
|
993
|
+
fixture.port === emailEmulatorPort &&
|
|
994
|
+
fixture.method === method &&
|
|
995
|
+
Equal.equals(scrub(fixture.request, true), comparable)
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
if (matching.length === 0) {
|
|
999
|
+
return refuse(method, request, 'no-matching-fixture', evidence)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const consistent = matching.filter(fixture => target.model.consistent(state, request, fixture))
|
|
1003
|
+
const chosen = consistent.find(fixture => !used.has(fixture.id)) ?? consistent[0]
|
|
1004
|
+
|
|
1005
|
+
if (chosen === undefined) {
|
|
1006
|
+
return refuse(method, request, 'state-conflict', evidence)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Faults apply only to a call a fixture would answer: an unmatched or state-conflicting call
|
|
1010
|
+
// stays refused above and leaves every fault untouched. A fault changes no mailbox state.
|
|
1011
|
+
const fault = faults.find(
|
|
1012
|
+
candidate =>
|
|
1013
|
+
candidate.fault.method === method &&
|
|
1014
|
+
(candidate.remaining === undefined || candidate.remaining > 0) &&
|
|
1015
|
+
(candidate.fault.match === undefined || containsSubset(request, candidate.fault.match))
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
if (fault !== undefined) {
|
|
1019
|
+
fault.applied += 1
|
|
1020
|
+
|
|
1021
|
+
if (fault.remaining !== undefined) {
|
|
1022
|
+
fault.remaining -= 1
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
record({ method, request, outcome: 'fault', evidence, faultId: fault.id })
|
|
1026
|
+
|
|
1027
|
+
return { failure: fault.fault.failure }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
target.model.apply(state, request, chosen)
|
|
1031
|
+
used.add(chosen.id)
|
|
1032
|
+
record({ method, request, outcome: 'answered', evidence, fixtureId: chosen.id })
|
|
1033
|
+
|
|
1034
|
+
return chosen.failure === undefined
|
|
1035
|
+
? { response: chosen.response }
|
|
1036
|
+
: { failure: chosen.failure }
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const faultState = (fault: (typeof faults)[number]): EmailEmulatorFaultState => ({
|
|
1040
|
+
id: fault.id,
|
|
1041
|
+
fault: fault.fault,
|
|
1042
|
+
remaining: fault.remaining,
|
|
1043
|
+
applied: fault.applied
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
call,
|
|
1048
|
+
state: () => snapshotOf(state),
|
|
1049
|
+
seed: () => snapshotOf(mailboxFrom(seed)),
|
|
1050
|
+
ledger: {
|
|
1051
|
+
entries: () => entries.map(entry => ({ ...entry })),
|
|
1052
|
+
clear: () => {
|
|
1053
|
+
entries = []
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
reset: () => {
|
|
1057
|
+
state = mailboxFrom(seed)
|
|
1058
|
+
entries = []
|
|
1059
|
+
faults = []
|
|
1060
|
+
used.clear()
|
|
1061
|
+
},
|
|
1062
|
+
faults: {
|
|
1063
|
+
add: input => {
|
|
1064
|
+
const decoded = decodeFault(input)
|
|
1065
|
+
|
|
1066
|
+
if (Result.isFailure(decoded)) {
|
|
1067
|
+
throw new EmailEmulatorInputInvalid({
|
|
1068
|
+
input: 'fault',
|
|
1069
|
+
reason: issueText(decoded.failure)
|
|
1070
|
+
})
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (!bound.has(decoded.success.method)) {
|
|
1074
|
+
throw new EmailEmulatorInputInvalid({
|
|
1075
|
+
input: 'fault',
|
|
1076
|
+
reason: `method ${decoded.success.method} has no emulated route`
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const added = {
|
|
1081
|
+
id: nextFaultId,
|
|
1082
|
+
fault: decoded.success,
|
|
1083
|
+
remaining: decoded.success.count,
|
|
1084
|
+
applied: 0
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
nextFaultId += 1
|
|
1088
|
+
faults.push(added)
|
|
1089
|
+
|
|
1090
|
+
return faultState(added)
|
|
1091
|
+
},
|
|
1092
|
+
list: () => faults.map(faultState),
|
|
1093
|
+
clear: () => {
|
|
1094
|
+
faults = []
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
coverage: () => ({
|
|
1098
|
+
routes: emailEmulatorRoutes.map(route => ({
|
|
1099
|
+
...route,
|
|
1100
|
+
calls: entries.filter(entry => `${emailEmulatorPort}.${entry.method}` === route.path).length
|
|
1101
|
+
})),
|
|
1102
|
+
notEmulatedCalls: entries.filter(entry => entry.outcome === 'not-emulated').length,
|
|
1103
|
+
unusedFixtureIds: emailEmulatorFixtures.flatMap(fixture =>
|
|
1104
|
+
used.has(fixture.id) ? [] : [fixture.id]
|
|
1105
|
+
)
|
|
1106
|
+
})
|
|
1107
|
+
}
|
|
1108
|
+
}
|