diff --git a/js-fp3.tests/.gitignore b/js-fp3.tests/.gitignore new file mode 100644 index 0000000..872d5f6 --- /dev/null +++ b/js-fp3.tests/.gitignore @@ -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/ diff --git a/js-fp3.tests/account.test.js b/js-fp3.tests/account.test.js new file mode 100644 index 0000000..751cf15 --- /dev/null +++ b/js-fp3.tests/account.test.js @@ -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); + }); +}); diff --git a/js-fp3.tests/package-lock.json b/js-fp3.tests/package-lock.json new file mode 100644 index 0000000..4bb3807 --- /dev/null +++ b/js-fp3.tests/package-lock.json @@ -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" + } + } + } +} diff --git a/js-fp3.tests/package.json b/js-fp3.tests/package.json new file mode 100644 index 0000000..f72f3d3 --- /dev/null +++ b/js-fp3.tests/package.json @@ -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" + } +} diff --git a/js-fp3.tests/patterns.test.js b/js-fp3.tests/patterns.test.js new file mode 100644 index 0000000..4182c41 --- /dev/null +++ b/js-fp3.tests/patterns.test.js @@ -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(); + }); +}); diff --git a/js-fp3.tests/result.test.js b/js-fp3.tests/result.test.js new file mode 100644 index 0000000..e8479ab --- /dev/null +++ b/js-fp3.tests/result.test.js @@ -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'); + }); +}); diff --git a/js-fp3.tests/withdrawMoney.test.js b/js-fp3.tests/withdrawMoney.test.js new file mode 100644 index 0000000..c3f6f78 --- /dev/null +++ b/js-fp3.tests/withdrawMoney.test.js @@ -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); + }); +});