Files
nexarch/ai/internal/docchatapi/docchatapi_test.go
patrickandClaude Sonnet 5 16e88d2ee4
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
SEM-06: semantische-such-/chat-oberflaeche
Kleinstmoegliche HTTP-Bindung docchatapi fuer den bestehenden docchat-Service
ergaenzt (analog summaryapi/semanticsearchapi), da SEM-05 nur die Bibliothek
lieferte. Neue Next.js-Oberflaeche web/ai-search-chat mit Suche und Chat als
Reiter auf einer Seite, Quellenverweise inkl. Zitat-Aufloesung, Verlauf im
Elternzustand ueberdauert Reiterwechsel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 01:28:29 +00:00

176 lines
5.7 KiB
Go

package docchatapi_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
)
type stubSearcher struct {
hits []semanticsearch.Hit
err error
}
func (s stubSearcher) Search(context.Context, string, string, int, []semanticsearch.Ref) ([]semanticsearch.Hit, error) {
return s.hits, s.err
}
type stubProvider struct{ response provider.ChatResponse }
func (p *stubProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) {
return p.response, nil
}
func (p *stubProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
return provider.CompletionResponse{}, errors.New("nicht implementiert")
}
func (p *stubProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return provider.EmbeddingResponse{}, errors.New("nicht implementiert")
}
type stubLocator struct {
chunkText string
err error
}
func (l *stubLocator) GetChunk(context.Context, string, string, string, int) (string, error) {
return l.chunkText, l.err
}
func newTestServer(searcher stubSearcher, locator *stubLocator) *docchatapi.Server {
svc := docchat.New(searcher, &stubProvider{response: provider.ChatResponse{
Message: provider.Message{Role: provider.RoleAssistant, Content: "Antwort laut Beleg [1]."},
}}, "test-model", locator)
return docchatapi.NewServer(svc, "geheimes-token")
}
func TestHandleAsk_RequiresServiceToken(t *testing.T) {
mux := http.NewServeMux()
newTestServer(stubSearcher{}, &stubLocator{}).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "question": "test", "top_k": 5})
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401 ohne service-token, bekommen %d", rec.Code)
}
}
func TestHandleAsk_ValidRequest_ReturnsAnswerWithCitations(t *testing.T) {
hits := []semanticsearch.Hit{{SourceType: "dms_document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "beleg"}}
mux := http.NewServeMux()
newTestServer(stubSearcher{hits: hits}, &stubLocator{}).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{
"tenant_slug": "acme",
"question": "worum geht es?",
"top_k": 5,
"allowed_refs": []map[string]string{
{"source_type": "dms_document", "source_id": "doc-1"},
},
})
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String())
}
var resp docchat.Answer
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if len(resp.Citations) != 1 || resp.Citations[0].SourceID != "doc-1" {
t.Fatalf("erwartet eine zitat-quelle, bekommen %+v", resp.Citations)
}
}
func TestHandleAsk_NoHits_ReturnsNoEvidenceAnswer(t *testing.T) {
mux := http.NewServeMux()
newTestServer(stubSearcher{hits: nil}, &stubLocator{}).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{
"tenant_slug": "acme",
"question": "worum geht es?",
"top_k": 5,
"allowed_refs": []map[string]string{
{"source_type": "dms_document", "source_id": "doc-1"},
},
})
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var resp docchat.Answer
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if !resp.NoEvidence || resp.Text != docchat.NoEvidenceAnswer {
t.Fatalf("erwartet no-evidence-antwort, bekommen %+v", resp)
}
}
func TestHandleResolveCitation_ReturnsChunkText(t *testing.T) {
mux := http.NewServeMux()
newTestServer(stubSearcher{}, &stubLocator{chunkText: "originaler abschnitt"}).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{
"tenant_slug": "acme",
"index": 1,
"source_type": "dms_document",
"source_id": "doc-1",
"chunk_index": 0,
})
req := httptest.NewRequest(http.MethodPost, "/chat/citation", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Text string `json:"text"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if resp.Text != "originaler abschnitt" {
t.Fatalf("erwartet originalen abschnitt, bekommen %q", resp.Text)
}
}
func TestHandleResolveCitation_UnknownChunk_ReturnsNotFound(t *testing.T) {
mux := http.NewServeMux()
newTestServer(stubSearcher{}, &stubLocator{err: vectorindex.ErrChunkNotFound}).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{
"tenant_slug": "acme",
"index": 1,
"source_type": "dms_document",
"source_id": "doc-1",
"chunk_index": 9,
})
req := httptest.NewRequest(http.MethodPost, "/chat/citation", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("erwartet 404, bekommen %d", rec.Code)
}
}