diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afbc47..0e2cbf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,5 +52,12 @@ jobs: files: coverage/cobertura-coverage.xml fail_ci_if_error: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: test-report.junit.xml + - name: Lint (Biome) run: npm run check diff --git a/README.md b/README.md index 27cc9e3..6fc82cd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm version](https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue)](https://www.npmjs.com/package/tsentials) [![npm downloads](https://img.shields.io/npm/dm/tsentials?style=flat-square)](https://www.npmjs.com/package/tsentials) [![bundle size](https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip)](https://bundlephobia.com/package/tsentials) -[![tests](https://img.shields.io/badge/tests-818%20passing-brightgreen?style=flat-square)](./tests) +[![tests](https://img.shields.io/badge/tests-978%20passing-brightgreen?style=flat-square)](./tests) [![CI](https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/senrecep/tsentials/actions) [![license](https://img.shields.io/github/license/senrecep/tsentials?style=flat-square)](./LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..1d7be51 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,15 @@ +coverage: + status: + project: + default: + target: auto + threshold: 0% + base: auto + +comment: + layout: "diff, flags, files" + behavior: default + require_changes: false + require_base: false + require_head: true + hide_project_coverage: false diff --git a/docs/index.html b/docs/index.html index c18ec64..c4a1f7b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -191,7 +191,7 @@

tsentials

npm version npm downloads bundle size - tests + tests CI TypeScript Node.js diff --git a/tests/clone/cloneable.test.ts b/tests/clone/cloneable.test.ts index e9c4e85..2776a85 100644 --- a/tests/clone/cloneable.test.ts +++ b/tests/clone/cloneable.test.ts @@ -419,4 +419,119 @@ describe('deepClone fallback (without native structuredClone)', () => { expect(copy.value.valueOf()).toBe(BigInt(99)); expect(copy.value).not.toBe(obj.value); }); + + it('fallback: clones Error with object cause (recursive clone of cause)', () => { + const cause = { reason: 'network', code: 500 }; + const err = new Error('request failed'); + (err as Error & { cause?: unknown }).cause = cause; + const copy = deepClone(err); + expect(copy.message).toBe('request failed'); + const copyCause = (copy as Error & { cause?: unknown }).cause as typeof cause; + expect(copyCause).toEqual(cause); + expect(copyCause).not.toBe(cause); + }); + + it('fallback: clones Error with primitive cause (string)', () => { + const err = new Error('bad state'); + (err as Error & { cause?: unknown }).cause = 'timeout'; + const copy = deepClone(err); + expect(copy.message).toBe('bad state'); + expect((copy as Error & { cause?: unknown }).cause).toBe('timeout'); + }); + + it('fallback: clones DataView', () => { + const buffer = new ArrayBuffer(8); + new Uint8Array(buffer).set([1, 2, 3, 4, 5, 6, 7, 8]); + const view = new DataView(buffer, 2, 4); + const copy = deepClone(view); + expect(copy).toBeInstanceOf(DataView); + expect(copy.byteLength).toBe(4); + expect(copy.buffer).not.toBe(buffer); + expect(copy.getUint8(0)).toBe(3); + }); + + it('fallback: clones Boolean wrapper object', () => { + const obj = Object(true); + const copy = deepClone(obj); + expect(copy.valueOf()).toBe(true); + expect(copy).not.toBe(obj); + }); + + it('fallback: clones Number wrapper object', () => { + const obj = Object(42); + const copy = deepClone(obj); + expect(copy.valueOf()).toBe(42); + expect(copy).not.toBe(obj); + }); + + it('fallback: clones String wrapper object', () => { + const obj = Object('hello'); + const copy = deepClone(obj); + expect(copy.valueOf()).toBe('hello'); + expect(copy).not.toBe(obj); + }); + + it('fallback: clones Error with custom enumerable properties', () => { + const err = new Error('bad input'); + (err as Error & { code?: string; statusCode?: number }).code = 'INVALID'; + (err as Error & { code?: string; statusCode?: number }).statusCode = 400; + const copy = deepClone(err); + expect(copy.message).toBe('bad input'); + expect((copy as Error & { code?: string }).code).toBe('INVALID'); + expect((copy as Error & { statusCode?: number }).statusCode).toBe(400); + }); + + it('fallback: deep clones Array values', () => { + const arr = [1, 'hello', { x: 42 }]; + const copy = deepClone(arr); + expect(copy).not.toBe(arr); + expect(copy).toEqual(arr); + expect(copy[2]).not.toBe(arr[2]); + }); + + it('fallback: preserves sparse array holes in Array case', () => { + // biome-ignore lint/suspicious/noSparseArray: intentionally testing sparse array behavior + const sparse = [10, , 30] as unknown[]; + const copy = deepClone(sparse); + expect(copy).toHaveLength(3); + expect(0 in copy).toBe(true); + expect(1 in copy).toBe(false); + expect(2 in copy).toBe(true); + expect(copy[0]).toBe(10); + expect(copy[2]).toBe(30); + }); + + it('fallback: deep clones RegExp', () => { + const re = /foo\d+/gi; + const copy = deepClone(re); + expect(copy).not.toBe(re); + expect(copy.source).toBe('foo\\d+'); + expect(copy.flags).toBe('gi'); + }); + + it('fallback: returns same reference for SharedArrayBuffer', () => { + const sab = new SharedArrayBuffer(4); + const copy = deepClone(sab); + expect(copy).toBe(sab); + }); + + it('fallback: clones TypedArray backed by SharedArrayBuffer (non-ArrayBuffer path)', () => { + const sab = new SharedArrayBuffer(4); + const view = new Uint8Array(sab); + view[0] = 7; + view[1] = 8; + const copy = deepClone(view); + expect(copy).toBeInstanceOf(Uint8Array); + expect(copy[0]).toBe(7); + expect(copy[1]).toBe(8); + }); + + it('fallback: clones DataView backed by SharedArrayBuffer (non-ArrayBuffer path)', () => { + const sab = new SharedArrayBuffer(4); + const view = new DataView(sab); + view.setUint8(0, 42); + const copy = deepClone(view); + expect(copy).toBeInstanceOf(DataView); + expect(copy.getUint8(0)).toBe(42); + }); }); diff --git a/tests/entity/entity-base.test.ts b/tests/entity/entity-base.test.ts index a273099..cf429b7 100644 --- a/tests/entity/entity-base.test.ts +++ b/tests/entity/entity-base.test.ts @@ -1,5 +1,5 @@ import type { DomainEvent } from '../../src/entity/domain-event.js'; -import { createEntityBase } from '../../src/entity/entity-base.js'; +import { createEntityBase, createEntityBaseWithId } from '../../src/entity/entity-base.js'; import { createSoftDeletable } from '../../src/entity/soft-deletable.js'; interface UserCreatedEvent extends DomainEvent { @@ -125,6 +125,50 @@ describe('createEntityBase', () => { }); }); +describe('createEntityBaseWithId', () => { + it('initializes with a provided id', () => { + const entity = createEntityBaseWithId('abc-123'); + expect(entity.id).toBe('abc-123'); + }); + + it('initializes with a numeric id', () => { + const entity = createEntityBaseWithId(42); + expect(entity.id).toBe(42); + }); + + it('throws when id is not set and accessed', () => { + const entity = createEntityBaseWithId(); + expect(() => entity.id).toThrow('Entity ID has not been set.'); + }); + + it('sets id via setId', () => { + const entity = createEntityBaseWithId(); + entity.setId('new-id'); + expect(entity.id).toBe('new-id'); + }); + + it('overwrites id via setId', () => { + const entity = createEntityBaseWithId('original'); + entity.setId('updated'); + expect(entity.id).toBe('updated'); + }); + + it('initializes createdAt to epoch via base', () => { + const entity = createEntityBaseWithId('init'); + expect(entity.createdAt).toEqual(new Date(0)); + }); + + it('initializes updatedAt to undefined via base', () => { + const entity = createEntityBaseWithId('init'); + expect(entity.updatedAt).toBeUndefined(); + }); + + it('initializes domainEvents to empty via base', () => { + const entity = createEntityBaseWithId('init'); + expect(entity.domainEvents).toHaveLength(0); + }); +}); + describe('createSoftDeletable', () => { it('initializes as not deleted', () => { const sd = createSoftDeletable(); diff --git a/tests/http/request-builder.test.ts b/tests/http/request-builder.test.ts index fee2499..05e0e82 100644 --- a/tests/http/request-builder.test.ts +++ b/tests/http/request-builder.test.ts @@ -338,4 +338,64 @@ describe('RequestBuilder fluent API', () => { expect(result.value.name).toBe('Alice'); } }); + + it('sends POST without body (null body fallback)', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + await RequestBuilder.post('https://api.example.com/users').send(); + + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.method).toBe('POST'); + }); + + it('sends PUT without body (null body fallback)', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + await RequestBuilder.put('https://api.example.com/users/1').send(); + + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.method).toBe('PUT'); + }); + + it('sends PATCH without body (null body fallback)', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + await RequestBuilder.patch('https://api.example.com/users/1').send(); + + const init = fetchSpy.mock.calls[0]![1] as RequestInit; + expect(init.method).toBe('PATCH'); + }); + + it('falls back to GET for unknown HTTP method (default branch)', async () => { + const fetchSpy = vi.fn(() => + Promise.resolve( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + vi.stubGlobal('fetch', fetchSpy); + + // Bypass private constructor to trigger the default case in send() + const builder = Reflect.construct(RequestBuilder, [ + 'OPTIONS', + 'https://api.example.com/test', + ]) as RequestBuilder; + await builder.send(); + + expect(fetchSpy).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/json/json.test.ts b/tests/json/json.test.ts index 3022845..48f8381 100644 --- a/tests/json/json.test.ts +++ b/tests/json/json.test.ts @@ -1,3 +1,5 @@ +import { vi } from 'vitest'; +import * as jsonGuards from '../../src/json/json-guards.js'; import { isJson, isJsonArray, isJsonObject, isJsonPrimitive } from '../../src/json/json-guards.js'; import { parseAndValidate, safeJsonParse, safeJsonStringify } from '../../src/json/json-utils.js'; import { Result } from '../../src/result/result.js'; @@ -155,3 +157,83 @@ describe('parseAndValidate', () => { expect(Result.firstError(validateError)?.code).toBe('Json.TypeValidationError'); }); }); + +// ─── safeJsonParse — ValidationError branch ────────────────────────────────── + +describe('safeJsonParse (ValidationError branch)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns Json.ValidationError when isJson returns false for parsed value', () => { + vi.spyOn(jsonGuards, 'isJson').mockReturnValue(false); + const result = safeJsonParse('{"key":"value"}'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('Json.ValidationError'); + } + }); + + it('uses fallback message when JSON.parse throws a non-Error value', () => { + vi.spyOn(JSON, 'parse').mockImplementation(() => { + throw 'string error'; + }); + const result = safeJsonParse('anything'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('Json.SyntaxError'); + expect(result.errors[0]?.description).toBe('JSON syntax error'); + } + }); +}); + +// ─── safeJsonStringify — error branch ──────────────────────────────────────── + +describe('safeJsonStringify (error branch)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns Json.StringifyFailed on circular reference', () => { + const circular: Record = {}; + circular.self = circular; + const result = safeJsonStringify(circular as never); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('Json.StringifyFailed'); + } + }); + + it('uses fallback message when JSON.stringify throws a non-Error value', () => { + vi.spyOn(JSON, 'stringify').mockImplementation(() => { + throw 'string error'; + }); + const result = safeJsonStringify({ a: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('Json.StringifyFailed'); + expect(result.errors[0]?.description).toBe('Failed to stringify JSON value.'); + } + }); +}); + +// ─── parseAndValidate — non-Error catch branch ─────────────────────────────── + +describe('parseAndValidate (non-Error catch branch)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses fallback message when JSON.parse throws a non-Error value', () => { + vi.spyOn(JSON, 'parse').mockImplementation(() => { + throw 'string error'; + }); + const isNumber = (v: unknown): v is number => typeof v === 'number'; + const result = parseAndValidate('anything', isNumber); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors[0]?.code).toBe('Json.SyntaxError'); + expect(result.errors[0]?.description).toBe('JSON syntax error'); + } + }); +}); diff --git a/tests/maybe/maybe.test.ts b/tests/maybe/maybe.test.ts index 7ed64be..8e485f5 100644 --- a/tests/maybe/maybe.test.ts +++ b/tests/maybe/maybe.test.ts @@ -456,3 +456,79 @@ describe('Maybe new methods', () => { if (m.hasValue) expect(m.value).toBe(30); }); }); + +describe('Maybe utility methods', () => { + // tryGet + it('tryGet returns [true, value] for Some', () => { + const [hasValue, value] = Maybe.tryGet(Maybe.some(42)); + expect(hasValue).toBe(true); + expect(value).toBe(42); + }); + + it('tryGet returns [false, undefined] for None', () => { + const [hasValue, value] = Maybe.tryGet(Maybe.none()); + expect(hasValue).toBe(false); + expect(value).toBeUndefined(); + }); + + // switch + it('switch calls onSome when Some', () => { + const seen: number[] = []; + Maybe.switch( + Maybe.some(5), + (n) => seen.push(n), + () => seen.push(-1), + ); + expect(seen).toEqual([5]); + }); + + it('switch calls onNone when None', () => { + const seen: number[] = []; + Maybe.switch( + Maybe.none(), + (n) => seen.push(n), + () => seen.push(-1), + ); + expect(seen).toEqual([-1]); + }); + + // flatten + it('flatten returns inner Maybe when outer is Some', () => { + const m = Maybe.flatten(Maybe.some(Maybe.some(42))); + expect(m.hasValue).toBe(true); + if (m.hasValue) expect(m.value).toBe(42); + }); + + it('flatten returns None when outer is Some of None', () => { + const m = Maybe.flatten(Maybe.some(Maybe.none())); + expect(m.hasValue).toBe(false); + }); + + it('flatten returns None when outer is None', () => { + const m = Maybe.flatten(Maybe.none>()); + expect(m.hasValue).toBe(false); + }); + + // toArray + it('toArray returns single-element array for Some', () => { + const arr = Maybe.toArray(Maybe.some(7)); + expect(arr).toEqual([7]); + }); + + it('toArray returns empty array for None', () => { + const arr = Maybe.toArray(Maybe.none()); + expect(arr).toEqual([]); + }); + + // getOrThrowFactory + it('getOrThrowFactory returns value when Some', () => { + const value = Maybe.getOrThrowFactory(Maybe.some(99), () => new Error('fail')); + expect(value).toBe(99); + }); + + it('getOrThrowFactory throws factory error when None', () => { + expect(() => + Maybe.getOrThrowFactory(Maybe.none(), () => new Error('domain error')), + ).toThrow('domain error'); + }); +}); diff --git a/tests/result/result-async.test.ts b/tests/result/result-async.test.ts index 81567a7..06be18a 100644 --- a/tests/result/result-async.test.ts +++ b/tests/result/result-async.test.ts @@ -418,3 +418,301 @@ describe('ResultAsync pipeline chaining', () => { if (r.ok) expect(r.value).toBe(6); }); }); + +describe('ResultAsync.compensate with Promise>', () => { + it('recovers with Promise>', async () => { + const r = await fromAsync(failAsync()).compensate(() => + Promise.resolve(Result.success(77)), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(77); + }); +}); + +describe('ResultAsync.bindIf', () => { + it('applies fn when boolean condition is true', async () => { + const r = await fromAsync(okAsync(5)).bindIf(true, (n) => Result.success(n * 2)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); + + it('skips fn when boolean condition is false', async () => { + const r = await fromAsync(okAsync(5)).bindIf(false, (n) => Result.success(n * 2)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(5); + }); + + it('applies fn when function condition returns true', async () => { + const r = await fromAsync(okAsync(10)).bindIf( + (n) => n > 5, + (n) => Result.success(n + 1), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(11); + }); + + it('skips fn when function condition returns false', async () => { + const r = await fromAsync(okAsync(3)).bindIf( + (n) => n > 5, + (n) => Result.success(n + 1), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(3); + }); + + it('short-circuits on failure', async () => { + const called: boolean[] = []; + const r = await fromAsync(failAsync()).bindIf(true, (n) => { + called.push(true); + return Result.success(n); + }); + expect(r.ok).toBe(false); + expect(called).toHaveLength(0); + }); + + it('works with ResultAsync return', async () => { + const r = await fromAsync(okAsync(5)).bindIf(true, (n) => ResultAsync.success(n * 3)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(15); + }); + + it('works with Promise return', async () => { + const r = await fromAsync(okAsync(5)).bindIf(true, (n) => + Promise.resolve(Result.success(n * 4)), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(20); + }); + + it('propagates failure from fn', async () => { + const r = await fromAsync(okAsync(5)).bindIf(true, () => Result.failure(err)); + expect(r.ok).toBe(false); + }); +}); + +describe('ResultAsync.tapIf', () => { + it('runs effect when boolean condition is true', async () => { + const seen: number[] = []; + const r = await fromAsync(okAsync(7)).tapIf(true, (n) => { + seen.push(n); + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(7); + expect(seen).toEqual([7]); + }); + + it('skips effect when boolean condition is false', async () => { + const seen: number[] = []; + await fromAsync(okAsync(7)).tapIf(false, (n) => { + seen.push(n); + }); + expect(seen).toHaveLength(0); + }); + + it('runs effect when function condition returns true', async () => { + const seen: number[] = []; + await fromAsync(okAsync(10)).tapIf( + (n) => n > 5, + (n) => { + seen.push(n); + }, + ); + expect(seen).toEqual([10]); + }); + + it('skips effect when function condition returns false', async () => { + const seen: number[] = []; + await fromAsync(okAsync(3)).tapIf( + (n) => n > 5, + (n) => { + seen.push(n); + }, + ); + expect(seen).toHaveLength(0); + }); + + it('short-circuits on failure', async () => { + const seen: number[] = []; + const r = await fromAsync(failAsync()).tapIf(true, (n) => { + seen.push(n); + }); + expect(r.ok).toBe(false); + expect(seen).toHaveLength(0); + }); + + it('supports async effect', async () => { + const seen: number[] = []; + await fromAsync(okAsync(9)).tapIf(true, async (n) => { + seen.push(n); + }); + expect(seen).toEqual([9]); + }); +}); + +describe('ResultAsync.compensateFirst', () => { + it('recovers using first error with Result', async () => { + const r = await fromAsync(failAsync()).compensateFirst((firstErr) => + Result.success(firstErr.code.length), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe('Test.Invalid'.length); + }); + + it('recovers using first error with ResultAsync', async () => { + const r = await fromAsync(failAsync()).compensateFirst(() => ResultAsync.success(42)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(42); + }); + + it('recovers using first error with Promise>', async () => { + const r = await fromAsync(failAsync()).compensateFirst(() => + Promise.resolve(Result.success(55)), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(55); + }); + + it('passes through success', async () => { + const r = await fromAsync(okAsync(10)).compensateFirst(() => Result.success(99)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); + + it('can return failure from recovery fn', async () => { + const r = await fromAsync(failAsync()).compensateFirst(() => + Result.failure(notFound), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound'); + }); +}); + +describe('ResultAsync.recover', () => { + it('recovers when predicate matches', async () => { + const r = await fromAsync(failAsync()).recover( + (e) => e.code === 'Test.Invalid', + () => Result.success(100), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(100); + }); + + it('does not recover when predicate does not match', async () => { + const r = await fromAsync(failAsync()).recover( + (e) => e.code === 'Other.Code', + () => Result.success(100), + ); + expect(r.ok).toBe(false); + }); + + it('passes through success', async () => { + const r = await fromAsync(okAsync(5)).recover( + () => true, + () => Result.success(99), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(5); + }); + + it('recovers with ResultAsync', async () => { + const r = await fromAsync(failAsync()).recover( + (e) => e.code === 'Test.Invalid', + () => ResultAsync.success(77), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(77); + }); + + it('recovers with Promise>', async () => { + const r = await fromAsync(failAsync()).recover( + (e) => e.code === 'Test.Invalid', + () => Promise.resolve(Result.success(66)), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(66); + }); +}); + +describe('ResultAsync.always', () => { + it('receives success result', async () => { + const val = await fromAsync(okAsync(10)).always((r) => (r.ok ? r.value * 2 : -1)); + expect(val).toBe(20); + }); + + it('receives failure result', async () => { + const val = await fromAsync(failAsync()).always((r) => + r.ok ? r.value : r.errors[0]!.code, + ); + expect(val).toBe('Test.Invalid'); + }); + + it('supports async fn', async () => { + const val = await fromAsync(okAsync(3)).always(async (r) => (r.ok ? r.value + 1 : 0)); + expect(val).toBe(4); + }); +}); + +describe('ResultAsync.sequence', () => { + it('collects all successes', async () => { + const r = await ResultAsync.sequence([ + ResultAsync.success(1), + ResultAsync.success(2), + ResultAsync.success(3), + ]); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([1, 2, 3]); + }); + + it('collects all errors on failure', async () => { + const r = await ResultAsync.sequence([ + ResultAsync.success(1), + ResultAsync.failure(err), + ResultAsync.failure(notFound), + ]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toHaveLength(2); + }); + + it('returns empty array for empty input', async () => { + const r = await ResultAsync.sequence([]); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual([]); + }); +}); + +describe('ResultAsync.partition', () => { + it('separates successes and failures', async () => { + const { ok, err: errs } = await ResultAsync.partition([ + ResultAsync.success(1), + ResultAsync.failure(err), + ResultAsync.success(3), + ResultAsync.failure(notFound), + ]); + expect(ok).toEqual([1, 3]); + expect(errs).toHaveLength(2); + }); + + it('returns all successes when no failures', async () => { + const { ok, err: errs } = await ResultAsync.partition([ + ResultAsync.success(10), + ResultAsync.success(20), + ]); + expect(ok).toEqual([10, 20]); + expect(errs).toHaveLength(0); + }); + + it('returns all failures when no successes', async () => { + const { ok, err: errs } = await ResultAsync.partition([ + ResultAsync.failure(err), + ResultAsync.failure(notFound), + ]); + expect(ok).toHaveLength(0); + expect(errs).toHaveLength(2); + }); + + it('handles empty input', async () => { + const { ok, err: errs } = await ResultAsync.partition([]); + expect(ok).toEqual([]); + expect(errs).toEqual([]); + }); +}); diff --git a/tests/result/result-chain.test.ts b/tests/result/result-chain.test.ts index fca9dad..8a3c384 100644 --- a/tests/result/result-chain.test.ts +++ b/tests/result/result-chain.test.ts @@ -335,6 +335,128 @@ describe('ResultChain async API', () => { }); }); +describe('ResultChain sync extras', () => { + it('bindIf binds when condition true', () => { + const r = chain(Result.success(5)) + .bindIf(true, (n) => Result.success(n * 3)) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(15); + }); + + it('bindIf skips when condition false', () => { + const called: boolean[] = []; + const r = chain(Result.success(5)) + .bindIf(false, (n) => { + called.push(true); + return Result.success(n * 3); + }) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(5); + expect(called).toHaveLength(0); + }); + + it('bindIf supports function condition', () => { + const r = chain(Result.success(5)) + .bindIf( + (n) => n > 3, + (n) => Result.success(n + 1), + ) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(6); + }); + + it('tapIf runs side effect when condition true', () => { + const seen: number[] = []; + chain(Result.success(7)).tapIf(true, (n) => seen.push(n)); + expect(seen).toEqual([7]); + }); + + it('tapIf skips side effect when condition false', () => { + const seen: number[] = []; + chain(Result.success(7)).tapIf(false, (n) => seen.push(n)); + expect(seen).toHaveLength(0); + }); + + it('tapErrorIf runs on failure when condition true', () => { + const seen: string[] = []; + chain(Result.failure(err)).tapErrorIf(true, (errs) => seen.push(errs[0]!.code)); + expect(seen).toEqual(['Test.Invalid']); + }); + + it('tapErrorIf skips on failure when condition false', () => { + const seen: string[] = []; + chain(Result.failure(err)).tapErrorIf(false, (errs) => seen.push(errs[0]!.code)); + expect(seen).toHaveLength(0); + }); + + it('tapErrorIf supports function condition', () => { + const seen: string[] = []; + chain(Result.failure(err)).tapErrorIf( + (errs) => errs.length > 0, + (errs) => seen.push(errs[0]!.code), + ); + expect(seen).toEqual(['Test.Invalid']); + }); + + it('compensateFirst recovers using first error', () => { + const r = chain(Result.failure(err)) + .compensateFirst((firstErr) => { + expect(firstErr.code).toBe('Test.Invalid'); + return Result.success(55); + }) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(55); + }); + + it('compensateFirst passes through success', () => { + const called: boolean[] = []; + const r = chain(Result.success(3)) + .compensateFirst(() => { + called.push(true); + return Result.success(0); + }) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(3); + expect(called).toHaveLength(0); + }); + + it('recover recovers matching error', () => { + const r = chain(Result.failure(err)) + .recover( + (e) => e.code === 'Test.Invalid', + () => Result.success(88), + ) + .unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(88); + }); + + it('recover skips non-matching error', () => { + const r = chain(Result.failure(err)) + .recover( + (e) => e.code === 'Other.Code', + () => Result.success(88), + ) + .unwrap(); + expect(r.ok).toBe(false); + }); + + it('always runs fn unconditionally on success', () => { + const v = chain(Result.success(42)).always((r) => (r.ok ? 'yes' : 'no')); + expect(v).toBe('yes'); + }); + + it('always runs fn unconditionally on failure', () => { + const v = chain(Result.failure(err)).always((r) => (r.ok ? 'yes' : 'no')); + expect(v).toBe('no'); + }); +}); + describe('ResultChain.elseWith', () => { it('returns value when chain is success', () => { const val = chain(Result.success(42)).elseWith(() => 0); @@ -372,4 +494,80 @@ describe('ResultChain async API continued', () => { ); expect(v).toBe('Test.Invalid'); }); + + it('alwaysAsync runs fn with result on success', async () => { + const v = await chain(Result.success(10)).alwaysAsync(async (r) => (r.ok ? 'yes' : 'no')); + expect(v).toBe('yes'); + }); + + it('alwaysAsync runs fn with result on failure', async () => { + const v = await chain(Result.failure(err)).alwaysAsync(async (r) => (r.ok ? 'yes' : 'no')); + expect(v).toBe('no'); + }); + + it('bindIfAsync binds when condition is true', async () => { + const c = await chain(Result.success(5)).bindIfAsync(true, async (n) => Result.success(n * 2)); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); + + it('bindIfAsync skips when condition is false', async () => { + const called: boolean[] = []; + const c = await chain(Result.success(5)).bindIfAsync(false, async (n) => { + called.push(true); + return Result.success(n * 2); + }); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(5); + expect(called).toHaveLength(0); + }); + + it('compensateFirstAsync recovers using first error', async () => { + const c = await chain(Result.failure(err)).compensateFirstAsync(async (firstErr) => { + expect(firstErr.code).toBe('Test.Invalid'); + return Result.success(99); + }); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(99); + }); + + it('compensateFirstAsync passes through success', async () => { + const called: boolean[] = []; + const c = await chain(Result.success(7)).compensateFirstAsync(async () => { + called.push(true); + return Result.success(0); + }); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(7); + expect(called).toHaveLength(0); + }); + + it('recoverAsync recovers matching error', async () => { + const c = await chain(Result.failure(err)).recoverAsync( + (e) => e.code === 'Test.Invalid', + async () => Result.success(42), + ); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(42); + }); + + it('recoverAsync skips non-matching error', async () => { + const c = await chain(Result.failure(err)).recoverAsync( + (e) => e.code === 'Other.Code', + async () => Result.success(42), + ); + expect(c.unwrap().ok).toBe(false); + }); + + it('elseAsync calls function fallback on failure', async () => { + const c = await chain(Result.failure(err)).elseAsync(async (errs) => errs.length * 10); + const r = c.unwrap(); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); }); diff --git a/tests/result/result.test.ts b/tests/result/result.test.ts index 88c98c7..b2729c6 100644 --- a/tests/result/result.test.ts +++ b/tests/result/result.test.ts @@ -930,3 +930,400 @@ describe('Result.elseWith', () => { expect(Result.firstError(r)?.code).toBe('Result.Or.Empty'); }); }); + +// ─── Coverage: uncovered functions and branches ────────────────────────────── + +describe('Result.tryGet', () => { + it('returns [true, value, undefined] on success', () => { + const [ok, value, errors] = Result.tryGet(Result.success(42)); + expect(ok).toBe(true); + expect(value).toBe(42); + expect(errors).toBeUndefined(); + }); + + it('returns [false, undefined, errors] on failure', () => { + const [ok, value, errors] = Result.tryGet(Result.failure(validationError)); + expect(ok).toBe(false); + expect(value).toBeUndefined(); + expect(errors).toHaveLength(1); + }); +}); + +describe('Result.switch', () => { + it('calls onSuccess for success result', () => { + const seen: number[] = []; + Result.switch( + Result.success(42), + (v) => seen.push(v), + () => seen.push(-1), + ); + expect(seen).toEqual([42]); + }); + + it('calls onError for failure result', () => { + const seen: string[] = []; + Result.switch( + Result.failure(validationError), + () => seen.push('ok'), + (errs) => seen.push(errs[0]!.code), + ); + expect(seen).toEqual(['Test.Invalid']); + }); +}); + +describe('Result.matchFirst', () => { + it('returns success branch value', () => { + const msg = Result.matchFirst( + Result.success(42), + (v) => `Got ${v}`, + (err) => err.code, + ); + expect(msg).toBe('Got 42'); + }); + + it('returns first error branch value', () => { + const msg = Result.matchFirst( + Result.failureFrom([validationError, notFoundError]), + () => 'OK', + (err) => err.code, + ); + expect(msg).toBe('Test.Invalid'); + }); + + it('returns fallback for empty errors (edge)', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const msg = Result.matchFirst( + fakeFailure, + () => 'OK', + (err) => err.code, + ); + expect(msg).toBe('Result.Empty'); + }); +}); + +describe('Result.matchLast', () => { + it('returns success branch value', () => { + const msg = Result.matchLast( + Result.success(42), + (v) => `Got ${v}`, + (err) => err.code, + ); + expect(msg).toBe('Got 42'); + }); + + it('returns last error branch value', () => { + const msg = Result.matchLast( + Result.failureFrom([validationError, notFoundError]), + () => 'OK', + (err) => err.code, + ); + expect(msg).toBe('Test.NotFound'); + }); + + it('returns fallback for empty errors (edge)', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const msg = Result.matchLast( + fakeFailure, + () => 'OK', + (err) => err.code, + ); + expect(msg).toBe('Result.Empty'); + }); +}); + +describe('Result.switchFirst', () => { + it('calls onSuccess for success result', () => { + const seen: number[] = []; + Result.switchFirst( + Result.success(42), + (v) => seen.push(v), + () => seen.push(-1), + ); + expect(seen).toEqual([42]); + }); + + it('calls onFirstError for failure result', () => { + const seen: string[] = []; + Result.switchFirst( + Result.failureFrom([validationError, notFoundError]), + () => seen.push('ok'), + (err) => seen.push(err.code), + ); + expect(seen).toEqual(['Test.Invalid']); + }); + + it('calls onFirstError with fallback for empty errors (edge)', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const seen: string[] = []; + Result.switchFirst( + fakeFailure, + () => seen.push('ok'), + (err) => seen.push(err.code), + ); + expect(seen).toEqual(['Result.Empty']); + }); +}); + +describe('Result.switchLast', () => { + it('calls onSuccess for success result', () => { + const seen: number[] = []; + Result.switchLast( + Result.success(42), + (v) => seen.push(v), + () => seen.push(-1), + ); + expect(seen).toEqual([42]); + }); + + it('calls onLastError for failure result', () => { + const seen: string[] = []; + Result.switchLast( + Result.failureFrom([validationError, notFoundError]), + () => seen.push('ok'), + (err) => seen.push(err.code), + ); + expect(seen).toEqual(['Test.NotFound']); + }); + + it('calls onLastError with fallback for empty errors (edge)', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const seen: string[] = []; + Result.switchLast( + fakeFailure, + () => seen.push('ok'), + (err) => seen.push(err.code), + ); + expect(seen).toEqual(['Result.Empty']); + }); +}); + +describe('Result.tapErrorFirst', () => { + it('runs fn with first error on failure', () => { + const seen: string[] = []; + const r = Result.tapErrorFirst(Result.failureFrom([validationError, notFoundError]), (err) => + seen.push(err.code), + ); + expect(r.ok).toBe(false); + expect(seen).toEqual(['Test.Invalid']); + }); + + it('skips fn on success', () => { + const seen: string[] = []; + const r = Result.tapErrorFirst(Result.success(42), (err) => seen.push(err.code)); + expect(r.ok).toBe(true); + expect(seen).toHaveLength(0); + }); + + it('skips fn on failure with empty errors (edge)', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const seen: string[] = []; + const r = Result.tapErrorFirst(fakeFailure, (err) => seen.push(err.code)); + expect(r.ok).toBe(false); + expect(seen).toHaveLength(0); + }); +}); + +describe('Result.ensureNotNull', () => { + it('passes through non-null success', () => { + const r = Result.ensureNotNull(Result.success(42), validationError); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(42); + }); + + it('fails on null value', () => { + const r = Result.ensureNotNull(Result.success(null), validationError); + expect(r.ok).toBe(false); + }); + + it('fails on undefined value', () => { + const r = Result.ensureNotNull(Result.success(undefined), validationError); + expect(r.ok).toBe(false); + }); + + it('passes through existing failure', () => { + const r = Result.ensureNotNull(Result.failure(notFoundError), validationError); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound'); + }); +}); + +describe('Result.failWhen', () => { + it('fails when predicate is true', () => { + const r = Result.failWhen(Result.success(3), (n) => n < 5, validationError); + expect(r.ok).toBe(false); + }); + + it('passes through when predicate is false', () => { + const r = Result.failWhen(Result.success(10), (n) => n < 5, validationError); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); + + it('supports function error factory', () => { + const r = Result.failWhen( + Result.success(3), + (n) => n < 5, + (n) => Err.validation('Value.TooSmall', `Value ${n} is too small`), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.description).toBe('Value 3 is too small'); + }); + + it('passes through existing failure', () => { + const r = Result.failWhen(Result.failure(notFoundError), () => true, validationError); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound'); + }); +}); + +describe('Result.tryCatch', () => { + it('maps value on success', () => { + const r = Result.tryCatch(Result.success('{"a":1}'), (s) => JSON.parse(s) as unknown); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toEqual({ a: 1 }); + }); + + it('catches thrown error with default handler', () => { + const r = Result.tryCatch(Result.success('{invalid}'), (s) => JSON.parse(s) as unknown); + expect(r.ok).toBe(false); + }); + + it('catches thrown error with static AppError', () => { + const r = Result.tryCatch( + Result.success('{invalid}'), + (s) => JSON.parse(s) as unknown, + Err.validation('JSON.Bad', 'Bad JSON'), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('JSON.Bad'); + }); + + it('catches thrown error with function error factory', () => { + const r = Result.tryCatch( + Result.success('{invalid}'), + (s) => JSON.parse(s) as unknown, + (e) => Err.validation('JSON.Custom', String(e)), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('JSON.Custom'); + }); + + it('passes through existing failure', () => { + const r = Result.tryCatch(Result.failure(notFoundError), (s) => s.toUpperCase()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound'); + }); +}); + +describe('Result.tryCatchAsync', () => { + it('maps value on success', async () => { + const r = await Result.tryCatchAsync(Result.success(5), async (n) => n * 2); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(10); + }); + + it('catches thrown error with default handler', async () => { + const r = await Result.tryCatchAsync(Result.success(1), async () => { + throw new Error('boom'); + }); + expect(r.ok).toBe(false); + }); + + it('catches thrown error with static AppError', async () => { + const r = await Result.tryCatchAsync( + Result.success(1), + async () => { + throw new Error('boom'); + }, + Err.validation('Async.Bad', 'Bad async'), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Async.Bad'); + }); + + it('catches thrown error with function error factory', async () => { + const r = await Result.tryCatchAsync( + Result.success(1), + async () => { + throw new Error('async boom'); + }, + (e) => Err.validation('Async.Custom', String(e)), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Async.Custom'); + }); + + it('passes through existing failure', async () => { + const r = await Result.tryCatchAsync(Result.failure(notFoundError), async (n) => n * 2); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.NotFound'); + }); +}); + +describe('Result.fromValue', () => { + it('creates a success result from a value', () => { + const r = Result.fromValue(42); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe(42); + }); +}); + +describe('Result.fromError', () => { + it('creates a failure result from an error', () => { + const r = Result.fromError(validationError); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors[0]!.code).toBe('Test.Invalid'); + }); +}); + +describe('Result.ensure passes through failure', () => { + it('returns the failure without calling predicate', () => { + const called: boolean[] = []; + const r = Result.ensure( + Result.failure(validationError), + () => { + called.push(true); + return true; + }, + notFoundError, + ); + expect(r.ok).toBe(false); + expect(called).toHaveLength(0); + }); +}); + +describe('Result.ensureAsync passes through failure', () => { + it('returns the failure without calling predicate', async () => { + const called: boolean[] = []; + const r = await Result.ensureAsync( + Result.failure(validationError), + async () => { + called.push(true); + return true; + }, + notFoundError, + ); + expect(r.ok).toBe(false); + expect(called).toHaveLength(0); + }); +}); + +describe('Result.compensateFirst with empty errors (edge)', () => { + it('uses fallback error for empty errors array', () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const r = Result.compensateFirst(fakeFailure, (err) => Result.success(err.code)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe('Result.Empty'); + }); +}); + +describe('Result.compensateFirstAsync with empty errors (edge)', () => { + it('uses fallback error for empty errors array', async () => { + const fakeFailure = { ok: false as const, errors: [] as const }; + const r = await Result.compensateFirstAsync(fakeFailure, async (err) => + Result.success(err.code), + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe('Result.Empty'); + }); +}); diff --git a/tests/rules/rule-engine.test.ts b/tests/rules/rule-engine.test.ts index 2b670ac..a9f549b 100644 --- a/tests/rules/rule-engine.test.ts +++ b/tests/rules/rule-engine.test.ts @@ -244,6 +244,175 @@ describe('RuleEngine async', () => { }); }); +describe('RuleEngine.linearTyped', () => { + const toAge = (ctx: UserContext): Result => Result.success(ctx.age); + const failTyped = (_ctx: UserContext): Result => + Result.failure(Err.validation('Typed.Fail', 'typed fail')); + + it('stops on first failure', () => { + const rule = RuleEngine.linearTyped(failTyped, toAge); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail'); + }); + + it('returns RuleEngine.Empty fallback when all rules pass', () => { + const rule = RuleEngine.linearTyped(toAge, toAge); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + }); + + it('returns RuleEngine.Empty for empty rules', () => { + const rule = RuleEngine.linearTyped(); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + }); +}); + +describe('RuleEngine.linearTypedAsync', () => { + const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age); + const failTypedAsync = async (_ctx: UserContext): Promise> => + Result.failure(Err.validation('Typed.Fail', 'typed fail')); + + it('stops on first failure', async () => { + const rule = RuleEngine.linearTypedAsync(failTypedAsync, toAgeAsync); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('Typed.Fail'); + }); + + it('returns RuleEngine.Empty fallback when all rules pass', async () => { + const rule = RuleEngine.linearTypedAsync(toAgeAsync); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + }); + + it('returns RuleEngine.Empty for empty rules', async () => { + const rule = RuleEngine.linearTypedAsync(); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors[0]!.code).toBe('RuleEngine.Empty'); + }); +}); + +describe('RuleEngine.andTyped', () => { + const toAge = (ctx: UserContext): Result => Result.success(ctx.age); + const failA = (_ctx: UserContext): Result => + Result.failure(Err.validation('A.Fail', 'a')); + const failB = (_ctx: UserContext): Result => + Result.failure(Err.validation('B.Fail', 'b')); + + it('returns last value when all pass', () => { + const rule = RuleEngine.andTyped(toAge, toAge); + const result = rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('collects ALL errors (no short-circuit)', () => { + const rule = RuleEngine.andTyped(failA, failB); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(2); + }); + + it('collects errors while tracking last success value', () => { + const rule = RuleEngine.andTyped(toAge, failA); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(1); + }); +}); + +describe('RuleEngine.andTypedAsync', () => { + const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age); + const failAAsync = async (_ctx: UserContext): Promise> => + Result.failure(Err.validation('A.Fail', 'a')); + const failBAsync = async (_ctx: UserContext): Promise> => + Result.failure(Err.validation('B.Fail', 'b')); + + it('returns last value when all pass', async () => { + const rule = RuleEngine.andTypedAsync(toAgeAsync); + const result = await rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('collects ALL errors', async () => { + const rule = RuleEngine.andTypedAsync(failAAsync, failBAsync); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(2); + }); + + it('collects errors while tracking last success value', async () => { + const rule = RuleEngine.andTypedAsync(toAgeAsync, failAAsync); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(1); + }); +}); + +describe('RuleEngine.orTyped', () => { + const toAge = (ctx: UserContext): Result => Result.success(ctx.age); + const failA = (_ctx: UserContext): Result => + Result.failure(Err.validation('A.Fail', 'a')); + const failB = (_ctx: UserContext): Result => + Result.failure(Err.validation('B.Fail', 'b')); + + it('returns first success', () => { + const rule = RuleEngine.orTyped(failA, toAge); + const result = rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('collects all errors when all fail', () => { + const rule = RuleEngine.orTyped(failA, failB); + const result = rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(2); + }); +}); + +describe('RuleEngine.orTypedAsync', () => { + const toAgeAsync = async (ctx: UserContext): Promise> => Result.success(ctx.age); + const failAAsync = async (_ctx: UserContext): Promise> => + Result.failure(Err.validation('A.Fail', 'a')); + const failBAsync = async (_ctx: UserContext): Promise> => + Result.failure(Err.validation('B.Fail', 'b')); + + it('returns first success', async () => { + const rule = RuleEngine.orTypedAsync(failAAsync, toAgeAsync); + const result = await rule(validUser); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(25); + }); + + it('collects all errors when all fail', async () => { + const rule = RuleEngine.orTypedAsync(failAAsync, failBAsync); + const result = await rule(validUser); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toHaveLength(2); + }); +}); + +describe('RuleEngine.ifAsync (no onFalse)', () => { + it('returns ok when condition fails and no onFalse provided', async () => { + const asyncAdult = RuleEngine.fromPredicateAsync(async (u) => u.age >= 18, ageErr); + const asyncEmail = RuleEngine.fromPredicateAsync( + async (u) => u.email.includes('@'), + emailErr, + ); + const rule = RuleEngine.ifAsync(asyncAdult, asyncEmail); + const result = await rule(minorUser); + expect(result.ok).toBe(true); + }); +}); + describe('RuleEngine.evaluate', () => { it('delegates to rule function', () => { const result = RuleEngine.evaluate(isAdult, validUser); @@ -264,4 +433,14 @@ describe('RuleEngine.evaluate', () => { expect(Result.isFailure(result)).toBe(true); expect(Result.firstError(result)?.code).toBe('Rule.EvaluationFailed'); }); + + it('evaluateAsync handles non-Error thrown values', async () => { + const throwingRule = async (_ctx: unknown): Promise => { + throw 'string error'; + }; + const result = await RuleEngine.evaluateAsync(throwingRule, {}); + expect(Result.isFailure(result)).toBe(true); + expect(Result.firstError(result)?.code).toBe('Rule.EvaluationFailed'); + expect(Result.firstError(result)?.description).toBe('string error'); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index be009b5..1a07ad2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,10 @@ export default defineConfig({ test: { globals: true, environment: 'node', + reporters: ['default', 'junit'], + outputFile: { + junit: 'test-report.junit.xml', + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'cobertura'],