mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
La façade sert l'éditeur des pages mémoire (lecture, enregistrement, renommage, suppression, déplacement vers un autre projet, index MEMORY.md tenu par le code), le panneau des trackers, le mode mémoire du projet actif et les interrupteurs du mode agent, quand LOKI_MEM=rust (expérimental ; le Go reste le défaut). Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre (.bak, temporaire synchronisé puis renommé, relecture vérifiée), les trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et n'écrit jamais en clair quand la mémoire est verrouillée. gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF, erreurs du scanner et de type, structs anonymes) pour renvoyer au client les mêmes messages que le Go ; il servira aux lots suivants. Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go. Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ; parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de route avec le disque et la base après chacun) → tests/memory.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
548 lines
20 KiB
Go
548 lines
20 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux.
|
|
type fausseFacade struct {
|
|
mu sync.Mutex
|
|
vu []string // "METHODE chemin jeton-ok corps"
|
|
srv *httptest.Server
|
|
t *testing.T
|
|
sse string
|
|
reply string
|
|
// Réponse de l'API interne (0 : 204 sans corps).
|
|
internalStatus int
|
|
internalBody string
|
|
}
|
|
|
|
// oublierDecisionFacade : la décision « qui tient la conversation » est prise
|
|
// une fois par process ; chaque test repart de zéro.
|
|
func oublierDecisionFacade(t *testing.T) {
|
|
t.Helper()
|
|
reset := func() {
|
|
facadeDecisionMu.Lock()
|
|
facadeDecisionDone, facadeDecisionWhy = false, ""
|
|
facadeDecisionMu.Unlock()
|
|
}
|
|
reset()
|
|
t.Cleanup(reset)
|
|
}
|
|
|
|
func demarrerFacade(t *testing.T) *fausseFacade {
|
|
t.Helper()
|
|
oublierDecisionFacade(t)
|
|
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
tok := r.Header.Get(internalTokenHeader) == jetonTest
|
|
f.mu.Lock()
|
|
f.vu = append(f.vu, r.Method+" "+r.URL.Path+" "+map[bool]string{true: "jeton", false: "sans-jeton"}[tok]+" "+string(b))
|
|
f.mu.Unlock()
|
|
if r.URL.Path == "/api/chat" {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
io.WriteString(w, f.sse)
|
|
return
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/internal/facade/") {
|
|
f.mu.Lock()
|
|
st, body := f.internalStatus, f.internalBody
|
|
f.mu.Unlock()
|
|
if st == 0 {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
w.WriteHeader(st)
|
|
io.WriteString(w, body)
|
|
return
|
|
}
|
|
io.WriteString(w, f.reply)
|
|
}))
|
|
t.Cleanup(f.srv.Close)
|
|
t.Setenv(internalTokenEnv, jetonTest)
|
|
t.Setenv(facadeChatEnv, "rust")
|
|
t.Setenv(facadeAddrEnv, strings.TrimPrefix(f.srv.URL, "http://"))
|
|
return f
|
|
}
|
|
|
|
func (f *fausseFacade) recu() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]string(nil), f.vu...)
|
|
}
|
|
|
|
// Les routes du chat arrivées ici (tunnel, redistribution E2E) partent à la
|
|
// façade avec le jeton interne ; les autres restent ici ; sans la variable,
|
|
// rien ne change.
|
|
func TestFacadeRelaieLesRoutesDuChat(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":"salut"}`)))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("réponse : %q", w.Body.String())
|
|
}
|
|
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
// Mode Code : reste au Go.
|
|
w = httptest.NewRecorder()
|
|
withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("mode relayé : %q", w.Body.String())
|
|
}
|
|
// Sans LOKI_CHAT=rust : tout reste ici.
|
|
t.Setenv(facadeChatEnv, "")
|
|
w = httptest.NewRecorder()
|
|
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil))
|
|
if w.Body.String() != "go" || len(f.recu()) != 1 {
|
|
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// /api/e2e/chat suit le journal de la façade : un événement par trame, les
|
|
// battements de la façade ignorés, les nombres repartent tels quels.
|
|
func TestFacadeFluxE2E(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
f.sse = "data: {\"choices\":[{\"delta\":{\"pad\":\"··\"}}]}\n\n" +
|
|
": ping\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{\"seq\":12345678901,\"user\":\"salut\"}}]}\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{\"caught_up\":true,\"id\":\"c1\"}}]}\n\n"
|
|
w := httptest.NewRecorder()
|
|
var trames []string
|
|
out := &sseStream{w: w, flusher: w, mu: &sync.Mutex{}, frame: func(obj map[string]any) ([]byte, bool) {
|
|
b, _ := json.Marshal(obj)
|
|
trames = append(trames, string(b))
|
|
return []byte("data: " + string(b) + "\n\n"), true
|
|
}}
|
|
facadeChatStream(t.Context(), []byte(`{"from":0,"tail":20}`), out)
|
|
want := []string{`{"pad":"··"}`, `{"seq":12345678901,"user":"salut"}`, `{"caught_up":true,"id":"c1"}`}
|
|
if strings.Join(trames, "|") != strings.Join(want, "|") {
|
|
t.Fatalf("trames : %v", trames)
|
|
}
|
|
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat jeton {"from":0,"tail":20}` {
|
|
t.Fatalf("abonnement : %v", got)
|
|
}
|
|
}
|
|
|
|
// Les événements d'affichage nés ici vont au journal de la façade, pas à
|
|
// celui du Go.
|
|
func TestFacadeEvenementsEtExport(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
resetConvForTest()
|
|
critNotify([]Criterion{{ID: 1, Text: "les tests passent", Status: "pending"}})
|
|
got := f.recu()
|
|
if len(got) != 1 || !strings.HasPrefix(got[0], "POST /internal/facade/v1/delta jeton ") || !strings.Contains(got[0], "les tests passent") {
|
|
t.Fatalf("critères : %v", got)
|
|
}
|
|
conv.mu.Lock()
|
|
n := len(conv.Log)
|
|
conv.mu.Unlock()
|
|
if n != 0 {
|
|
t.Fatal("événement journalisé aussi dans la conversation du Go")
|
|
}
|
|
|
|
// Export : la discussion enregistrée, pas la conversation du Go.
|
|
id := convEnsureActive()
|
|
b, _ := json.Marshal(map[string]any{"messages": []Message{{Role: "user", Content: "fil de la façade"}}, "log": []any{}, "seq": 0, "ctx_used": 0})
|
|
if err := putStoreBytes(bkChat, convKey(id), b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _, _, err := exportBody(exportOpts{Format: "json"})
|
|
if err != nil || !strings.Contains(string(body), "fil de la façade") {
|
|
t.Fatalf("export : %v %s", err, body)
|
|
}
|
|
}
|
|
|
|
// Changer de projet quand la façade tient la conversation : c'est elle qui
|
|
// enregistre sa discussion et en ouvre une autre (la copie d'ici est périmée,
|
|
// l'enregistrer écraserait la sienne). Son refus (tour en cours) remonte tel
|
|
// quel ; un projet inconnu est refusé ici sans la déranger.
|
|
func TestFacadeChangeDeProjet(t *testing.T) {
|
|
testHome(t)
|
|
ensureDefaultProject()
|
|
p, err := createProject("Deux")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := demarrerFacade(t)
|
|
post := func(body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(body)))
|
|
return w
|
|
}
|
|
before := activeProjectSlug()
|
|
if w := post(`{"action":"switch","slug":"` + p.Slug + `"}`); w.Code != 200 {
|
|
t.Fatalf("bascule : %d %s", w.Code, w.Body.String())
|
|
}
|
|
if got := f.recu(); len(got) != 1 || got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+p.Slug+`"}` {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
// Le projet actif ne change que par la façade (/internal/v1/project/activate).
|
|
if activeProjectSlug() != before {
|
|
t.Fatalf("projet actif changé sans la façade : %s", activeProjectSlug())
|
|
}
|
|
if w := post(`{"action":"switch","slug":"inconnu"}`); w.Code != 400 || len(f.recu()) != 1 {
|
|
t.Fatalf("projet inconnu : %d, façade %v", w.Code, f.recu())
|
|
}
|
|
f.mu.Lock()
|
|
f.internalStatus, f.internalBody = 409, `{"ok":false,"error":"une génération est en cours"}`
|
|
f.mu.Unlock()
|
|
w := post(`{"action":"switch","slug":"` + p.Slug + `"}`)
|
|
if w.Code != 409 || !strings.Contains(w.Body.String(), "une génération est en cours") {
|
|
t.Fatalf("refus de la façade : %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Supprimer le projet actif : la façade le quitte d'abord (discussion
|
|
// enregistrée, une du projet restant ouverte), puis supprime ses discussions.
|
|
func TestFacadeSuppressionDeProjet(t *testing.T) {
|
|
testHome(t)
|
|
ensureDefaultProject()
|
|
p, err := createProject("Deux")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setActiveProject(p.Slug); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := demarrerFacade(t)
|
|
w := httptest.NewRecorder()
|
|
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`)))
|
|
if w.Code != 200 {
|
|
t.Fatalf("suppression : %d %s", w.Code, w.Body.String())
|
|
}
|
|
got := f.recu()
|
|
if len(got) != 2 ||
|
|
got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+defaultProjectSlug+`"}` ||
|
|
got[1] != `POST /internal/facade/v1/project/drop jeton {"slug":"`+p.Slug+`"}` {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
if projectExists(p.Slug) {
|
|
t.Fatal("projet toujours là")
|
|
}
|
|
}
|
|
|
|
// /internal/v1/project/activate : le projet actif change, s'il existe.
|
|
func TestFacadeActivationDeProjet(t *testing.T) {
|
|
testHome(t)
|
|
ensureDefaultProject()
|
|
p, err := createProject("Trois")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(internalTokenEnv, jetonTest)
|
|
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"`+p.Slug+`"}`); w.Code != 204 {
|
|
t.Fatalf("activation : %d %s", w.Code, w.Body.String())
|
|
}
|
|
if activeProjectSlug() != p.Slug {
|
|
t.Fatalf("actif : %s", activeProjectSlug())
|
|
}
|
|
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"nulle-part"}`); w.Code != 400 {
|
|
t.Fatalf("inconnu : %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Un réglage que la façade ne sait pas honorer garde la conversation ici :
|
|
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
|
|
// jusqu'au redémarrage.
|
|
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(internalTokenEnv, jetonTest)
|
|
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) {
|
|
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
|
|
}
|
|
// Posé après la décision : sans effet avant le redémarrage.
|
|
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !facadeChat() {
|
|
t.Fatal("décision changée en cours de route")
|
|
}
|
|
oublierDecisionFacade(t)
|
|
if facadeChat() {
|
|
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
|
|
}
|
|
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
|
|
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
|
|
t.Fatalf("détenteur : %s", w.Body.String())
|
|
}
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
rec := httptest.NewRecorder()
|
|
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
|
|
if rec.Body.String() != "go" || len(f.recu()) != 0 {
|
|
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
|
|
}
|
|
for k, want := range map[string]string{
|
|
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT",
|
|
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
|
|
} {
|
|
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
|
|
t.Errorf("%s : %q", k, got)
|
|
}
|
|
}
|
|
if got := facadeChatBlocker(map[string]string{}); got != "" {
|
|
t.Errorf("sans réglage : %q", got)
|
|
}
|
|
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
|
|
oublierDecisionFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
|
|
t.Fatalf("sans la variable : %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// LOKI_MODELS=rust : les routes des modèles partent à la façade (avec ou sans
|
|
// LOKI_CHAT), les autres restent ici ; sans la variable, rien ne change.
|
|
// Façade muette : erreur qui le dit.
|
|
func TestFacadeRelaieLesModeles(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
t.Setenv(facadeModelsEnv, "rust")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
|
|
routes := []string{"/api/models/download", "/api/models/download/probe",
|
|
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
|
|
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
|
|
for _, p := range routes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`)))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
if got := f.recu(); len(got) != len(routes) || got[0] != `POST /api/models/download jeton {"url":"u"}` {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
// Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi.
|
|
for _, p := range []string{"/api/chat/send", "/api/model/layers", "/api/presets"} {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("%s relayé : %q", p, w.Body.String())
|
|
}
|
|
}
|
|
// Façade injoignable : 502 qui nomme ce qui manque.
|
|
f.srv.Close()
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil))
|
|
if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "modèles indisponibles : ") {
|
|
t.Fatalf("façade muette : %d %s", w.Code, w.Body.String())
|
|
}
|
|
// Sans LOKI_MODELS=rust : tout reste ici.
|
|
t.Setenv(facadeModelsEnv, "")
|
|
w = httptest.NewRecorder()
|
|
withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les
|
|
// modèles : elle les vide à son démarrage, et un Go relancé seul
|
|
// effacerait celui qu'elle mène.
|
|
func TestFacadeTelechargementsGardeLesPart(t *testing.T) {
|
|
home := testHome(t)
|
|
dir := filepath.Join(home, "models")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
part := filepath.Join(dir, "m.gguf.part")
|
|
if err := os.WriteFile(part, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(internalTokenEnv, jetonTest)
|
|
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
|
|
t.Setenv(facadeModelsEnv, "rust")
|
|
cleanStartupPartFiles()
|
|
if _, err := os.Stat(part); err != nil {
|
|
t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err)
|
|
}
|
|
t.Setenv(facadeModelsEnv, "")
|
|
cleanStartupPartFiles()
|
|
if _, err := os.Stat(part); !os.IsNotExist(err) {
|
|
t.Fatalf(".part orphelin gardé : %v", err)
|
|
}
|
|
}
|
|
|
|
// LOKI_DICTATE=rust : les routes de la dictée partent à la façade, et arrêter
|
|
// la dictée (libération de la VRAM, optimiseur) le lui demande au lieu de
|
|
// toucher au serveur de ce process. Sans la variable, rien ne change.
|
|
func TestFacadeDictee(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
t.Setenv(facadeDictateEnv, "rust")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
for p := range facadeDictateRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
asrShutdownVite()
|
|
got := f.recu()
|
|
if len(got) != len(facadeDictateRoutes)+1 || got[len(got)-1] != "POST /internal/facade/v1/dictate/stop jeton {}" {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
t.Setenv(facadeDictateEnv, "")
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat("/api/transcribe", local)(w, httptest.NewRequest("POST", "/api/transcribe", nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
|
}
|
|
asrShutdownVite()
|
|
if n := len(f.recu()); n != len(facadeDictateRoutes)+1 {
|
|
t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n)
|
|
}
|
|
}
|
|
|
|
// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la
|
|
// variable, ils restent ici.
|
|
func TestFacadeReglages(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
t.Setenv(facadeSettingsEnv, "rust")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
for p := range facadeSettingsRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
if n := len(f.recu()); n != len(facadeSettingsRoutes) {
|
|
t.Fatalf("%d appels reçus par la façade", n)
|
|
}
|
|
t.Setenv(facadeSettingsEnv, "")
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// LOKI_FILES=rust : les routes des fichiers de la discussion partent à la
|
|
// façade ; /internal/v1/conv/workspace lui donne le dossier de la discussion
|
|
// active ; les .part d'envois ne sont plus vidés ici au démarrage.
|
|
func TestFacadeFichiers(t *testing.T) {
|
|
home := testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
t.Setenv(facadeFilesEnv, "rust")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
for p := range facadeFilesRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
prev := agentWorkspace()
|
|
workspacePath = filepath.Join(home, "ws")
|
|
t.Cleanup(func() { workspacePath = prev })
|
|
convActiveRemember("c-test")
|
|
w := httptest.NewRecorder()
|
|
handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}")))
|
|
var ws map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &ws); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ws["dir"] != filepath.Join(home, "ws", "discussions", "c-test") || ws["root"] != filepath.Join(home, "ws") || ws["id"] != "c-test" {
|
|
t.Fatalf("dossier de la discussion : %v", ws)
|
|
}
|
|
if st, err := os.Stat(ws["dir"]); err != nil || !st.IsDir() {
|
|
t.Fatalf("dossier non créé : %v", err)
|
|
}
|
|
part := filepath.Join(ws["dir"], "uploads", ".upload-1.part")
|
|
_ = os.MkdirAll(filepath.Dir(part), 0o755)
|
|
_ = os.WriteFile(part, []byte("x"), 0o644)
|
|
cleanStartupUploadParts()
|
|
if _, err := os.Stat(part); err != nil {
|
|
t.Fatalf(".part effacé alors que la façade tient les fichiers : %v", err)
|
|
}
|
|
t.Setenv(facadeFilesEnv, "")
|
|
cleanStartupUploadParts()
|
|
if _, err := os.Stat(part); !os.IsNotExist(err) {
|
|
t.Fatalf(".part gardé sans la variable : %v", err)
|
|
}
|
|
}
|
|
|
|
// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les
|
|
// envois de ce process (fin de tour, fin de tâche) lui sont délégués.
|
|
func TestFacadeNotifications(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
t.Setenv(facadePushEnv, "rust")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
for p := range facadePushRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
sendPushToAll("Loki", "Réponse prête · 3s")
|
|
got := f.recu()
|
|
if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` {
|
|
t.Fatalf("reçu par la façade : %v", got)
|
|
}
|
|
t.Setenv(facadePushEnv, "")
|
|
sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué
|
|
if n := len(f.recu()); n != len(facadePushRoutes)+1 {
|
|
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
|
|
}
|
|
}
|
|
|
|
func TestFacadeMemoire(t *testing.T) {
|
|
testHome(t)
|
|
f := demarrerFacade(t)
|
|
t.Setenv(facadeChatEnv, "")
|
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
|
// Sans la variable : le Go sert lui-même.
|
|
for p := range facadeMemRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String())
|
|
}
|
|
}
|
|
t.Setenv(facadeMemEnv, "rust")
|
|
for p := range facadeMemRoutes {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
|
if w.Body.String() != f.reply {
|
|
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
|
}
|
|
}
|
|
if n := len(f.recu()); n != len(facadeMemRoutes) {
|
|
t.Fatalf("reçu par la façade : %d appels", n)
|
|
}
|
|
// Les routes voisines (coffre, projets) restent au Go.
|
|
for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} {
|
|
w := httptest.NewRecorder()
|
|
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
|
|
if w.Body.String() != "go" {
|
|
t.Fatalf("%s remis à la façade : %q", p, w.Body.String())
|
|
}
|
|
}
|
|
}
|