add js-fp3.tests

This commit is contained in:
Oli Sturm
2026-04-27 13:19:17 +01:00
parent 3194231879
commit 8d22f0f1a8
7 changed files with 1191 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# 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
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# 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 directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { openAccount, withdraw } from '../js-fp3/domain/account.js';
import { createMoney } from '../js-fp3/domain/money.js';
describe('account domain', () => {
it('opening an account with a negative balance returns openingBalanceMustBeNonNegative', () => {
const result = openAccount('id-1', createMoney(-1));
expect(result.error.type).toBe('OpeningBalanceMustBeNonNegative');
});
it('withdrawing a zero amount returns amountMustBePositive', () => {
const account = openAccount('id-1', createMoney(100)).value;
const result = withdraw(account, createMoney(0));
expect(result.error.type).toBe('AmountMustBePositive');
});
it('withdrawing a negative amount returns amountMustBePositive', () => {
const account = openAccount('id-1', createMoney(100)).value;
const result = withdraw(account, createMoney(-10));
expect(result.error.type).toBe('AmountMustBePositive');
});
it('withdrawing more than the balance returns insufficientBalance', () => {
const account = openAccount('id-1', createMoney(100)).value;
const result = withdraw(account, createMoney(101));
expect(result.error.type).toBe('InsufficientBalance');
});
it('insufficientBalance error reports the current balance and attempted amount', () => {
const account = openAccount('id-1', createMoney(100)).value;
const error = withdraw(account, createMoney(150)).error;
expect(error.type).toBe('InsufficientBalance');
expect(error.balance.amount).toBe(100);
expect(error.amount.amount).toBe(150);
});
it('successive withdrawals are each applied to the running balance', () => {
const account = openAccount('id-1', createMoney(300)).value;
const updatedAccount1 = withdraw(account, createMoney(100)).value;
const updatedAccount2 = withdraw(updatedAccount1, createMoney(100)).value;
expect(updatedAccount2.balance.amount).toBe(100);
});
});
+610
View File
@@ -0,0 +1,610 @@
{
"name": "js-fp3-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "js-fp3-tests",
"version": "1.0.0",
"devDependencies": {
"vitest": "^2.0.0"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.2",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/cac": {
"version": "6.7.14",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/debug": {
"version": "4.4.3",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.12",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.60.2",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "js-fp3-tests",
"version": "1.0.0",
"description": "Vitest tests for js-fp3",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^2.0.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import { match, when, any } from '../js-fp3/library/patterns.js';
describe('when', () => {
it('returns a two-element tuple of [predicate, handler]', () => {
const predicate = () => true;
const handler = () => 'result';
const branch = when(predicate, handler);
expect(branch).toEqual([predicate, handler]);
});
});
describe('any', () => {
it('returns true for any value', () => {
expect(any(0)).toBe(true);
expect(any(null)).toBe(true);
expect(any('string')).toBe(true);
expect(any({ x: 1 })).toBe(true);
});
});
describe('match', () => {
it('executes the handler of the first matching branch', () => {
const result = match(
when((v) => v === 1, () => 'one'),
when((v) => v === 2, () => 'two'),
when(any, () => 'other')
)(1);
expect(result).toBe('one');
});
it('falls through to the next branch when the first does not match', () => {
const result = match(
when((v) => v === 1, () => 'one'),
when((v) => v === 2, () => 'two'),
when(any, () => 'other')
)(2);
expect(result).toBe('two');
});
it('uses the any catch-all when no earlier branch matches', () => {
const result = match(
when((v) => v === 1, () => 'one'),
when(any, () => 'catch-all')
)(99);
expect(result).toBe('catch-all');
});
it('passes the value to the handler', () => {
const result = match(
when(any, (v) => v * 2)
)(7);
expect(result).toBe(14);
});
it('throws when no branch matches', () => {
expect(() =>
match(
when((v) => v === 1, () => 'one')
)(99)
).toThrow('No matching pattern');
});
it('does not call handlers for branches that were not selected', () => {
const skipped = vi.fn(() => 'skipped');
const selected = vi.fn(() => 'selected');
match(
when((v) => v === 1, selected),
when(any, skipped)
)(1);
expect(selected).toHaveBeenCalledOnce();
expect(skipped).not.toHaveBeenCalled();
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, vi } from 'vitest';
import {
ok,
fail,
catchResult,
bind,
map,
tap,
tapError,
mapError,
match,
switch_,
pipe,
} from '../js-fp3/library/result.js';
describe('ok / fail construction', () => {
it('ok is success', () => {
const result = ok(42);
expect(result.isSuccess).toBe(true);
expect(result.isFailure).toBe(false);
});
it('fail is failure', () => {
const result = fail('oops');
expect(result.isFailure).toBe(true);
expect(result.isSuccess).toBe(false);
});
it('ok exposes value', () => {
const result = ok(42);
expect(result.value).toBe(42);
});
it('fail exposes error', () => {
const result = fail('oops');
expect(result.error).toBe('oops');
});
});
describe('match', () => {
it('calls onSuccess for ok', () => {
const result = ok(10);
const output = match((v) => `value:${v}`, (e) => `error:${e}`)(result);
expect(output).toBe('value:10');
});
it('calls onFailure for fail', () => {
const result = fail('bad');
const output = match((v) => `value:${v}`, (e) => `error:${e}`)(result);
expect(output).toBe('error:bad');
});
});
describe('switch_', () => {
it('calls onSuccess for ok', () => {
const result = ok(7);
let called = false;
switch_(() => { called = true; }, () => {})(result);
expect(called).toBe(true);
});
it('calls onFailure for fail', () => {
const result = fail('err');
let called = false;
switch_(() => {}, () => { called = true; })(result);
expect(called).toBe(true);
});
});
describe('catchResult', () => {
it('returns ok when no exception is thrown', () => {
const result = catchResult(() => 99, (ex) => ex.message);
expect(result.isSuccess).toBe(true);
expect(result.value).toBe(99);
});
it('returns fail when an exception is thrown', () => {
const result = catchResult(
() => { throw new Error('boom'); },
(ex) => ex.message
);
expect(result.isFailure).toBe(true);
expect(result.error).toBe('boom');
});
});
describe('bind', () => {
it('chains to the next result on success', () => {
const result = bind((v) => ok(`got ${v}`))(ok(5));
expect(result.value).toBe('got 5');
});
it('short-circuits on failure without calling the binder', () => {
const binder = vi.fn((v) => ok(`got ${v}`));
const result = bind(binder)(fail('nope'));
expect(binder).not.toHaveBeenCalled();
expect(result.error).toBe('nope');
});
it('propagates the inner failure when the binder returns fail', () => {
const result = bind(() => fail('inner fail'))(ok(5));
expect(result.error).toBe('inner fail');
});
});
describe('map', () => {
it('transforms the value on success', () => {
const result = map((v) => v * 2)(ok(3));
expect(result.value).toBe(6);
});
it('does not call the mapper on failure', () => {
const mapper = vi.fn((v) => v * 2);
const result = map(mapper)(fail('err'));
expect(mapper).not.toHaveBeenCalled();
expect(result.error).toBe('err');
});
});
describe('tap', () => {
it('calls the action and returns the original result on success', () => {
let seen = -1;
const result = tap((v) => { seen = v; })(ok(8));
expect(seen).toBe(8);
expect(result.value).toBe(8);
});
it('does not call the action on failure', () => {
const action = vi.fn();
tap(action)(fail('err'));
expect(action).not.toHaveBeenCalled();
});
});
describe('tapError', () => {
it('calls the action and returns the original result on failure', () => {
let seen = '';
const result = tapError((e) => { seen = e; })(fail('bad'));
expect(seen).toBe('bad');
expect(result.error).toBe('bad');
});
it('does not call the action on success', () => {
const action = vi.fn();
tapError(action)(ok(1));
expect(action).not.toHaveBeenCalled();
});
});
describe('mapError', () => {
it('transforms the error on failure', () => {
const result = mapError((e) => e.length)(fail('oops'));
expect(result.error).toBe(4);
});
it('does not call the mapper on success', () => {
const mapper = vi.fn((e) => e.length);
const result = mapError(mapper)(ok(1));
expect(mapper).not.toHaveBeenCalled();
expect(result.value).toBe(1);
});
});
describe('pipe', () => {
it('applies functions left to right starting from the initial result', () => {
const result = pipe(ok(2), map((v) => v + 1), map((v) => v * 3));
expect(result.value).toBe(9);
});
it('short-circuits on the first failure', () => {
const mapper = vi.fn((v) => v * 2);
const result = pipe(fail('stop'), map(mapper));
expect(mapper).not.toHaveBeenCalled();
expect(result.error).toBe('stop');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { createWithdrawMoney } from '../js-fp3/application/accountApp.js';
import { openAccount } from '../js-fp3/domain/account.js';
import { createMoney } from '../js-fp3/domain/money.js';
import { createInMemoryRepository } from '../js-fp3/infrastructure/repository.js';
const buildHandler = () => {
const repo = createInMemoryRepository();
const withdraw = createWithdrawMoney(repo).value;
return { withdraw, repo };
};
describe('withdrawMoney application use case', () => {
it('withdrawing from an account reduces its balance by the withdrawn amount', () => {
const { withdraw, repo } = buildHandler();
const accountId = 'acc-1';
repo.saveAccount(openAccount(accountId, createMoney(200)).value);
withdraw(accountId, 75);
const account = repo.loadAccount(accountId).value;
expect(account.balance.amount).toBe(125);
});
it('withdrawing the entire balance leaves the account at zero', () => {
const { withdraw, repo } = buildHandler();
const accountId = 'acc-2';
repo.saveAccount(openAccount(accountId, createMoney(100)).value);
withdraw(accountId, 100);
const account = repo.loadAccount(accountId).value;
expect(account.balance.amount).toBe(0);
});
it('withdrawing from a non-existent account returns accountNotFound', () => {
const { withdraw } = buildHandler();
const result = withdraw('no-such-id', 50);
expect(result.error.type).toBe('AccountNotFound');
});
it('withdrawing more than the available balance returns insufficientBalance', () => {
const { withdraw, repo } = buildHandler();
const accountId = 'acc-3';
repo.saveAccount(openAccount(accountId, createMoney(50)).value);
const result = withdraw(accountId, 100);
expect(result.error.type).toBe('InsufficientBalance');
});
it('accountNotFound error reports the requested account id', () => {
const { withdraw } = buildHandler();
const accountId = 'missing-acc';
const error = withdraw(accountId, 50).error;
expect(error.type).toBe('AccountNotFound');
expect(error.accountId).toBe(accountId);
});
it('insufficientBalance error reports the current balance and attempted amount', () => {
const { withdraw, repo } = buildHandler();
const accountId = 'acc-4';
repo.saveAccount(openAccount(accountId, createMoney(50)).value);
const error = withdraw(accountId, 120).error;
expect(error.type).toBe('InsufficientBalance');
expect(error.balance.amount).toBe(50);
expect(error.amount.amount).toBe(120);
});
it('after a failed withdrawal the balance is unchanged', () => {
const { withdraw, repo } = buildHandler();
const accountId = 'acc-5';
repo.saveAccount(openAccount(accountId, createMoney(50)).value);
withdraw(accountId, 999);
const account = repo.loadAccount(accountId).value;
expect(account.balance.amount).toBe(50);
});
});