package ajean import ( "os" "path/filepath" "strings" "testing" ) // fakeLayout monte un faux /etc : dossier de donnees installe, /etc/default et // une unite systemd, exactement l'agencement d'une machine ou `jean install` est // passe. Les commandes systemctl sont remplacees par des mouchards. type fakeLayout struct { plan layoutPlan root string stopped []string started []string active map[string]bool backedUp bool } func newFakeLayout(t *testing.T) *fakeLayout { t.Helper() root := t.TempDir() f := &fakeLayout{ root: root, active: map[string]bool{"jean": true, "jean-link": true}, } old := filepath.Join(root, "etc", "jean") for _, d := range []string{ filepath.Join(old, "configs"), filepath.Join(old, "MEMORY"), filepath.Join(old, "backends", "llama.cpp", "build"), filepath.Join(root, "etc", "default"), filepath.Join(root, "etc", "systemd", "system"), } { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } write := func(p, body string) { if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } // Un BIN externe (donc a NE PAS toucher) et un jinja interne (a suivre). write(filepath.Join(old, "config.env"), "BIN=\"/home/n/llama.cpp/llama-server\"\nEXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n") write(filepath.Join(old, "fix.jinja"), "template") write(filepath.Join(old, "configs", "GROS MODELE (2).env"), "EXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n") write(filepath.Join(old, "MEMORY", "note.md"), "voir "+old+"/dataset.jsonl\n") write(filepath.Join(old, ".e2e_key"), "cle-precieuse\n") // Un faux modele et un gros fichier de build : ni l'un ni l'autre ne doit // etre reecrit ni meme lu. write(filepath.Join(old, "modele.gguf"), "binaire") write(filepath.Join(old, "backends", "llama.cpp", "build", "cache.txt"), old+"/quelque-chose\n") write(filepath.Join(root, "etc", "default", "jean"), "# Generated by jean install\nJEAN_HOME="+old+"\n") write(filepath.Join(root, "etc", "systemd", "system", "jean.service"), "[Service]\nWorkingDirectory="+old+"\nExecStart=/usr/local/bin/jean serve\n") write(filepath.Join(root, "etc", "systemd", "system", "jean-link.service"), "[Service]\nExecStart=/usr/local/bin/jean link serve\n") f.plan = layoutPlan{ Old: old, New: filepath.Join(root, "etc", "ajean"), DefaultDir: filepath.Join(root, "etc", "default"), UnitDir: filepath.Join(root, "etc", "systemd", "system"), Units: []string{"jean", "jean-link"}, BackupDir: filepath.Join(root, "sauvegardes"), stopUnit: func(u string) error { f.stopped = append(f.stopped, u); f.active[u] = false; return nil }, startUnit: func(u string) error { f.started = append(f.started, u); f.active[u] = true; return nil }, unitActive: func(u string) bool { return f.active[u] }, reloadUnits: func() error { return nil }, isRoot: func() bool { return true }, // tar refuse un chemin contenant « C: » sous Windows (il y voit un hote // distant) : on remplace la sauvegarde par un mouchard, elle est verifiee // pour ce qu'elle est — un prealable bloquant — dans son propre test. backup: func(layoutPlan) error { f.backedUp = true; return nil }, } return f } func (f *fakeLayout) read(t *testing.T, rel string) string { t.Helper() b, err := os.ReadFile(filepath.Join(f.plan.New, rel)) if err != nil { t.Fatalf("%s illisible: %v", rel, err) } return string(b) } // Cas nominal complet : donnees deplacees, chemins reecrits partout, /etc/default // et unite corriges, services arretes puis relances. func TestMigrateLayoutNominal(t *testing.T) { f := newFakeLayout(t) if err := migrateLayout(f.plan); err != nil { t.Fatalf("migration echouee: %v", err) } if isDir(f.plan.Old) { t.Error("l'ancien dossier existe encore") } if !isDir(f.plan.New) { t.Fatal("le nouveau dossier est absent") } // Le jinja interne suit, le BIN externe ne bouge pas. conf := f.read(t, "config.env") if !strings.Contains(conf, f.plan.New+"/fix.jinja") { t.Errorf("le chat-template-file n'a pas suivi:\n%s", conf) } if !strings.Contains(conf, "/home/n/llama.cpp/llama-server") { t.Errorf("le BIN externe a ete modifie a tort:\n%s", conf) } // Preset avec espaces/parentheses, et note de MEMORY. if strings.Contains(f.read(t, filepath.Join("configs", "GROS MODELE (2).env")), f.plan.Old) { t.Error("preset non reecrit") } if strings.Contains(f.read(t, filepath.Join("MEMORY", "note.md")), f.plan.Old) { t.Error("note MEMORY non reecrite") } if f.read(t, ".e2e_key") != "cle-precieuse\n" { t.Error("la cle E2E a ete alteree") } // backends/ doit etre saute : le reecrire ferait perdre des heures pour rien. if !strings.Contains(f.read(t, filepath.Join("backends", "llama.cpp", "build", "cache.txt")), f.plan.Old) { t.Error("backends/ a ete parcouru alors qu'il doit etre saute") } // /etc/default : nouveau fichier ET ancien repointe (des scripts le sourcent). newDef, err := os.ReadFile(filepath.Join(f.plan.DefaultDir, "ajean")) if err != nil { t.Fatal(err) } if !strings.Contains(string(newDef), "AJEAN_HOME="+f.plan.New) || !strings.Contains(string(newDef), "JEAN_HOME="+f.plan.New) { t.Errorf("/etc/default/ajean incomplet:\n%s", newDef) } oldDef, _ := os.ReadFile(filepath.Join(f.plan.DefaultDir, "jean")) if strings.Contains(string(oldDef), f.plan.Old) { t.Errorf("/etc/default/jean pointe encore sur l'ancien chemin:\n%s", oldDef) } // Unite corrigee, copie de securite conservee, nom inchange. unit, _ := os.ReadFile(filepath.Join(f.plan.UnitDir, "jean.service")) if strings.Contains(string(unit), f.plan.Old) { t.Errorf("unite non corrigee:\n%s", unit) } if _, err := os.Stat(filepath.Join(f.plan.UnitDir, "jean.service.avant-ajean")); err != nil { t.Error("copie de securite de l'unite absente") } if !f.backedUp { t.Error("aucune sauvegarde avant migration") } if len(f.stopped) != 2 || len(f.started) != 2 { t.Errorf("services: arretes=%v relances=%v", f.stopped, f.started) } } // Chemin choisi explicitement par l'utilisateur : on ne migre RIEN. func TestMigrateLayoutRespectsUserChoice(t *testing.T) { f := newFakeLayout(t) custom := filepath.Join(f.root, "mnt", "gros-disque", "jean") if err := os.WriteFile(filepath.Join(f.plan.DefaultDir, "jean"), []byte("JEAN_HOME="+custom+"\n"), 0o644); err != nil { t.Fatal(err) } if ok, why := f.plan.layoutNeedsMigration(); ok { t.Fatal("une migration a ete jugee legitime malgre un chemin impose") } else if !strings.Contains(why, custom) { t.Errorf("raison peu claire: %q", why) } if err := migrateLayout(f.plan); err != nil { t.Fatal(err) } if !isDir(f.plan.Old) || isDir(f.plan.New) { t.Error("le dossier a bouge alors que l'utilisateur avait impose un chemin") } if len(f.stopped) != 0 { t.Error("des services ont ete arretes pour rien") } } // Sans les droits root, on n'entreprend rien et on ne casse rien. func TestMigrateLayoutNonRoot(t *testing.T) { f := newFakeLayout(t) f.plan.isRoot = func() bool { return false } if err := migrateLayout(f.plan); err != nil { t.Fatal(err) } if !isDir(f.plan.Old) || isDir(f.plan.New) { t.Error("migration effectuee sans les droits root") } } // Les deux dossiers presents : situation ambigue, on s'abstient. func TestMigrateLayoutBothPresent(t *testing.T) { f := newFakeLayout(t) if err := os.MkdirAll(f.plan.New, 0o755); err != nil { t.Fatal(err) } if ok, why := f.plan.layoutNeedsMigration(); ok || why == "" { t.Fatalf("attendu un refus explicite, got ok=%v why=%q", ok, why) } } // Deja migre (plus d'ancien dossier) : silencieux, aucun message inquietant. func TestMigrateLayoutAlreadyDone(t *testing.T) { f := newFakeLayout(t) if err := os.Rename(f.plan.Old, f.plan.New); err != nil { t.Fatal(err) } ok, why := f.plan.layoutNeedsMigration() if ok || why != "" { t.Fatalf("attendu un silence, got ok=%v why=%q", ok, why) } } // La variante « apres mise a jour » ne doit toucher a AUCUN service : on tourne // dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration. func TestUpdateLayoutPlanNeverStopsServices(t *testing.T) { f := newFakeLayout(t) p := f.plan p.unitActive = updateLayoutPlan().unitActive // seule difference testee if err := migrateLayout(p); err != nil { t.Fatalf("migration echouee: %v", err) } if len(f.stopped) != 0 || len(f.started) != 0 { t.Fatalf("services touches: arretes=%v relances=%v", f.stopped, f.started) } // La migration doit rester complete malgre tout. if isDir(p.Old) || !isDir(p.New) { t.Fatal("le dossier n'a pas ete migre") } if strings.Contains(f.read(t, "config.env"), p.Old) { t.Error("chemins non reecrits") } unit, _ := os.ReadFile(filepath.Join(p.UnitDir, "jean.service")) if strings.Contains(string(unit), p.Old) { t.Error("unite non corrigee : le prochain redemarrage echouerait") } }