Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
15 changes: 15 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ <h1><span>tsentials</span></h1>
<img src="https://img.shields.io/npm/v/tsentials?style=flat-square&color=blue" alt="npm version" />
<img src="https://img.shields.io/npm/dm/tsentials?style=flat-square" alt="npm downloads" />
<img src="https://img.shields.io/bundlephobia/minzip/tsentials?style=flat-square&label=gzip" alt="bundle size" />
<img src="https://img.shields.io/badge/tests-818%20passing-brightgreen?style=flat-square" alt="tests" />
<img src="https://img.shields.io/badge/tests-978%20passing-brightgreen?style=flat-square" alt="tests" />
<img src="https://img.shields.io/github/actions/workflow/status/senrecep/tsentials/ci.yml?branch=main&style=flat-square&label=CI" alt="CI" />
<img src="https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript" alt="TypeScript" />
<img src="https://img.shields.io/badge/node-%3E%3D18-339933?style=flat-square&logo=node.js" alt="Node.js" />
Expand Down
115 changes: 115 additions & 0 deletions tests/clone/cloneable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
46 changes: 45 additions & 1 deletion tests/entity/entity-base.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -125,6 +125,50 @@ describe('createEntityBase', () => {
});
});

describe('createEntityBaseWithId', () => {
it('initializes with a provided id', () => {
const entity = createEntityBaseWithId<string>('abc-123');
expect(entity.id).toBe('abc-123');
});

it('initializes with a numeric id', () => {
const entity = createEntityBaseWithId<number>(42);
expect(entity.id).toBe(42);
});

it('throws when id is not set and accessed', () => {
const entity = createEntityBaseWithId<string>();
expect(() => entity.id).toThrow('Entity ID has not been set.');
});

it('sets id via setId', () => {
const entity = createEntityBaseWithId<string>();
entity.setId('new-id');
expect(entity.id).toBe('new-id');
});

it('overwrites id via setId', () => {
const entity = createEntityBaseWithId<string>('original');
entity.setId('updated');
expect(entity.id).toBe('updated');
});

it('initializes createdAt to epoch via base', () => {
const entity = createEntityBaseWithId<string>('init');
expect(entity.createdAt).toEqual(new Date(0));
});

it('initializes updatedAt to undefined via base', () => {
const entity = createEntityBaseWithId<string>('init');
expect(entity.updatedAt).toBeUndefined();
});

it('initializes domainEvents to empty via base', () => {
const entity = createEntityBaseWithId<string>('init');
expect(entity.domainEvents).toHaveLength(0);
});
});

describe('createSoftDeletable', () => {
it('initializes as not deleted', () => {
const sd = createSoftDeletable();
Expand Down
60 changes: 60 additions & 0 deletions tests/http/request-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>();

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<unknown>();

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<unknown>();

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<unknown>();

expect(fetchSpy).toHaveBeenCalledOnce();
});
});
82 changes: 82 additions & 0 deletions tests/json/json.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown> = {};
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<number>('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');
}
});
});
Loading
Loading