package main import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" ) // 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("

frise

")}}, } 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 3 (js/model.js, VERSION_FORMAT). if planning.Version != 3 { t.Errorf("version = %d, attendu 3", 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é") } } func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { s, routes := serveurDeTest(t) ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) for i := 0; i < 3; 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) != 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) } }