feat(archive): RET-05 Modul-Adapter-Schnittstelle (Interface-Freeze)
internal/moduleadapter: Registrierungs-API + Rueckruf-Ausloeser fuer DMS/Mail, bewusst NUR Archives eigene Seite - keine Modul-Empfaenger- Implementierung (Nutzervorgabe: Interface zuerst festlegen, damit DMS/Mail spaeter nicht gegen ein sich noch aenderndes Interface bauen). Register ist ON-CONFLICT-DO-NOTHING (erneute Registrierung aendert nie bestehende callback_url), NotifyDestruction echter HTTP-POST mit festem DestructionNotice-Vertrag. Idempotenz sowohl auf Go- als auch HTTP- Ebene bewiesen, Rueckruf gegen echten Testendpunkt verifiziert.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type registerRequest struct {
|
||||
ModuleName string `json:"module_name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
// RegisterHandler ist die REST-Schnittstelle (Ticket-Technikvorgabe), über
|
||||
// die ein Modul einen Objekttyp registriert (Akzeptanzkriterium 1).
|
||||
// POST /register mit JSON-Body {module_name, object_type, callback_url}.
|
||||
func RegisterHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.ModuleName == "" || req.ObjectType == "" || req.CallbackURL == "" {
|
||||
http.Error(w, "module_name, object_type und callback_url sind pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := Register(r.Context(), pool, req.ModuleName, req.ObjectType, req.CallbackURL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(reg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRegisterHandler_RealHTTPRoundTrip beweist die REST-Schnittstelle
|
||||
// (Ticket-Technikvorgabe) end-to-end: echter HTTP-Server, echter POST,
|
||||
// zweiter POST mit abweichender callback_url aendert nichts (Pruefung 3
|
||||
// auch ueber die HTTP-Schicht, nicht nur die Go-Funktion direkt).
|
||||
func TestRegisterHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
server := httptest.NewServer(RegisterHandler(pool))
|
||||
defer server.Close()
|
||||
|
||||
post := func(body registerRequest) (int, Registration) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(body)
|
||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var reg Registration
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.NewDecoder(resp.Body).Decode(®); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, reg
|
||||
}
|
||||
|
||||
status1, reg1 := post(registerRequest{ModuleName: "dms", ObjectType: "document", CallbackURL: "https://dms.example.test/original"})
|
||||
if status1 != http.StatusOK {
|
||||
t.Fatalf("erster post: status = %d, want 200", status1)
|
||||
}
|
||||
|
||||
status2, reg2 := post(registerRequest{ModuleName: "dms", ObjectType: "document", CallbackURL: "https://dms.example.test/andere"})
|
||||
if status2 != http.StatusOK {
|
||||
t.Fatalf("zweiter post: status = %d, want 200", status2)
|
||||
}
|
||||
if reg2.ID != reg1.ID || reg2.CallbackURL != "https://dms.example.test/original" {
|
||||
t.Fatalf("zweiter post veraenderte bestehenden zustand: %+v, erster war %+v", reg2, reg1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterHandler_RejectsMissingFields ist Nachweis des
|
||||
// Fehlerverhaltens auf der REST-Schicht.
|
||||
func TestRegisterHandler_RejectsMissingFields(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
server := httptest.NewServer(RegisterHandler(pool))
|
||||
defer server.Close()
|
||||
|
||||
data, _ := json.Marshal(registerRequest{ModuleName: "dms"})
|
||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 bei fehlenden pflichtfeldern", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package moduleadapter implementiert RET-05: die Schnittstelle, über
|
||||
// die DMS und Mail ihre Objekttypen bei Archive registrieren, statt
|
||||
// eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND
|
||||
// ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die
|
||||
// eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte)
|
||||
// sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln
|
||||
// gegen ein bereits feststehendes, nicht nachträglich verändertes
|
||||
// Interface bauen (Nutzervorgabe).
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Registration ist EIN registrierter Objekttyp eines Moduls.
|
||||
type Registration struct {
|
||||
ID string
|
||||
ModuleName string
|
||||
ObjectType string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse
|
||||
// für Löschbestätigung — mandantenfähig durch Modell C (physisch
|
||||
// isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne
|
||||
// von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN
|
||||
// Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben
|
||||
// der callback_url) — anders als RET-01s RegisterObject (dort
|
||||
// UPSERT-artig), weil ein bereits registrierter Rückruf nicht
|
||||
// stillschweigend durch eine zweite, möglicherweise abweichende
|
||||
// Registrierung ersetzt werden darf.
|
||||
func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, callbackURL string) (Registration, error) {
|
||||
var reg Registration
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO module_registrations (module_name, object_type, callback_url)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (module_name, object_type) DO NOTHING
|
||||
RETURNING id, module_name, object_type, callback_url
|
||||
`, moduleName, objectType, callbackURL).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.CallbackURL)
|
||||
if err == nil {
|
||||
return reg, nil
|
||||
}
|
||||
// ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) -
|
||||
// bestehende Registrierung unveraendert nachlesen und zurueckgeben.
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT id, module_name, object_type, callback_url FROM module_registrations
|
||||
WHERE module_name = $1 AND object_type = $2
|
||||
`, moduleName, objectType).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.CallbackURL)
|
||||
if err != nil {
|
||||
return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err)
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// ListRegistrations liefert alle registrierten Objekttypen — Grundlage
|
||||
// für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module
|
||||
// parallel registriert ohne Kollision).
|
||||
func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, callback_url FROM module_registrations ORDER BY module_name, object_type`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var regs []Registration
|
||||
for rows.Next() {
|
||||
var r Registration
|
||||
if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.CallbackURL); err != nil {
|
||||
return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err)
|
||||
}
|
||||
regs = append(regs, r)
|
||||
}
|
||||
return regs, rows.Err()
|
||||
}
|
||||
|
||||
// DestructionNotice ist der Rückruf-Payload bei Vernichtung eines
|
||||
// Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das
|
||||
// jeder Modul-Rückruf-Empfänger erwarten muss.
|
||||
type DestructionNotice struct {
|
||||
ObjectType string `json:"object_type"`
|
||||
ObjectReference string `json:"object_reference"`
|
||||
DestroyedAt time.Time `json:"destroyed_at"`
|
||||
}
|
||||
|
||||
// NotifyDestruction ruft das registrierte Modul beim Vernichten eines
|
||||
// Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste
|
||||
// (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den
|
||||
// Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener
|
||||
// Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss
|
||||
// (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der
|
||||
// Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler).
|
||||
func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error {
|
||||
body, err := json.Marshal(notice)
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func jsonDecode(r *http.Request, v interface{}) error {
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS module_registrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), module_name TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL, callback_url TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (module_name, object_type)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE module_registrations`) })
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestRegister_TwoModulesNoCollision ist Pruefung 1: zwei fiktive Module
|
||||
// (DMS, Mail) parallel registriert ohne Kollision.
|
||||
func TestRegister_TwoModulesNoCollision(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
dmsReg, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("dms registrieren: %v", err)
|
||||
}
|
||||
mailReg, err := Register(ctx, pool, "mail", "message", "https://mail.example.test/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("mail registrieren: %v", err)
|
||||
}
|
||||
if dmsReg.ID == mailReg.ID {
|
||||
t.Fatal("dms und mail erhielten dieselbe id - kollision")
|
||||
}
|
||||
|
||||
all, err := ListRegistrations(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listregistrations: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("erwartet 2 registrierungen, habe %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_IsIdempotent_UnchangedExistingState ist Pruefung 3:
|
||||
// erneute Registrierung desselben Objekttyps aendert NICHTS am
|
||||
// bestehenden Zustand - auch nicht bei abweichender callback_url im
|
||||
// zweiten Aufruf.
|
||||
func TestRegister_IsIdempotent_UnchangedExistingState(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/original")
|
||||
if err != nil {
|
||||
t.Fatalf("erste registrierung: %v", err)
|
||||
}
|
||||
second, err := Register(ctx, pool, "dms", "document", "https://dms.example.test/ANDERE-url")
|
||||
if err != nil {
|
||||
t.Fatalf("zweite registrierung: %v", err)
|
||||
}
|
||||
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("erneute registrierung erzeugte neue id: %q, want %q", second.ID, first.ID)
|
||||
}
|
||||
if second.CallbackURL != "https://dms.example.test/original" {
|
||||
t.Fatalf("callback_url wurde ueberschrieben: %q, want unveraendert %q", second.CallbackURL, first.CallbackURL)
|
||||
}
|
||||
|
||||
all, err := ListRegistrations(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listregistrations: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("erwartet weiterhin genau 1 registrierung, habe %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDestruction_CallsRealTestEndpoint ist Pruefung 2: Rueckruf
|
||||
// bei Vernichtung erfolgreich gegen einen Testendpunkt ausgefuehrt -
|
||||
// echter HTTP-Server, echter Request, echte Payload-Pruefung.
|
||||
func TestNotifyDestruction_CallsRealTestEndpoint(t *testing.T) {
|
||||
var receivedNotice DestructionNotice
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("erwartet POST, habe %s", r.Method)
|
||||
}
|
||||
if err := jsonDecode(r, &receivedNotice); err != nil {
|
||||
t.Errorf("payload dekodieren: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
notice := DestructionNotice{ObjectType: "document", ObjectReference: "doc-789", DestroyedAt: time.Now().UTC()}
|
||||
if err := NotifyDestruction(context.Background(), server.Client(), server.URL, notice); err != nil {
|
||||
t.Fatalf("notifydestruction: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("testendpunkt wurde nie aufgerufen")
|
||||
}
|
||||
if receivedNotice.ObjectReference != "doc-789" {
|
||||
t.Fatalf("empfangene objekt-referenz = %q, want doc-789", receivedNotice.ObjectReference)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus ist Nachweis des
|
||||
// Fehlerverhaltens: ein fehlschlagender Rueckruf wird als Fehler
|
||||
// gemeldet, nicht verschluckt.
|
||||
func TestNotifyDestruction_ReturnsErrorOnNonSuccessStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NotifyDestruction(context.Background(), server.Client(), server.URL, DestructionNotice{})
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei 500-antwort des rueckruf-endpunkts")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user