Files
Apiflow-gateway/sync/test/sql.test.js
T
MichaelandClaude Opus 5.5 ea084034b9 feat: gateway sécurisé, miroir de schéma automatique et sync incrémentale
- Schéma Hostinger recopié automatiquement depuis PG Debian (colonnes, PK,
  index) : plus d'init.sql maintenu à la main (corrige articles.artcentrale,
  mvtart.no_id et les 12 tables manquantes)
- Stratégies : full transactionnel (DELETE + INSERT, garde-fou de volume) et
  window (signature mensuelle + 10 derniers jours) pour les grosses tables
- Attente de la fin de la sync Debian (_run), pg_try_advisory_lock,
  cron 07:30 Europe/Paris, force honoré, alertes webhook
- Dashboard : basic auth, CSP sans script inline, plus d'innerHTML,
  port 3002 non publié (accès via Nginx Proxy Manager)
- Secrets hors du compose (.env), frps épinglé 0.71.0 avec TLS forcé,
  allowPorts et additionalScopes
- Suppression de sync.sh, tables.js, utils.js, init.sql ; README de déploiement

L'ancien mot de passe de la base `post` figure dans l'historique git :
il doit être changé sur le serveur (voir README).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:41:29 +02:00

98 lines
4.3 KiB
JavaScript

'use strict';
// Tests des fonctions pures de génération SQL : `npm test` (node --test, sans base de données).
const test = require('node:test');
const assert = require('node:assert/strict');
const s = require('../sql');
test('qi échappe les guillemets', () => {
assert.equal(s.qi('mvtart'), '"mvtart"');
assert.equal(s.qi('a"b'), '"a""b"');
});
test('buildCreateTable : colonnes ordonnées, NOT NULL, PK', () => {
const sql = s.buildCreateTable('mvtart', [
{ name: 'no_id', type: 'bigint', notnull: true },
{ name: 'datmvt', type: 'timestamp without time zone', notnull: false },
{ name: 'qte', type: 'numeric(12,4)', notnull: false },
], ['no_id']);
assert.equal(sql,
'CREATE TABLE IF NOT EXISTS public."mvtart" (\n' +
' "no_id" bigint NOT NULL,\n' +
' "datmvt" timestamp without time zone,\n' +
' "qte" numeric(12,4),\n' +
' PRIMARY KEY ("no_id")\n)');
assert.ok(!s.buildCreateTable('t', [{ name: 'a', type: 'text' }], []).includes('PRIMARY KEY'));
});
test('buildAddColumn est idempotent et nullable', () => {
assert.equal(s.buildAddColumn('articles', { name: 'artcentrale', type: 'text', notnull: true }),
'ALTER TABLE public."articles" ADD COLUMN IF NOT EXISTS "artcentrale" text');
});
test('indexDefIfNotExists', () => {
assert.equal(s.indexDefIfNotExists('CREATE INDEX idx_a ON public.articles USING btree (codein)'),
'CREATE INDEX IF NOT EXISTS idx_a ON public.articles USING btree (codein)');
assert.equal(s.indexDefIfNotExists('CREATE UNIQUE INDEX u ON public.t USING btree (a, b)'),
'CREATE UNIQUE INDEX IF NOT EXISTS u ON public.t USING btree (a, b)');
assert.equal(s.indexDefIfNotExists('CREATE INDEX IF NOT EXISTS x ON public.t (a)'),
'CREATE INDEX IF NOT EXISTS x ON public.t (a)');
assert.throws(() => s.indexDefIfNotExists('DROP TABLE x'));
});
test('needsTrgm', () => {
assert.ok(s.needsTrgm('CREATE INDEX i ON public.a USING gin (libelle1 gin_trgm_ops)'));
assert.ok(!s.needsTrgm('CREATE INDEX i ON public.a USING btree (libelle1)'));
});
test('buildSignatureQuery : colonnes explicites dans l\'ordre source', () => {
const sql = s.buildSignatureQuery('mvtart', [{ name: 'no_id' }, { name: 'datmvt' }], 'datmvt');
assert.match(sql, /date_trunc\('month', "datmvt"\)/);
assert.match(sql, /hashtext\(ROW\("no_id", "datmvt"\)::text\)::bigint/);
assert.match(sql, /FROM public\."mvtart"\nGROUP BY 1$/);
});
test('signatures : diff, total, mois NULL', () => {
const src = s.signatureMap([
{ m: '2026-08-01', n: '10', h: '123' },
{ m: '2026-09-01', n: '5', h: '7' },
{ m: null, n: '1', h: '9' },
]);
const dst = s.signatureMap([
{ m: '2026-08-01', n: '10', h: '123' },
{ m: '2026-09-01', n: '5', h: '8' },
{ m: '2026-07-01', n: '3', h: '1' },
]);
assert.deepEqual(s.diffMonths(src, dst), ['2026-07-01', '2026-09-01', s.NULL_MONTH]);
assert.equal(s.signatureTotal(src), 16);
});
test('monthCondition / nextMonth / recentCondition', () => {
assert.equal(s.nextMonth('2026-12-01'), '2027-01-01');
assert.equal(s.nextMonth('2026-02-01'), '2026-03-01');
assert.equal(s.monthCondition('datmvt', '2026-12-01'), `"datmvt" >= '2026-12-01' AND "datmvt" < '2027-01-01'`);
assert.equal(s.monthCondition('datmvt', s.NULL_MONTH), '"datmvt" IS NULL');
assert.equal(s.recentCondition('date', '2026-10-07'), `"date" >= '2026-10-07'`);
assert.throws(() => s.monthCondition('d', "2026-01-01'; DROP TABLE x; --"));
});
test('buildInsert / insertChunkSize', () => {
assert.equal(s.buildInsert('t', [{ name: 'a' }, { name: 'b' }], 2),
'INSERT INTO public."t" ("a", "b") VALUES ($1,$2),($3,$4)');
assert.equal(s.insertChunkSize(30), 2000);
assert.equal(s.insertChunkSize(100000), 1);
assert.ok(s.insertChunkSize(7) * 7 <= 65535);
});
test('parseWindowTables / parseList', () => {
assert.deepEqual(s.parseWindowTables('mvtart:datmvt, meteo:date'), { mvtart: 'datmvt', meteo: 'date' });
assert.throws(() => s.parseWindowTables('mvtart'));
assert.deepEqual(s.parseList(' a, ,b '), ['a', 'b']);
assert.deepEqual(s.parseList(undefined), []);
});
test('cutoffDate en Europe/Paris', () => {
// 17/10/2026 23:30 UTC = 18/10/2026 01:30 à Paris
assert.equal(s.cutoffDate(10, 'Europe/Paris', new Date('2026-10-17T23:30:00Z')), '2026-10-08');
assert.equal(s.cutoffDate(0, 'Europe/Paris', new Date('2026-03-01T12:00:00Z')), '2026-03-01');
});