commit 452c41a75aa2f9036614d677fda3867221db959e Author: Elizabeth Hunt Date: Sun Jul 21 15:41:56 2024 -0700 add a simple email job diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea6e4ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +config.json +tests.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e85f298 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# canary + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..afe7a4c Binary files /dev/null and b/bun.lockb differ diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4c181c3 --- /dev/null +++ b/index.ts @@ -0,0 +1,12 @@ +import * as S from "fp-ts/String"; +import * as M from "fp-ts/Map"; +import { getMonoid } from "fp-ts/lib/Array"; + +enum DurationUnit { + MILLISECOND = "ms", + SECOND = "sec", + MINUTE = "min", + HOUR = "hr", +} + +const unitMap = M.fromFoldable(S.Ord, getMonoid()); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad1f7c8 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "canary", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "@types/imapflow": "^1.0.19", + "@types/nodemailer": "^6.4.15" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "fp-ts": "^2.16.7", + "imapflow": "^1.0.164", + "nodemailer": "^6.9.14" + } +} diff --git a/src/canary/email.ts b/src/canary/email.ts new file mode 100644 index 0000000..384212b --- /dev/null +++ b/src/canary/email.ts @@ -0,0 +1,275 @@ +import type { EmailFromInstruction, EmailJob, EmailToInstruction } from "./job"; +import * as TE from "fp-ts/lib/TaskEither"; +import * as O from "fp-ts/lib/Option"; +import { createTransport } from "nodemailer"; +import { toError } from "fp-ts/lib/Either"; +import { pipe } from "fp-ts/lib/function"; +import { + ImapFlow, + type FetchMessageObject, + type FetchQueryObject, + type MailboxLockObject, +} from "imapflow"; +import * as IO from "fp-ts/lib/IO"; +import * as T from "fp-ts/lib/Task"; +import { ConsoleLogger } from "../util/logger"; + +interface ImapClientI { + fetchAll: ( + range: string, + options: FetchQueryObject, + ) => Promise; + connect: () => Promise; + getMailboxLock: (mailbox: string) => Promise; + messageDelete: (uids: number[]) => Promise; + close: () => void; +} + +type Email = { + from: string; + to: string; + subject: string; + text: string; +}; + +class ErrorWithLock extends Error { + lock: O.Option; + constructor(message: string, lock?: MailboxLockObject) { + super(message); + this.lock = O.fromNullable(lock); + } +} +const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) => + new ErrorWithLock( + error instanceof Error ? error.message : "Unknown error", + lock, + ); + +/** + * Generate a unique email. + * @param from is the email to send from. + * @param to is the email to send to. + * @returns an {@link Email}. + */ +type EmailGenerator = ( + from: EmailFromInstruction, + to: EmailToInstruction, +) => IO.IO; +const generateEmail: EmailGenerator = + (from: EmailFromInstruction, to: EmailToInstruction) => () => ({ + from: from.email, + to: to.email, + subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "), + text: crypto.randomUUID(), + }); + +/** + * Get the transport layer for a mailbox to send a piece of mail. + * @param param0 is the mailbox to send from. + * @returns a function that takes an email and sends it. + */ +type GetSendEmail = ( + from: EmailFromInstruction, +) => (email: Email) => TE.TaskEither; +const getSendTransport: GetSendEmail = ({ + username, + password, + server, + send_port, +}) => { + const transport = createTransport({ + host: server, + port: send_port, + auth: { + user: username, + pass: password, + }, + tls: { + rejectUnauthorized: false, + }, + }); + return (email: Email) => + TE.tryCatch( + () => + new Promise((resolve, reject) => + transport.sendMail(email, (error) => { + if (error) { + reject(error); + } else { + resolve(email); + } + }), + ), + toError, + ); +}; + +/** + * Get an Imap client connected to a mailbox. + * @param param0 is the mailbox to read from. + * @returns a Right({@link ImapFlow}) if it connected, else an Left(error). + */ +type GetImapClient = ( + to: EmailToInstruction, +) => TE.TaskEither; +const getImap: GetImapClient = ({ username, password, server, read_port }) => { + const imap = new ImapFlow({ + logger: false, + host: server, + port: read_port, + secure: true, + auth: { + user: username, + pass: password, + }, + }); + return TE.tryCatch(() => imap.connect().then(() => imap), toError); +}; + +/** + * @param imap is the Imap client to fetch messages from. + * @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error). + */ +const fetchMessages = ( + imap: ImapClientI, +): TE.TaskEither => + TE.tryCatch( + () => + imap.fetchAll("*", { + uid: true, + envelope: true, + headers: true, + bodyParts: ["text"], + }), + toError, + ); + +/** + * Curry a function to check if a message matches an email. + * @param email is the email to match. + * @returns a function that takes a message and returns true if it matches the email. + */ +type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean; +const matchesEmail: EmailMatcher = (email) => (message) => { + const subjectMatches = email.subject === message.envelope.subject; + const bodyMatches = + message.bodyParts.get("text")?.toString().trim() === email.text.trim(); + const headers = message.headers.toLocaleString(); + const fromMatches = headers.includes(`Return-Path: <${email.from}>`); + const toMatches = headers.includes(`Delivered-To: ${email.to}`); + return subjectMatches && bodyMatches && fromMatches && toMatches; +}; + +/** + * Find an email in the inbox. + * @param imap is the Imap client to search with. + * @param email is the email to search for. + * @param retries is the number of retries left. + * @param pollIntervalMs is the time to wait between retries. + * @returns a Right(number) if the email was found, else a Left(error). + */ +type FindEmailUidInInbox = ( + imap: ImapClientI, + equalsEmail: (message: FetchMessageObject) => boolean, + retries: number, + pollIntervalMs: number, +) => TE.TaskEither; +const findEmailUidInInbox: FindEmailUidInInbox = ( + imap, + equalsEmail, + retries, + pollIntervalMs, +) => + pipe( + fetchMessages(imap), + TE.flatMap((messages) => { + const message = messages.find(equalsEmail); + if (message) { + return TE.right(message.uid); + } + return TE.left(new Error("Email message not found")); + }), + TE.fold( + (e) => + pipe( + TE.fromIO(ConsoleLogger.log(`failed; ${retries} retries left.`)), + TE.chain(() => + retries === 0 + ? TE.left(e) + : T.delay(pollIntervalMs)(TE.right(null)), + ), + TE.chain(() => + findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs), + ), + ), + TE.of, + ), + ); + +export type EmailJobDependencies = { + generateEmailImpl: EmailGenerator; + getSendImpl: GetSendEmail; + getImapImpl: GetImapClient; + findEmailUidInInboxImpl: FindEmailUidInInbox; + matchesEmailImpl: EmailMatcher; +}; + +/** + * Perform an email job. + * @param job is the job to perform. + */ +export const perform = ( + { from, to, readRetry: { retries, interval } }: EmailJob, + { + generateEmailImpl = generateEmail, + getSendImpl = getSendTransport, + getImapImpl = getImap, + findEmailUidInInboxImpl = findEmailUidInInbox, + matchesEmailImpl = matchesEmail, + }: Partial = {}, +): TE.TaskEither => + pipe( + // arrange. + TE.fromIO(generateEmailImpl(from, to)), + TE.bindTo("email"), + // act. + TE.tap(({ email }) => + pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock())), + ), + TE.bind("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))), + TE.bind("mailboxLock", ({ imap }) => + TE.tryCatch(() => imap.getMailboxLock("INBOX"), ToErrorWithLock()), + ), + // "assert". + TE.bind("uid", ({ imap, email, mailboxLock }) => + pipe( + findEmailUidInInboxImpl( + imap, + matchesEmailImpl(email), + retries, + interval, + ), + TE.mapLeft(ToErrorWithLock(mailboxLock)), + ), + ), + // cleanup. + TE.bind("deleted", ({ imap, uid, mailboxLock }) => + TE.tryCatch( + // () => imap.messageDelete([uid], { uid: true }), + () => imap.messageDelete([uid]), + ToErrorWithLock(mailboxLock), + ), + ), + TE.fold( + (e) => { + if (O.isSome(e.lock)) { + e.lock.value.release(); + } + return TE.left(e); + }, + ({ mailboxLock, deleted }) => { + mailboxLock.release(); + return TE.right(deleted); + }, + ), + ); diff --git a/src/canary/index.ts b/src/canary/index.ts new file mode 100644 index 0000000..1d42dec --- /dev/null +++ b/src/canary/index.ts @@ -0,0 +1,2 @@ +export * from "./job"; +export * from "./testable"; diff --git a/src/canary/job.ts b/src/canary/job.ts new file mode 100644 index 0000000..61af876 --- /dev/null +++ b/src/canary/job.ts @@ -0,0 +1,41 @@ +import { D } from "../util"; + +export interface PingJob { + hosts: string[]; +} + +export interface Retry { + retries: number; + interval: D.Duration; +} + +export interface HealthCheckJob { + healthcheck_routes: string[]; +} + +export interface DnsJob { + resolutions: { [key: string]: string }; +} + +export interface EmailInstruction { + email: string; + username: string; + password: string; + server: string; +} + +export interface EmailFromInstruction extends EmailInstruction { + send_port: number; +} + +export interface EmailToInstruction extends EmailInstruction { + read_port: number; +} + +export interface EmailJob { + from: EmailFromInstruction; + to: EmailToInstruction; + readRetry: Retry; +} + +export type Job = EmailJob | PingJob | HealthCheckJob | DnsJob; diff --git a/src/canary/testable.ts b/src/canary/testable.ts new file mode 100644 index 0000000..d79aa99 --- /dev/null +++ b/src/canary/testable.ts @@ -0,0 +1,24 @@ +import type { TaskEither } from "fp-ts/lib/TaskEither"; +import type { Job } from "./job"; +import type { D } from "../util"; + +export enum TestType { + EMAIL = "email", + PING = "ping", + HEALTHCHECK = "healthcheck", + DNS = "dns", +} + +export interface Schedule { + every: D.Duration; + jitter: D.Duration; +} + +export interface Test { + name: string; + type: TestType; + job: Job; + schedule: Schedule; +} + +export type Testable = (job: T) => TaskEither; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dd96169 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,7 @@ +import type { Publisher } from "./publisher"; + +export interface Config { + result_publishers: Publisher[]; + dns: string[]; + timeout: string; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/publisher/index.ts b/src/publisher/index.ts new file mode 100644 index 0000000..e721160 --- /dev/null +++ b/src/publisher/index.ts @@ -0,0 +1,27 @@ +import type { Duration, Result } from "../util"; + +export enum PublisherType { + DISCORD = "discord", + NTFY = "ntfy", +} + +export interface DiscordPost { + webhook: string; + role_id: string; +} + +export interface NtfyPost { + webhook: string; +} + +export type PublisherPost = DiscordPost | NtfyPost; + +export interface Publisher { + type: PublisherType; + at: Duration; + post: PublisherPost; +} + +export interface ResultPublishable { + publish(testResult: Result): void; +} diff --git a/src/util/duration.ts b/src/util/duration.ts new file mode 100644 index 0000000..9c10375 --- /dev/null +++ b/src/util/duration.ts @@ -0,0 +1,155 @@ +import { flow, pipe } from "fp-ts/function"; +import * as E from "fp-ts/Either"; +import * as S from "fp-ts/String"; +import * as O from "fp-ts/lib/Option"; +import * as R from "fp-ts/lib/ReadonlyArray"; + +export type Duration = number; + +export enum DurationUnit { + MILLISECOND, + SECOND, + MINUTE, + HOUR, +} +const durationUnitMap: Record = { + ms: DurationUnit.MILLISECOND, + milliseconds: DurationUnit.MILLISECOND, + sec: DurationUnit.SECOND, + seconds: DurationUnit.SECOND, + min: DurationUnit.MINUTE, + minutes: DurationUnit.MINUTE, + hr: DurationUnit.HOUR, + hour: DurationUnit.HOUR, + hours: DurationUnit.HOUR, +}; +const getDurationUnit = (key: string): O.Option => + O.fromNullable(durationUnitMap[key.toLowerCase()]); + +export const getMs = (duration: Duration): number => duration; +export const getSeconds = (duration: Duration): number => duration / 1000; +export const getMinutes = (duration: Duration): number => + getSeconds(duration) / 60; +export const getHours = (duration: Duration): number => + getMinutes(duration) / 60; +export const format = (duration: Duration): string => { + const ms = getMs(duration) % 1000; + const seconds = getSeconds(duration) % 60; + const minutes = getMinutes(duration) % 60; + const hours = getHours(duration); + + return ( + [hours, minutes, seconds] + .map((x) => Math.floor(x).toString().padStart(2, "0")) + .join(":") + + "." + + ms.toString().padStart(3, "0") + ); +}; + +export interface DurationBuilder { + readonly millis: number; + readonly seconds: number; + readonly minutes: number; + readonly hours: number; +} +export const createDurationBuilder = (): DurationBuilder => ({ + millis: 0, + seconds: 0, + minutes: 0, + hours: 0, +}); + +export const withMillis = + (millis: number) => + (builder: DurationBuilder): DurationBuilder => ({ + ...builder, + millis, + }); + +export const withSeconds = + (seconds: number) => + (builder: DurationBuilder): DurationBuilder => ({ + ...builder, + seconds, + }); + +export const withMinutes = + (minutes: number) => + (builder: DurationBuilder): DurationBuilder => ({ + ...builder, + minutes, + }); + +export const withHours = + (hours: number) => + (builder: DurationBuilder): DurationBuilder => ({ + ...builder, + hours, + }); + +export const build = (builder: DurationBuilder): Duration => + builder.millis + + builder.seconds * 1000 + + builder.minutes * 60 * 1000 + + builder.hours * 60 * 60 * 1000; + +export const parse = (duration: string): E.Either => { + const parts = pipe( + duration, + S.split(" "), + R.map(S.trim), + R.filter((part) => !S.isEmpty(part)) + ); + + const valueUnitPairs = pipe( + parts, + R.mapWithIndex((i, part) => { + const isUnit = i % 2 !== 0; + if (!isUnit) return E.right(O.none); + + const value = Number(parts[i - 1]); + if (isNaN(value)) return E.left(`bad value: "${parts[i - 1]}"`); + + const unit = getDurationUnit(part); + if (O.isNone(unit)) return E.left(`unknown duration type: ${part}`); + + return E.right(O.some([unit.value, value] as [DurationUnit, number])); + }), + E.sequenceArray, + E.map( + flow( + R.filter(O.isSome), + R.map(({ value }) => value) + ) + ) + ); + + return pipe( + valueUnitPairs, + E.flatMap( + R.reduce( + E.of(createDurationBuilder()), + (builderEither, [unit, value]) => + pipe( + builderEither, + E.chain((builder) => { + switch (unit) { + case DurationUnit.MILLISECOND: + return E.right(withMillis(value)(builder)); + case DurationUnit.SECOND: + return E.right(withSeconds(value)(builder)); + case DurationUnit.MINUTE: + return E.right(withMinutes(value)(builder)); + case DurationUnit.HOUR: + return E.right(withHours(value)(builder)); + default: + return E.left(`unknown unit: ${unit}`); + } + }) + ) + ) + ), + E.map(build) + ); +}; diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..acf8771 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1 @@ +export * as D from "./duration"; diff --git a/src/util/logger.ts b/src/util/logger.ts new file mode 100644 index 0000000..28e38f0 --- /dev/null +++ b/src/util/logger.ts @@ -0,0 +1,9 @@ +import type { IO } from "fp-ts/lib/IO"; + +export interface Logger { + log: (message: string) => IO; +} + +export const ConsoleLogger: Logger = { + log: (message: string) => () => console.log(message), +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/tst/canary/email.spec.ts b/tst/canary/email.spec.ts new file mode 100644 index 0000000..fa22e37 --- /dev/null +++ b/tst/canary/email.spec.ts @@ -0,0 +1,155 @@ +import { mock, test, expect, beforeEach } from "bun:test"; +import * as TE from "fp-ts/lib/TaskEither"; +import { perform, type EmailJobDependencies } from "../../src/canary/email"; +import type { + EmailFromInstruction, + EmailToInstruction, +} from "../../src/canary"; +import { constVoid, pipe } from "fp-ts/lib/function"; + +const from: EmailFromInstruction = { + send_port: 465, + email: "test@localhost", + username: "test", + password: "password", + server: "localhost", +}; + +const to: EmailToInstruction = { + read_port: 993, + email: "test@localhost", + username: "test", + password: "password", + server: "localhost", +}; + +const getMocks = () => { + const lock = { + path: "INBOX", + release: mock(() => constVoid()), + }; + const imap = { + fetchAll: mock(() => Promise.resolve([])), + connect: mock(() => Promise.resolve()), + getMailboxLock: mock(() => Promise.resolve(lock)), + messageDelete: mock(() => Promise.resolve(true)), + close: mock(() => constVoid()), + }; + + const mockDependencies: Partial = { + getImapImpl: () => TE.right(imap), + getSendImpl: mock(() => (email: any) => TE.right(email)), + matchesEmailImpl: mock(() => () => true), + }; + + return { lock, imap, mockDependencies }; +}; + +test("retries until message is in inbox", async () => { + const { imap, mockDependencies } = getMocks(); + + const retry = { retries: 3, interval: 400 }; + const emailJob = { from, to, readRetry: retry }; + + let attempts = 0; + const messageInInbox = { uid: 1 } as any; + imap.fetchAll = mock(() => { + attempts++; + if (attempts === 3) { + return Promise.resolve([messageInInbox] as any); + } + return Promise.resolve([]); + }); + mockDependencies.matchesEmailImpl = mock( + (_: any) => (message: any) => message.uid === 1, + ); + + await pipe( + perform(emailJob, mockDependencies), + TE.map((x) => { + expect(x).toBeTruthy(); + expect(attempts).toBe(3); + }), + TE.mapLeft(() => expect(false).toBeTruthy()), + )(); +}); + +test("failure to send message goes left", async () => { + const { mockDependencies } = getMocks(); + + const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } }; + mockDependencies.getSendImpl = mock(() => () => TE.left(new Error("fail"))); + + await pipe( + perform(emailJob, mockDependencies), + TE.map(() => expect(false).toBeTruthy()), + TE.mapLeft((e) => { + expect(e.message).toBe("fail"); + }), + )(); +}); + +test("goes left when message not ever received", async () => { + const { imap, mockDependencies } = getMocks(); + + const emailJob = { from, to, readRetry: { retries: 3, interval: 1 } }; + imap.fetchAll = mock(() => Promise.resolve([])); + + expect( + await pipe( + perform(emailJob, mockDependencies), + TE.map(() => expect(false).toBeTruthy()), + TE.mapLeft((e) => { + expect(e.message).toBe("Email message not found"); + }), + )(), + ); +}); + +test("releases lock on left", async () => { + const { lock, imap, mockDependencies } = getMocks(); + + const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } }; + imap.fetchAll = mock(() => Promise.resolve([])); + + await pipe( + perform(emailJob, mockDependencies), + TE.map(() => expect(false).toBeTruthy()), + TE.mapLeft(() => { + expect(imap.getMailboxLock).toHaveBeenCalledTimes(1); + expect(lock.release).toHaveBeenCalledTimes(1); + }), + )(); +}); + +test("releases lock on right", async () => { + const { lock, imap, mockDependencies } = getMocks(); + + const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } }; + mockDependencies.findEmailUidInInboxImpl = () => TE.right(1); + + await pipe( + perform(emailJob, mockDependencies), + TE.map(() => { + expect(imap.getMailboxLock).toHaveBeenCalledTimes(1); + expect(lock.release).toHaveBeenCalledTimes(1); + }), + TE.mapLeft(() => expect(false).toBeTruthy()), + )(); +}); + +test("cleans up sent messages from inbox", async () => { + const { imap, mockDependencies } = getMocks(); + + const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } }; + mockDependencies.findEmailUidInInboxImpl = () => TE.right(1); + + await pipe( + perform(emailJob, mockDependencies), + TE.map(() => { + expect(imap.messageDelete).toHaveBeenCalledTimes(1); + expect(imap.messageDelete).toHaveBeenCalledWith([1]); + }), + TE.mapLeft(() => expect(false).toBeTruthy()), + )(); +}); diff --git a/tst/config.spec.ts b/tst/config.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/tst/util/duration.spec.ts b/tst/util/duration.spec.ts new file mode 100644 index 0000000..de4af92 --- /dev/null +++ b/tst/util/duration.spec.ts @@ -0,0 +1,78 @@ +import { pipe } from "fp-ts/function"; +import * as E from "fp-ts/Either"; +import { describe, test, expect } from "bun:test"; +import { D } from "../../src/util"; + +describe("Duration Utility", () => { + test("get unit should convert correctly", () => { + expect(D.getMs(1000)).toBe(1000); + expect(D.getSeconds(1000)).toBe(1); + expect(D.getMinutes(60000)).toBe(1); + expect(D.getHours(3600000)).toBe(1); + }); + + test("format should format duration correctly", () => { + expect(D.format(3600000 + 237 + 5 * 60 * 1000)).toBe("01:05:00.237"); + }); +}); + +describe("DurationBuilder", () => { + test("createDurationBuilder should create a builder with zero values", () => { + const builder = D.createDurationBuilder(); + expect(builder.millis).toBe(0); + expect(builder.seconds).toBe(0); + expect(builder.minutes).toBe(0); + expect(builder.hours).toBe(0); + }); + + test("withMillis should set fields correctly and with precedence", () => { + const builder = pipe( + D.createDurationBuilder(), + D.withMillis(0), + D.withSeconds(20), + D.withMinutes(30), + D.withHours(40), + D.withMillis(10) + ); + expect(builder.millis).toBe(10); + expect(builder.seconds).toBe(20); + expect(builder.minutes).toBe(30); + expect(builder.hours).toBe(40); + }); + + test("build should calculate total duration correctly", () => { + const duration = pipe( + D.createDurationBuilder(), + D.withMillis(10), + D.withSeconds(20), + D.withMinutes(30), + D.withHours(40), + D.build + ); + expect(duration).toBe( + 10 + 20 * 1000 + 30 * 60 * 1000 + 40 * 60 * 60 * 1000 + ); + }); +}); + +describe("parse", () => { + test("should return right for a valid duration", () => { + expect(D.parse("10 seconds 1 hr 30 min")).toEqual( + E.right(1 * 60 * 60 * 1000 + 30 * 60 * 1000 + 10 * 1000) + ); + }); + + test("should operate with order", () => { + expect(D.parse("1 hr 30 min 2 hours")).toEqual( + E.right(2 * 60 * 60 * 1000 + 30 * 60 * 1000) + ); + }); + + test("returns left for unknown duration unit", () => { + expect(D.parse("1 xyz")).toEqual(E.left("unknown duration type: xyz")); + }); + + test("return left for invalid number", () => { + expect(D.parse("abc ms")).toEqual(E.left('bad value: "abc"')); + }); +});