add a simple email job

This commit is contained in:
Elizabeth Hunt 2024-07-21 15:41:56 -07:00
commit 452c41a75a
Signed by untrusted user who does not match committer: simponic
GPG Key ID: 2909B9A7FF6213EE
19 changed files with 1024 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@ -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

15
README.md Normal file
View File

@ -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.

BIN
bun.lockb Executable file

Binary file not shown.

12
index.ts Normal file
View File

@ -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<String>());

18
package.json Normal file
View File

@ -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"
}
}

275
src/canary/email.ts Normal file
View File

@ -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<FetchMessageObject[]>;
connect: () => Promise<void>;
getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
messageDelete: (uids: number[]) => Promise<boolean>;
close: () => void;
}
type Email = {
from: string;
to: string;
subject: string;
text: string;
};
class ErrorWithLock extends Error {
lock: O.Option<MailboxLockObject>;
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<Email>;
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<Error, Email>;
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<Email>((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<Error, ImapClientI>;
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<Error, FetchMessageObject[]> =>
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<Error, number>;
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<EmailJobDependencies> = {},
): TE.TaskEither<Error, boolean> =>
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);
},
),
);

2
src/canary/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./job";
export * from "./testable";

41
src/canary/job.ts Normal file
View File

@ -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;

24
src/canary/testable.ts Normal file
View File

@ -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<T extends Job> = (job: T) => TaskEither<Error, boolean>;

7
src/config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { Publisher } from "./publisher";
export interface Config {
result_publishers: Publisher[];
dns: string[];
timeout: string;
}

0
src/index.ts Normal file
View File

27
src/publisher/index.ts Normal file
View File

@ -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<boolean, Error>): void;
}

155
src/util/duration.ts Normal file
View File

@ -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<string, DurationUnit> = {
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<DurationUnit> =>
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<string, Duration> => {
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<string, DurationBuilder>(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)
);
};

1
src/util/index.ts Normal file
View File

@ -0,0 +1 @@
export * as D from "./duration";

9
src/util/logger.ts Normal file
View File

@ -0,0 +1,9 @@
import type { IO } from "fp-ts/lib/IO";
export interface Logger {
log: (message: string) => IO<void>;
}
export const ConsoleLogger: Logger = {
log: (message: string) => () => console.log(message),
};

27
tsconfig.json Normal file
View File

@ -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
}
}

155
tst/canary/email.spec.ts Normal file
View File

@ -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<EmailJobDependencies> = {
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()),
)();
});

0
tst/config.spec.ts Normal file
View File

78
tst/util/duration.spec.ts Normal file
View File

@ -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"'));
});
});