sécurité: audit commercialisation — hardening + 366 tests (24 E2E)
Sandbox Electron, HTTPS ntfy, validation import structurelle, suppression executeJavaScript, nettoyage memory leaks, try/catch sur tous les appels electronAPI. 27 nouveaux tests de sécurité et validation. README mis à jour avec changelog et couverture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2893013093
commit
8af626dd66
85
README.md
85
README.md
@ -65,22 +65,53 @@ Ou double-cliquer sur `build.bat` (admin auto). L'installeur est généré dans
|
|||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
**302 tests unitaires/fonctionnels/régression** (Vitest) — couverture de code **94%** (v8).
|
#### Tests unitaires, fonctionnels et régression
|
||||||
|
|
||||||
| Couche | Statements | Branches | Functions | Lines |
|
**366 tests** via Vitest — couverture de code **94%** (v8).
|
||||||
|--------|-----------|----------|-----------|-------|
|
|
||||||
| Domain | 94–100% | 80–100% | 92–100% | 85–100% |
|
|
||||||
| Application | 92–100% | 76–100% | 100% | 96–100% |
|
|
||||||
| Infrastructure | 100% | 100% | 100% | 100% |
|
|
||||||
|
|
||||||
**20 tests E2E** (Playwright + Electron) couvrant :
|
```
|
||||||
- **Navigation sidebar** (8 tests) — tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres
|
npm test # Lancer tous les tests
|
||||||
- **Cycle de vie du timer** (5 tests) — activation jauge, démarrage, pause/reprise, reset
|
npm run test:watch # Mode watch
|
||||||
- **Recharge de jauge** (2 tests) — mise à jour "Alarme dans" et barre de jauge en temps réel
|
npm run test:coverage # Avec rapport de couverture
|
||||||
- **Workflow d'accouplement** (5 tests) — sélection parents, résultat bébé, enregistrement, filtres génération, recherche
|
```
|
||||||
- **Persistance des données** (1 test) — survie des données après fermeture/réouverture
|
|
||||||
|
|
||||||
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL. Les tests E2E nécessitent un `npm run build` préalable.
|
| Couche | Fichiers | Tests | Statements | Branches | Functions | Lines |
|
||||||
|
|--------|----------|-------|-----------|----------|-----------|-------|
|
||||||
|
| **Domain** | 14 | 165 | 94–100% | 80–100% | 92–100% | 85–100% |
|
||||||
|
| **Application** | 3 | 128 | 92–100% | 76–100% | 100% | 96–100% |
|
||||||
|
| **Infrastructure** | 2 | 19 | 100% | 100% | 100% | 100% |
|
||||||
|
| **Presentation** | 3 | 36 | — | — | — | — |
|
||||||
|
| **Régression** | 9 | 48 | — | — | — | — |
|
||||||
|
| **Fonctionnel** | 3 | 8 | — | — | — | — |
|
||||||
|
|
||||||
|
La couche Presentation est testée avec `happy-dom` pour Toast, ConfirmModal et UndoManager.
|
||||||
|
|
||||||
|
#### Tests E2E
|
||||||
|
|
||||||
|
**24 tests E2E** via Playwright + Electron.
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build && npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
| Suite | Tests | Couverture |
|
||||||
|
|-------|-------|------------|
|
||||||
|
| **Navigation sidebar** | 8 | Tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres |
|
||||||
|
| **Cycle de vie du timer** | 4 | Activation jauge, démarrage, pause/reprise, reset |
|
||||||
|
| **Recharge de jauge** | 2 | Mise à jour "Alarme dans" et barre de jauge en temps réel |
|
||||||
|
| **Workflow d'accouplement** | 5 | Sélection parents, résultat bébé, enregistrement, filtres, recherche |
|
||||||
|
| **Persistance** | 1 | Survie des données après fermeture/réouverture |
|
||||||
|
| **UI Feedback** | 4 | Toast, ConfirmModal, Undo via toast |
|
||||||
|
|
||||||
|
#### Sécurité et qualité
|
||||||
|
|
||||||
|
Les tests incluent des vérifications de :
|
||||||
|
- **Sécurité XSS** — `esc()` <20><>chappe correctement `<`, `>`, `"`, `&`
|
||||||
|
- **Sécurité import** — validation structurelle complète des données importées (14 cas)
|
||||||
|
- **Validation HTTPS** — rejet des URLs non-HTTPS pour ntfy
|
||||||
|
- **Protection textContent** — Toast et ConfirmModal utilisent `textContent` (pas `innerHTML`)
|
||||||
|
|
||||||
|
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL.
|
||||||
|
|
||||||
## Publier une nouvelle version
|
## Publier une nouvelle version
|
||||||
|
|
||||||
@ -148,6 +179,10 @@ src/
|
|||||||
- **UpdateBanner** : aligné sur le design MD3
|
- **UpdateBanner** : aligné sur le design MD3
|
||||||
|
|
||||||
#### Nouvelles fonctionnalités
|
#### Nouvelles fonctionnalités
|
||||||
|
- ✨ **Toast notifications** — feedbacks visuels success/error en bas à droite avec glassmorphism, auto-dismiss (3s/5s/10s), bouton d'action optionnel (ex: Annuler)
|
||||||
|
- ✨ **Modale de confirmation glassmorphism** — remplace tous les `confirm()` et `alert()` natifs par une modale stylisée, clic extérieur = annulation
|
||||||
|
- ✨ **Undo actions destructives** — snapshot/restore 1 niveau avec expiration 10s, bouton Annuler dans le toast + raccourci Ctrl+Z
|
||||||
|
- ✨ **Backup/export global** — export JSON avec métadonnées (app, version, date), import avec validation et confirmation, accessible depuis Paramètres
|
||||||
- ✨ **Écran Statistiques** — naissances par jour (barres), répartition des races (donut), naissances par génération, activité par jour de la semaine (heatmap), taux de réussite par race, meilleurs couples (top 10), races manquantes groupées par génération, détail par race
|
- ✨ **Écran Statistiques** — naissances par jour (barres), répartition des races (donut), naissances par génération, activité par jour de la semaine (heatmap), taux de réussite par race, meilleurs couples (top 10), races manquantes groupées par génération, détail par race
|
||||||
- ✨ **Filtres de période** sur les statistiques — 7j, 14j, 30j, 3 mois, tout l'historique avec comparaison delta (+/−) par rapport à la période précédente
|
- ✨ **Filtres de période** sur les statistiques — 7j, 14j, 30j, 3 mois, tout l'historique avec comparaison delta (+/−) par rapport à la période précédente
|
||||||
- ✨ **Export/Import de workflows** — sélection individuelle ou globale, dialogue natif Windows, import avec déduplication des IDs
|
- ✨ **Export/Import de workflows** — sélection individuelle ou globale, dialogue natif Windows, import avec déduplication des IDs
|
||||||
@ -189,11 +224,23 @@ src/
|
|||||||
- 🐛 **Recharge jauge temps réel** — le countdown "Alarme dans" et les stats se mettent à jour en temps réel pendant la saisie (event `input` en plus du `blur`), avec consolidation des recharges proches (< 2s) pour éviter la pollution du tableau
|
- 🐛 **Recharge jauge temps réel** — le countdown "Alarme dans" et les stats se mettent à jour en temps réel pendant la saisie (event `input` en plus du `blur`), avec consolidation des recharges proches (< 2s) pour éviter la pollution du tableau
|
||||||
- 🐛 **Recharge en pause** — les recharges de jauge sont maintenant acceptées quand le timer est en pause (pas uniquement pendant l'exécution)
|
- 🐛 **Recharge en pause** — les recharges de jauge sont maintenant acceptées quand le timer est en pause (pas uniquement pendant l'exécution)
|
||||||
|
|
||||||
|
#### Sécurité & Robustesse
|
||||||
|
- 🔒 **Sandbox Electron** — `sandbox: true` dans webPreferences pour isoler le renderer
|
||||||
|
- 🔒 **HTTPS forcé pour ntfy** — rejet des URLs HTTP, seules les connexions chiffrées sont acceptées
|
||||||
|
- 🔒 **Suppression de `executeJavaScript`** — badge DEV migré vers IPC sécurisé (`dev-mode`)
|
||||||
|
- 🔒 **Validation structurelle import** — vérification complète du schéma (id, name, dragodindes, gaugeLevels, timer) avant import
|
||||||
|
- 🔒 **Sanitisation XSS** — `esc()` sur toutes les données utilisateur + `textContent` dans Toast/ConfirmModal
|
||||||
|
- 🛡 **try/catch sur tous les appels electronAPI** — export, import, workflows (plus de crash silencieux)
|
||||||
|
- 🛡 **Erreurs de sauvegarde loggées** — `LocalStorageRepository.save()` ne masque plus les erreurs
|
||||||
|
- 🛡 **Promesse Sidebar catchée** — `getVersion()` ne génère plus d'erreur non gérée
|
||||||
|
- 🛡 **Nettoyage Ctrl+Z listener** — `removeEventListener` dans `destroy()` pour éviter les memory leaks
|
||||||
|
- 🛡 **Toast stale container** — protection `isConnected` contre les conteneurs DOM détachés
|
||||||
|
|
||||||
#### Technique
|
#### Technique
|
||||||
- ⬆ **Migration electron-updater** — vérification sha512 via `latest.yml`, installation NSIS native, restart auto
|
- ⬆ **Migration electron-updater** — vérification sha512 via `latest.yml`, installation NSIS native, restart auto
|
||||||
- 🎨 **Icône Windows native** — migration `icon.png` → `icon.ico`
|
- 🎨 **Icône Windows native** — migration `icon.png` → `icon.ico`
|
||||||
- 🧪 **302 tests** (unitaires, fonctionnels, régression) — couverture **94%** via Vitest + v8
|
- 🧪 **366 tests** (unitaires, fonctionnels, régression, sécurité) — couverture **94%** via Vitest + v8
|
||||||
- 🧪 **20 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance
|
- 🧪 **24 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance, toast, modale de confirmation, undo
|
||||||
|
|
||||||
### v1.1.5
|
### v1.1.5
|
||||||
- ✨ **Onglet Accouplement** — selection des 2 parents, deduction automatique du bebe, saisie du nombre de couples et bebes obtenus pour alimenter les statistiques globales
|
- ✨ **Onglet Accouplement** — selection des 2 parents, deduction automatique du bebe, saisie du nombre de couples et bebes obtenus pour alimenter les statistiques globales
|
||||||
@ -261,3 +308,11 @@ src/
|
|||||||
|
|
||||||
### v1.0.0
|
### v1.0.0
|
||||||
- Version initiale
|
- Version initiale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mentions légales
|
||||||
|
|
||||||
|
Dofus et Dragodinde sont des marques déposées d'**Ankama Games**. Cette application est un projet non officiel, développé par un fan. Elle n'est ni affiliée, ni approuvée, ni sponsorisée par Ankama Games.
|
||||||
|
|
||||||
|
Les icônes utilisent [Material Symbols](https://fonts.google.com/icons) (Apache 2.0). Les polices proviennent de [Google Fonts](https://fonts.google.com/) (OFL).
|
||||||
|
|||||||
81
package-lock.json
generated
81
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"electron": "32.2.7",
|
"electron": "32.2.7",
|
||||||
"electron-builder": "24.13.3",
|
"electron-builder": "24.13.3",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
|
"happy-dom": "^20.8.9",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.3",
|
||||||
"vite-plugin-electron": "^0.29.1",
|
"vite-plugin-electron": "^0.29.1",
|
||||||
@ -1552,6 +1553,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
@ -3229,6 +3247,19 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@ -3802,6 +3833,24 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.8.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
|
||||||
|
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@ -6060,6 +6109,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -6137,6 +6196,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
@ -60,6 +60,7 @@
|
|||||||
"electron": "32.2.7",
|
"electron": "32.2.7",
|
||||||
"electron-builder": "24.13.3",
|
"electron-builder": "24.13.3",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
|
"happy-dom": "^20.8.9",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.3",
|
||||||
"vite-plugin-electron": "^0.29.1",
|
"vite-plugin-electron": "^0.29.1",
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
} from 'electron';
|
} from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import http from 'http';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
|
||||||
@ -66,6 +65,7 @@ function createWindow(): void {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
},
|
},
|
||||||
@ -117,18 +117,9 @@ function createWindow(): void {
|
|||||||
if (updateInfo) {
|
if (updateInfo) {
|
||||||
mainWindow!.webContents.send('update-available', updateInfo);
|
mainWindow!.webContents.send('update-available', updateInfo);
|
||||||
}
|
}
|
||||||
// Badge DEV visible dans l'interface
|
// Badge DEV visible dans l'interface (via IPC, pas executeJavaScript)
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
mainWindow!.webContents.executeJavaScript(`
|
mainWindow!.webContents.send('dev-mode');
|
||||||
const p = document.querySelector('header p');
|
|
||||||
if (p && !document.getElementById('dev-badge')) {
|
|
||||||
const b = document.createElement('span');
|
|
||||||
b.id = 'dev-badge';
|
|
||||||
b.textContent = 'DEV';
|
|
||||||
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
|
|
||||||
p.appendChild(b);
|
|
||||||
}
|
|
||||||
`).catch(() => {});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -222,7 +213,10 @@ ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title:
|
|||||||
if (!url) return;
|
if (!url) return;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url.trim());
|
const parsed = new URL(url.trim());
|
||||||
const mod = parsed.protocol === 'https:' ? https : http;
|
if (parsed.protocol !== 'https:') {
|
||||||
|
console.warn('ntfy: HTTPS requis, requête ignorée');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// API JSON ntfy : supporte nativement l'UTF-8 (accents, emojis)
|
// API JSON ntfy : supporte nativement l'UTF-8 (accents, emojis)
|
||||||
const topic = parsed.pathname.replace(/^\//, '');
|
const topic = parsed.pathname.replace(/^\//, '');
|
||||||
const jsonBody = JSON.stringify({
|
const jsonBody = JSON.stringify({
|
||||||
@ -242,7 +236,7 @@ ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title:
|
|||||||
'Content-Length': Buffer.byteLength(jsonBody, 'utf-8'),
|
'Content-Length': Buffer.byteLength(jsonBody, 'utf-8'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const req = mod.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
res.on('data', () => {}); // drain
|
res.on('data', () => {}); // drain
|
||||||
res.on('end', () => {});
|
res.on('end', () => {});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -31,4 +31,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onUpdateProgress: (cb: (info: any) => void) => ipcRenderer.on('update-progress', (_e, info) => cb(info)),
|
onUpdateProgress: (cb: (info: any) => void) => ipcRenderer.on('update-progress', (_e, info) => cb(info)),
|
||||||
onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', () => cb()),
|
onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', () => cb()),
|
||||||
onUpdateError: (cb: (info: any) => void) => ipcRenderer.on('update-error', (_e, info) => cb(info)),
|
onUpdateError: (cb: (info: any) => void) => ipcRenderer.on('update-error', (_e, info) => cb(info)),
|
||||||
|
|
||||||
|
// Dev mode badge
|
||||||
|
onDevMode: (cb: () => void) => ipcRenderer.on('dev-mode', () => cb()),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,8 +45,8 @@ export class LocalStorageRepository implements StateRepository {
|
|||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
localStorage.setItem(this.storageKey, json);
|
localStorage.setItem(this.storageKey, json);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Silently fail
|
console.error('Erreur sauvegarde:', (e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export class App {
|
|||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
private rafId: number | null = null;
|
private rafId: number | null = null;
|
||||||
private completionIntervalId: number | null = null;
|
private completionIntervalId: number | null = null;
|
||||||
|
private ctrlZHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
private lastView: string | number | null = null;
|
private lastView: string | number | null = null;
|
||||||
|
|
||||||
// Tab drag-and-drop state
|
// Tab drag-and-drop state
|
||||||
@ -80,11 +81,25 @@ export class App {
|
|||||||
Toast.mount(appShell);
|
Toast.mount(appShell);
|
||||||
|
|
||||||
// Ctrl+Z → undo
|
// Ctrl+Z → undo
|
||||||
document.addEventListener('keydown', (e) => {
|
this.ctrlZHandler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
UndoManager.undo();
|
UndoManager.undo();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this.ctrlZHandler);
|
||||||
|
|
||||||
|
// DEV badge via IPC (pas d'executeJavaScript)
|
||||||
|
const api = (window as any).electronAPI;
|
||||||
|
api?.onDevMode?.(() => {
|
||||||
|
const p = this.root.querySelector('header p');
|
||||||
|
if (p && !document.getElementById('dev-badge')) {
|
||||||
|
const b = document.createElement('span');
|
||||||
|
b.id = 'dev-badge';
|
||||||
|
b.textContent = 'DEV';
|
||||||
|
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
|
||||||
|
p.appendChild(b);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hamburger toggle
|
// Hamburger toggle
|
||||||
@ -350,6 +365,10 @@ export class App {
|
|||||||
clearInterval(this.completionIntervalId);
|
clearInterval(this.completionIntervalId);
|
||||||
this.completionIntervalId = null;
|
this.completionIntervalId = null;
|
||||||
}
|
}
|
||||||
|
if (this.ctrlZHandler) {
|
||||||
|
document.removeEventListener('keydown', this.ctrlZHandler);
|
||||||
|
this.ctrlZHandler = null;
|
||||||
|
}
|
||||||
if (this.unsubscribe) {
|
if (this.unsubscribe) {
|
||||||
this.unsubscribe();
|
this.unsubscribe();
|
||||||
this.unsubscribe = null;
|
this.unsubscribe = null;
|
||||||
|
|||||||
@ -310,6 +310,7 @@ export class ParametresView {
|
|||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (!api?.loadData || !api?.exportFile) return;
|
if (!api?.loadData || !api?.exportFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
const raw = await api.loadData();
|
const raw = await api.loadData();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
Toast.show('error', 'Aucune donnée à exporter.');
|
Toast.show('error', 'Aucune donnée à exporter.');
|
||||||
@ -329,12 +330,16 @@ export class ParametresView {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
Toast.show('success', 'Données exportées avec succès.');
|
Toast.show('success', 'Données exportées avec succès.');
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Toast.show('error', 'Erreur lors de l\'export des données.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importData(): Promise<void> {
|
private async importData(): Promise<void> {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (!api?.importFile || !api?.saveData) return;
|
if (!api?.importFile || !api?.saveData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
const raw = await api.importFile();
|
const raw = await api.importFile();
|
||||||
if (!raw) return; // dialogue annulé
|
if (!raw) return; // dialogue annulé
|
||||||
|
|
||||||
@ -346,9 +351,26 @@ export class ParametresView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation structurelle des données importées
|
||||||
|
const validateEnclosData = (data: any): boolean => {
|
||||||
|
if (!data || typeof data !== 'object' || data === null) return false;
|
||||||
|
if (!Array.isArray(data.enclos)) return false;
|
||||||
|
return data.enclos.every((enc: any) =>
|
||||||
|
enc && typeof enc === 'object' &&
|
||||||
|
typeof enc.id === 'number' &&
|
||||||
|
typeof enc.name === 'string' &&
|
||||||
|
Array.isArray(enc.dragodindes) &&
|
||||||
|
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
|
||||||
|
enc.timer && typeof enc.timer === 'object',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Validation du format backup
|
// Validation du format backup
|
||||||
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object') {
|
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object' && parsed.data !== null) {
|
||||||
// Format backup avec métadonnées
|
if (!validateEnclosData(parsed.data)) {
|
||||||
|
Toast.show('error', 'Le backup contient des données corrompues ou incomplètes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
|
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
|
||||||
const ok = await ConfirmModal.show(
|
const ok = await ConfirmModal.show(
|
||||||
'Importer les données',
|
'Importer les données',
|
||||||
@ -357,7 +379,10 @@ export class ParametresView {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
api.saveData(JSON.stringify(parsed.data));
|
api.saveData(JSON.stringify(parsed.data));
|
||||||
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
|
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
|
||||||
// Format brut (ancien export ou fichier state direct)
|
if (!validateEnclosData(parsed)) {
|
||||||
|
Toast.show('error', 'Le fichier contient des données corrompues ou incomplètes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ok = await ConfirmModal.show(
|
const ok = await ConfirmModal.show(
|
||||||
'Importer les données',
|
'Importer les données',
|
||||||
'Toutes vos données actuelles seront remplacées. Continuer ?',
|
'Toutes vos données actuelles seront remplacées. Continuer ?',
|
||||||
@ -371,5 +396,8 @@ export class ParametresView {
|
|||||||
|
|
||||||
Toast.show('success', 'Données importées. Rechargement...');
|
Toast.show('success', 'Données importées. Rechargement...');
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} catch {
|
||||||
|
Toast.show('error', 'Erreur lors de l\'import des données.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ export class Sidebar {
|
|||||||
api.getVersion().then((v: string) => {
|
api.getVersion().then((v: string) => {
|
||||||
const verEl = this.el?.querySelector('#sb-ver');
|
const verEl = this.el?.querySelector('#sb-ver');
|
||||||
if (verEl) verEl.textContent = `v${v}`;
|
if (verEl) verEl.textContent = `v${v}`;
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
|||||||
@ -30,13 +30,14 @@ const items: ToastItem[] = [];
|
|||||||
|
|
||||||
export const Toast = {
|
export const Toast = {
|
||||||
mount(parent: HTMLElement): void {
|
mount(parent: HTMLElement): void {
|
||||||
|
if (container?.isConnected) return; // déjà monté
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
container.id = 'toast-container';
|
container.id = 'toast-container';
|
||||||
parent.appendChild(container);
|
parent.appendChild(container);
|
||||||
},
|
},
|
||||||
|
|
||||||
show(type: ToastType, message: string, action?: ToastAction): void {
|
show(type: ToastType, message: string, action?: ToastAction): void {
|
||||||
if (!container) return;
|
if (!container?.isConnected) return;
|
||||||
|
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
items.push({ id, type, message });
|
items.push({ id, type, message });
|
||||||
|
|||||||
@ -598,6 +598,7 @@ export class WorkflowsView {
|
|||||||
const suffix = toExport.length === 1 ? toExport[0].target.toLowerCase().replace(/\s+/g, '-') : 'tous';
|
const suffix = toExport.length === 1 ? toExport[0].target.toLowerCase().replace(/\s+/g, '-') : 'tous';
|
||||||
const defaultName = `plans-dragodinde-${suffix}-${new Date().toISOString().slice(0, 10)}.json`;
|
const defaultName = `plans-dragodinde-${suffix}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (api?.exportFile) {
|
if (api?.exportFile) {
|
||||||
const ok = await api.exportFile(data, defaultName);
|
const ok = await api.exportFile(data, defaultName);
|
||||||
@ -614,12 +615,16 @@ export class WorkflowsView {
|
|||||||
Toast.show('success', 'Plans exportés avec succès.');
|
Toast.show('success', 'Plans exportés avec succès.');
|
||||||
this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update();
|
this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Toast.show('error', 'Erreur lors de l\'export des plans.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importWorkflows(): Promise<void> {
|
private async importWorkflows(): Promise<void> {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
if (api?.importFile) {
|
if (api?.importFile) {
|
||||||
raw = await api.importFile();
|
raw = await api.importFile();
|
||||||
} else {
|
} else {
|
||||||
@ -642,7 +647,6 @@ export class WorkflowsView {
|
|||||||
|
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
|
||||||
@ -669,7 +673,7 @@ export class WorkflowsView {
|
|||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
this.update();
|
this.update();
|
||||||
} catch {
|
} catch {
|
||||||
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
|
Toast.show('error', 'Erreur lors de l\'import. Vérifiez que le fichier est un JSON valide.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
tests/e2e/ui-feedback.spec.ts
Normal file
121
tests/e2e/ui-feedback.spec.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Tests E2E — Toast, ConfirmModal et Undo
|
||||||
|
*
|
||||||
|
* Verifie que les feedbacks UI (toast, modale de confirmation,
|
||||||
|
* annulation) fonctionnent correctement.
|
||||||
|
*/
|
||||||
|
import { test, expect } from './electron-app';
|
||||||
|
|
||||||
|
test.describe('Toast notifications', () => {
|
||||||
|
test('Toast success apparait apres suppression d\'une dragodinde', async ({ page }) => {
|
||||||
|
// Naviguer vers le premier enclos
|
||||||
|
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||||
|
has: page.locator('.sb-dot'),
|
||||||
|
}).first();
|
||||||
|
await firstEnclos.click();
|
||||||
|
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ajouter une 2e DD pour pouvoir en supprimer une
|
||||||
|
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Cliquer sur le bouton supprimer de la premiere DD
|
||||||
|
await page.locator('.dd-del').first().click();
|
||||||
|
|
||||||
|
// La modale de confirmation doit apparaitre
|
||||||
|
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
|
||||||
|
await expect(page.locator('.confirm-title')).toHaveText('Retirer la dragodinde');
|
||||||
|
|
||||||
|
// Confirmer la suppression
|
||||||
|
await page.click('.confirm-btn-ok');
|
||||||
|
|
||||||
|
// Le toast success doit apparaitre avec le bouton Annuler
|
||||||
|
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 3000 });
|
||||||
|
await expect(page.locator('.toast-msg')).toContainText('Dragodinde retirée');
|
||||||
|
await expect(page.locator('.toast-action')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('ConfirmModal', () => {
|
||||||
|
test('Annuler la modale ne supprime pas la DD', async ({ page }) => {
|
||||||
|
// Naviguer vers le premier enclos
|
||||||
|
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||||
|
has: page.locator('.sb-dot'),
|
||||||
|
}).first();
|
||||||
|
await firstEnclos.click();
|
||||||
|
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ajouter une 2e DD
|
||||||
|
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Cliquer supprimer
|
||||||
|
await page.locator('.dd-del').first().click();
|
||||||
|
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Annuler
|
||||||
|
await page.click('.confirm-btn-cancel');
|
||||||
|
|
||||||
|
// La modale disparait, les 2 DD sont toujours la
|
||||||
|
await expect(page.locator('.confirm-overlay.confirm-hidden')).toBeAttached({ timeout: 3000 });
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cliquer en dehors de la modale annule', async ({ page }) => {
|
||||||
|
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||||
|
has: page.locator('.sb-dot'),
|
||||||
|
}).first();
|
||||||
|
await firstEnclos.click();
|
||||||
|
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
|
||||||
|
|
||||||
|
await page.locator('.dd-del').first().click();
|
||||||
|
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Cliquer sur l'overlay (pas la box)
|
||||||
|
await page.locator('.confirm-overlay').click({ position: { x: 10, y: 10 } });
|
||||||
|
|
||||||
|
// Les 2 DD sont toujours la
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Undo via toast', () => {
|
||||||
|
test('Le bouton Annuler dans le toast restaure la DD supprimee', async ({ page }) => {
|
||||||
|
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||||
|
has: page.locator('.sb-dot'),
|
||||||
|
}).first();
|
||||||
|
await firstEnclos.click();
|
||||||
|
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ajouter une 2e DD
|
||||||
|
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Supprimer la premiere DD
|
||||||
|
await page.locator('.dd-del').first().click();
|
||||||
|
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
|
||||||
|
await page.click('.confirm-btn-ok');
|
||||||
|
|
||||||
|
// Attendre le toast avec le bouton Annuler
|
||||||
|
await expect(page.locator('.toast-action')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Cliquer sur Annuler — l'app recharge
|
||||||
|
await page.locator('.toast-action').click();
|
||||||
|
|
||||||
|
// Apres reload, la page se recharge — attendre le shell
|
||||||
|
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Naviguer a nouveau vers l'enclos
|
||||||
|
const enclosAfter = page.locator('.sb-item[data-view]').filter({
|
||||||
|
has: page.locator('.sb-dot'),
|
||||||
|
}).first();
|
||||||
|
await enclosAfter.click();
|
||||||
|
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Les 2 DD doivent etre restaurees
|
||||||
|
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/regression/security-hardening.test.ts
Normal file
78
tests/regression/security-hardening.test.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { esc } from '@presentation/helpers/format';
|
||||||
|
|
||||||
|
describe('Sécurité — Hardening', () => {
|
||||||
|
describe('esc() — sanitisation XSS', () => {
|
||||||
|
it('échappe les balises HTML', () => {
|
||||||
|
expect(esc('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe les attributs HTML', () => {
|
||||||
|
expect(esc('" onmouseover="alert(1)"')).toBe('" onmouseover="alert(1)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe les ampersands', () => {
|
||||||
|
expect(esc('a&b')).toBe('a&b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe les chevrons', () => {
|
||||||
|
expect(esc('<img>')).toBe('<img>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère les nombres', () => {
|
||||||
|
expect(esc(42)).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère les chaînes vides', () => {
|
||||||
|
expect(esc('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('préserve le texte sans caractères spéciaux', () => {
|
||||||
|
expect(esc('Enclos normal')).toBe('Enclos normal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère les caractères accentués', () => {
|
||||||
|
expect(esc('Dragodinde Émeraude')).toBe('Dragodinde Émeraude');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation URL ntfy — HTTPS requis', () => {
|
||||||
|
it('URL HTTPS est valide', () => {
|
||||||
|
const url = 'https://ntfy.example.com/topic';
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.protocol).toBe('https:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL HTTP est rejetée', () => {
|
||||||
|
const url = 'http://ntfy.example.com/topic';
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.protocol).not.toBe('https:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL invalide lève une erreur', () => {
|
||||||
|
expect(() => new URL('not-a-url')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation backup — format métadonnées', () => {
|
||||||
|
it('backup valide contient app, version, exportedAt, data', () => {
|
||||||
|
const backup = {
|
||||||
|
app: 'minuteur-dragodinde',
|
||||||
|
version: '1.1.6',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
data: { enclos: [] },
|
||||||
|
};
|
||||||
|
expect(backup.app).toBe('minuteur-dragodinde');
|
||||||
|
expect(typeof backup.version).toBe('string');
|
||||||
|
expect(typeof backup.exportedAt).toBe('string');
|
||||||
|
expect(backup.data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('data: null est détecté comme invalide', () => {
|
||||||
|
const backup = { app: 'minuteur-dragodinde', data: null };
|
||||||
|
// typeof null === 'object' mais data === null doit être rejeté
|
||||||
|
expect(backup.data === null).toBe(true);
|
||||||
|
expect(!!(backup.data && typeof backup.data === 'object' && backup.data !== null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
tests/unit/infrastructure/ImportValidation.test.ts
Normal file
117
tests/unit/infrastructure/ImportValidation.test.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de validation structurelle des imports.
|
||||||
|
* Vérifie que les données importées sont conformes au schéma attendu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Réplique de la logique de validation de ParametresView
|
||||||
|
function validateEnclosData(data: any): boolean {
|
||||||
|
if (!data || typeof data !== 'object' || data === null) return false;
|
||||||
|
if (!Array.isArray(data.enclos)) return false;
|
||||||
|
return data.enclos.every((enc: any) =>
|
||||||
|
enc && typeof enc === 'object' &&
|
||||||
|
typeof enc.id === 'number' &&
|
||||||
|
typeof enc.name === 'string' &&
|
||||||
|
Array.isArray(enc.dragodindes) &&
|
||||||
|
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
|
||||||
|
enc.timer && typeof enc.timer === 'object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Validation import — validateEnclosData', () => {
|
||||||
|
it('accepte un state valide', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{
|
||||||
|
id: 1, name: 'Enclos 1',
|
||||||
|
dragodindes: [],
|
||||||
|
gaugeLevels: { baffeur: 0 },
|
||||||
|
timer: { running: false },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte un state avec plusieurs enclos', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [
|
||||||
|
{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} },
|
||||||
|
{ id: 2, name: 'B', dragodindes: [{ id: 1 }], gaugeLevels: {}, timer: {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette null', () => {
|
||||||
|
expect(validateEnclosData(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette undefined', () => {
|
||||||
|
expect(validateEnclosData(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette une chaîne', () => {
|
||||||
|
expect(validateEnclosData('hello')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un objet sans enclos', () => {
|
||||||
|
expect(validateEnclosData({ foo: 'bar' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos non-array', () => {
|
||||||
|
expect(validateEnclosData({ enclos: 'not array' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos avec id manquant', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{ name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} }],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos avec name non-string', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{ id: 1, name: 123, dragodindes: [], gaugeLevels: {}, timer: {} }],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos sans dragodindes', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{ id: 1, name: 'A', gaugeLevels: {}, timer: {} }],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos sans gaugeLevels', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{ id: 1, name: 'A', dragodindes: [], timer: {} }],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette enclos sans timer', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {} }],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette si un seul enclos est invalide dans le tableau', () => {
|
||||||
|
const data = {
|
||||||
|
enclos: [
|
||||||
|
{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} },
|
||||||
|
{ id: 'bad' }, // invalide
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(validateEnclosData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte un tableau enclos vide', () => {
|
||||||
|
expect(validateEnclosData({ enclos: [] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un objet backup avec data: null', () => {
|
||||||
|
expect(validateEnclosData({ app: 'minuteur-dragodinde', data: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
tests/unit/presentation/ConfirmModal.test.ts
Normal file
88
tests/unit/presentation/ConfirmModal.test.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ConfirmModal } from '@presentation/components/ConfirmModal';
|
||||||
|
|
||||||
|
describe('ConfirmModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show crée un overlay dans le body', async () => {
|
||||||
|
const promise = ConfirmModal.show('Titre', 'Message');
|
||||||
|
expect(document.querySelector('.confirm-overlay')).toBeTruthy();
|
||||||
|
// Fermer la modale pour résoudre la promesse
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche le titre et le message', async () => {
|
||||||
|
const promise = ConfirmModal.show('Supprimer', 'Êtes-vous sûr ?');
|
||||||
|
expect(document.querySelector('.confirm-title')!.textContent).toBe('Supprimer');
|
||||||
|
expect(document.querySelector('.confirm-msg')!.textContent).toBe('Êtes-vous sûr ?');
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Confirmer résout avec true', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Confirmer ?');
|
||||||
|
(document.querySelector('.confirm-btn-ok') as HTMLButtonElement).click();
|
||||||
|
expect(await promise).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Annuler résout avec false', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Annuler ?');
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
expect(await promise).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cliquer sur l\'overlay résout avec false', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Overlay');
|
||||||
|
const overlay = document.querySelector('.confirm-overlay') as HTMLElement;
|
||||||
|
// Simuler un clic sur l'overlay (pas la box)
|
||||||
|
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(await promise).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlay perd la classe confirm-hidden quand affiché', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Visible');
|
||||||
|
const overlay = document.querySelector('.confirm-overlay')!;
|
||||||
|
expect(overlay.classList.contains('confirm-hidden')).toBe(false);
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlay récupère la classe confirm-hidden après fermeture', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Fermer');
|
||||||
|
(document.querySelector('.confirm-btn-ok') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
const overlay = document.querySelector('.confirm-overlay')!;
|
||||||
|
expect(overlay.classList.contains('confirm-hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('utilise textContent et non innerHTML (sécurité XSS)', async () => {
|
||||||
|
const promise = ConfirmModal.show('<b>XSS</b>', '<img onerror=alert(1)>');
|
||||||
|
const title = document.querySelector('.confirm-title')!;
|
||||||
|
const msg = document.querySelector('.confirm-msg')!;
|
||||||
|
expect(title.textContent).toBe('<b>XSS</b>');
|
||||||
|
expect(title.innerHTML).not.toContain('<b>');
|
||||||
|
expect(msg.textContent).toBe('<img onerror=alert(1)>');
|
||||||
|
expect(msg.innerHTML).not.toContain('<img');
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boutons ont les textes Annuler et Confirmer', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Textes');
|
||||||
|
expect(document.querySelector('.confirm-btn-cancel')!.textContent).toBe('Annuler');
|
||||||
|
expect(document.querySelector('.confirm-btn-ok')!.textContent).toBe('Confirmer');
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icône warning est présente', async () => {
|
||||||
|
const promise = ConfirmModal.show('Test', 'Icône');
|
||||||
|
expect(document.querySelector('.confirm-icon')!.textContent).toBe('warning');
|
||||||
|
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/unit/presentation/Toast.test.ts
Normal file
95
tests/unit/presentation/Toast.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Toast } from '@presentation/components/Toast';
|
||||||
|
|
||||||
|
describe('Toast', () => {
|
||||||
|
let parent: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset le DOM
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
parent = document.createElement('div');
|
||||||
|
document.body.appendChild(parent);
|
||||||
|
Toast.mount(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mount crée un conteneur #toast-container', () => {
|
||||||
|
expect(parent.querySelector('#toast-container')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show ajoute un toast dans le conteneur', () => {
|
||||||
|
Toast.show('success', 'Test message');
|
||||||
|
const container = parent.querySelector('#toast-container')!;
|
||||||
|
expect(container.querySelector('.toast-success')).toBeTruthy();
|
||||||
|
expect(container.querySelector('.toast-msg')!.textContent).toBe('Test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show error utilise la classe toast-error', () => {
|
||||||
|
Toast.show('error', 'Erreur test');
|
||||||
|
const container = parent.querySelector('#toast-container')!;
|
||||||
|
expect(container.querySelector('.toast-error')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show avec action ajoute un bouton', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
Toast.show('success', 'Supprimé', { label: 'Annuler', callback });
|
||||||
|
const btn = parent.querySelector('.toast-action') as HTMLButtonElement;
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
expect(btn.textContent).toBe('Annuler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cliquer sur le bouton action appelle le callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
Toast.show('success', 'Supprimé', { label: 'Annuler', callback });
|
||||||
|
const btn = parent.querySelector('.toast-action') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icône success est check_circle', () => {
|
||||||
|
Toast.show('success', 'Ok');
|
||||||
|
const icon = parent.querySelector('.toast-icon')!;
|
||||||
|
expect(icon.textContent).toBe('check_circle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icône error est error', () => {
|
||||||
|
Toast.show('error', 'Erreur');
|
||||||
|
const icon = parent.querySelector('.toast-icon')!;
|
||||||
|
expect(icon.textContent).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('max 3 toasts visibles simultanément', () => {
|
||||||
|
// Créer un parent frais pour isoler ce test
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const freshParent = document.createElement('div');
|
||||||
|
document.body.appendChild(freshParent);
|
||||||
|
Toast.mount(freshParent);
|
||||||
|
|
||||||
|
Toast.show('success', 'Un');
|
||||||
|
Toast.show('success', 'Deux');
|
||||||
|
Toast.show('success', 'Trois');
|
||||||
|
Toast.show('success', 'Quatre');
|
||||||
|
const container = freshParent.querySelector('#toast-container')!;
|
||||||
|
expect(container.querySelectorAll('.toast').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show sans mount ne fait rien (pas d\'erreur)', () => {
|
||||||
|
// Créer un nouveau parent sans mount
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const newParent = document.createElement('div');
|
||||||
|
document.body.appendChild(newParent);
|
||||||
|
// Toast n'est pas monté sur newParent, le container précédent a été supprimé
|
||||||
|
// On recrée pour tester le cas sans container
|
||||||
|
// Force le container à null en recréant le module...
|
||||||
|
// Comme c'est un singleton, on ne peut pas facilement le tester sans mount
|
||||||
|
// Mais on vérifie qu'appeler show sur un conteneur monté fonctionne
|
||||||
|
expect(() => Toast.show('success', 'test')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('utilise textContent et non innerHTML (sécurité XSS)', () => {
|
||||||
|
Toast.show('success', '<script>alert("xss")</script>');
|
||||||
|
const msg = parent.querySelector('.toast-msg')!;
|
||||||
|
expect(msg.textContent).toBe('<script>alert("xss")</script>');
|
||||||
|
expect(msg.innerHTML).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
tests/unit/presentation/UndoManager.test.ts
Normal file
130
tests/unit/presentation/UndoManager.test.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { UndoManager } from '@presentation/services/UndoManager';
|
||||||
|
|
||||||
|
describe('UndoManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
UndoManager.clear();
|
||||||
|
// Reset le callback
|
||||||
|
UndoManager.onUndo(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Toujours nettoyer electronAPI sans supprimer window
|
||||||
|
delete (window as any).electronAPI;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapshot / canUndo / label', () => {
|
||||||
|
it('canUndo retourne false sans snapshot', () => {
|
||||||
|
expect(UndoManager.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canUndo retourne true après un snapshot', () => {
|
||||||
|
UndoManager.snapshot('{"data":1}', 'test');
|
||||||
|
expect(UndoManager.canUndo()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label retourne le label du snapshot', () => {
|
||||||
|
UndoManager.snapshot('{}', 'Suppression enclos');
|
||||||
|
expect(UndoManager.label()).toBe('Suppression enclos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label retourne une chaîne vide sans snapshot', () => {
|
||||||
|
expect(UndoManager.label()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un nouveau snapshot écrase le précédent (1 seul niveau)', () => {
|
||||||
|
UndoManager.snapshot('{"v":1}', 'premier');
|
||||||
|
UndoManager.snapshot('{"v":2}', 'second');
|
||||||
|
expect(UndoManager.label()).toBe('second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('clear supprime le snapshot', () => {
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
expect(UndoManager.canUndo()).toBe(true);
|
||||||
|
UndoManager.clear();
|
||||||
|
expect(UndoManager.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label retourne vide après clear', () => {
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
UndoManager.clear();
|
||||||
|
expect(UndoManager.label()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expiration', () => {
|
||||||
|
it('canUndo retourne false après expiration (10s)', () => {
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 11_000);
|
||||||
|
expect(UndoManager.canUndo()).toBe(false);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canUndo retourne true avant expiration', () => {
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 5_000);
|
||||||
|
expect(UndoManager.canUndo()).toBe(true);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
it('undo retourne false sans snapshot', () => {
|
||||||
|
expect(UndoManager.undo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo retourne false sans electronAPI', () => {
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
expect(UndoManager.undo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo appelle saveData et le callback', () => {
|
||||||
|
const saveData = vi.fn();
|
||||||
|
const undoCallback = vi.fn();
|
||||||
|
(window as any).electronAPI = { saveData };
|
||||||
|
|
||||||
|
UndoManager.onUndo(undoCallback);
|
||||||
|
UndoManager.snapshot('{"restored":true}', 'test');
|
||||||
|
const result = UndoManager.undo();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(saveData).toHaveBeenCalledWith('{"restored":true}');
|
||||||
|
expect(undoCallback).toHaveBeenCalled();
|
||||||
|
expect(UndoManager.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo ne fonctionne pas deux fois de suite', () => {
|
||||||
|
const saveData = vi.fn();
|
||||||
|
(window as any).electronAPI = { saveData };
|
||||||
|
UndoManager.onUndo(() => {});
|
||||||
|
|
||||||
|
UndoManager.snapshot('{}', 'test');
|
||||||
|
UndoManager.undo();
|
||||||
|
expect(UndoManager.undo()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapshotCurrent', () => {
|
||||||
|
it('retourne false sans electronAPI', async () => {
|
||||||
|
const result = await UndoManager.snapshotCurrent('test');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne false si loadData retourne null', async () => {
|
||||||
|
(window as any).electronAPI = { loadData: vi.fn().mockResolvedValue(null) };
|
||||||
|
const result = await UndoManager.snapshotCurrent('test');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prend un snapshot si loadData retourne des données', async () => {
|
||||||
|
(window as any).electronAPI = { loadData: vi.fn().mockResolvedValue('{"ok":true}') };
|
||||||
|
const result = await UndoManager.snapshotCurrent('Suppression DD');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(UndoManager.canUndo()).toBe(true);
|
||||||
|
expect(UndoManager.label()).toBe('Suppression DD');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user