package main import ( "bytes" "encoding/json" "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "kb-editor/internal/aifallback" "kb-editor/internal/staging" "kb-editor/internal/store" ) func TestBulkAllMatchingUsesJSONFilterNames(t *testing.T) { dir := t.TempDir() backup := filepath.Join(t.TempDir(), "backups") t.Setenv("BACKUP_DIR", backup) write := func(name string, auto bool) { t.Helper() b, _ := json.Marshal(map[string]any{"id": name, "title": name, "auto_reply": auto, "language": "de-DE"}) if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil { t.Fatal(err) } } write("true-one", true) write("false-one", false) s, err := store.New(dir) if err != nil { t.Fatal(err) } web, err := fs.Sub(webFS, "web") if err != nil { t.Fatal(err) } h := newApp(s, web).routes() body := []byte(`{"keys":[],"all_matching":true,"query":{"auto_reply":"false"},"patch":{"set_language":"en-US"},"dry_run":true}`) req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } var result store.BulkResult if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Targeted != 1 || result.Changed != 1 { t.Fatalf("unexpected result: %+v", result) } } func TestGoogleModeBlocksWrites(t *testing.T) { dir := t.TempDir() b, _ := json.Marshal(map[string]any{"id": "KB-1", "title": "Test", "answer": "Lösung"}) if err := os.WriteFile(filepath.Join(dir, "one.json"), b, 0o644); err != nil { t.Fatal(err) } s, err := store.New(dir) if err != nil { t.Fatal(err) } web, err := fs.Sub(webFS, "viewer") if err != nil { t.Fatal(err) } h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes() item := s.List(store.Query{Page: 1, PageSize: 10}).Items[0] req := httptest.NewRequest(http.MethodPut, "/api/items/"+item.Key, bytes.NewBufferString(`{"title":"changed"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } doc, _, err := s.Get(item.Key) if err != nil { t.Fatal(err) } if doc["title"] != "Test" { t.Fatalf("document changed in google mode: %+v", doc) } } func TestSearchEndpointReturnsRankedHits(t *testing.T) { dir := t.TempDir() write := func(name string, doc map[string]any) { t.Helper() b, _ := json.Marshal(doc) if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil { t.Fatal(err) } } write("exact", map[string]any{"id": "0x80070005", "title": "Zugriff verweigert", "text": "Berechtigungen prüfen"}) write("mention", map[string]any{"id": "KB-2", "title": "Allgemeiner Windows-Fehler", "answer": "Kann 0x80070005 enthalten"}) s, err := store.New(dir) if err != nil { t.Fatal(err) } web, err := fs.Sub(webFS, "viewer") if err != nil { t.Fatal(err) } h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes() req := httptest.NewRequest(http.MethodGet, "/api/search?q=0x80070005&page=1&page_size=20", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } var result store.SearchResult if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Total != 2 || len(result.Items) != 2 { t.Fatalf("unexpected result: %+v", result) } if result.Items[0].ID != "0x80070005" { t.Fatalf("exact ID should rank first: %+v", result.Items) } } func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T) { knowledge := t.TempDir() b, _ := json.Marshal(map[string]any{"id": "KB-KNOWN", "title": "Bekannter Fehler", "answer": "Bekannte Lösung"}) if err := os.WriteFile(filepath.Join(knowledge, "known.json"), b, 0o644); err != nil { t.Fatal(err) } s, err := store.New(knowledge) if err != nil { t.Fatal(err) } calls := 0 ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": map[string]any{"content": `{"title":"KI-Entwurf","text":"Symptom","answer":"1. Diagnose","categories":["Windows"],"keywords":["unbekannt"]}`}, "done": true, }) })) defer ollama.Close() st, err := staging.New(t.TempDir()) if err != nil { t.Fatal(err) } ai, err := aifallback.New(aifallback.Config{BaseURL: ollama.URL, Model: "test-model", Timeout: time.Second, MaxConcurrent: 1, MinScore: 0.78}, st) if err != nil { t.Fatal(err) } web, err := fs.Sub(webFS, "viewer") if err != nil { t.Fatal(err) } h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withAI(ai).routes() // Existing results must block the AI path before Ollama is called. req := httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"Bekannter Fehler"}`)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusConflict { t.Fatalf("known query status=%d body=%s", rr.Code, rr.Body.String()) } if calls != 0 { t.Fatalf("Ollama should not be called when KB has hits, calls=%d", calls) } // Unknown query is generated and stored in staging. req = httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"0xDEADBEEF völlig unbekannt"}`)) req.Header.Set("Content-Type", "application/json") rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("unknown query status=%d body=%s", rr.Code, rr.Body.String()) } var generated aifallback.Result if err := json.Unmarshal(rr.Body.Bytes(), &generated); err != nil { t.Fatal(err) } if calls != 1 || generated.Key == "" { t.Fatalf("calls=%d result=%+v", calls, generated) } get := httptest.NewRequest(http.MethodGet, "/api/staging/"+generated.Key, nil) getRR := httptest.NewRecorder() h.ServeHTTP(getRR, get) if getRR.Code != http.StatusOK { t.Fatalf("staging get status=%d body=%s", getRR.Code, getRR.Body.String()) } }