Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.
Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.
Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).
La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.
Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.
Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
427 lines
13 KiB
Go
427 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
)
|
|
|
|
// serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de
|
|
// données n'existe pas encore : c'est l'état d'un premier lancement.
|
|
func serveurDeTest(t *testing.T) (*serveur, http.Handler) {
|
|
t.Helper()
|
|
dossier := t.TempDir()
|
|
s := &serveur{
|
|
fichierDonnees: filepath.Join(dossier, "projets.json"),
|
|
interfaceWeb: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<h1>frise</h1>")}},
|
|
}
|
|
return s, s.routes()
|
|
}
|
|
|
|
func appeler(t *testing.T, routes http.Handler, methode, cible, corps string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var lecteur *strings.Reader
|
|
if corps == "" {
|
|
lecteur = strings.NewReader("")
|
|
} else {
|
|
lecteur = strings.NewReader(corps)
|
|
}
|
|
req := httptest.NewRequest(methode, cible, lecteur)
|
|
rec := httptest.NewRecorder()
|
|
routes.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func ecrireFichier(t *testing.T, chemin, contenu string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(chemin, []byte(contenu), 0o644); err != nil {
|
|
t.Fatalf("préparation du fichier : %v", err)
|
|
}
|
|
}
|
|
|
|
func lireFichier(t *testing.T, chemin string) string {
|
|
t.Helper()
|
|
contenu, err := os.ReadFile(chemin)
|
|
if err != nil {
|
|
t.Fatalf("lecture du fichier : %v", err)
|
|
}
|
|
return string(contenu)
|
|
}
|
|
|
|
// --- lecture --------------------------------------------------------------
|
|
|
|
func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodGet, "/api/data", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("statut = %d, attendu 200", rec.Code)
|
|
}
|
|
|
|
var planning struct {
|
|
Version int `json:"version"`
|
|
Projects []any `json:"projects"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
|
|
t.Fatalf("réponse illisible : %v", err)
|
|
}
|
|
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT).
|
|
if planning.Version != 4 {
|
|
t.Errorf("version = %d, attendu 4", planning.Version)
|
|
}
|
|
if len(planning.Projects) != 0 {
|
|
t.Errorf("projects = %v, attendu vide", planning.Projects)
|
|
}
|
|
|
|
// Rien ne doit avoir été écrit : le fichier naît à la première sauvegarde.
|
|
if existe(s.fichierDonnees) {
|
|
t.Error("le fichier de données a été créé alors qu'aucune écriture n'a eu lieu")
|
|
}
|
|
}
|
|
|
|
func TestGetRenvoieLeFichierTelQuel(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
contenu := "{\n \"version\": 3,\n \"projects\": [],\n \"note\": \"éàü\"\n}\n"
|
|
ecrireFichier(t, s.fichierDonnees, contenu)
|
|
|
|
rec := appeler(t, routes, http.MethodGet, "/api/data", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("statut = %d, attendu 200", rec.Code)
|
|
}
|
|
if rec.Body.String() != contenu {
|
|
t.Errorf("corps = %q, attendu %q", rec.Body.String(), contenu)
|
|
}
|
|
}
|
|
|
|
func TestGetRefuseUnFichierCorrompu(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
ecrireFichier(t, s.fichierDonnees, "{ceci n'est pas du JSON")
|
|
|
|
rec := appeler(t, routes, http.MethodGet, "/api/data", "")
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("statut = %d, attendu 500", rec.Code)
|
|
}
|
|
// Le message doit orienter vers la réparation, pas seulement constater.
|
|
if !strings.Contains(rec.Body.String(), "backups") {
|
|
t.Errorf("le message n'oriente pas vers les sauvegardes : %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- écriture -------------------------------------------------------------
|
|
|
|
func TestPutEcritLeFichierIndente(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data",
|
|
`{"version":3,"projects":[{"id":"a","name":"Réunion"}]}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("statut = %d, attendu 200 (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
ecrit := lireFichier(t, s.fichierDonnees)
|
|
if !strings.HasSuffix(ecrit, "}\n") {
|
|
t.Errorf("le fichier ne se termine pas par une nouvelle ligne : %q", ecrit)
|
|
}
|
|
if !strings.Contains(ecrit, "\n \"version\": 3") {
|
|
t.Errorf("le fichier n'est pas indenté de deux espaces : %s", ecrit)
|
|
}
|
|
// L'ordre des clés du client est conservé : « version » avant « projects ».
|
|
if strings.Index(ecrit, "version") > strings.Index(ecrit, "projects") {
|
|
t.Errorf("l'ordre des clés n'a pas été préservé : %s", ecrit)
|
|
}
|
|
// Les accents restent littéraux, pour que le fichier se lise à l'œil.
|
|
if !strings.Contains(ecrit, "Réunion") {
|
|
t.Errorf("les accents ont été échappés : %s", ecrit)
|
|
}
|
|
}
|
|
|
|
func TestPutRefuseUnJSONInvalideSansToucherAuFichier(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
origine := "{\n \"version\": 3,\n \"projects\": []\n}\n"
|
|
ecrireFichier(t, s.fichierDonnees, origine)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data", "{cassé")
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("statut = %d, attendu 400", rec.Code)
|
|
}
|
|
if lireFichier(t, s.fichierDonnees) != origine {
|
|
t.Error("le fichier a été modifié alors que la requête était invalide")
|
|
}
|
|
if existe(s.dossierBackups()) {
|
|
t.Error("une sauvegarde a été créée pour une requête refusée")
|
|
}
|
|
}
|
|
|
|
func TestPutRefuseUnObjetSansProjects(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3}`)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("statut = %d, attendu 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPutRefuseUnTableauALaRacine(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data", `[1,2,3]`)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("statut = %d, attendu 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPutRefuseUnCorpsVide(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data", "")
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("statut = %d, attendu 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPutRefuseUnCorpsTropVolumineux(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/data", strings.NewReader(`{"projects":[]}`))
|
|
req.ContentLength = tailleMax + 1
|
|
rec := httptest.NewRecorder()
|
|
routes.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("statut = %d, attendu 413", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- sauvegardes ----------------------------------------------------------
|
|
|
|
func TestPutSauvegardeLaVersionPrecedente(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
origine := "{\n \"version\": 3,\n \"projects\": [],\n \"marqueur\": \"ancien\"\n}\n"
|
|
ecrireFichier(t, s.fichierDonnees, origine)
|
|
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("statut = %d, attendu 200", rec.Code)
|
|
}
|
|
|
|
sauvegardes, err := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
|
|
if err != nil || len(sauvegardes) != 1 {
|
|
t.Fatalf("sauvegardes = %v (err %v), attendu une seule", sauvegardes, err)
|
|
}
|
|
if lireFichier(t, sauvegardes[0]) != origine {
|
|
t.Error("la sauvegarde ne contient pas la version précédente")
|
|
}
|
|
}
|
|
|
|
func TestElaguerBackupsNeGardeQueLesPlusRecents(t *testing.T) {
|
|
dossier := t.TempDir()
|
|
// Horodatages croissants : l'ordre lexicographique doit être l'ordre
|
|
// chronologique, c'est sur quoi repose l'élagage.
|
|
for i := 0; i < backupsConserves+5; i++ {
|
|
nom := fmt.Sprintf("projets-20260801-%06d.json", i)
|
|
ecrireFichier(t, filepath.Join(dossier, nom), "{}")
|
|
}
|
|
|
|
if err := elaguerBackups(dossier, "projets"); err != nil {
|
|
t.Fatalf("élagage : %v", err)
|
|
}
|
|
|
|
restants, _ := filepath.Glob(filepath.Join(dossier, "projets-*.json"))
|
|
if len(restants) != backupsConserves {
|
|
t.Fatalf("restants = %d, attendu %d", len(restants), backupsConserves)
|
|
}
|
|
// Les cinq plus anciens doivent avoir disparu, pas d'autres.
|
|
if strings.Contains(strings.Join(restants, " "), "-000004.json") {
|
|
t.Error("un fichier ancien a survécu à l'élagage")
|
|
}
|
|
if !strings.Contains(strings.Join(restants, " "), "-000005.json") {
|
|
t.Error("un fichier qui devait être conservé a été supprimé")
|
|
}
|
|
}
|
|
|
|
// Le point de la décision 23 : une séance d'édition ne doit pas remplir le
|
|
// dossier de copies quasi identiques, sous peine d'en chasser les états
|
|
// anciens — les seuls qu'on cherche vraiment à retrouver.
|
|
func TestEcrituresRapprocheesNeProduisentQuUneSauvegarde(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
rec := appeler(t, routes, http.MethodPut, "/api/data",
|
|
fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("écriture %d : statut %d", i, rec.Code)
|
|
}
|
|
}
|
|
|
|
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
|
|
if len(sauvegardes) != 1 {
|
|
t.Errorf("sauvegardes = %d, attendu 1 pour dix écritures rapprochées", len(sauvegardes))
|
|
}
|
|
// Et c'est bien l'état d'avant la séance qui est conservé.
|
|
if lireFichier(t, sauvegardes[0]) != `{"projects":[]}` {
|
|
t.Errorf("la sauvegarde ne contient pas l'état initial : %s", lireFichier(t, sauvegardes[0]))
|
|
}
|
|
}
|
|
|
|
func TestSauvegardeDOuvertureEstToujoursPrise(t *testing.T) {
|
|
s, _ := serveurDeTest(t)
|
|
ecrireFichier(t, s.fichierDonnees, `{"projects":["avant"]}`)
|
|
|
|
// Une écriture vient d'avoir lieu : l'intervalle n'est pas écoulé.
|
|
s.dernierBackup = time.Now()
|
|
|
|
if err := s.sauvegarderVersionPrecedente(true); err != nil {
|
|
t.Fatalf("sauvegarde d'ouverture : %v", err)
|
|
}
|
|
|
|
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
|
|
if len(sauvegardes) != 1 {
|
|
t.Fatalf("sauvegardes = %d, attendu 1 — l'ouverture doit passer outre l'intervalle",
|
|
len(sauvegardes))
|
|
}
|
|
}
|
|
|
|
func TestOuvrirSansRienModifierNeDupliquePas(t *testing.T) {
|
|
s, _ := serveurDeTest(t)
|
|
ecrireFichier(t, s.fichierDonnees, `{"projects":["inchangé"]}`)
|
|
|
|
// Trois ouvertures successives sur un planning qu'on n'a pas touché.
|
|
for i := 0; i < 3; i++ {
|
|
if err := s.sauvegarderVersionPrecedente(true); err != nil {
|
|
t.Fatalf("ouverture %d : %v", i, err)
|
|
}
|
|
}
|
|
|
|
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
|
|
if len(sauvegardes) != 1 {
|
|
t.Errorf("sauvegardes = %d, attendu 1 — le contenu est identique", len(sauvegardes))
|
|
}
|
|
}
|
|
|
|
func TestSansFichierAucuneSauvegarde(t *testing.T) {
|
|
s, _ := serveurDeTest(t)
|
|
|
|
if err := s.sauvegarderVersionPrecedente(true); err != nil {
|
|
t.Fatalf("sauvegarde : %v", err)
|
|
}
|
|
|
|
if existe(s.dossierBackups()) {
|
|
t.Error("un dossier de sauvegardes a été créé alors qu'il n'y a pas de planning")
|
|
}
|
|
}
|
|
|
|
// Deux copies dans la même seconde ne doivent pas s'écraser. Le cas est devenu
|
|
// rare depuis l'espacement des sauvegardes, mais il reste atteignable — deux
|
|
// exemplaires lancés en même temps sur le même planning.
|
|
func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) {
|
|
s, _ := serveurDeTest(t)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
ecrireFichier(t, s.fichierDonnees, fmt.Sprintf(`{"projects":[],"n":%d}`, i))
|
|
if err := s.sauvegarderVersionPrecedente(true); err != nil {
|
|
t.Fatalf("sauvegarde %d : %v", i, err)
|
|
}
|
|
}
|
|
|
|
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
|
|
if len(sauvegardes) != 3 {
|
|
t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes))
|
|
}
|
|
}
|
|
|
|
func TestEcritureAtomiqueNeLaissePasDeTemporaire(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
|
|
appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`)
|
|
|
|
if existe(s.fichierDonnees + ".tmp") {
|
|
t.Error("le fichier temporaire subsiste après l'écriture")
|
|
}
|
|
}
|
|
|
|
// --- routes ---------------------------------------------------------------
|
|
|
|
func TestMethodeNonAutoriseeSurApiData(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodDelete, "/api/data", "")
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("statut = %d, attendu 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestInterfaceServieDepuisLeBinaire(t *testing.T) {
|
|
_, routes := serveurDeTest(t)
|
|
|
|
rec := appeler(t, routes, http.MethodGet, "/", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("statut = %d, attendu 200", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "frise") {
|
|
t.Errorf("index.html non servi : %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAucuneReponseNEstMiseEnCache(t *testing.T) {
|
|
s, routes := serveurDeTest(t)
|
|
ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`)
|
|
|
|
for _, cible := range []string{"/", "/api/data"} {
|
|
rec := appeler(t, routes, http.MethodGet, cible, "")
|
|
if rec.Header().Get("Cache-Control") != "no-store" {
|
|
t.Errorf("%s : Cache-Control = %q, attendu no-store",
|
|
cible, rec.Header().Get("Cache-Control"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- chemin des données ---------------------------------------------------
|
|
|
|
func TestFichierDonneesParDefautEstACoteDeLExecutable(t *testing.T) {
|
|
chemin, err := resoudreFichierDonnees("", false)
|
|
if err != nil {
|
|
t.Fatalf("résolution : %v", err)
|
|
}
|
|
|
|
executable, _ := os.Executable()
|
|
attendu := filepath.Join(filepath.Dir(executable), "projets.json")
|
|
// Le binaire de test peut être un lien ; on compare après résolution.
|
|
if resolu, err := filepath.EvalSymlinks(executable); err == nil {
|
|
attendu = filepath.Join(filepath.Dir(resolu), "projets.json")
|
|
}
|
|
if chemin != attendu {
|
|
t.Errorf("chemin = %q, attendu %q", chemin, attendu)
|
|
}
|
|
}
|
|
|
|
func TestOptionDataLEmporteSurLeDefaut(t *testing.T) {
|
|
chemin, err := resoudreFichierDonnees(filepath.Join(t.TempDir(), "ailleurs.json"), false)
|
|
if err != nil {
|
|
t.Fatalf("résolution : %v", err)
|
|
}
|
|
if filepath.Base(chemin) != "ailleurs.json" {
|
|
t.Errorf("chemin = %q, attendu un fichier ailleurs.json", chemin)
|
|
}
|
|
if !filepath.IsAbs(chemin) {
|
|
t.Errorf("chemin = %q, attendu un chemin absolu", chemin)
|
|
}
|
|
}
|