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
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# CI — typecheck + suite de tests in-process (@mostajs/orm).
# Aucune infrastructure : tout tourne en mémoire (sqlite/sql.js/pglite/duckdb).
# Author: Dr Hamid MADANI <drmdh@msn.com>
name: test

on:
push:
branches: [main, 'feat/**', 'test/**']
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4

- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

# package-lock.json n'est pas versionné (choix du projet) → npm install (pas npm ci).
- name: Install dependencies
run: npm install

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build

- name: Test (in-process dialects)
run: npm test
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"
},
"dependencies": {
Expand All @@ -92,16 +95,16 @@
},
"peerDependencies": {
"@clickhouse/client": ">=1.0.0",
"cassandra-driver": ">=4.0.0",
"ioredis": ">=5.0.0",
"@electric-sql/pglite": ">=0.2.0",
"@google-cloud/firestore": ">=7.0.0",
"@google-cloud/spanner": ">=7.0.0",
"@mostajs/socle": ">=2.0.0",
"@sap/hana-client": ">=2.0.0",
"better-sqlite3": ">=9.0.0",
"cassandra-driver": ">=4.0.0",
"duckdb": ">=1.0.0",
"ibm_db": ">=3.0.0",
"ioredis": ">=5.0.0",
"mariadb": ">=3.0.0",
"mongoose": ">=7.0.0",
"mssql": ">=10.0.0",
Expand Down Expand Up @@ -176,7 +179,8 @@
"mongoose": "^8.0.0",
"pg": "^8.21.0",
"sql.js": "^1.14.1",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^4.1.8"
},
"funding": {
"type": "github",
Expand Down
124 changes: 124 additions & 0 deletions tests/crud.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Suite CRUD paramétrée — rejoue le scénario de validation (création, lecture, count,
// filtres, update, upsert, delete, relations, pagination) sur CHAQUE dialecte in-process.
// Aucune infra : sqlite / sql.js / pglite / duckdb, tout en mémoire.
// Author: Dr Hamid MADANI <drmdh@msn.com>
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { IN_PROCESS_DIALECTS, setupRepos, type TestRepos } from './helpers.js';

describe.each(IN_PROCESS_DIALECTS)('CRUD — $label', (cfg) => {
let repos: TestRepos;
const id: Record<string, string> = {};

beforeAll(async () => {
repos = await setupRepos(cfg);
});

afterAll(async () => {
await repos?.dialect.disconnect();
});

it('schéma vide : count() = 0', async () => {
expect(await repos.cat.count()).toBe(0);
expect(await repos.prod.count()).toBe(0);
expect(await repos.order.count()).toBe(0);
});

it('create — catégories ×2', async () => {
const c1 = await repos.cat.create({ name: 'Electronique', description: 'Appareils', order: 1 });
const c2 = await repos.cat.create({ name: 'Vetements', description: 'Mode', order: 2 });
expect(c1.id).toBeTruthy();
expect(c2.id).toBeTruthy();
id.cat1 = c1.id as string;
id.cat2 = c2.id as string;
});

it('create — produits ×3 avec relation many-to-one', async () => {
const p1 = await repos.prod.create({ name: 'Laptop Pro', slug: 'laptop-pro', price: 120000, stock: 15, status: 'active', category: id.cat1, tags: ['laptop', 'pro'], metadata: { brand: 'MostaTech', year: 2025 } });
const p2 = await repos.prod.create({ name: 'T-Shirt', slug: 'tshirt', price: 2500, stock: 100, status: 'active', category: id.cat2, tags: ['coton'] });
const p3 = await repos.prod.create({ name: 'Ecouteurs', slug: 'ecouteurs', price: 5000, stock: 0, status: 'draft', category: id.cat1 });
expect([p1.id, p2.id, p3.id].every(Boolean)).toBe(true);
id.prod1 = p1.id as string;
id.prod3 = p3.id as string;
});

it('create — commandes ×2 avec relation required', async () => {
const o1 = await repos.order.create({ orderNumber: 'CMD-001', total: 120000, status: 'paid', product: id.prod1 });
const o2 = await repos.order.create({ orderNumber: 'CMD-002', total: 5000, status: 'pending', product: id.prod1 });
expect([o1.id, o2.id].every(Boolean)).toBe(true);
});

it('findById — retrouve par id', async () => {
const cat = await repos.cat.findById(id.cat1);
expect(cat).not.toBeNull();
expect((cat as Record<string, unknown>).name).toBe('Electronique');
});

it('findOne — filtre sur champ unique', async () => {
const prod = await repos.prod.findOne({ slug: 'laptop-pro' });
expect(prod).not.toBeNull();
expect((prod as Record<string, unknown>).price).toBe(120000);
});

it('findAll — récupère tout', async () => {
expect((await repos.prod.findAll()).length).toBe(3);
});

it('count() — total exact (régression casse alias cnt/CNT)', async () => {
expect(await repos.cat.count()).toBe(2);
expect(await repos.prod.count()).toBe(3);
expect(await repos.order.count()).toBe(2);
});

it('count(filter) — comptage filtré', async () => {
expect(await repos.prod.count({ status: 'active' })).toBe(2);
expect(await repos.prod.count({ status: 'draft' })).toBe(1);
});

it('findAll(filter) — lecture filtrée', async () => {
const active = await repos.prod.findAll({ status: 'active' });
expect(active.length).toBe(2);
});

it('update — modifie prix/stock/statut', async () => {
await repos.prod.update(id.prod3, { price: 4500, stock: 25, status: 'active' });
const u = await repos.prod.findById(id.prod3) as Record<string, unknown>;
expect(u.price).toBe(4500);
expect(u.stock).toBe(25);
expect(u.status).toBe('active');
});

it('upsert — crée si absent puis met à jour', async () => {
const created = await repos.cat.upsert({ name: 'Sport' }, { name: 'Sport', description: 'Articles', order: 3 });
expect(created.id).toBeTruthy();
expect(await repos.cat.count()).toBe(3);
const updated = await repos.cat.upsert({ name: 'Sport' }, { name: 'Sport', description: 'Sport & fitness', order: 3 });
expect(updated.id).toBe(created.id);
expect((await repos.cat.findById(created.id as string) as Record<string, unknown>).description).toBe('Sport & fitness');
expect(await repos.cat.count()).toBe(3); // pas de doublon
});

it('pagination — limit/skip/sort cohérents', async () => {
const page1 = await repos.prod.findAll({}, { sort: { price: 'asc' }, limit: 2, skip: 0 });
const page2 = await repos.prod.findAll({}, { sort: { price: 'asc' }, limit: 2, skip: 2 });
expect(page1.length).toBe(2);
expect(page2.length).toBe(1);
const prices = page1.map((p) => (p as Record<string, number>).price);
expect(prices[0]).toBeLessThanOrEqual(prices[1]);
});

it('delete — supprime une ligne', async () => {
const ok = await repos.prod.delete(id.prod3);
expect(ok).toBe(true);
expect(await repos.prod.findById(id.prod3)).toBeNull();
expect(await repos.prod.count()).toBe(2);
});

it('deleteMany — vide les collections', async () => {
await repos.order.deleteMany({});
await repos.prod.deleteMany({});
await repos.cat.deleteMany({});
expect(await repos.order.count()).toBe(0);
expect(await repos.prod.count()).toBe(0);
expect(await repos.cat.count()).toBe(0);
});
});
56 changes: 56 additions & 0 deletions tests/fixtures/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Schémas d'entités de test — autonomes, zéro dépendance externe.
// Repris du harnais de validation live (test-sgbd) pour cohérence des scénarios.
// Author: Dr Hamid MADANI <drmdh@msn.com>
import type { EntitySchema } from '../../src/index.js';

export const CategorySchema = {
name: 'Category',
collection: 'test_categories',
timestamps: true,
fields: {
name: { type: 'string', required: true, unique: true },
description: { type: 'string', default: '' },
order: { type: 'number', default: 0 },
active: { type: 'boolean', default: true },
},
relations: {},
indexes: [{ fields: { order: 'asc' } }],
} satisfies EntitySchema;

export const ProductSchema = {
name: 'Product',
collection: 'test_products',
timestamps: true,
fields: {
name: { type: 'string', required: true },
slug: { type: 'string', required: true, unique: true },
price: { type: 'number', required: true },
stock: { type: 'number', default: 0 },
status: { type: 'string', enum: ['active', 'archived', 'draft'], default: 'draft' },
tags: { type: 'array', arrayOf: 'string' },
metadata: { type: 'json' },
},
relations: {
category: { target: 'Category', type: 'many-to-one' },
},
indexes: [{ fields: { status: 'asc', price: 'desc' } }],
} satisfies EntitySchema;

export const OrderSchema = {
name: 'Order',
collection: 'test_orders',
timestamps: true,
fields: {
orderNumber: { type: 'string', required: true, unique: true },
total: { type: 'number', required: true },
status: { type: 'string', enum: ['pending', 'paid', 'shipped', 'cancelled'], default: 'pending' },
notes: { type: 'string' },
orderDate: { type: 'date', default: 'now' },
},
relations: {
product: { target: 'Product', type: 'many-to-one', required: true },
},
indexes: [{ fields: { status: 'asc' } }],
} satisfies EntitySchema;

export const ALL_SCHEMAS = [CategorySchema, ProductSchema, OrderSchema];
42 changes: 42 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Helpers de test — instancie un dialecte IN-PROCESS isolé (hors singleton/env global)
// et les repositories associés, à partir des schémas de fixtures.
// Author: Dr Hamid MADANI <drmdh@msn.com>
import { createIsolatedDialect, BaseRepository } from '../src/index.js';
import type { IDialect } from '../src/index.js';
import { CategorySchema, ProductSchema, OrderSchema, ALL_SCHEMAS } from './fixtures/schemas.js';

export interface InProcessDialect {
dialect: string;
uri: string;
label: string;
}

/** Dialectes testables sans aucune infra (mémoire / WASM / in-process). */
export const IN_PROCESS_DIALECTS: InProcessDialect[] = [
{ dialect: 'sqlite', uri: ':memory:', label: 'SQLite (better-sqlite3)' },
{ dialect: 'sqljs', uri: ':memory:', label: 'sql.js (WASM)' },
{ dialect: 'pglite', uri: ':memory:', label: 'pglite (WASM)' },
{ dialect: 'duckdb', uri: ':memory:', label: 'DuckDB (in-process)' },
];

export interface TestRepos {
dialect: IDialect;
cat: BaseRepository<Record<string, unknown>>;
prod: BaseRepository<Record<string, unknown>>;
order: BaseRepository<Record<string, unknown>>;
}

/** Crée un dialecte isolé + les 3 repositories de test, schéma déjà initialisé. */
export async function setupRepos(cfg: InProcessDialect): Promise<TestRepos> {
const dialect = await createIsolatedDialect(
// schemaStrategy: 'create' → initSchema émet le DDL (CREATE TABLE/INDEX).
{ dialect: cfg.dialect as never, uri: cfg.uri, schemaStrategy: 'create' },
ALL_SCHEMAS,
);
return {
dialect,
cat: new BaseRepository(CategorySchema, dialect),
prod: new BaseRepository(ProductSchema, dialect),
order: new BaseRepository(OrderSchema, dialect),
};
}
42 changes: 42 additions & 0 deletions tests/transactions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Transactions ACID — commit (persiste) et rollback (annule) via $transaction(cb),
// sur chaque dialecte in-process supportant les transactions.
// Author: Dr Hamid MADANI <drmdh@msn.com>
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { BaseRepository } from '../src/index.js';
import { IN_PROCESS_DIALECTS, setupRepos, type TestRepos } from './helpers.js';
import { CategorySchema } from './fixtures/schemas.js';

describe.each(IN_PROCESS_DIALECTS)('Transactions — $label', (cfg) => {
let repos: TestRepos;

beforeAll(async () => {
repos = await setupRepos(cfg);
});

afterAll(async () => {
await repos?.dialect.disconnect();
});

it('commit — les écritures du callback persistent', async () => {
const before = await repos.cat.count();
await repos.dialect.$transaction(async (tx) => {
const catTx = new BaseRepository(CategorySchema, tx);
await catTx.create({ name: 'Tx-Commit', order: 10 });
});
expect(await repos.cat.count()).toBe(before + 1);
expect(await repos.cat.findOne({ name: 'Tx-Commit' })).not.toBeNull();
});

it('rollback — une exception annule toutes les écritures', async () => {
const before = await repos.cat.count();
await expect(
repos.dialect.$transaction(async (tx) => {
const catTx = new BaseRepository(CategorySchema, tx);
await catTx.create({ name: 'Tx-Rollback', order: 11 });
throw new Error('boom — déclenche le rollback');
}),
).rejects.toThrow('boom');
expect(await repos.cat.count()).toBe(before);
expect(await repos.cat.findOne({ name: 'Tx-Rollback' })).toBeNull();
});
});
17 changes: 17 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Configuration Vitest — suite de tests automatisés @mostajs/orm
// Tests rejouables en CI sur les dialectes IN-PROCESS (sqlite/sqljs/pglite/duckdb),
// sans aucune infrastructure (ni Docker, ni serveur distant).
// Author: Dr Hamid MADANI <drmdh@msn.com>
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
testTimeout: 20_000,
hookTimeout: 20_000,
// Les dialectes in-process gardent un état fichier/mémoire : on isole par fichier.
pool: 'forks',
reporters: 'default',
},
});
Loading