Files
patrickandClaude Sonnet 5 d796a159b3
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (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
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 / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (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
Integration: Such-Latenztests nur im seriellen Lauf, Gate erzwingt sie (parallele Paketlaeufe verfaelschten p95)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:28:21 +02:00

247 lines
7.6 KiB
Go

package searchapi
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"sync"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testlock"
)
const responseTimeLimit = 500 * time.Millisecond
type denyList map[string]bool
func (d denyList) CanAccessDocument(_ context.Context, _, documentID string) (bool, error) {
return !d[documentID], nil
}
func setup(t *testing.T, perms PermissionChecker) (*httptest.Server, *sql.DB) {
t.Helper()
addr := os.Getenv("TEST_MANTICORE_ADDR")
if addr == "" {
t.Skip("TEST_MANTICORE_ADDR nicht gesetzt, Manticore-Integrationstest uebersprungen")
}
testlock.Acquire(t, "manticore")
db, err := searchindex.Open(addr)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := searchindex.EnsureSchema(context.Background(), db); err != nil {
t.Fatalf("schema: %v", err)
}
mux := http.NewServeMux()
NewServer(db, perms).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, db
}
var idSeq = time.Now().UnixNano() & 0x3FFFFFFFFFFFFFFF
func index(t *testing.T, db *sql.DB, doc searchindex.Doc) searchindex.Doc {
t.Helper()
idSeq++
doc.ID = idSeq
if doc.DocumentID == "" {
doc.DocumentID = fmt.Sprintf("doc-%d", doc.ID)
}
if doc.UpdatedAtSec == 0 {
doc.UpdatedAtSec = time.Now().Unix()
}
doc.CreatedAtSec = doc.UpdatedAtSec
if err := searchindex.Upsert(context.Background(), db, doc); err != nil {
t.Fatalf("upsert: %v", err)
}
t.Cleanup(func() { _ = searchindex.Delete(context.Background(), db, doc.ID) })
return doc
}
func token(t *testing.T) string {
return fmt.Sprintf("zq%dx", time.Now().UnixNano())
}
func get(t *testing.T, url string) (int, responseJSON) {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var out responseJSON
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// AK 1 + Pruefung 1: Fundstellen im Ausschnitt, Titel vor Tags vor Volltext.
func TestSearch_RankingAndSnippets(t *testing.T) {
srv, db := setup(t, denyList{})
tok := token(t)
body := index(t, db, searchindex.Doc{Title: "Allgemein", Body: strings.Repeat("fuellwort ", 30) + tok + " steht im Text", CreatedBy: "u1"})
title := index(t, db, searchindex.Doc{Title: tok + " Vertrag", Body: "anderer inhalt", CreatedBy: "u1"})
tags := index(t, db, searchindex.Doc{Title: "Sonstiges", Body: "anderer inhalt", Tags: tok, CreatedBy: "u1"})
status, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
if status != 200 {
t.Fatalf("status = %d", status)
}
var order []string
for _, h := range out.Hits {
order = append(order, h.DocumentID)
}
want := []string{title.DocumentID, tags.DocumentID, body.DocumentID}
if strings.Join(order, ",") != strings.Join(want, ",") {
t.Fatalf("reihenfolge = %v, want %v", order, want)
}
if out.Total != 3 {
t.Errorf("total = %d, want 3", out.Total)
}
for _, h := range out.Hits {
if h.DocumentID == body.DocumentID && !strings.Contains(h.SnippetHTML, "<mark>"+tok+"</mark>") {
t.Errorf("fundstelle nicht markiert: %q", h.SnippetHTML)
}
}
}
func TestSearch_SnippetEscapesHTMLFromDocumentText(t *testing.T) {
srv, db := setup(t, denyList{})
tok := token(t)
index(t, db, searchindex.Doc{Title: "x", Body: "<script>alert(1)</script> " + tok, CreatedBy: "u1"})
_, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
if len(out.Hits) != 1 {
t.Fatalf("hits = %d", len(out.Hits))
}
if strings.Contains(out.Hits[0].SnippetHTML, "<script>") {
t.Errorf("unescaptes html im snippet: %q", out.Hits[0].SnippetHTML)
}
}
// AK 2 + Pruefung 2: Negativtest - fremde und vom Checker verweigerte Dokumente erscheinen nicht.
func TestSearch_OnlyPermittedDocumentsAppear(t *testing.T) {
tok := token(t)
denied := "doc-denied-" + tok
srv, db := setup(t, denyList{denied: true})
own := index(t, db, searchindex.Doc{Title: tok + " eigen", CreatedBy: "u1"})
index(t, db, searchindex.Doc{Title: tok + " fremd", CreatedBy: "u2"})
index(t, db, searchindex.Doc{DocumentID: denied, Title: tok + " verweigert", CreatedBy: "u1"})
_, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
if len(out.Hits) != 1 || out.Hits[0].DocumentID != own.DocumentID {
t.Fatalf("hits = %+v, want nur %s", out.Hits, own.DocumentID)
}
}
func TestSearch_PaginationAndSorting(t *testing.T) {
srv, db := setup(t, denyList{})
tok := token(t)
now := time.Now().Unix()
var ids []string
for i := 0; i < 5; i++ {
d := index(t, db, searchindex.Doc{Title: tok, CreatedBy: "u1", UpdatedAtSec: now - int64(i*100)})
ids = append(ids, d.DocumentID)
}
_, p1 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=0&q="+tok)
_, p2 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=2&q="+tok)
_, p3 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=4&q="+tok)
if p1.Total != 5 || p2.Total != 5 {
t.Errorf("total = %d/%d, want 5", p1.Total, p2.Total)
}
var got []string
for _, p := range []responseJSON{p1, p2, p3} {
for _, h := range p.Hits {
got = append(got, h.DocumentID)
}
}
if strings.Join(got, ",") != strings.Join(ids, ",") {
t.Errorf("reihenfolge ueber seiten = %v, want %v", got, ids)
}
if !sort.SliceIsSorted(p1.Hits, func(i, j int) bool { return p1.Hits[i].UpdatedAt > p1.Hits[j].UpdatedAt }) {
t.Error("seite 1 nicht nach updated absteigend")
}
}
func TestSearch_InvalidParametersReturn400(t *testing.T) {
srv, _ := setup(t, denyList{})
for _, q := range []string{
"caller=u1", "q=x", "caller=u1&q=x&sort=title", "caller=u1&q=x&limit=abc",
"caller=u1&q=x&limit=101", "caller=u1&q=x&offset=-1", "caller=u1&q=x&limit=100&offset=950",
} {
resp, err := http.Get(srv.URL + "/search?" + q)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%q: status = %d, want 400", q, resp.StatusCode)
}
}
}
// AK 3 + Pruefung 3: gleichzeitige Suchanfragen, p95 unter dem Grenzwert.
func requireSerialPerf(t *testing.T) {
t.Helper()
if os.Getenv("TEST_PERF") != "1" {
t.Skip("Latenzmessung nur im seriellen Lauf (TEST_PERF=1 go test -p 1, Gate qa02-foundation-gate.sh): parallele Paketlaeufe verfaelschen sie")
}
}
func TestSearch_ConcurrentRequestsStayUnderResponseTimeLimit(t *testing.T) {
requireSerialPerf(t)
srv, db := setup(t, denyList{})
tok := token(t)
for i := 0; i < 200; i++ {
index(t, db, searchindex.Doc{Title: fmt.Sprintf("%s dokument %d", tok, i), Body: strings.Repeat("vertrag kuendigung ", 50) + tok, CreatedBy: "u1"})
}
for i := 0; i < 20; i++ {
get(t, srv.URL+"/search?caller=u1&q="+tok)
}
const workers, perWorker = 20, 10
var mu sync.Mutex
var durations []time.Duration
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perWorker; i++ {
start := time.Now()
status, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
d := time.Since(start)
if status != 200 || len(out.Hits) == 0 {
t.Errorf("status = %d, hits = %d", status, len(out.Hits))
return
}
mu.Lock()
durations = append(durations, d)
mu.Unlock()
}
}()
}
wg.Wait()
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
if len(durations) != workers*perWorker {
t.Fatalf("erfolgreiche anfragen = %d, want %d", len(durations), workers*perWorker)
}
p95 := durations[len(durations)*95/100]
t.Logf("anfragen=%d p50=%s p95=%s max=%s", len(durations), durations[len(durations)/2], p95, durations[len(durations)-1])
if p95 > responseTimeLimit {
t.Errorf("p95 = %s, grenzwert %s", p95, responseTimeLimit)
}
}