L'interface enregistre après une seconde d'inactivité, et le serveur copiait le planning avant chaque écriture : une après-midi d'édition produisait des dizaines de fichiers quasi identiques. Le problème n'est pas la place occupée mais ce que la rotation en faisait — les cinquante emplacements se remplissaient en quelques heures et chassaient les états anciens, les seuls qu'on cherche à retrouver. Un filet qui ne remonte pas au-delà de la dernière demi-heure ne protège pas de la bêtise qu'on découvre le lendemain. La copie est désormais prise à l'ouverture, quand le planning est encore dans l'état d'avant la séance. Deux garde-fous : au plus une par jour si le serveur reste allumé longtemps, et rien du tout si le contenu n'a pas bougé depuis la dernière copie. Les cinquante conservées couvrent maintenant des mois. La copie et le remplacement partagent un état — la date de la dernière sauvegarde — donc un verrou les sérialise, ce qui protège au passage du cas où deux onglets enregistrent simultanément. 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 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é")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|