From 383b98a00304585ec5448bb00999affd706969a0 Mon Sep 17 00:00:00 2001 From: MADANI Date: Sat, 13 Jun 2026 02:39:39 +0100 Subject: [PATCH] =?UTF-8?q?test:=20suite=20de=20tests=20automatis=C3=A9s?= =?UTF-8?q?=20in-process=20+=20CI=20(vitest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier filet de non-régression rejouable en CI, SANS infrastructure : tout tourne en mémoire sur les dialectes in-process (sqlite, sql.js, pglite, duckdb). - vitest 4 + config (tests/**/*.test.ts, pool forks, timeouts). - Helpers : createIsolatedDialect (hors singleton/env global) + BaseRepository, paramétrés sur les 4 dialectes in-process. - tests/crud.test.ts : scénario complet ×4 dialectes (60 tests) — schéma, create, relations many-to-one, findById/findOne/findAll, count + count(filter) [couvre la régression de casse d'alias cnt/CNT], update, upsert (idempotent), pagination, delete/deleteMany. - tests/transactions.test.ts : commit (persiste) & rollback (annule) via $transaction(cb) ×4 dialectes (8 tests). - Scripts npm : test (vitest run), test:watch, typecheck (tsc --noEmit). - CI GitHub Actions (.github/workflows/test.yml) : Node 20/22, typecheck + build + test (npm install — package-lock.json non versionné). Total : 68 tests, ~6 s, zéro dépendance serveur. Author: Dr Hamid MADANI --- .github/workflows/test.yml | 37 +++++++++++ package.json | 10 ++- tests/crud.test.ts | 124 +++++++++++++++++++++++++++++++++++++ tests/fixtures/schemas.ts | 56 +++++++++++++++++ tests/helpers.ts | 42 +++++++++++++ tests/transactions.test.ts | 42 +++++++++++++ vitest.config.ts | 17 +++++ 7 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/crud.test.ts create mode 100644 tests/fixtures/schemas.ts create mode 100644 tests/helpers.ts create mode 100644 tests/transactions.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d612217 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 +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 diff --git a/package.json b/package.json index 5151ab3..a91ebe0 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,9 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "prepublishOnly": "npm run build" }, "dependencies": { @@ -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", @@ -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", diff --git a/tests/crud.test.ts b/tests/crud.test.ts new file mode 100644 index 0000000..c6c64da --- /dev/null +++ b/tests/crud.test.ts @@ -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 +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 = {}; + + 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).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).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; + 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).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).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); + }); +}); diff --git a/tests/fixtures/schemas.ts b/tests/fixtures/schemas.ts new file mode 100644 index 0000000..b41e00e --- /dev/null +++ b/tests/fixtures/schemas.ts @@ -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 +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]; diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..bea34cb --- /dev/null +++ b/tests/helpers.ts @@ -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 +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>; + prod: BaseRepository>; + order: BaseRepository>; +} + +/** Crée un dialecte isolé + les 3 repositories de test, schéma déjà initialisé. */ +export async function setupRepos(cfg: InProcessDialect): Promise { + 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), + }; +} diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts new file mode 100644 index 0000000..66faf49 --- /dev/null +++ b/tests/transactions.test.ts @@ -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 +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(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..381bdfb --- /dev/null +++ b/vitest.config.ts @@ -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 +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', + }, +});