Files
patrickandClaude Sonnet 5.5 678bd01335
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) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 24s
CI / Web (dms-browser) (push) Failing after 1m23s
CI / Web (pdf-viewer) (push) Failing after 59s
CI / Web (shl) (push) Failing after 13s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 56s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Failing after 59s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m1s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m17s
CI / Web (pdf-viewer) (pull_request) Failing after 59s
CI / Web (shl) (pull_request) Failing after 13s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 3m51s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 56s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 2m6s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m50s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m41s
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Failing after 41s
Workflow-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m18s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
workflow: testdb aus common statt interner Kopie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:38:24 +01:00

303 lines
11 KiB
Go

package routingruleapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient"
)
func findModuleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod nicht gefunden")
}
dir = parent
}
}
func newTestServer(t *testing.T) *Server {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return NewServer(routingrule.NewStore(pool), nil)
}
// fakeSuggester simuliert WFS-02 fuer RTE-05-Tests ohne echten AI-Dienst.
type fakeSuggester struct {
result routingsuggestclient.Suggestion
err error
}
func (f fakeSuggester) Suggest(_ context.Context, _ []string) (routingsuggestclient.Suggestion, error) {
return f.result, f.err
}
func doJSON(t *testing.T, s *Server, method, path string, body any, userID string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("body kodieren: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
if userID != "" {
req.Header.Set("X-User-Id", userID)
}
mux := http.NewServeMux()
s.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleList_RequiresUser(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "GET", "/routing-rules", nil, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401, bekommen %d", rec.Code)
}
}
// AC1: Regeln sind priorisierbar (SetPriority ueber die API erreichbar).
func TestHandleSetPriority_ReordersRule(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "regel-a", Priority: 1,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "approval"}},
}, "u1")
if rec.Code != http.StatusCreated {
t.Fatalf("anlegen fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var created routingrule.Rule
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
rec = doJSON(t, s, "PUT", "/routing-rules/"+created.ID+"/priority", priorityInput{Priority: 99}, "u1")
if rec.Code != http.StatusNoContent {
t.Fatalf("prioritaet setzen fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, s, "GET", "/routing-rules", nil, "u1")
var rules []routingrule.Rule
if err := json.Unmarshal(rec.Body.Bytes(), &rules); err != nil {
t.Fatalf("liste dekodieren: %v", err)
}
if len(rules) == 0 || rules[0].ID != created.ID || rules[0].Priority != 99 {
t.Fatalf("regel nicht mit neuer prioritaet an erster stelle: %+v", rules)
}
}
// AC3 / Pruefung 2: Testlauf einer Regel vor Aktivierung, ohne echte
// Ausfuehrung anzustossen (keine routing_decisions-Zeile).
func TestHandleTest_MatchesEnabledRuleByPriority(t *testing.T) {
s := newTestServer(t)
doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "niedrig", Priority: 1,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "low"}},
}, "u1")
doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "hoch", Priority: 10,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "high"}},
}, "u1")
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{Object: map[string]any{"object_type": "invoice"}}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if !result.Matched || result.RuleName != "hoch" {
t.Fatalf("erwartet match auf hoehere prioritaet, bekommen %+v", result)
}
}
func TestHandleTest_NoMatch(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{Object: map[string]any{"object_type": "unbekannt"}}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if result.Matched {
t.Fatalf("erwartet kein match, bekommen %+v", result)
}
}
// Pruefung 3: ungueltige Bedingungseingabe wird abgewiesen.
func TestHandleCreate_RejectsInvalidCondition(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "ungueltig",
Priority: 1,
Conditions: []routingrule.Condition{
{Field: "object_type", Operator: "nicht_unterstuetzt", Value: "invoice"},
},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "x"}},
}, "u1")
if rec.Code != http.StatusBadRequest {
t.Fatalf("erwartet 400 bei ungueltigem operator, bekommen %d", rec.Code)
}
}
// RTE-05 AK1/Pruefung1: bei aktiviertem AI-Modul liefert der Testlauf einen
// KI-Vorschlag zusaetzlich zur regelbasierten Entscheidung, nachvollziehbar
// begruendet.
func TestHandleTest_ReturnsAISuggestionWhenModuleEnabled(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
s := NewServer(routingrule.NewStore(pool), fakeSuggester{result: routingsuggestclient.Suggestion{
Category: "rechnung", Confidence: 0.8, SampleSize: 5, Reasoning: "5 von 6 aehnlichen faellen gingen an rechnung",
}})
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{
Object: map[string]any{"object_type": "unbekannt"},
History: []string{"rechnung", "rechnung", "mahnung", "rechnung", "rechnung", "rechnung"},
}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if result.AISuggestion == nil {
t.Fatal("erwartet ki-vorschlag, keiner erhalten")
}
if result.AISuggestion.Category != "rechnung" || result.AISuggestion.Reasoning == "" {
t.Fatalf("unerwarteter ki-vorschlag: %+v", result.AISuggestion)
}
// AK3: die regelbasierte Entscheidung (hier: kein Treffer) bleibt vom
// Vorschlag unberuehrt.
if result.Matched {
t.Fatalf("regelbasierte entscheidung haette sich durch den ki-vorschlag nicht aendern duerfen: %+v", result)
}
}
// RTE-05 AK2/Pruefung2: AI-Endpunkt nicht erreichbar -- regelbasiertes
// Routing laeuft unveraendert weiter, kein Fehler im Regelpfad.
func TestHandleTest_UnreachableAIDoesNotBlockRuleBasedRouting(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
s := NewServer(routingrule.NewStore(pool), fakeSuggester{err: context.DeadlineExceeded})
doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "hoch", Priority: 10,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "high"}},
}, "u1")
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{
Object: map[string]any{"object_type": "invoice"},
History: []string{"rechnung"},
}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf haette trotz nicht erreichbarem ai-endpunkt erfolgreich sein muessen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if !result.Matched || result.RuleName != "hoch" {
t.Fatalf("regelbasiertes routing haette trotz nicht erreichbarer ai unveraendert laufen muessen: %+v", result)
}
if result.AISuggestion != nil {
t.Fatalf("erwartet keinen ki-vorschlag bei nicht erreichbarer ai, bekommen %+v", result.AISuggestion)
}
}
// AK2: kein Suggester (AI-Modul nicht aktiviert) -> kein Vorschlag, aber
// regelbasiertes Routing unveraendert.
func TestHandleTest_NoSuggesterConfigured(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{
Object: map[string]any{"object_type": "unbekannt"},
History: []string{"rechnung"},
}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if result.AISuggestion != nil {
t.Fatalf("erwartet keinen ki-vorschlag ohne konfigurierten suggester, bekommen %+v", result.AISuggestion)
}
}
func TestHandleCreate_RejectsMissingField(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "ungueltig-2",
Priority: 1,
Conditions: []routingrule.Condition{
{Field: "", Operator: "equals", Value: "invoice"},
},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "x"}},
}, "u1")
if rec.Code != http.StatusBadRequest {
t.Fatalf("erwartet 400 bei fehlendem feld, bekommen %d", rec.Code)
}
}