Glisser la barre résumé décale toutes les phases du même nombre de jours, écarts conservés : un projet engagé glisse souvent, et le faire phase par phase les déformait. Restent en place ce qui est terminé — on ne réécrit pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour ce qui est délégué ou tenu du dehors. D'où le format en version 6 : sans l'incrément, un binaire antérieur effacerait ces dates imposées à 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 6 (js/model.js, VERSION_FORMAT).
|
|
if planning.Version != 6 {
|
|
t.Errorf("version = %d, attendu 6", 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)
|
|
}
|
|
}
|