// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA). // Doit ÉCHOUER si une écriture peut sortir des QUATRE constantes autorisées : // airtria_vehicle_label (étiquette), victron_evcs_vehicle (sélecteur, ajouté // 19/09/2026) et victron_evcs_vehicle_level_pct (niveau annoncé, 03/10/2026) et evcs_releve_km // (relevés kilométriques, 04/10/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor). // Statique + require + fetch STUBBÉ : aucun réseau, aucun token, aucune écriture. 'use strict'; const fs = require('fs'); const path = require('path'); const assert = require('assert'); const SRC = path.join(__dirname, 'concierge-write.js'); const src = fs.readFileSync(SRC, 'utf8'); const mod = require('./concierge-write.js'); // Les tests 6-9 sont asynchrones : on déclare, puis on déroule dans l'ordre. let ko = 0; const queue = []; const check = (name, fn) => queue.push([name, fn]); console.log('=== Test de portée d\'écriture concierge-write.js ==='); // 1. Les constantes sont exactement les entités attendues. check('constantes = airtria_vehicle_label + victron_evcs_vehicle + niveau', () => { assert.strictEqual(mod.HELPER_ENTITY, 'input_text.airtria_vehicle_label'); assert.strictEqual(mod.SELECTOR_ENTITY, 'input_select.victron_evcs_vehicle'); assert.strictEqual(mod.LEVEL_ENTITY, 'input_number.victron_evcs_vehicle_level_pct'); assert.strictEqual(mod.RELEVE_ENTITY, 'input_text.evcs_releve_km'); assert.deepStrictEqual(mod.SELECTOR_LABELS, ['VW', 'JEEP', 'ZERO']); }); // 2. entity_id n'est JAMAIS accolé à autre chose que l'une des DEUX constantes // (pas de littéral d'entité, pas d'interpolation, pas de concaténation). check('entity_id uniquement = HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY ou RELEVE_ENTITY', () => { const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim()); assert(occ.length >= 3, `entity_id attendus >= 3, trouvés ${occ.length}`); for (const v of occ) assert(['HELPER_ENTITY', 'SELECTOR_ENTITY', 'LEVEL_ENTITY', 'RELEVE_ENTITY'].includes(v), `entity_id dynamique ou littéral interdit : ${v}`); }); // 3. Aucune entité HA en dur hors les DEUX déclarations de constantes. check('aucune entité HA littérale hors les quatre constantes', () => { const decls = ["'input_text.airtria_vehicle_label'", "'input_select.victron_evcs_vehicle'", "'input_number.victron_evcs_vehicle_level_pct'", "'input_text.evcs_releve_km'"]; const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]); for (const l of lits) assert(decls.includes(l), `littéral d'entité interdit : ${l}`); }); // 4. DEUX appels de service exactement : set_value puis select_option. check('services = input_text/set_value + input_select/select_option + input_number/set_value', () => { const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)] .map(m => m[1]); assert.deepStrictEqual(services.sort(), ['input_number/set_value', 'input_select/select_option', 'input_text/set_value', 'input_text/set_value'], `services inattendus : ${services.join(', ') || '(aucun)'}`); }); // 5. La relecture existe pour CHAQUE écriture (garde-fou rejet silencieux). check('relectures présentes (/api/states/ x3)', () => { const n = [...src.matchAll(/\/api\/states\//g)].length; assert(n >= 3, `relectures attendues >= 3, trouvées ${n}`); }); // ---- Comportement du sélecteur, fetch STUBBÉ (aucun réseau) ---- const calls = []; global.fetch = async (url, opts) => { calls.push({ url: String(url), body: opts && opts.body }); if (String(url).includes('/api/states/input_number.victron_evcs_vehicle_level_pct')) { return { ok: true, json: async () => ({ state: '40.0' }) }; } if (String(url).includes('/api/states/')) { return { ok: true, json: async () => ({ state: 'VW' }) }; } return { ok: true, json: async () => ({}) }; }; process.env.HA_TOKEN_FILE = '/dev/null'; // R1-R4. Releves km : refus SANS aucun appel reseau (04/10/2026). for (const [nom, args] of [ ['releve refuse une source non humaine', [[{ vehicule: 'JEEP', km: 1000, niveau: 50, ts: 1791066924 }], 'regle']], ['releve refuse un vehicule hors foyer', [[{ vehicule: 'MEGANE', km: 1000, niveau: 50, ts: 1791066924 }], 'whatsapp']], ['releve refuse un niveau de 150', [[{ vehicule: 'JEEP', km: 1000, niveau: 150, ts: 1791066924 }], 'whatsapp']], ['releve refuse un km non entier', [[{ vehicule: 'JEEP', km: 1000.5, niveau: null, ts: 1791066924 }], 'whatsapp']], ]) { check(nom, async () => { const avant = calls.length; await assert.rejects(() => mod.setReleveKm(...args)); assert.strictEqual(calls.length, avant, 'aucun appel reseau attendu'); }); } check('file des releves : 255 caracteres max, dernier garde', () => { let b = ''; for (let i = 0; i < 10; i++) b = mod.fileReleves(b, [{ vehicule: 'JEEP', km: 112694 + i, niveau: 54, ts: 1791066924 + i, source: 'whatsapp' }]); assert(b.length <= 255, `longueur ${b.length}`); assert.strictEqual(JSON.parse(b).at(-1)[1], 112703); }); check('releve ecrit la file puis la relit', async () => { const avant = calls.length; const stub = global.fetch; let ecrit = null; global.fetch = async (url, opts) => { calls.push({ url: String(url), body: opts && opts.body }); if (opts && opts.method === 'POST') { ecrit = JSON.parse(opts.body).value; return { ok: true, json: async () => ({}) }; } return { ok: true, json: async () => ({ state: ecrit ?? '[]' }) }; }; try { await mod.setReleveKm([{ vehicule: 'ZERO', km: 16469, niveau: 78, ts: 1791066924 }], 'whatsapp'); } finally { global.fetch = stub; } const posts = calls.slice(avant).filter(c => c.body); assert.strictEqual(posts.length, 1); assert.strictEqual(JSON.parse(posts[0].body).entity_id, 'input_text.evcs_releve_km'); assert.deepStrictEqual(JSON.parse(ecrit), [['ZERO', 16469, 78, 1791066924, 'whatsapp']]); }); // 6. Refus d'une source non humaine, sans AUCUN appel réseau. check('sélecteur refuse une source non humaine', async () => { calls.length = 0; await assert.rejects(() => mod.setVehicleSelector('VW', 'deduction'), /non humaine/); assert.strictEqual(calls.length, 0, 'un fetch est parti malgré le refus'); }); // 7. Refus des labels hors foyer, sans AUCUN appel réseau. check('sélecteur refuse MEGANE / INVITE (ZERO accepte depuis le 03/10)', async () => { for (const bad of ['MEGANE', 'INVITE', 'vw', 'Jeep', 'zero']) { calls.length = 0; await assert.rejects(() => mod.setVehicleSelector(bad, 'whatsapp'), /hors foyer/); assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`); } }); // 8. VW accepté : select_option sur la constante, puis relecture. check('sélecteur VW : écriture + relecture', async () => { calls.length = 0; const r = await mod.setVehicleSelector('VW', 'whatsapp'); assert.strictEqual(r.entity, 'input_select.victron_evcs_vehicle'); assert.strictEqual(calls.length, 2, `2 appels attendus, ${calls.length} faits`); assert(calls[0].url.includes('/api/services/input_select/select_option')); assert(calls[0].body.includes('"option":"VW"')); assert(calls[1].url.includes('/api/states/input_select.victron_evcs_vehicle')); }); // 9. Relecture discordante = erreur (JEEP demandé, HA resté sur VW). check('sélecteur détecte le refus silencieux HA', async () => { await assert.rejects(() => mod.setVehicleSelector('JEEP', 'whatsapp'), /refusée en silence/); }); // 10. Niveau : refus des valeurs invalides SANS correction ni appel réseau // (02/10 : le modèle avait « corrigé » 150 en 100 avant de l'écrire). check('niveau refuse 150, -1, 40.5, NaN — aucun fetch', async () => { for (const bad of [150, -1, 40.5, NaN, '40']) { calls.length = 0; await assert.rejects(() => mod.setVehicleLevel(bad, 'whatsapp'), /invalide/); assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`); } }); // 11. Niveau : source non humaine refusée. check('niveau refuse une source non humaine', async () => { calls.length = 0; await assert.rejects(() => mod.setVehicleLevel(40, 'deduction'), /non humaine/); assert.strictEqual(calls.length, 0); }); // 12. Niveau 40 : set_value sur la constante, puis relecture. check('niveau 40 : écriture + relecture', async () => { calls.length = 0; const r = await mod.setVehicleLevel(40, 'whatsapp'); assert.strictEqual(r.entity, 'input_number.victron_evcs_vehicle_level_pct'); assert.strictEqual(calls.length, 2); assert(calls[0].url.includes('/api/services/input_number/set_value')); assert(calls[0].body.includes('"value":40')); }); // 13. Niveau : relecture discordante = erreur (HA relit 40, on demandait 70). check('niveau détecte le refus silencieux HA', async () => { await assert.rejects(() => mod.setVehicleLevel(70, 'whatsapp'), /refusé en silence/); }); (async () => { for (const [name, fn] of queue) { try { await fn(); console.log(` ok ${name}`); } catch (e) { ko++; console.log(` FAIL ${name} : ${e.message}`); } } console.log(ko === 0 ? '\nTOUT VERT' : `\n${ko} test(s) EN ÉCHEC`); process.exit(ko === 0 ? 0 : 1); })();