EXT-05: Extraktions-Ergebnis-Kopplung an Modul-Metadatenfelder #236
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -19,8 +20,11 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractconfirmapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
|
||||
@@ -46,6 +50,25 @@ type config struct {
|
||||
serviceToken string
|
||||
addr string
|
||||
model string
|
||||
moduleURLs map[string]string
|
||||
}
|
||||
|
||||
// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine
|
||||
// Basis-URL fuer den Rueckschreib-Push in dessen Metadaten-API).
|
||||
func parseModuleBaseURLs(raw string) map[string]string {
|
||||
urls := map[string]string{}
|
||||
for _, pair := range strings.Split(raw, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
name, url, ok := strings.Cut(pair, "=")
|
||||
if !ok || strings.TrimSpace(name) == "" || strings.TrimSpace(url) == "" {
|
||||
continue
|
||||
}
|
||||
urls[strings.TrimSpace(name)] = strings.TrimSpace(url)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// loadConfig liest die Pflicht- und optionalen Umgebungsvariablen (AK3, AK4):
|
||||
@@ -57,6 +80,7 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
|
||||
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
|
||||
model: getenv("NEXARCH_AI_APP_MODEL"),
|
||||
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
|
||||
}
|
||||
if cfg.dsn == "" {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
|
||||
@@ -109,6 +133,16 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
|
||||
|
||||
routingsuggestapi.NewServer(cfg.serviceToken).RegisterRoutes(mux)
|
||||
|
||||
moduleClients := make(map[string]*metadatapushclient.Client, len(cfg.moduleURLs))
|
||||
for name, baseURL := range cfg.moduleURLs {
|
||||
moduleClients[name] = metadatapushclient.New(baseURL, cfg.serviceToken)
|
||||
}
|
||||
extractconfirmapi.NewServer(
|
||||
&extractmapping.Registry{Pool: pool},
|
||||
moduleClients,
|
||||
cfg.serviceToken,
|
||||
).RegisterRoutes(mux)
|
||||
|
||||
quotaStore := quota.NewStore(pool, nil)
|
||||
q := queue.NewQueue(pool, 5*time.Minute)
|
||||
q.SetQuotaEnforcer(quotaStore)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# EXT-05 – Prüfprotokoll
|
||||
|
||||
Kopplung bestätigter Extraktionsergebnisse an die Metadatenfelder des aufrufenden Moduls (DMS/Mail) über den Modul-Adapter.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `ai/internal/extractmapping`: Postgres-gestützte Registry für das Feld-Mapping (Extraktionsfeld → Modul-Feldschlüssel), je Modul/Objekttyp konfigurierbar (AK2), analog zum bestehenden `moduletype`-Muster. `Apply` ordnet bestätigte Werte zu und meldet Extraktionsfelder ohne Mapping-Eintrag statt sie zu verwerfen (AK3).
|
||||
- `ai/internal/metadatapushclient`: schlanker HTTP-Client zur Metadaten-API des aufrufenden Moduls (Muster wie `auditclient`): löst Modul-Feldschlüssel zu den internen Feld-IDs des Moduls auf (`GET /metadata-fields`) und schreibt die Werte (`PUT /documents/{id}/metadata`). Ein Feldschlüssel, den das Modul aktuell nicht registriert hat, wird gemeldet statt den gesamten Aufruf zu verwerfen (AK3).
|
||||
- `ai/internal/extractconfirmapi`: neuer, Service-Token-geschützter Endpunkt `POST /extract/confirm` (Fail-Closed wie `extractapi`), der Mapping-Anwendung und Push zusammenführt und `applied_fields`/`unmapped_fields` zurückmeldet (AK1).
|
||||
- Migration `0009_extract_field_mappings`: Tabelle `extract_field_mappings`.
|
||||
- `ai/cmd/ai-app/main.go`: neuer Endpunkt verdrahtet; Modul-Basis-URLs je Modul über `NEXARCH_AI_APP_MODULE_BASE_URLS` (Format `modul=url,modul2=url2`) konfigurierbar.
|
||||
|
||||
## Automatisierte Tests (je Akzeptanzkriterium)
|
||||
|
||||
| AK | Test | Ergebnis |
|
||||
|---|---|---|
|
||||
| AK1 (Wert landet im richtigen Metadatenfeld) | `extractmapping.TestApply_MapsKnownFieldsAndReportsUnmapped`, `metadatapushclient.TestPush_ResolvesFieldKeyToIDAndWrites`, `extractconfirmapi.TestHandleConfirm_AppliesMappedValueToModuleField` | PASS |
|
||||
| AK2 (Mapping je Modul konfigurierbar) | `extractmapping.TestRegistry_SetAndGet`, `TestRegistry_Set_ReplacesExisting` | PASS |
|
||||
| AK3 (nicht zuordenbarer Wert wird gemeldet, nicht verworfen) | `extractmapping.TestApply_EmptyMapping_ReportsAllAsUnmapped`, `metadatapushclient.TestPush_UnknownFieldKey_ReportedNotDropped`, `TestPush_AllUnknownFieldKeys_NoWriteAttempted`, `extractconfirmapi.TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped`, `TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded` | PASS |
|
||||
| Zugriffsschutz | `extractconfirmapi.TestHandleConfirm_RequiresServiceToken` | PASS |
|
||||
|
||||
Ausführung (host 204, `TEST_TENANT_DSN` aus `/etc/nexarch-dev/tenant-dev.env`):
|
||||
|
||||
```
|
||||
go test ./internal/extractmapping/... ./internal/metadatapushclient/... ./internal/extractconfirmapi/...
|
||||
ok .../extractmapping 0.503s
|
||||
ok .../metadatapushclient (cached)
|
||||
ok .../extractconfirmapi 0.616s
|
||||
```
|
||||
|
||||
Vollständiger Modul-Testlauf (`go vet ./... && go build ./... && go test ./...`) ohne Regressionen auf host 204.
|
||||
|
||||
## Prüfungen vor der Abnahme (reale Durchführung auf root@192.168.1.204)
|
||||
|
||||
Aufbau: die unveränderte, echte `dms/internal/metadataapi`-Implementierung (inkl. `metadatafield`-Validierung) wurde über einen temporären, nicht committeten Test-Server (`dms/cmd/ext05-tmp-metaserver`, nach der Prüfung gelöscht) auf einem eigenen TCP-Port gegen ein eigenes, isoliertes Postgres-Schema in `tenantdb` gestartet – reale Zieldatenbank und realer Code des Moduls, keine Simulation. Ein Feld `rechnungsdatum` (Typ `date`) und ein Dokument wurden angelegt. Ein zweites, ebenfalls temporäres Werkzeug (`ai/cmd/ext05-tmp-pruefung`, nach der Prüfung gelöscht) rief den echten `extractconfirmapi`-Endpunkt auf, dessen `metadatapushclient` real per HTTP gegen den laufenden dms-Metadaten-Server pushte.
|
||||
|
||||
1. **Übernahme in DMS-Metadatenfeld im Test nachvollzogen.**
|
||||
Zielwert: bestätigter Wert `invoice_date=2026-01-15` landet über das Mapping `invoice_date→rechnungsdatum` im DMS-Metadatenfeld des Dokuments.
|
||||
Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":["rechnungsdatum"],"unmapped_fields":[]}`. Per `psql` direkt gegen das dms-Test-Schema verifiziert: `SELECT field_key, value FROM document_metadata_values v JOIN metadata_fields f ON f.id=v.field_id` lieferte `rechnungsdatum | 2026-01-15`. **Erreicht.**
|
||||
|
||||
2. **Fehlkonfiguriertes Mapping im Test erkannt.**
|
||||
Zielwert: eine Anfrage für einen Objekttyp ohne konfiguriertes Mapping (`dms/unbekannter_objekttyp`) schreibt nichts und meldet den Wert als nicht zuordenbar.
|
||||
Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":[],"unmapped_fields":["invoice_date"]}`, kein Schreibzugriff auf das dms-Metadatenfeld erfolgte. **Erreicht.**
|
||||
|
||||
3. **Nicht zuordenbarer Wert im Test korrekt gemeldet.**
|
||||
Zielwert: ein bestätigter Wert ohne Mapping-Eintrag (`vendor`) wird gemeldet statt verworfen, während der zuordenbare Wert (`invoice_date→rechnungsdatum`, neuer Wert `2026-03-01`) trotzdem übernommen wird.
|
||||
Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":["rechnungsdatum"],"unmapped_fields":["vendor"]}`. Per `psql` verifiziert: `rechnungsdatum | 2026-03-01` (überschrieben, wie erwartet bei erneutem Push desselben Feldschlüssels). **Erreicht.**
|
||||
|
||||
Aufräumen: Prozess beendet, temporäre Postgres-Schemata (`ext05tmp_*`, `ext05aimap*`) per `DROP SCHEMA ... CASCADE` entfernt, temporäre Binaries/Logs gelöscht, temporäre Quelldateien (`dms/cmd/ext05-tmp-metaserver`, `ai/cmd/ext05-tmp-pruefung`) aus dem Arbeitsverzeichnis entfernt und nicht committet. Host 131 und archivdms-Daten wurden nicht berührt.
|
||||
|
||||
## Nicht Bestandteil (laut Ticket)
|
||||
|
||||
`QA-03` (Prüfgate Klassifizierung & Extraktion) – nicht umgesetzt, wie im Ticket vorgesehen.
|
||||
|
||||
## Offene Punkte / Entscheidungen
|
||||
|
||||
- Modul-Feldschlüssel statt interner Feld-ID als Mapping-Ziel gewählt (stabiler über Feld-Neuanlage hinweg); `metadatapushclient` löst den Schlüssel bei jedem Push frisch über `GET /metadata-fields` auf.
|
||||
- Modul-Basis-URLs werden serverseitig über Umgebungsvariablen konfiguriert (nicht aus dem Request übernommen), um SSRF über einen beliebigen, vom Aufrufer übergebenen Ziel-Host zu vermeiden.
|
||||
- Mail-seitige Anbindung folgt demselben, modulunabhängigen `metadatapushclient`-Vertrag (Feldliste + Werte-PUT); ein konkreter Mail-Metadaten-Endpunkt war zum Zeitpunkt dieses Tickets nicht Teil des Scopes und wurde nicht geprüft.
|
||||
@@ -0,0 +1,137 @@
|
||||
// Package extractconfirmapi ist die HTTP-Bindung fuer EXT-05: bestaetigte
|
||||
// Extraktionswerte (nach EXT-04-Freigabe durch den Nutzer) werden ueber das
|
||||
// je Modul/Objekttyp konfigurierte Feld-Mapping (extractmapping) den
|
||||
// tatsaechlichen Metadatenfeldern des aufrufenden Moduls zugeordnet und
|
||||
// dorthin geschrieben (metadatapushclient). Aufrufer ist die
|
||||
// Backend-for-Frontend-Route des anfragenden Moduls (DMS/Mail),
|
||||
// Service-Token-Pflicht wie extractapi, fail closed.
|
||||
package extractconfirmapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
mapping *extractmapping.Registry
|
||||
moduleClients map[string]*metadatapushclient.Client
|
||||
serviceToken string
|
||||
}
|
||||
|
||||
func NewServer(mapping *extractmapping.Registry, moduleClients map[string]*metadatapushclient.Client, serviceToken string) *Server {
|
||||
return &Server{mapping: mapping, moduleClients: moduleClients, serviceToken: serviceToken}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /extract/confirm", s.requireAuth(s.handleConfirm))
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := r.Header.Get("X-Service-Token")
|
||||
if !svcauth.Equal(got, s.serviceToken) {
|
||||
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type confirmRequest struct {
|
||||
ModuleName string `json:"module_name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
ObjectID string `json:"object_id"`
|
||||
Caller string `json:"caller"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type confirmResponse struct {
|
||||
// AppliedFields sind Modul-Feldschluessel, die erfolgreich geschrieben
|
||||
// wurden (AK1).
|
||||
AppliedFields []string `json:"applied_fields"`
|
||||
// UnmappedFields sind Extraktionsfelder bzw. Modul-Feldschluessel, die
|
||||
// sich mangels Mapping oder mangels registriertem Zielfeld nicht
|
||||
// zuordnen liessen (AK3: gemeldet statt verworfen).
|
||||
UnmappedFields []string `json:"unmapped_fields"`
|
||||
}
|
||||
|
||||
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var req confirmRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
if req.ModuleName == "" || req.ObjectType == "" || req.ObjectID == "" || req.Caller == "" {
|
||||
writeError(w, http.StatusBadRequest, "module_name, object_type, object_id und caller sind pflicht")
|
||||
return
|
||||
}
|
||||
if len(req.Values) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "keine werte zur uebernahme")
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := s.moduleClients[req.ModuleName]
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "modul \""+req.ModuleName+"\" ist nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
|
||||
mapping, err := s.mapping.Get(r.Context(), req.ModuleName, req.ObjectType)
|
||||
var mapped map[string]string
|
||||
unmapped := make([]string, 0, len(req.Values))
|
||||
if err != nil {
|
||||
if !errors.Is(err, extractmapping.ErrNotConfigured) {
|
||||
writeError(w, http.StatusInternalServerError, "mapping konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
// Fehlkonfiguration (kein Mapping hinterlegt): jeder Wert ist
|
||||
// unzuordenbar, wird aber gemeldet statt verworfen (AK3).
|
||||
for name := range req.Values {
|
||||
unmapped = append(unmapped, name)
|
||||
}
|
||||
mapped = map[string]string{}
|
||||
} else {
|
||||
applied := extractmapping.Apply(mapping, req.Values)
|
||||
mapped = applied.Mapped
|
||||
unmapped = append(unmapped, applied.Unmapped...)
|
||||
}
|
||||
|
||||
appliedFields := make([]string, 0, len(mapped))
|
||||
if len(mapped) > 0 {
|
||||
pushResult, err := client.Push(r.Context(), req.ObjectID, req.Caller, mapped)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "uebernahme in modul-metadatenfeld fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
unresolved := make(map[string]bool, len(pushResult.Unresolved))
|
||||
for _, key := range pushResult.Unresolved {
|
||||
unresolved[key] = true
|
||||
unmapped = append(unmapped, key)
|
||||
}
|
||||
for key := range mapped {
|
||||
if !unresolved[key] {
|
||||
appliedFields = append(appliedFields, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(appliedFields)
|
||||
sort.Strings(unmapped)
|
||||
|
||||
writeJSON(w, http.StatusOK, confirmResponse{AppliedFields: appliedFields, UnmappedFields: unmapped})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package extractconfirmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newMappingRegistry(t *testing.T) *extractmapping.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 &extractmapping.Registry{Pool: pool}
|
||||
}
|
||||
|
||||
type fakeField struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// newFakeModule stellt die Metadaten-API-Kontraktflaeche des aufrufenden
|
||||
// Moduls nach (GET /metadata-fields, PUT /documents/{id}/metadata) und
|
||||
// merkt sich geschriebene Werte je Feld-ID.
|
||||
func newFakeModule(t *testing.T, fields []fakeField) (*httptest.Server, *map[string]string) {
|
||||
t.Helper()
|
||||
written := map[string]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/metadata-fields":
|
||||
_ = json.NewEncoder(w).Encode(fields)
|
||||
case r.Method == http.MethodPut:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
for k, v := range body {
|
||||
written[k] = v
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unerwarteter aufruf %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
return srv, &written
|
||||
}
|
||||
|
||||
// TestHandleConfirm_AppliesMappedValueToModuleField deckt AK1+AK2 ab: ein
|
||||
// bestaetigter Wert landet ueber das konfigurierte Mapping im richtigen
|
||||
// Metadatenfeld des Moduls (Pruefung 1).
|
||||
func TestHandleConfirm_AppliesMappedValueToModuleField(t *testing.T) {
|
||||
mappingRegistry := newMappingRegistry(t)
|
||||
ctx := context.Background()
|
||||
if err := mappingRegistry.Set(ctx, "dms", "invoice", map[string]string{"invoice_date": "rechnungsdatum"}); err != nil {
|
||||
t.Fatalf("mapping setzen: %v", err)
|
||||
}
|
||||
|
||||
fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}})
|
||||
defer fakeModule.Close()
|
||||
|
||||
srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{
|
||||
"dms": metadatapushclient.New(fakeModule.URL, "svc-token"),
|
||||
}, "svc-token")
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{
|
||||
ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai",
|
||||
Values: map[string]string{"invoice_date": "2026-01-15"},
|
||||
})
|
||||
|
||||
if len(resp.AppliedFields) != 1 || resp.AppliedFields[0] != "rechnungsdatum" {
|
||||
t.Fatalf("applied_fields = %+v, want [rechnungsdatum]", resp.AppliedFields)
|
||||
}
|
||||
if len(resp.UnmappedFields) != 0 {
|
||||
t.Fatalf("unmapped_fields = %+v, want leer", resp.UnmappedFields)
|
||||
}
|
||||
if (*written)["field-uuid-1"] != "2026-01-15" {
|
||||
t.Fatalf("wert nicht im modul-metadatenfeld angekommen: %+v", *written)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped deckt AK3 ab: ein
|
||||
// fehlkonfiguriertes (fehlendes) Mapping wird im Test erkannt und gemeldet,
|
||||
// nicht stillschweigend verworfen (Pruefung 2).
|
||||
func TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped(t *testing.T) {
|
||||
mappingRegistry := newMappingRegistry(t)
|
||||
|
||||
fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}})
|
||||
defer fakeModule.Close()
|
||||
|
||||
srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{
|
||||
"dms": metadatapushclient.New(fakeModule.URL, "svc-token"),
|
||||
}, "svc-token")
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
// Kein Mapping fuer "dms"/"unbekannter_objekttyp" konfiguriert.
|
||||
resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{
|
||||
ModuleName: "dms", ObjectType: "unbekannter_objekttyp", ObjectID: "doc-1", Caller: "ai",
|
||||
Values: map[string]string{"invoice_date": "2026-01-15"},
|
||||
})
|
||||
|
||||
if len(resp.AppliedFields) != 0 {
|
||||
t.Fatalf("applied_fields = %+v, want leer", resp.AppliedFields)
|
||||
}
|
||||
if len(resp.UnmappedFields) != 1 || resp.UnmappedFields[0] != "invoice_date" {
|
||||
t.Fatalf("unmapped_fields = %+v, want [invoice_date]", resp.UnmappedFields)
|
||||
}
|
||||
if len(*written) != 0 {
|
||||
t.Fatalf("trotz fehlendem mapping wurde geschrieben: %+v", *written)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded deckt
|
||||
// AK3 ab: ein Extraktionsfeld ohne Mapping-Eintrag wird gemeldet, waehrend
|
||||
// die uebrigen, zuordenbaren Werte trotzdem uebernommen werden
|
||||
// (Pruefung 3).
|
||||
func TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded(t *testing.T) {
|
||||
mappingRegistry := newMappingRegistry(t)
|
||||
ctx := context.Background()
|
||||
if err := mappingRegistry.Set(ctx, "dms", "invoice", map[string]string{"invoice_date": "rechnungsdatum"}); err != nil {
|
||||
t.Fatalf("mapping setzen: %v", err)
|
||||
}
|
||||
|
||||
fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}})
|
||||
defer fakeModule.Close()
|
||||
|
||||
srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{
|
||||
"dms": metadatapushclient.New(fakeModule.URL, "svc-token"),
|
||||
}, "svc-token")
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{
|
||||
ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai",
|
||||
Values: map[string]string{
|
||||
"invoice_date": "2026-01-15",
|
||||
"vendor": "Acme GmbH", // kein Mapping-Eintrag fuer "vendor"
|
||||
},
|
||||
})
|
||||
|
||||
if len(resp.AppliedFields) != 1 || resp.AppliedFields[0] != "rechnungsdatum" {
|
||||
t.Fatalf("applied_fields = %+v, want [rechnungsdatum]", resp.AppliedFields)
|
||||
}
|
||||
if len(resp.UnmappedFields) != 1 || resp.UnmappedFields[0] != "vendor" {
|
||||
t.Fatalf("unmapped_fields = %+v, want [vendor]", resp.UnmappedFields)
|
||||
}
|
||||
if (*written)["field-uuid-1"] != "2026-01-15" {
|
||||
t.Fatalf("zuordenbarer wert wurde trotz gemeldetem restwert nicht uebernommen: %+v", *written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfirm_RequiresServiceToken(t *testing.T) {
|
||||
mappingRegistry := newMappingRegistry(t)
|
||||
srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{}, "svc-token")
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
testServer := httptest.NewServer(mux)
|
||||
defer testServer.Close()
|
||||
|
||||
body, _ := json.Marshal(confirmRequest{ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai", Values: map[string]string{"x": "y"}})
|
||||
req, _ := http.NewRequest(http.MethodPost, testServer.URL+"/extract/confirm", bytes.NewReader(body))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 ohne service-token", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func postConfirm(t *testing.T, baseURL, token string, req confirmRequest) confirmResponse {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request kodieren: %v", err)
|
||||
}
|
||||
httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/extract/confirm", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request bauen: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("X-Service-Token", token)
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var out confirmResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("response dekodieren: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package extractmapping ordnet bestaetigte Extraktionsfelder (EXT-01/EXT-03)
|
||||
// den Metadatenfeldern des aufrufenden Moduls zu (EXT-05). Das Mapping ist
|
||||
// reine, zur Laufzeit aenderbare Konfiguration je Modul/Objekttyp
|
||||
// (module_objecttypes-Muster, siehe moduletype), kein starres Feldschema.
|
||||
package extractmapping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotConfigured signalisiert ein fehlendes Feld-Mapping fuer
|
||||
// moduleName/objectType (AK3: wird vom Aufrufer gemeldet, nicht verworfen).
|
||||
var ErrNotConfigured = errors.New("extractmapping: kein feld-mapping konfiguriert")
|
||||
|
||||
// Registry verwaltet die Feld-Mappings je Modul/Objekttyp.
|
||||
type Registry struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Set legt das Feld-Mapping (Extraktionsfeld -> Modul-Feldschluessel) fuer
|
||||
// moduleName/objectType fest und ersetzt ein vorhandenes Mapping vollstaendig
|
||||
// (AK2: je Modul konfigurierbar, ohne Codeaenderung).
|
||||
func (r *Registry) Set(ctx context.Context, moduleName, objectType string, mapping map[string]string) error {
|
||||
moduleName = strings.TrimSpace(moduleName)
|
||||
objectType = strings.TrimSpace(objectType)
|
||||
if moduleName == "" || objectType == "" {
|
||||
return fmt.Errorf("extractmapping: moduleName und objectType duerfen nicht leer sein")
|
||||
}
|
||||
if len(mapping) == 0 {
|
||||
return fmt.Errorf("extractmapping: mindestens eine feldzuordnung erforderlich")
|
||||
}
|
||||
payload, err := json.Marshal(mapping)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extractmapping: mapping kodieren: %w", err)
|
||||
}
|
||||
_, err = r.Pool.Exec(ctx, `
|
||||
INSERT INTO extract_field_mappings (module_name, object_type, mapping)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (module_name, object_type) DO UPDATE SET mapping = EXCLUDED.mapping
|
||||
`, moduleName, objectType, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extractmapping: speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get liefert das Mapping fuer moduleName/objectType oder ErrNotConfigured.
|
||||
func (r *Registry) Get(ctx context.Context, moduleName, objectType string) (map[string]string, error) {
|
||||
var payload []byte
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT mapping FROM extract_field_mappings WHERE module_name = $1 AND object_type = $2
|
||||
`, moduleName, objectType).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s/%s", ErrNotConfigured, moduleName, objectType)
|
||||
}
|
||||
var mapping map[string]string
|
||||
if err := json.Unmarshal(payload, &mapping); err != nil {
|
||||
return nil, fmt.Errorf("extractmapping: mapping dekodieren: %w", err)
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// Result ist das Ergebnis der Anwendung eines Mappings auf bestaetigte
|
||||
// Extraktionswerte.
|
||||
type Result struct {
|
||||
// Mapped ist Modul-Feldschluessel -> bestaetigter Wert.
|
||||
Mapped map[string]string
|
||||
// Unmapped listet Extraktionsfelder aus confirmed, fuer die mapping
|
||||
// keinen Ziel-Feldschluessel kennt (AK3: gemeldet statt verworfen).
|
||||
Unmapped []string
|
||||
}
|
||||
|
||||
// Apply ordnet confirmed (Extraktionsfeld -> bestaetigter Wert) ueber
|
||||
// mapping (Extraktionsfeld -> Modul-Feldschluessel) den Modul-
|
||||
// Metadatenfeldern zu.
|
||||
func Apply(mapping map[string]string, confirmed map[string]string) Result {
|
||||
res := Result{Mapped: make(map[string]string, len(confirmed))}
|
||||
for extractField, value := range confirmed {
|
||||
moduleField, ok := mapping[extractField]
|
||||
if !ok || strings.TrimSpace(moduleField) == "" {
|
||||
res.Unmapped = append(res.Unmapped, extractField)
|
||||
continue
|
||||
}
|
||||
res.Mapped[moduleField] = value
|
||||
}
|
||||
sort.Strings(res.Unmapped)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package extractmapping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newRegistry(t *testing.T) *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 &Registry{Pool: pool}
|
||||
}
|
||||
|
||||
// TestRegistry_SetAndGet deckt AK2 ab: das Feld-Mapping ist je Modul/Objekttyp
|
||||
// konfigurierbar, ohne dass dieses Paket dafuer geaendert werden muss.
|
||||
func TestRegistry_SetAndGet(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := r.Set(ctx, "dms", "invoice", map[string]string{
|
||||
"invoice_date": "rechnungsdatum",
|
||||
"amount": "betrag",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
|
||||
got, err := r.Get(ctx, "dms", "invoice")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
want := map[string]string{"invoice_date": "rechnungsdatum", "amount": "betrag"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("mapping = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Set_ReplacesExisting deckt eine fehlkonfigurierte, spaeter
|
||||
// korrigierte Zuordnung ab: ein erneutes Set ersetzt das Mapping
|
||||
// vollstaendig.
|
||||
func TestRegistry_Set_ReplacesExisting(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := r.Set(ctx, "mail", "email", map[string]string{"sender": "falsches_feld"}); err != nil {
|
||||
t.Fatalf("set 1: %v", err)
|
||||
}
|
||||
if err := r.Set(ctx, "mail", "email", map[string]string{"sender": "absender"}); err != nil {
|
||||
t.Fatalf("set 2: %v", err)
|
||||
}
|
||||
|
||||
got, err := r.Get(ctx, "mail", "email")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got["sender"] != "absender" {
|
||||
t.Fatalf("mapping nicht ersetzt: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Get_UnconfiguredModule_ReturnsErrNotConfigured deckt die
|
||||
// Grundlage von AK3 ab: ein fehlkonfiguriertes (hier: gar nicht
|
||||
// konfiguriertes) Mapping wird als solches erkannt, nicht stillschweigend
|
||||
// als leeres Mapping behandelt.
|
||||
func TestRegistry_Get_UnconfiguredModule_ReturnsErrNotConfigured(t *testing.T) {
|
||||
r := newRegistry(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := r.Get(ctx, "unbekanntes-modul", "irgendwas")
|
||||
if !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_MapsKnownFieldsAndReportsUnmapped(t *testing.T) {
|
||||
mapping := map[string]string{"invoice_date": "rechnungsdatum", "amount": "betrag"}
|
||||
confirmed := map[string]string{
|
||||
"invoice_date": "2026-01-15",
|
||||
"amount": "199.00",
|
||||
"vendor": "Acme GmbH", // kein Mapping-Eintrag
|
||||
}
|
||||
|
||||
res := Apply(mapping, confirmed)
|
||||
|
||||
wantMapped := map[string]string{"rechnungsdatum": "2026-01-15", "betrag": "199.00"}
|
||||
if !reflect.DeepEqual(res.Mapped, wantMapped) {
|
||||
t.Fatalf("mapped = %+v, want %+v", res.Mapped, wantMapped)
|
||||
}
|
||||
if !sort.StringsAreSorted(res.Unmapped) || len(res.Unmapped) != 1 || res.Unmapped[0] != "vendor" {
|
||||
t.Fatalf("unmapped = %+v, want [vendor]", res.Unmapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_EmptyMapping_ReportsAllAsUnmapped(t *testing.T) {
|
||||
res := Apply(map[string]string{}, map[string]string{"invoice_date": "2026-01-15"})
|
||||
if len(res.Mapped) != 0 {
|
||||
t.Fatalf("mapped = %+v, want leer", res.Mapped)
|
||||
}
|
||||
if len(res.Unmapped) != 1 || res.Unmapped[0] != "invoice_date" {
|
||||
t.Fatalf("unmapped = %+v, want [invoice_date]", res.Unmapped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package metadatapushclient ist ein schlanker HTTP-Client fuer die
|
||||
// Metadatenfeld-API des aufrufenden Moduls (z.B. DMS MET-03: GET
|
||||
// /metadata-fields, PUT /documents/{id}/metadata). AI ist ein physisch
|
||||
// getrenntes Go-Modul und kann die Metadaten-Implementierung des Moduls
|
||||
// daher nicht direkt importieren, nur ueber dessen HTTP-Schnittstelle
|
||||
// (Muster wie ai/internal/auditclient).
|
||||
package metadatapushclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
ServiceToken string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpclient.Default()}
|
||||
}
|
||||
|
||||
type fieldDTO struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// listFields liefert Modul-Feldschluessel -> interne Feld-ID. Push arbeitet
|
||||
// mit dem stabilen Feldschluessel statt der internen ID, da diese sich beim
|
||||
// Neuanlegen eines Felds aendern kann.
|
||||
func (c *Client) listFields(ctx context.Context) (map[string]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/metadata-fields", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("metadatapushclient: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("metadatapushclient: felder abrufen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("metadatapushclient: unerwarteter status %d beim feld-abruf", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fields []fieldDTO
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fields); err != nil {
|
||||
return nil, fmt.Errorf("metadatapushclient: felder dekodieren: %w", err)
|
||||
}
|
||||
byKey := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
byKey[f.Key] = f.ID
|
||||
}
|
||||
return byKey, nil
|
||||
}
|
||||
|
||||
// PushResult ist das Ergebnis eines Push-Versuchs.
|
||||
type PushResult struct {
|
||||
// Unresolved listet Modul-Feldschluessel aus values, fuer die das Modul
|
||||
// aktuell kein Feld dieses Schluessels registriert hat (AK3: gemeldet,
|
||||
// die uebrigen Werte werden trotzdem geschrieben statt den gesamten
|
||||
// Aufruf zu verwerfen).
|
||||
Unresolved []string
|
||||
}
|
||||
|
||||
// Push schreibt values (Modul-Feldschluessel -> bestaetigter Wert) in die
|
||||
// Metadatenfelder von objectID ueber die Metadaten-API des Moduls (AK1).
|
||||
func (c *Client) Push(ctx context.Context, objectID, caller string, values map[string]string) (PushResult, error) {
|
||||
fieldIDs, err := c.listFields(ctx)
|
||||
if err != nil {
|
||||
return PushResult{}, err
|
||||
}
|
||||
|
||||
byID := make(map[string]string, len(values))
|
||||
var result PushResult
|
||||
for key, value := range values {
|
||||
id, ok := fieldIDs[key]
|
||||
if !ok {
|
||||
result.Unresolved = append(result.Unresolved, key)
|
||||
continue
|
||||
}
|
||||
byID[id] = value
|
||||
}
|
||||
sort.Strings(result.Unresolved)
|
||||
|
||||
if len(byID) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(byID)
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("metadatapushclient: request kodieren: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/documents/%s/metadata?%s", c.BaseURL, url.PathEscape(objectID),
|
||||
url.Values{"caller": {caller}}.Encode())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("metadatapushclient: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("metadatapushclient: push fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return PushResult{}, fmt.Errorf("metadatapushclient: unerwarteter status %d beim schreiben", resp.StatusCode)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return httpclient.Default()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package metadatapushclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type dtoField struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func newFakeModule(t *testing.T, fields []dtoField, wantValues map[string]string) (*httptest.Server, *string, *string) {
|
||||
t.Helper()
|
||||
var gotToken, gotCaller string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotToken = r.Header.Get("X-Service-Token")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/metadata-fields":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(fields)
|
||||
case r.Method == http.MethodPut:
|
||||
gotCaller = r.URL.Query().Get("caller")
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("body dekodieren: %v", err)
|
||||
}
|
||||
for k, v := range wantValues {
|
||||
if body[k] != v {
|
||||
t.Fatalf("push body[%q] = %q, want %q (body=%+v)", k, body[k], v, body)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unerwarteter aufruf %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
return srv, &gotToken, &gotCaller
|
||||
}
|
||||
|
||||
// TestPush_ResolvesFieldKeyToIDAndWrites deckt AK1 ab: der bestaetigte Wert
|
||||
// landet im richtigen Metadatenfeld (per Feldschluessel aufgeloest).
|
||||
func TestPush_ResolvesFieldKeyToIDAndWrites(t *testing.T) {
|
||||
srv, gotToken, gotCaller := newFakeModule(t, []dtoField{
|
||||
{ID: "field-uuid-1", Key: "rechnungsdatum"},
|
||||
{ID: "field-uuid-2", Key: "betrag"},
|
||||
}, map[string]string{"field-uuid-1": "2026-01-15", "field-uuid-2": "199.00"})
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "svc-token-123")
|
||||
result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{
|
||||
"rechnungsdatum": "2026-01-15",
|
||||
"betrag": "199.00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if len(result.Unresolved) != 0 {
|
||||
t.Fatalf("unresolved = %+v, want leer", result.Unresolved)
|
||||
}
|
||||
if *gotToken != "svc-token-123" {
|
||||
t.Fatalf("service-token nicht gesendet: %q", *gotToken)
|
||||
}
|
||||
if *gotCaller != "ai" {
|
||||
t.Fatalf("caller nicht gesendet: %q", *gotCaller)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPush_UnknownFieldKey_ReportedNotDropped deckt AK3 ab: ein Modul-
|
||||
// Feldschluessel, den das Modul aktuell nicht registriert hat, wird
|
||||
// gemeldet statt stillschweigend verworfen — die uebrigen Werte werden
|
||||
// trotzdem geschrieben.
|
||||
func TestPush_UnknownFieldKey_ReportedNotDropped(t *testing.T) {
|
||||
srv, _, _ := newFakeModule(t, []dtoField{
|
||||
{ID: "field-uuid-1", Key: "rechnungsdatum"},
|
||||
}, map[string]string{"field-uuid-1": "2026-01-15"})
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "svc-token-123")
|
||||
result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{
|
||||
"rechnungsdatum": "2026-01-15",
|
||||
"nicht_existent_feld": "wert",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if len(result.Unresolved) != 1 || result.Unresolved[0] != "nicht_existent_feld" {
|
||||
t.Fatalf("unresolved = %+v, want [nicht_existent_feld]", result.Unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_AllUnknownFieldKeys_NoWriteAttempted(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_ = json.NewEncoder(w).Encode([]dtoField{{ID: "field-uuid-1", Key: "rechnungsdatum"}})
|
||||
return
|
||||
}
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "svc-token-123")
|
||||
result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{"unbekannt": "wert"})
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Fatalf("PUT wurde trotz vollstaendig unaufloesbarer werte gesendet")
|
||||
}
|
||||
if len(result.Unresolved) != 1 || result.Unresolved[0] != "unbekannt" {
|
||||
t.Fatalf("unresolved = %+v, want [unbekannt]", result.Unresolved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE extract_field_mappings;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE extract_field_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
module_name TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL,
|
||||
mapping JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (module_name, object_type)
|
||||
);
|
||||
Reference in new issue
Block a user