Distribuer l'outil comme un binaire Go autonome

serve.py supposait Python installé et une commande tapée dans un terminal
depuis le dossier du dépôt. L'objectif ici : poser l'exécutable et son
planning dans un dossier synchronisé, et les retrouver sur n'importe quelle
machine sans rien installer.

Le binaire embarque l'interface, expose les mêmes routes sur le même format,
et cherche son projets.json à côté de lui. Compilé avec CGO_ENABLED=0, il ne
dépend d'aucune bibliothèque système, pas même de la glibc ; build.sh produit
les cinq plateformes depuis une seule machine.

Le front est inchangé : les modules ES sont embarqués et servis tels quels,
et --dev les sert depuis le disque pour développer sans recompiler.

Deux ajustements dictés par le double-clic, où aucun drapeau ne peut être
passé : le port se cherche à partir de 8000 s'il est occupé, et le fichier de
données est résolu depuis l'exécutable et non depuis le répertoire courant.

La décision 3 est rectifiée au passage : elle affirmait à tort que la File
System Access API ne fonctionne pas depuis file://. Vérification faite, c'est
CORS sur les modules ES qui ferme cette voie, pas la sécurité de l'API.

serve.py est supprimé, le binaire l'ayant remplacé en usage réel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:48:59 +02:00
parent 6b15972e3b
commit 558d42f4cb
9 changed files with 940 additions and 259 deletions

352
main_test.go Normal file
View File

@@ -0,0 +1,352 @@
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("<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é")
}
}
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)
}
}