package internal_test import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "testing" "time" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" "mgit.msbls.de/m/KanzlAI-mGMT/internal/config" "mgit.msbls.de/m/KanzlAI-mGMT/internal/db" "mgit.msbls.de/m/KanzlAI-mGMT/internal/router" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) const testJWTSecret = "test-jwt-secret-for-integration-tests" // testServer sets up the full router with a real DB connection. // Requires DATABASE_URL env var. func testServer(t *testing.T) (http.Handler, func()) { t.Helper() dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { t.Skip("DATABASE_URL not set, skipping integration test") } jwtSecret := os.Getenv("SUPABASE_JWT_SECRET") if jwtSecret == "" { jwtSecret = testJWTSecret os.Setenv("SUPABASE_JWT_SECRET", jwtSecret) } database, err := db.Connect(dbURL) if err != nil { t.Fatalf("connecting to database: %v", err) } cfg := &config.Config{ Port: "0", DatabaseURL: dbURL, SupabaseJWTSecret: jwtSecret, } authMW := auth.NewMiddleware(jwtSecret, database) notifSvc := services.NewNotificationService(database) handler := router.New(database, authMW, cfg, nil, notifSvc) return handler, func() { notifSvc.Stop() database.Close() } } // createTestJWT creates a JWT for the given user ID, signed with the test secret. func createTestJWT(t *testing.T, userID uuid.UUID) string { t.Helper() secret := os.Getenv("SUPABASE_JWT_SECRET") if secret == "" { secret = testJWTSecret } claims := jwt.MapClaims{ "sub": userID.String(), "aud": "authenticated", "exp": time.Now().Add(1 * time.Hour).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("signing JWT: %v", err) } return signed } // createExpiredJWT creates an expired JWT for testing auth rejection. func createExpiredJWT(t *testing.T, userID uuid.UUID) string { t.Helper() secret := os.Getenv("SUPABASE_JWT_SECRET") if secret == "" { secret = testJWTSecret } claims := jwt.MapClaims{ "sub": userID.String(), "aud": "authenticated", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("signing expired JWT: %v", err) } return signed } // setupTestTenant creates a temporary tenant and user_tenant for testing. // Returns tenantID and userID. Cleans up on test completion. func setupTestTenant(t *testing.T, _ http.Handler) (tenantID, userID uuid.UUID) { t.Helper() dbURL := os.Getenv("DATABASE_URL") database, err := db.Connect(dbURL) if err != nil { t.Fatalf("connecting to database for setup: %v", err) } defer database.Close() tenantID = uuid.New() userID = uuid.New() _, err = database.Exec(`INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, tenantID, "Integration Test Kanzlei "+tenantID.String()[:8], "test-"+tenantID.String()[:8]) if err != nil { t.Fatalf("creating test tenant: %v", err) } // Create a user in auth.users so JWT auth works _, err = database.Exec(`INSERT INTO auth.users (id, instance_id, role, aud, email, encrypted_password, email_confirmed_at, created_at, updated_at, confirmation_token, recovery_token) VALUES ($1, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', $2, '', NOW(), NOW(), NOW(), '', '')`, userID, fmt.Sprintf("test-%s@kanzlai.test", userID.String()[:8])) if err != nil { t.Fatalf("creating test user: %v", err) } _, err = database.Exec(`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`, userID, tenantID) if err != nil { t.Fatalf("creating user_tenant: %v", err) } t.Cleanup(func() { cleanupDB, _ := db.Connect(dbURL) if cleanupDB != nil { cleanupDB.Exec(`DELETE FROM notification_preferences WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM notifications WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM notes WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM time_entries WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM invoices WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM billing_rates WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM case_assignments WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM audit_log WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM case_events WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM deadlines WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM appointments WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM parties WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM document_templates WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM documents WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM cases WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM user_tenants WHERE tenant_id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM tenants WHERE id = $1`, tenantID) cleanupDB.Exec(`DELETE FROM auth.users WHERE id = $1`, userID) cleanupDB.Close() } }) return tenantID, userID } // doRequest is a test helper that makes an HTTP request to the test handler. func doRequest(t *testing.T, handler http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var bodyReader *bytes.Buffer if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshaling request body: %v", err) } bodyReader = bytes.NewBuffer(b) } else { bodyReader = &bytes.Buffer{} } req := httptest.NewRequest(method, path, bodyReader) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // decodeJSON decodes the response body into the given target. func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, target any) { t.Helper() if err := json.Unmarshal(w.Body.Bytes(), target); err != nil { t.Fatalf("decoding JSON response: %v (body: %s)", err, w.Body.String()) } } // createTestCase is a helper that creates a case and returns its ID. func createTestCase(t *testing.T, handler http.Handler, token, caseNum, title string) string { t.Helper() body := map[string]string{ "case_number": caseNum, "title": title, } w := doRequest(t, handler, "POST", "/api/cases", token, body) if w.Code != http.StatusCreated { t.Fatalf("create case: expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) id, _ := resp["id"].(string) if id == "" { t.Fatal("created case missing ID") } return id } // ── Health ────────────────────────────────────────────────────────────────── func TestIntegration_Health(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() w := doRequest(t, handler, "GET", "/health", "", nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]string decodeJSON(t, w, &resp) if resp["status"] != "ok" { t.Fatalf("expected status ok, got %v", resp) } } // ── Auth Middleware ────────────────────────────────────────────────────────── func TestIntegration_Auth(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() t.Run("Unauthenticated", func(t *testing.T) { endpoints := []struct { method string path string }{ {"GET", "/api/cases"}, {"POST", "/api/cases"}, {"GET", "/api/tenants"}, {"GET", "/api/deadlines"}, {"GET", "/api/appointments"}, {"GET", "/api/dashboard"}, {"GET", "/api/notes"}, {"GET", "/api/reports/cases"}, {"GET", "/api/templates"}, {"GET", "/api/time-entries"}, {"GET", "/api/invoices"}, {"GET", "/api/notifications"}, {"GET", "/api/audit-log"}, } for _, ep := range endpoints { t.Run(ep.method+" "+ep.path, func(t *testing.T) { w := doRequest(t, handler, ep.method, ep.path, "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String()) } }) } }) t.Run("ExpiredJWT", func(t *testing.T) { token := createExpiredJWT(t, uuid.New()) w := doRequest(t, handler, "GET", "/api/cases", token, nil) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for expired token, got %d", w.Code) } }) t.Run("InvalidJWT", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases", "totally-not-a-jwt", nil) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for invalid token, got %d", w.Code) } }) t.Run("WrongSecretJWT", func(t *testing.T) { claims := jwt.MapClaims{ "sub": uuid.New().String(), "exp": time.Now().Add(1 * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, _ := token.SignedString([]byte("wrong-secret-key")) w := doRequest(t, handler, "GET", "/api/cases", signed, nil) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for wrong-secret token, got %d", w.Code) } }) } // ── Current User (GET /api/me) ───────────────────────────────────────────── func TestIntegration_Me(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) w := doRequest(t, handler, "GET", "/api/me", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["role"] != "owner" { t.Errorf("expected role owner, got %v", resp["role"]) } if _, ok := resp["permissions"]; !ok { t.Error("missing permissions in response") } } // ── Tenant CRUD ───────────────────────────────────────────────────────────── func TestIntegration_TenantCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() tenantID, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) // List tenants t.Run("ListTenants", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/tenants", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var tenants []map[string]any decodeJSON(t, w, &tenants) if len(tenants) < 1 { t.Fatalf("expected at least 1 tenant, got %d", len(tenants)) } }) // Create a new tenant var newTenantID string t.Run("CreateTenant", func(t *testing.T) { body := map[string]string{"name": "Test Kanzlei Neu", "slug": "test-neu-" + uuid.New().String()[:8]} w := doRequest(t, handler, "POST", "/api/tenants", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) newTenantID, _ = resp["id"].(string) if newTenantID == "" { t.Fatal("created tenant missing ID") } t.Cleanup(func() { dbURL := os.Getenv("DATABASE_URL") cleanupDB, _ := db.Connect(dbURL) if cleanupDB != nil { cleanupDB.Exec(`DELETE FROM user_tenants WHERE tenant_id = $1`, newTenantID) cleanupDB.Exec(`DELETE FROM tenants WHERE id = $1`, newTenantID) cleanupDB.Close() } }) }) // Get tenant by ID (use the setup tenant we know the user is a member of) t.Run("GetTenant", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/tenants/"+tenantID.String(), token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["id"] != tenantID.String() { t.Errorf("unexpected tenant ID: %v", resp["id"]) } }) // Get tenant with invalid ID t.Run("GetTenant_InvalidID", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/tenants/not-a-uuid", token, nil) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) // Create tenant missing fields t.Run("CreateTenant_MissingFields", func(t *testing.T) { body := map[string]string{"name": "", "slug": ""} w := doRequest(t, handler, "POST", "/api/tenants", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } }) } // ── Tenant Auto-Assign ───────────────────────────────────────────────────── func TestIntegration_TenantAutoAssign(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("AutoAssign_NoMatch", func(t *testing.T) { body := map[string]string{"email": "nobody@nonexistent-domain-xyz.test"} w := doRequest(t, handler, "POST", "/api/tenants/auto-assign", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["assigned"] != false { t.Errorf("expected assigned=false, got %v", resp["assigned"]) } }) } // ── Case CRUD ─────────────────────────────────────────────────────────────── func TestIntegration_CaseCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) var caseID string t.Run("Create", func(t *testing.T) { body := map[string]string{ "case_number": "TEST/CRUD/001", "title": "CRUD Test — Patentverletzung", "case_type": "patent", "court": "UPC München", } w := doRequest(t, handler, "POST", "/api/cases", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) caseID, _ = resp["id"].(string) if caseID == "" { t.Fatal("created case missing ID") } if resp["status"] != "active" { t.Errorf("expected default status active, got %v", resp["status"]) } }) t.Run("Create_MissingFields", func(t *testing.T) { body := map[string]string{"title": ""} w := doRequest(t, handler, "POST", "/api/cases", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } }) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) total := resp["total"].(float64) if total < 1 { t.Fatalf("expected at least 1 case, got %.0f", total) } }) t.Run("List_SearchFilter", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases?search=CRUD", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp map[string]any decodeJSON(t, w, &resp) total := resp["total"].(float64) if total < 1 { t.Error("search filter should find our test case") } }) t.Run("List_StatusFilter", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases?status=active", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } }) t.Run("Get", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") } w := doRequest(t, handler, "GET", "/api/cases/"+caseID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != "CRUD Test — Patentverletzung" { t.Errorf("unexpected title: %v", resp["title"]) } if _, ok := resp["parties"]; !ok { t.Error("missing parties in CaseDetail response") } if _, ok := resp["recent_events"]; !ok { t.Error("missing recent_events in CaseDetail response") } }) t.Run("Get_InvalidID", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/not-a-uuid", token, nil) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) t.Run("Get_NotFound", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+uuid.New().String(), token, nil) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } }) t.Run("Update", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") } body := map[string]string{"title": "Updated Title", "court": "UPC Paris"} w := doRequest(t, handler, "PUT", "/api/cases/"+caseID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != "Updated Title" { t.Errorf("title not updated: %v", resp["title"]) } }) t.Run("Delete", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") } w := doRequest(t, handler, "DELETE", "/api/cases/"+caseID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]string decodeJSON(t, w, &resp) if resp["status"] != "archived" { t.Errorf("expected archived status, got %s", resp["status"]) } }) } // ── Party CRUD ────────────────────────────────────────────────────────────── func TestIntegration_PartyCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/PARTY/001", "Party Test Case") var partyID string t.Run("Create", func(t *testing.T) { body := map[string]any{ "name": "Acme Corp", "role": "claimant", "representative": "Dr. Schmidt", } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/parties", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) partyID, _ = resp["id"].(string) if partyID == "" { t.Fatal("created party missing ID") } }) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/parties", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) parties, ok := resp["parties"].([]any) if !ok { t.Fatal("expected parties array in response") } if len(parties) < 1 { t.Fatal("expected at least 1 party") } }) t.Run("Update", func(t *testing.T) { if partyID == "" { t.Skip("no party ID") } body := map[string]any{ "name": "Acme Corp GmbH", "representative": "RA Dr. Müller", } w := doRequest(t, handler, "PUT", "/api/parties/"+partyID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["name"] != "Acme Corp GmbH" { t.Errorf("name not updated: %v", resp["name"]) } }) t.Run("Delete", func(t *testing.T) { if partyID == "" { t.Skip("no party ID") } w := doRequest(t, handler, "DELETE", "/api/parties/"+partyID, token, nil) if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String()) } }) } // ── Deadline CRUD ─────────────────────────────────────────────────────────── func TestIntegration_DeadlineCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/DL/001", "Deadline Test Case") var deadlineID string dueDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02") warnDate := time.Now().AddDate(0, 0, 10).Format("2006-01-02") t.Run("Create", func(t *testing.T) { body := map[string]string{ "title": "Klageerwiderung einreichen", "due_date": dueDate, "warning_date": warnDate, "source": "manual", } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) deadlineID, _ = resp["id"].(string) if deadlineID == "" { t.Fatal("created deadline missing ID") } if resp["status"] != "pending" { t.Errorf("expected status pending, got %v", resp["status"]) } }) t.Run("Create_MissingFields", func(t *testing.T) { body := map[string]string{"title": ""} w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } }) t.Run("ListAll", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/deadlines", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("ListForCase", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/deadlines", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var deadlines []any decodeJSON(t, w, &deadlines) if len(deadlines) < 1 { t.Fatal("expected at least 1 deadline") } }) t.Run("Get", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") } w := doRequest(t, handler, "GET", "/api/deadlines/"+deadlineID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != "Klageerwiderung einreichen" { t.Errorf("unexpected title: %v", resp["title"]) } }) t.Run("Update", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") } newTitle := "Aktualisierte Frist" body := map[string]*string{"title": &newTitle} w := doRequest(t, handler, "PUT", "/api/deadlines/"+deadlineID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != newTitle { t.Errorf("expected title %q, got %v", newTitle, resp["title"]) } }) t.Run("Complete", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") } w := doRequest(t, handler, "PATCH", "/api/deadlines/"+deadlineID+"/complete", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["status"] != "completed" { t.Errorf("expected status completed, got %v", resp["status"]) } if resp["completed_at"] == nil { t.Error("expected completed_at to be set") } }) t.Run("Delete", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") } w := doRequest(t, handler, "DELETE", "/api/deadlines/"+deadlineID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Delete_NotFound", func(t *testing.T) { w := doRequest(t, handler, "DELETE", "/api/deadlines/"+uuid.New().String(), token, nil) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } }) } // ── Appointment CRUD ──────────────────────────────────────────────────────── func TestIntegration_AppointmentCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) startAt := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Second) endAt := startAt.Add(2 * time.Hour) var appointmentID string t.Run("Create", func(t *testing.T) { body := map[string]any{ "title": "Mündliche Verhandlung", "start_at": startAt.Format(time.RFC3339), "end_at": endAt.Format(time.RFC3339), "location": "UPC München, Saal 3", "appointment_type": "hearing", } w := doRequest(t, handler, "POST", "/api/appointments", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) appointmentID, _ = resp["id"].(string) if appointmentID == "" { t.Fatal("created appointment missing ID") } }) t.Run("Create_MissingTitle", func(t *testing.T) { body := map[string]any{"start_at": startAt.Format(time.RFC3339)} w := doRequest(t, handler, "POST", "/api/appointments", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) t.Run("Create_MissingStartAt", func(t *testing.T) { body := map[string]any{"title": "Test"} w := doRequest(t, handler, "POST", "/api/appointments", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) t.Run("Get", func(t *testing.T) { if appointmentID == "" { t.Skip("no appointment ID") } w := doRequest(t, handler, "GET", "/api/appointments/"+appointmentID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != "Mündliche Verhandlung" { t.Errorf("unexpected title: %v", resp["title"]) } }) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/appointments", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var appointments []any decodeJSON(t, w, &appointments) if len(appointments) < 1 { t.Fatal("expected at least 1 appointment") } }) t.Run("Update", func(t *testing.T) { if appointmentID == "" { t.Skip("no appointment ID") } newStart := startAt.Add(24 * time.Hour) body := map[string]any{ "title": "Verlegter Termin", "start_at": newStart.Format(time.RFC3339), "location": "UPC Paris, Saal 1", } w := doRequest(t, handler, "PUT", "/api/appointments/"+appointmentID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["title"] != "Verlegter Termin" { t.Errorf("title not updated: %v", resp["title"]) } }) t.Run("Delete", func(t *testing.T) { if appointmentID == "" { t.Skip("no appointment ID") } w := doRequest(t, handler, "DELETE", "/api/appointments/"+appointmentID, token, nil) if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Delete_NotFound", func(t *testing.T) { w := doRequest(t, handler, "DELETE", "/api/appointments/"+uuid.New().String(), token, nil) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } }) } // ── Note CRUD ─────────────────────────────────────────────────────────────── func TestIntegration_NoteCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/NOTE/001", "Note Test Case") var noteID string t.Run("Create", func(t *testing.T) { body := map[string]any{ "case_id": caseID, "content": "Mandant hat neue Unterlagen übermittelt.", } w := doRequest(t, handler, "POST", "/api/notes", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) noteID, _ = resp["id"].(string) if noteID == "" { t.Fatal("created note missing ID") } if resp["content"] != "Mandant hat neue Unterlagen übermittelt." { t.Errorf("unexpected content: %v", resp["content"]) } }) t.Run("Create_MissingContent", func(t *testing.T) { body := map[string]any{"case_id": caseID, "content": ""} w := doRequest(t, handler, "POST", "/api/notes", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } }) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/notes?case_id="+caseID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var notes []any decodeJSON(t, w, ¬es) if len(notes) < 1 { t.Fatal("expected at least 1 note") } }) t.Run("Update", func(t *testing.T) { if noteID == "" { t.Skip("no note ID") } body := map[string]any{"content": "Aktualisierte Notiz mit neuen Details."} w := doRequest(t, handler, "PUT", "/api/notes/"+noteID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["content"] != "Aktualisierte Notiz mit neuen Details." { t.Errorf("content not updated: %v", resp["content"]) } }) t.Run("Delete", func(t *testing.T) { if noteID == "" { t.Skip("no note ID") } w := doRequest(t, handler, "DELETE", "/api/notes/"+noteID, token, nil) if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Delete_NotFound", func(t *testing.T) { w := doRequest(t, handler, "DELETE", "/api/notes/"+uuid.New().String(), token, nil) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } }) } // ── Dashboard ─────────────────────────────────────────────────────────────── func TestIntegration_Dashboard(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/DASH/001", "Dashboard Test Case") // Add a deadline so dashboard has data dueDate := time.Now().AddDate(0, 0, 3).Format("2006-01-02") dlBody := map[string]string{"title": "Dashboard Test Deadline", "due_date": dueDate} w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, dlBody) if w.Code != http.StatusCreated { t.Fatalf("create deadline: %d: %s", w.Code, w.Body.String()) } t.Run("Get", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/dashboard", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var dashboard map[string]any decodeJSON(t, w, &dashboard) requiredKeys := []string{"deadline_summary", "case_summary", "upcoming_deadlines", "upcoming_appointments", "recent_activity"} for _, key := range requiredKeys { if _, exists := dashboard[key]; !exists { t.Errorf("missing key %q in dashboard", key) } } if cs, ok := dashboard["case_summary"].(map[string]any); ok { if active, ok := cs["active_count"].(float64); ok && active < 1 { t.Errorf("expected at least 1 active case, got %.0f", active) } } if ds, ok := dashboard["deadline_summary"].(map[string]any); ok { for _, key := range []string{"overdue_count", "due_this_week", "due_next_week", "ok_count"} { if _, exists := ds[key]; !exists { t.Errorf("missing deadline_summary.%s", key) } } } }) } // ── Proceeding Types & Deadline Rules ─────────────────────────────────────── func TestIntegration_ProceedingTypesAndRules(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("ListProceedingTypes", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/proceeding-types", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var types []any decodeJSON(t, w, &types) if len(types) == 0 { t.Error("expected at least 1 proceeding type") } }) t.Run("ListDeadlineRules", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/deadline-rules", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("GetRuleTree_INF", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/deadline-rules/INF", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var tree []any decodeJSON(t, w, &tree) if len(tree) == 0 { t.Error("expected non-empty rule tree for INF") } }) } // ── Deadline Calculator ───────────────────────────────────────────────────── func TestIntegration_DeadlineCalculator(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("Calculate_ValidRequest", func(t *testing.T) { body := map[string]any{ "proceeding_type": "INF", "trigger_event_date": "2026-06-01", } w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["proceeding_type"] != "INF" { t.Errorf("expected INF, got %v", resp["proceeding_type"]) } deadlines, ok := resp["deadlines"].([]any) if !ok { t.Fatal("deadlines not an array") } if len(deadlines) == 0 { t.Error("expected at least 1 calculated deadline") } for i, dl := range deadlines { m, ok := dl.(map[string]any) if !ok { continue } for _, key := range []string{"rule_code", "title", "due_date"} { if _, exists := m[key]; !exists { t.Errorf("deadline[%d] missing field %q", i, key) } } } }) t.Run("Calculate_MissingFields", func(t *testing.T) { body := map[string]string{} w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) t.Run("Calculate_InvalidDateFormat", func(t *testing.T) { body := map[string]string{ "proceeding_type": "INF", "trigger_event_date": "01/06/2026", } w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for bad date format, got %d", w.Code) } }) t.Run("Calculate_UnknownProceedingType", func(t *testing.T) { body := map[string]string{ "proceeding_type": "NONEXISTENT", "trigger_event_date": "2026-06-01", } w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for unknown type, got %d", w.Code) } }) } // ── Deadline Determination ────────────────────────────────────────────────── func TestIntegration_Determine(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("GetTimeline_INF", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/proceeding-types/INF/timeline", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["proceeding_type"]; !ok { t.Error("missing proceeding_type in timeline response") } timeline, ok := resp["timeline"].([]any) if !ok { t.Fatal("timeline not an array") } if len(timeline) == 0 { t.Error("expected non-empty timeline for INF") } }) t.Run("GetTimeline_Unknown", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/proceeding-types/NONEXISTENT/timeline", token, nil) if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest { t.Errorf("expected 404 or 400 for unknown type, got %d", w.Code) } }) t.Run("Determine_ValidRequest", func(t *testing.T) { body := map[string]any{ "proceeding_type": "INF", "trigger_event_date": "2026-06-01", "conditions": map[string]bool{}, } w := doRequest(t, handler, "POST", "/api/deadlines/determine", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["proceeding_type"] != "INF" { t.Errorf("expected INF, got %v", resp["proceeding_type"]) } if _, ok := resp["timeline"]; !ok { t.Error("missing timeline in determine response") } if _, ok := resp["total_deadlines"]; !ok { t.Error("missing total_deadlines in determine response") } }) t.Run("Determine_MissingFields", func(t *testing.T) { body := map[string]any{} w := doRequest(t, handler, "POST", "/api/deadlines/determine", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) t.Run("BatchCreate", func(t *testing.T) { caseID := createTestCase(t, handler, token, "TEST/BATCH/001", "Batch Deadline Test") dueDate := time.Now().AddDate(0, 1, 0).Format("2006-01-02") body := map[string]any{ "deadlines": []map[string]any{ {"title": "Batch Frist 1", "due_date": dueDate}, {"title": "Batch Frist 2", "due_date": dueDate}, }, } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines/batch", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) created, ok := resp["created"].(float64) if !ok || created < 2 { t.Errorf("expected at least 2 created, got %v", resp["created"]) } }) } // ── Reports ───────────────────────────────────────────────────────────────── func TestIntegration_Reports(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("CaseReport", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/reports/cases", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp any decodeJSON(t, w, &resp) }) t.Run("DeadlineReport", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/reports/deadlines", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp any decodeJSON(t, w, &resp) }) t.Run("WorkloadReport", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/reports/workload", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp any decodeJSON(t, w, &resp) }) t.Run("BillingReport", func(t *testing.T) { // Owner role has PermManageBilling w := doRequest(t, handler, "GET", "/api/reports/billing", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp any decodeJSON(t, w, &resp) }) t.Run("CaseReport_WithDateRange", func(t *testing.T) { from := time.Now().AddDate(-1, 0, 0).Format("2006-01-02") to := time.Now().Format("2006-01-02") w := doRequest(t, handler, "GET", fmt.Sprintf("/api/reports/cases?from=%s&to=%s", from, to), token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) } // ── Templates ─────────────────────────────────────────────────────────────── func TestIntegration_TemplateCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) var templateID string t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/templates", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["data"]; !ok { t.Error("missing data in templates response") } if _, ok := resp["total"]; !ok { t.Error("missing total in templates response") } }) t.Run("Create", func(t *testing.T) { body := map[string]any{ "name": "Klageerwiderung Vorlage", "category": "schriftsatz", "content": "Sehr geehrte Damen und Herren,\n\nim Namen und im Auftrag des {{party_name}}...", } w := doRequest(t, handler, "POST", "/api/templates", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) templateID, _ = resp["id"].(string) if templateID == "" { t.Fatal("created template missing ID") } if resp["name"] != "Klageerwiderung Vorlage" { t.Errorf("unexpected name: %v", resp["name"]) } }) t.Run("Get", func(t *testing.T) { if templateID == "" { t.Skip("no template ID") } w := doRequest(t, handler, "GET", "/api/templates/"+templateID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["name"] != "Klageerwiderung Vorlage" { t.Errorf("unexpected name: %v", resp["name"]) } }) t.Run("Update", func(t *testing.T) { if templateID == "" { t.Skip("no template ID") } body := map[string]any{ "name": "Aktualisierte Vorlage", "category": "intern", } w := doRequest(t, handler, "PUT", "/api/templates/"+templateID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["name"] != "Aktualisierte Vorlage" { t.Errorf("name not updated: %v", resp["name"]) } }) t.Run("Render", func(t *testing.T) { if templateID == "" { t.Skip("no template ID") } w := doRequest(t, handler, "POST", "/api/templates/"+templateID+"/render", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["content"]; !ok { t.Error("missing content in render response") } }) t.Run("Delete", func(t *testing.T) { if templateID == "" { t.Skip("no template ID") } w := doRequest(t, handler, "DELETE", "/api/templates/"+templateID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) } // ── Time Entries ──────────────────────────────────────────────────────────── func TestIntegration_TimeEntryCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/TIME/001", "Time Entry Test Case") var entryID string t.Run("Create", func(t *testing.T) { body := map[string]any{ "date": time.Now().Format("2006-01-02"), "duration_minutes": 90, "description": "Schriftsatz vorbereitet", "activity": "drafting", "billable": true, } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/time-entries", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) entryID, _ = resp["id"].(string) if entryID == "" { t.Fatal("created time entry missing ID") } }) t.Run("Create_MissingDescription", func(t *testing.T) { body := map[string]any{ "date": time.Now().Format("2006-01-02"), "duration_minutes": 60, } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/time-entries", token, body) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } }) t.Run("ListAll", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/time-entries", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["time_entries"]; !ok { t.Error("missing time_entries in response") } }) t.Run("ListForCase", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/time-entries", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) entries, ok := resp["time_entries"].([]any) if !ok { t.Fatal("expected time_entries array") } if len(entries) < 1 { t.Fatal("expected at least 1 time entry") } }) t.Run("Update", func(t *testing.T) { if entryID == "" { t.Skip("no entry ID") } body := map[string]any{ "description": "Schriftsatz überarbeitet", "duration_minutes": 120, } w := doRequest(t, handler, "PUT", "/api/time-entries/"+entryID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Summary", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/time-entries/summary", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["summary"]; !ok { t.Error("missing summary in response") } }) t.Run("Delete", func(t *testing.T) { if entryID == "" { t.Skip("no entry ID") } w := doRequest(t, handler, "DELETE", "/api/time-entries/"+entryID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) } // ── Invoices ──────────────────────────────────────────────────────────────── func TestIntegration_InvoiceCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/INV/001", "Invoice Test Case") var invoiceID string t.Run("List_Empty", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/invoices", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["invoices"]; !ok { t.Error("missing invoices in response") } }) t.Run("Create", func(t *testing.T) { body := map[string]any{ "case_id": caseID, "client_name": "Acme Corp GmbH", "items": []map[string]any{ { "description": "Beratung Patentrecht", "amount": 500.0, }, }, "tax_rate": 0.19, } w := doRequest(t, handler, "POST", "/api/invoices", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) invoiceID, _ = resp["id"].(string) if invoiceID == "" { t.Fatal("created invoice missing ID") } if resp["client_name"] != "Acme Corp GmbH" { t.Errorf("unexpected client_name: %v", resp["client_name"]) } }) t.Run("Get", func(t *testing.T) { if invoiceID == "" { t.Skip("no invoice ID") } w := doRequest(t, handler, "GET", "/api/invoices/"+invoiceID, token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Update", func(t *testing.T) { if invoiceID == "" { t.Skip("no invoice ID") } body := map[string]any{ "client_name": "Acme Corp GmbH (updated)", "notes": "Zahlung bis 30.04.2026", } w := doRequest(t, handler, "PUT", "/api/invoices/"+invoiceID, token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("UpdateStatus", func(t *testing.T) { if invoiceID == "" { t.Skip("no invoice ID") } body := map[string]any{"status": "sent"} w := doRequest(t, handler, "PATCH", "/api/invoices/"+invoiceID+"/status", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if resp["status"] != "sent" { t.Errorf("expected status sent, got %v", resp["status"]) } }) } // ── Billing Rates ─────────────────────────────────────────────────────────── func TestIntegration_BillingRates(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/billing-rates", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["billing_rates"]; !ok { t.Error("missing billing_rates in response") } }) t.Run("Upsert", func(t *testing.T) { body := map[string]any{ "rate": 350.0, "currency": "EUR", "valid_from": "2026-01-01", } w := doRequest(t, handler, "PUT", "/api/billing-rates", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if rate, ok := resp["rate"].(float64); !ok || rate != 350.0 { t.Errorf("expected rate 350, got %v", resp["rate"]) } }) } // ── Notifications ─────────────────────────────────────────────────────────── func TestIntegration_Notifications(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/notifications", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["data"]; !ok { t.Error("missing data in notifications response") } if _, ok := resp["total"]; !ok { t.Error("missing total in notifications response") } }) t.Run("UnreadCount", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/notifications/unread-count", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["unread_count"]; !ok { t.Error("missing unread_count in response") } }) t.Run("MarkAllRead", func(t *testing.T) { w := doRequest(t, handler, "PATCH", "/api/notifications/read-all", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("GetPreferences", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/notification-preferences", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) // Should have preference fields for _, key := range []string{"email_enabled", "daily_digest"} { if _, ok := resp[key]; !ok { t.Errorf("missing %s in notification preferences", key) } } }) t.Run("UpdatePreferences", func(t *testing.T) { body := map[string]any{ "deadline_reminder_days": []int{1, 3, 7}, "email_enabled": true, "daily_digest": false, } w := doRequest(t, handler, "PUT", "/api/notification-preferences", token, body) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) } // ── Audit Log ─────────────────────────────────────────────────────────────── func TestIntegration_AuditLog(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) // Create a case first to generate audit entries createTestCase(t, handler, token, "TEST/AUDIT/001", "Audit Log Test Case") t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/audit-log", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["entries"]; !ok { t.Error("missing entries in audit log response") } if _, ok := resp["total"]; !ok { t.Error("missing total in audit log response") } }) t.Run("List_WithFilters", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/audit-log?entity_type=case&limit=10", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) if _, ok := resp["entries"]; !ok { t.Error("missing entries in filtered audit log response") } // Note: audit entries may not exist in test environment due to httptest's // RemoteAddr (192.0.2.1:1234) failing inet column validation }) } // ── Case Assignments ──────────────────────────────────────────────────────── func TestIntegration_CaseAssignments(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/ASSIGN/001", "Assignment Test Case") t.Run("List_Empty", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/assignments", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Assign", func(t *testing.T) { body := map[string]any{ "user_id": userID.String(), "role": "lead", } w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/assignments", token, body) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) } }) t.Run("List_AfterAssign", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/assignments", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) t.Run("Unassign", func(t *testing.T) { w := doRequest(t, handler, "DELETE", "/api/cases/"+caseID+"/assignments/"+userID.String(), token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } }) } // ── Documents ─────────────────────────────────────────────────────────────── func TestIntegration_Documents(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) caseID := createTestCase(t, handler, token, "TEST/DOC/001", "Document Test Case") t.Run("ListDocuments_Empty", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/documents", token, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]any decodeJSON(t, w, &resp) total := resp["total"].(float64) if total != 0 { t.Errorf("expected 0 documents, got %.0f", total) } }) t.Run("GetMeta_NotFound", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/documents/"+uuid.New().String()+"/meta", token, nil) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } }) t.Run("GetMeta_InvalidID", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/documents/not-a-uuid/meta", token, nil) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } }) } // ── AI Endpoints ──────────────────────────────────────────────────────────── func TestIntegration_AI_NoAPIKey(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) body := map[string]string{"text": "Die Frist beträgt 3 Monate ab Zustellung."} w := doRequest(t, handler, "POST", "/api/ai/extract-deadlines", token, body) // Should be 404 (route not registered) since we don't set ANTHROPIC_API_KEY // or 200 if the key happens to be set in the env if w.Code == http.StatusNotFound { t.Log("AI endpoint correctly not available without API key") } else if w.Code == http.StatusOK { t.Log("AI endpoint available (API key configured in env)") } else { t.Logf("AI endpoint returned %d (may need API key)", w.Code) } } // ── Critical Path E2E ─────────────────────────────────────────────────────── func TestIntegration_CriticalPath(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) // Step 1: Create a case caseBody := `{"case_number":"TEST/001","title":"Integration Test — Patentverletzung","case_type":"patent","court":"UPC München"}` req := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(caseBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("create case: expected 201, got %d: %s", w.Code, w.Body.String()) } var createdCase map[string]any json.Unmarshal(w.Body.Bytes(), &createdCase) caseID, ok := createdCase["id"].(string) if !ok || caseID == "" { t.Fatalf("create case: missing ID in response: %v", createdCase) } // Step 2: List cases — verify our case is there w = doRequest(t, handler, "GET", "/api/cases", token, nil) if w.Code != http.StatusOK { t.Fatalf("list cases: expected 200, got %d: %s", w.Code, w.Body.String()) } var caseList map[string]any decodeJSON(t, w, &caseList) total := caseList["total"].(float64) if total < 1 { t.Fatalf("list cases: expected at least 1 case, got %.0f", total) } // Step 3: Add a deadline to the case dueDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02") warnDate := time.Now().AddDate(0, 0, 10).Format("2006-01-02") dlBody := map[string]any{ "title": "Klageerwiderung einreichen", "due_date": dueDate, "warning_date": warnDate, "source": "manual", } w = doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, dlBody) if w.Code != http.StatusCreated { t.Fatalf("create deadline: expected 201, got %d: %s", w.Code, w.Body.String()) } var createdDeadline map[string]any decodeJSON(t, w, &createdDeadline) deadlineID, ok := createdDeadline["id"].(string) if !ok || deadlineID == "" { t.Fatalf("create deadline: missing ID in response: %v", createdDeadline) } // Step 4: Create an appointment linked to the case startAt := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) apptBody := map[string]any{ "title": "Mündliche Verhandlung", "start_at": startAt.Format(time.RFC3339), "location": "UPC München", "appointment_type": "hearing", "case_id": caseID, } w = doRequest(t, handler, "POST", "/api/appointments", token, apptBody) if w.Code != http.StatusCreated { t.Fatalf("create appointment: expected 201, got %d: %s", w.Code, w.Body.String()) } // Step 5: Add a note to the case noteBody := map[string]any{ "case_id": caseID, "content": "Mandant hat Klage erhalten, Fristberechnung durchgeführt.", } w = doRequest(t, handler, "POST", "/api/notes", token, noteBody) if w.Code != http.StatusCreated { t.Fatalf("create note: expected 201, got %d: %s", w.Code, w.Body.String()) } // Step 6: Add a time entry timeBody := map[string]any{ "date": time.Now().Format("2006-01-02"), "duration_minutes": 45, "description": "Erstberatung zum Fall", "billable": true, } w = doRequest(t, handler, "POST", "/api/cases/"+caseID+"/time-entries", token, timeBody) if w.Code != http.StatusCreated { t.Fatalf("create time entry: expected 201, got %d: %s", w.Code, w.Body.String()) } // Step 7: Verify deadline appears in case deadlines w = doRequest(t, handler, "GET", "/api/cases/"+caseID+"/deadlines", token, nil) if w.Code != http.StatusOK { t.Fatalf("list deadlines: expected 200, got %d: %s", w.Code, w.Body.String()) } var deadlines []any decodeJSON(t, w, &deadlines) if len(deadlines) < 1 { t.Fatalf("list deadlines: expected at least 1, got %d", len(deadlines)) } // Step 8: Fetch dashboard w = doRequest(t, handler, "GET", "/api/dashboard", token, nil) if w.Code != http.StatusOK { t.Fatalf("dashboard: expected 200, got %d: %s", w.Code, w.Body.String()) } var dashboard map[string]any decodeJSON(t, w, &dashboard) for _, key := range []string{"deadline_summary", "case_summary", "upcoming_deadlines"} { if _, exists := dashboard[key]; !exists { t.Errorf("dashboard: missing key %q in response", key) } } // Step 9: Complete the deadline w = doRequest(t, handler, "PATCH", "/api/deadlines/"+deadlineID+"/complete", token, nil) if w.Code != http.StatusOK { t.Fatalf("complete deadline: expected 200, got %d: %s", w.Code, w.Body.String()) } var completedDL map[string]any decodeJSON(t, w, &completedDL) if completedDL["status"] != "completed" { t.Errorf("expected completed, got %v", completedDL["status"]) } // Step 10: Verify audit log endpoint is accessible w = doRequest(t, handler, "GET", "/api/audit-log", token, nil) if w.Code != http.StatusOK { t.Fatalf("audit log: expected 200, got %d: %s", w.Code, w.Body.String()) } var auditResp map[string]any decodeJSON(t, w, &auditResp) if _, ok := auditResp["entries"]; !ok { t.Error("missing entries in audit log response") } }