package retentionapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine" ) 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 retention_objects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, object_reference TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (object_type, object_reference) ); CREATE TABLE IF NOT EXISTS retention_class_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS retention_class_rules ( retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, active BOOLEAN NOT NULL DEFAULT true ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) }) return pool } func authedRequest(t *testing.T, method, url string, body []byte) *http.Response { t.Helper() var reader *bytes.Reader if body != nil { reader = bytes.NewReader(body) } else { reader = bytes.NewReader(nil) } req, err := http.NewRequest(method, url, reader) if err != nil { t.Fatalf("request erstellen: %v", err) } req.Header.Set("X-Admin-Roles", adminRole) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request senden: %v", err) } return resp } // TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern, // über die Liste sichtbar - Akzeptanzkriterium 1. func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) { pool := requireTestPool(t) mux := http.NewServeMux() Mount(mux, pool) server := httptest.NewServer(mux) defer server.Close() body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"}) resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body) if resp.StatusCode != http.StatusOK { t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode) } _ = resp.Body.Close() listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil) defer func() { _ = listResp.Body.Close() }() var rules []retentionengine.ClassRule if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil { t.Fatalf("liste dekodieren: %v", err) } if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active { t.Fatalf("unerwartete liste: %+v", rules) } } // TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real. func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil { t.Fatal(err) } mux := http.NewServeMux() Mount(mux, pool) server := httptest.NewServer(mux) defer server.Close() resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil) defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode) } rules, err := retentionengine.ListClassRules(ctx, pool) if err != nil { t.Fatalf("listclassrules: %v", err) } if len(rules) != 1 || rules[0].Active { t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules) } } // TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte // Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des // periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real // per HTTP UND direkt verglichen. func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) { pool := requireTestPool(t) ctx := context.Background() if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil { t.Fatal(err) } var objID string if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil { t.Fatal(err) } mux := http.NewServeMux() Mount(mux, pool) server := httptest.NewServer(mux) defer server.Close() resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil) defer func() { _ = resp.Body.Close() }() var httpResult []retentionengine.ExpiringObject if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil { t.Fatalf("preview-antwort dekodieren: %v", err) } directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30)) if err != nil { t.Fatalf("listexpiringobjects direkt: %v", err) } if len(httpResult) != len(directResult) || len(httpResult) != 1 { t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult)) } if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID { t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0]) } }