diff --git a/.gitea/workflows/ai-contract-tests.yml b/.gitea/workflows/ai-contract-tests.yml new file mode 100644 index 00000000..07f33375 --- /dev/null +++ b/.gitea/workflows/ai-contract-tests.yml @@ -0,0 +1,23 @@ +name: AI-Schnittstellen-Vertragstests + +on: + push: + paths: + - "ai/internal/contracttest/**" + - "ai/internal/moduletype/**" + pull_request: + paths: + - "ai/internal/contracttest/**" + - "ai/internal/moduletype/**" + +jobs: + contract-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Vertragstests ausfuehren + working-directory: ai + run: go test ./internal/contracttest/... -v diff --git a/ai/docs/QA-07-PRUEFPROTOKOLL.md b/ai/docs/QA-07-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b1444251 --- /dev/null +++ b/ai/docs/QA-07-PRUEFPROTOKOLL.md @@ -0,0 +1,40 @@ +# QA-07 – Prüfprotokoll + +Datum: 2026-10-17. Host: Entwicklungshost 192.168.1.204, Wegwerf-Cluster (Port 5433, eigene Rolle/DB `aiqa07test`/`aiqa07testdb`). Frischer Klon von `main`. + +## Umsetzung + +Neues Paket `ai/internal/contracttest`: Vertragstests für die Modul-Adapter-Schnittstelle GOV-01 (`ai/internal/moduletype`), über die DMS und Mail KI-fähige Objekttypen registrieren. Gleiches Muster wie Archive QA-06 (`archive/internal/contracttest`): `RequireJSONFields` dekodiert eine echte JSON-Antwort und meldet fehlende Pflichtschlüssel. + +Zwei Verträge geprüft, jeweils gegen die TATSÄCHLICHE Antwort der echten Komponente, nicht gegen eine nachgebaute Attrappe: + +- **Vertrag 1 – registrierter Objekttyp** (`moduletype.ObjectType`): echte `Registry.Register`/`Get`-Rückgabe (gegen Wegwerf-Postgres) JSON-kodiert, Pflichtfelder `TenantSlug`, `ModuleName`, `ObjectType`, `Fields` geprüft. Läuft nur mit `TEST_TENANT_DSN` (sonst Skip, wie bei Archive QA-06). +- **Vertrag 2 – Feld-Schema** (`moduletype.Field`): echte JSON-Kodierung eines `Field`-Werts geprüft auf `name`, `kind`. Läuft immer, ohne Datenbank. + +Zu jedem Vertrag ein Test, der einen simulierten Breaking Change (entferntes bzw. umbenanntes Pflichtfeld) gegen `RequireJSONFields` prüft und dessen Fehlschlagen erwartet (AK2). + +CI-Integration: `.gitea/workflows/ai-contract-tests.yml`, ausgelöst bei Änderungen unter `ai/internal/contracttest/**` oder `ai/internal/moduletype/**`, führt `go test ./internal/contracttest/... -v` automatisiert aus (AK3). + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Vertragstests decken die Modul-Adapter-Schnittstelle GOV-01 ab | **Bestanden.** `TestContract_GOV01_RegisteredObjectTypeSchema` (Objekttyp-Rückgabe) und `TestContract_GOV01_FieldSchema` (Feld-Schema) prüfen beide die echte `moduletype`-Komponente. | +| 2 | Ein absichtlich brechender Schema-Wechsel lässt die Vertragstests fehlschlagen | **Bestanden.** Siehe Prüfung 2 unten: Umbenennung von `json:"name"` auf `json:"field_name"` in `moduletype.Field` live im Code herbeigeführt, `TestContract_GOV01_FieldSchema` schlägt fehl mit `field-schema fehlen pflichtfelder: [name]`, danach zurückgesetzt. | +| 3 | Vertragstests laufen automatisiert in der CI, nicht nur manuell | **Bestanden.** `.gitea/workflows/ai-contract-tests.yml` führt die Tests bei Push/PR auf die betroffenen Pfade automatisch aus. | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Messwert | Ergebnis | +|---|---|---|---|---| +| 1 | Testlauf gegen aktuellen Stand | 0 Fehlschläge | `go test ./internal/contracttest/... -v`: 4/4 Tests PASS | **Bestanden** | +| 2 | Simulierte Breaking Change wird erkannt | Testlauf nach Rename schlägt fehl, nach Rückbau wieder grün | Rename `json:"name"` → `json:"field_name"` in `moduletype.go`: `TestContract_GOV01_FieldSchema` FAIL (`fehlen pflichtfelder: [name]`); nach `git checkout -- internal/moduletype/moduletype.go`: 4/4 Tests wieder PASS | **Bestanden** | +| 3 | CI-Integration nachgewiesen | Workflow-Datei mit passenden `paths`-Filtern vorhanden | `.gitea/workflows/ai-contract-tests.yml` vorhanden, Trigger auf `ai/internal/contracttest/**` und `ai/internal/moduletype/**`, Schritt `go test ./internal/contracttest/... -v` | **Bestanden** | + +## Testergebnis + +4/4 neue Tests grün (`go test ./internal/contracttest/... -v`), `go build ./...` und `go vet ./...` im AI-Modul sauber. Volle Modul-Regression (`go test ./...`) ohne Fehler, keine bestehenden Tests betroffen. + +## Hinweis zur Datenbankabdeckung + +Wie bei Archive QA-06 läuft Vertrag 1 (Objekttyp-Rückgabe) nur mit gesetztem `TEST_TENANT_DSN` gegen eine echte Postgres-Instanz; in der CI ohne diese Variable wird dieser Test übersprungen (`t.Skip`), nicht als Fehler gewertet. Vertrag 2 (Feld-Schema) sowie beide Breaking-Change-Tests laufen immer, auch ohne Datenbank — das ist der in der CI tatsächlich scharfe Teil der Vertragstests. Kein offener Befund, aber bewusste Einschränkung, identisch zum etablierten Muster. diff --git a/ai/internal/contracttest/contracttest.go b/ai/internal/contracttest/contracttest.go new file mode 100644 index 00000000..dd94dd9d --- /dev/null +++ b/ai/internal/contracttest/contracttest.go @@ -0,0 +1,30 @@ +// Package contracttest implementiert AI QA-07: Vertragstests für die +// Modul-Adapter-Schnittstelle GOV-01 (ai/internal/moduletype), über die DMS +// und Mail KI-fähige Objekttypen registrieren. Jeder Test prüft die +// TATSÄCHLICHE Antwort der echten moduletype.Registry gegen ihr +// dokumentiertes Schema — eine entfernte oder umbenannte Pflichteigenschaft +// lässt den jeweiligen Test fehlschlagen, BEVOR sie DMS und Mail gleichzeitig +// bricht (Akzeptanzkriterium 2). Gleiches Muster wie Archive QA-06 +// (archive/internal/contracttest). +package contracttest + +import ( + "encoding/json" + "fmt" +) + +// RequireJSONFields dekodiert data als JSON-Objekt und prüft, dass ALLE +// angegebenen Top-Level-Schlüssel vorhanden sind. Liefert die fehlenden +// Schlüssel zurück — leer bedeutet: Vertrag eingehalten. +func RequireJSONFields(data []byte, required []string) (missing []string, err error) { + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, fmt.Errorf("contracttest: antwort ist kein json-objekt: %w", err) + } + for _, field := range required { + if _, ok := decoded[field]; !ok { + missing = append(missing, field) + } + } + return missing, nil +} diff --git a/ai/internal/contracttest/contracttest_test.go b/ai/internal/contracttest/contracttest_test.go new file mode 100644 index 00000000..ebb89276 --- /dev/null +++ b/ai/internal/contracttest/contracttest_test.go @@ -0,0 +1,126 @@ +package contracttest + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" +) + +// --- Vertrag 1: GOV-01 registrierter Objekttyp (moduletype.ObjectType) --- +// Akzeptanzkriterium 1 + Prüfung 1. + +type fakeVerifier struct{} + +func (fakeVerifier) VerifyTenant(_ context.Context, _ string) (bool, error) { return true, nil } + +func setupRegistry(t *testing.T) *moduletype.Registry { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + migrationsDir, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(migrationsDir) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return &moduletype.Registry{Pool: pool, Verifier: fakeVerifier{}} +} + +// TestContract_GOV01_RegisteredObjectTypeSchema prüft das TATSÄCHLICHE, +// von der echten Registry gelieferte ObjectType gegen das dokumentierte +// Schema (Pflichtfelder, auf die DMS/Mail sich als registrierende Module +// verlassen dürfen). +func TestContract_GOV01_RegisteredObjectTypeSchema(t *testing.T) { + r := setupRegistry(t) + ctx := context.Background() + + if err := r.Register(ctx, "mandant-vertragstest", "contracttest-dms", "document", []moduletype.Field{ + {Name: "title", Kind: "text"}, + }); err != nil { + t.Fatalf("register: %v", err) + } + got, err := r.Get(ctx, "mandant-vertragstest", "contracttest-dms", "document") + if err != nil { + t.Fatalf("get: %v", err) + } + + encoded, err := json.Marshal(got) + if err != nil { + t.Fatalf("objecttype kodieren: %v", err) + } + + required := []string{"TenantSlug", "ModuleName", "ObjectType", "Fields"} + missing, err := RequireJSONFields(encoded, required) + if err != nil { + t.Fatalf("antwort nicht lesbar: %v", err) + } + if len(missing) != 0 { + t.Fatalf("objekttyp-antwort fehlen pflichtfelder: %v", missing) + } +} + +// Prüfung 2: absichtlich simulierter Breaking Change (Feld "ObjectType" +// umbenannt, z.B. fälschlich zu "ObjType") lässt den Vertragstest +// fehlschlagen. +func TestContract_GOV01_DetectsBreakingChange_RenamedField(t *testing.T) { + brokenResponse := []byte(`{"TenantSlug":"mandant-a","ModuleName":"dms","ObjType":"document","Fields":[]}`) // "ObjectType" absichtlich umbenannt + required := []string{"TenantSlug", "ModuleName", "ObjectType", "Fields"} + + missing, err := RequireJSONFields(brokenResponse, required) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "ObjectType" { + t.Fatalf("erwartet erkanntes umbenanntes feld 'ObjectType', habe: %v", missing) + } +} + +// --- Vertrag 2: GOV-01 Feld-Schema (moduletype.Field, JSON-Persistenz) --- +// Akzeptanzkriterium 1 + Prüfung 1. + +// TestContract_GOV01_FieldSchema prüft die TATSÄCHLICHE JSON-Kodierung von +// moduletype.Field — das Schema, mit dem DMS/Mail ihre Feldlisten an +// Register übergeben und über Get/ListByModule zurückerhalten. +func TestContract_GOV01_FieldSchema(t *testing.T) { + encoded, err := json.Marshal(moduletype.Field{Name: "title", Kind: "text"}) + if err != nil { + t.Fatalf("field kodieren: %v", err) + } + + required := []string{"name", "kind"} + missing, err := RequireJSONFields(encoded, required) + if err != nil { + t.Fatalf("antwort nicht lesbar: %v", err) + } + if len(missing) != 0 { + t.Fatalf("field-schema fehlen pflichtfelder: %v", missing) + } +} + +// Prüfung 2: absichtlich simulierter Breaking Change (Feld "kind" entfernt). +func TestContract_GOV01_DetectsBreakingChange_RemovedField(t *testing.T) { + brokenField := []byte(`{"name":"title"}`) // "kind" fehlt absichtlich + required := []string{"name", "kind"} + + missing, err := RequireJSONFields(brokenField, required) + if err != nil { + t.Fatalf("pruefung selbst fehlgeschlagen: %v", err) + } + if len(missing) != 1 || missing[0] != "kind" { + t.Fatalf("erwartet erkanntes fehlendes feld 'kind', habe: %v", missing) + } +}