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("
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 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)
}
}