package notifyapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/notify" "gitea.perlbach24.de/scripte/nexarch/internal/notifyprefs" ) const testToken = "test-service-token-cfg05" func setupTest(t *testing.T) (*notifyprefs.Store, *notify.Dispatcher, *pgxpool.Pool) { t.Helper() dsn := os.Getenv("TEST_ADMIN_DSN") if dsn == "" { t.Skip("TEST_ADMIN_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 TABLE IF NOT EXISTS notification_preferences ( tenant_slug TEXT NOT NULL, user_id TEXT NOT NULL, event_type TEXT NOT NULL, channel TEXT NOT NULL, enabled BOOLEAN NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, user_id, event_type, channel) ); CREATE TABLE IF NOT EXISTS notification_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM notification_jobs WHERE recipient LIKE 'cfg05\_%' ESCAPE '\'`) _, _ = pool.Exec(context.Background(), `DELETE FROM notification_preferences WHERE user_id LIKE 'cfg05\_%' ESCAPE '\'`) }) return notifyprefs.NewStore(pool), notify.NewDispatcher(pool), pool } func post(t *testing.T, server *httptest.Server, token string, req enqueueRequest) *http.Response { t.Helper() body, _ := json.Marshal(req) httpReq, _ := http.NewRequest(http.MethodPost, server.URL+"/notify/enqueue", bytes.NewReader(body)) if token != "" { httpReq.Header.Set("X-Service-Token", token) } resp, err := http.DefaultClient.Do(httpReq) if err != nil { t.Fatalf("post: %v", err) } return resp } // TestEnqueueHandler_MissingTokenReturns401 ist die geforderte // Pflichtpruefung: Aufruf ohne Service-Token wird abgewiesen, Handler nie // erreicht, keine Zustellung ausgeloest. func TestEnqueueHandler_MissingTokenReturns401(t *testing.T) { prefs, dispatcher, _ := setupTest(t) mux := http.NewServeMux() Mount(mux, prefs, dispatcher, testToken) server := httptest.NewServer(mux) defer server.Close() resp := post(t, server, "", enqueueRequest{ TenantSlug: "acme", UserID: "cfg05_user1", EventType: "invoice_ready", Channel: "email", Recipient: "cfg05_user1@acme.example", }) defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("ohne token: status = %d, want 401", resp.StatusCode) } } // TestEnqueueHandler_MatchesDirectEnqueueIfAllowed ist die geforderte // Pflichtpruefung: Endpunkt-Verhalten stimmt exakt mit dem direkten // EnqueueIfAllowed-Aufruf ueberein, real fuer aktivierten UND deaktivierten // Kanal. func TestEnqueueHandler_MatchesDirectEnqueueIfAllowed(t *testing.T) { prefs, dispatcher, _ := setupTest(t) mux := http.NewServeMux() Mount(mux, prefs, dispatcher, testToken) server := httptest.NewServer(mux) defer server.Close() ctx := context.Background() // Fall 1: kein explizites Preference -> Opt-out-Default aktiviert. resp1 := post(t, server, testToken, enqueueRequest{ TenantSlug: "acme", UserID: "cfg05_user_enabled", EventType: "invoice_ready", Channel: "email", Recipient: "cfg05_user_enabled@acme.example", }) defer func() { _ = resp1.Body.Close() }() if resp1.StatusCode != http.StatusOK { t.Fatalf("aktivierter fall: status = %d, want 200", resp1.StatusCode) } var out1 enqueueResponse if err := json.NewDecoder(resp1.Body).Decode(&out1); err != nil { t.Fatalf("antwort dekodieren: %v", err) } if out1.Skipped || out1.JobID == "" { t.Fatalf("erwartet zugestellt mit job-id, habe: %+v", out1) } // Fall 2: Kanal explizit deaktiviert -> muss uebereinstimmend skipped=true liefern, // wie ein direkter EnqueueIfAllowed-Aufruf es auch taete. if err := prefs.Set(ctx, "acme", "cfg05_user_disabled", "invoice_ready", "email", false); err != nil { t.Fatalf("praeferenz setzen: %v", err) } directJobID, directSkipped, err := notifyprefs.EnqueueIfAllowed(ctx, prefs, dispatcher, "acme", "cfg05_user_disabled", "invoice_ready", "email", "cfg05_user_disabled@acme.example", nil) if err != nil { t.Fatalf("direkter aufruf: %v", err) } resp2 := post(t, server, testToken, enqueueRequest{ TenantSlug: "acme", UserID: "cfg05_user_disabled", EventType: "invoice_ready", Channel: "email", Recipient: "cfg05_user_disabled@acme.example", }) defer func() { _ = resp2.Body.Close() }() var out2 enqueueResponse if err := json.NewDecoder(resp2.Body).Decode(&out2); err != nil { t.Fatalf("antwort dekodieren: %v", err) } if out2.Skipped != directSkipped { t.Fatalf("http skipped=%t weicht vom direkten aufruf skipped=%t ab", out2.Skipped, directSkipped) } if !out2.Skipped { t.Fatalf("deaktivierter kanal haette uebersprungen werden muessen, direkt=%q http=%q", directJobID, out2.JobID) } } // TestEnqueueHandler_RealForeignModuleClient simuliert einen Fremd-Modul- // Testclient (z. B. Archive/RET-07), der real gegen den laufenden Endpunkt // eine Benachrichtigung ausloest. func TestEnqueueHandler_RealForeignModuleClient(t *testing.T) { prefs, dispatcher, pool := setupTest(t) mux := http.NewServeMux() Mount(mux, prefs, dispatcher, testToken) server := httptest.NewServer(mux) defer server.Close() resp := post(t, server, testToken, enqueueRequest{ TenantSlug: "acme", UserID: "cfg05_fremd_modul", EventType: "retention_due_soon", Channel: "email", Recipient: "cfg05_fremd_modul@acme.example", }) defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var out enqueueResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("antwort dekodieren: %v", err) } if out.Skipped || out.JobID == "" { t.Fatalf("erwartet echte job-id, habe: %+v", out) } var count int if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM notification_jobs WHERE id = $1`, out.JobID).Scan(&count); err != nil { t.Fatalf("job pruefen: %v", err) } if count != 1 { t.Fatalf("erwartet real angelegte notification_jobs-zeile fuer job-id %s", out.JobID) } }