diff --git a/backend/internal/integration_test.go b/backend/internal/integration_test.go index c6ac3ce..9a8ac30 100644 --- a/backend/internal/integration_test.go +++ b/backend/internal/integration_test.go @@ -14,13 +14,16 @@ import ( "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 and SUPABASE_JWT_SECRET env vars. +// Requires DATABASE_URL env var. func testServer(t *testing.T) (http.Handler, func()) { t.Helper() @@ -30,7 +33,7 @@ func testServer(t *testing.T) (http.Handler, func()) { } jwtSecret := os.Getenv("SUPABASE_JWT_SECRET") if jwtSecret == "" { - jwtSecret = "test-jwt-secret-for-integration-tests" + jwtSecret = testJWTSecret os.Setenv("SUPABASE_JWT_SECRET", jwtSecret) } @@ -46,9 +49,13 @@ func testServer(t *testing.T) (http.Handler, func()) { } authMW := auth.NewMiddleware(jwtSecret, database) - handler := router.New(database, authMW, cfg, nil, nil) + notifSvc := services.NewNotificationService(database) + handler := router.New(database, authMW, cfg, nil, notifSvc) - return handler, func() { database.Close() } + return handler, func() { + notifSvc.Stop() + database.Close() + } } // createTestJWT creates a JWT for the given user ID, signed with the test secret. @@ -56,7 +63,7 @@ func createTestJWT(t *testing.T, userID uuid.UUID) string { t.Helper() secret := os.Getenv("SUPABASE_JWT_SECRET") if secret == "" { - secret = "test-jwt-secret-for-integration-tests" + secret = testJWTSecret } claims := jwt.MapClaims{ @@ -78,7 +85,7 @@ func createExpiredJWT(t *testing.T, userID uuid.UUID) string { t.Helper() secret := os.Getenv("SUPABASE_JWT_SECRET") if secret == "" { - secret = "test-jwt-secret-for-integration-tests" + secret = testJWTSecret } claims := jwt.MapClaims{ @@ -133,10 +140,19 @@ func setupTestTenant(t *testing.T, _ http.Handler) (tenantID, userID uuid.UUID) 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) @@ -184,22 +200,39 @@ func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, target any) { } } +// 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 TestHealthEndpoint(t *testing.T) { +func TestIntegration_Health(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() - req := httptest.NewRequest("GET", "/health", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - + 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 - json.Unmarshal(w.Body.Bytes(), &resp) + decodeJSON(t, w, &resp) if resp["status"] != "ok" { t.Fatalf("expected status ok, got %v", resp) } @@ -207,81 +240,104 @@ func TestHealthEndpoint(t *testing.T) { // ── Auth Middleware ────────────────────────────────────────────────────────── -func TestUnauthenticatedAccess(t *testing.T) { +func TestIntegration_Auth(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() - endpoints := []struct { - method string - path string - }{ - {"GET", "/api/cases"}, - {"POST", "/api/cases"}, - {"GET", "/api/tenants"}, - {"GET", "/api/deadlines"}, - {"GET", "/api/appointments"}, - {"GET", "/api/dashboard"}, - } + 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()) - } - }) - } + 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) + } + }) } -func TestExpiredJWT(t *testing.T) { - handler, cleanup := testServer(t) - defer cleanup() +// ── Current User (GET /api/me) ───────────────────────────────────────────── - 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) - } -} - -func TestInvalidJWT(t *testing.T) { - handler, cleanup := testServer(t) - defer cleanup() - - 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) - } -} - -func TestWrongSecretJWT(t *testing.T) { - handler, cleanup := testServer(t) - defer cleanup() - - // Sign with a different secret - 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) - } -} - -// ── Tenant CRUD ───────────────────────────────────────────────────────────── - -func TestTenantCRUD(t *testing.T) { +func TestIntegration_Me(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) - // List tenants — should have the one from setup + 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 { @@ -309,7 +365,6 @@ func TestTenantCRUD(t *testing.T) { t.Fatal("created tenant missing ID") } - // Cleanup the extra tenant t.Cleanup(func() { dbURL := os.Getenv("DATABASE_URL") cleanupDB, _ := db.Connect(dbURL) @@ -321,15 +376,17 @@ func TestTenantCRUD(t *testing.T) { }) }) - // Get tenant by ID + // Get tenant by ID (use the setup tenant we know the user is a member of) t.Run("GetTenant", func(t *testing.T) { - if newTenantID == "" { - t.Skip("no tenant ID from create test") - } - w := doRequest(t, handler, "GET", "/api/tenants/"+newTenantID, token, nil) + 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 @@ -350,9 +407,32 @@ func TestTenantCRUD(t *testing.T) { }) } +// ── 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 TestCaseCRUD(t *testing.T) { +func TestIntegration_CaseCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() @@ -361,7 +441,6 @@ func TestCaseCRUD(t *testing.T) { var caseID string - // Create case t.Run("Create", func(t *testing.T) { body := map[string]string{ "case_number": "TEST/CRUD/001", @@ -384,7 +463,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Create case - missing fields t.Run("Create_MissingFields", func(t *testing.T) { body := map[string]string{"title": ""} w := doRequest(t, handler, "POST", "/api/cases", token, body) @@ -393,7 +471,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // List cases t.Run("List", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases", token, nil) if w.Code != http.StatusOK { @@ -407,7 +484,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // List with search filter t.Run("List_SearchFilter", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases?search=CRUD", token, nil) if w.Code != http.StatusOK { @@ -421,7 +497,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // List with status filter t.Run("List_StatusFilter", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases?status=active", token, nil) if w.Code != http.StatusOK { @@ -429,7 +504,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Get case by ID t.Run("Get", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") @@ -443,7 +517,6 @@ func TestCaseCRUD(t *testing.T) { if resp["title"] != "CRUD Test — Patentverletzung" { t.Errorf("unexpected title: %v", resp["title"]) } - // CaseDetail should include parties and recent_events if _, ok := resp["parties"]; !ok { t.Error("missing parties in CaseDetail response") } @@ -452,7 +525,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Get case - invalid ID 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 { @@ -460,7 +532,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Get case - not found 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 { @@ -468,7 +539,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Update case t.Run("Update", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") @@ -485,7 +555,6 @@ func TestCaseCRUD(t *testing.T) { } }) - // Delete (archive) case t.Run("Delete", func(t *testing.T) { if caseID == "" { t.Skip("no case ID") @@ -502,33 +571,96 @@ func TestCaseCRUD(t *testing.T) { }) } -// ── Deadline CRUD ─────────────────────────────────────────────────────────── +// ── Party CRUD ────────────────────────────────────────────────────────────── -func TestDeadlineCRUD(t *testing.T) { +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") - // First create a case to attach deadlines to - caseBody := map[string]string{ - "case_number": "TEST/DL/001", - "title": "Deadline Test Case", - } - w := doRequest(t, handler, "POST", "/api/cases", token, caseBody) - if w.Code != http.StatusCreated { - t.Fatalf("create case: expected 201, got %d: %s", w.Code, w.Body.String()) - } - var caseResp map[string]any - decodeJSON(t, w, &caseResp) - caseID := caseResp["id"].(string) + 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") - // Create deadline t.Run("Create", func(t *testing.T) { body := map[string]string{ "title": "Klageerwiderung einreichen", @@ -551,7 +683,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // Create deadline - missing fields t.Run("Create_MissingFields", func(t *testing.T) { body := map[string]string{"title": ""} w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, body) @@ -560,7 +691,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // List all deadlines t.Run("ListAll", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/deadlines", token, nil) if w.Code != http.StatusOK { @@ -568,7 +698,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // List deadlines for case t.Run("ListForCase", func(t *testing.T) { w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/deadlines", token, nil) if w.Code != http.StatusOK { @@ -581,7 +710,21 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // Update 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") @@ -599,7 +742,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // Complete deadline t.Run("Complete", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") @@ -618,7 +760,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // Delete deadline t.Run("Delete", func(t *testing.T) { if deadlineID == "" { t.Skip("no deadline ID") @@ -629,7 +770,6 @@ func TestDeadlineCRUD(t *testing.T) { } }) - // Delete non-existent deadline 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 { @@ -640,7 +780,7 @@ func TestDeadlineCRUD(t *testing.T) { // ── Appointment CRUD ──────────────────────────────────────────────────────── -func TestAppointmentCRUD(t *testing.T) { +func TestIntegration_AppointmentCRUD(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() @@ -652,7 +792,6 @@ func TestAppointmentCRUD(t *testing.T) { var appointmentID string - // Create appointment t.Run("Create", func(t *testing.T) { body := map[string]any{ "title": "Mündliche Verhandlung", @@ -673,18 +812,14 @@ func TestAppointmentCRUD(t *testing.T) { } }) - // Create appointment - missing title t.Run("Create_MissingTitle", func(t *testing.T) { - body := map[string]any{ - "start_at": startAt.Format(time.RFC3339), - } + 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) } }) - // Create appointment - missing start_at t.Run("Create_MissingStartAt", func(t *testing.T) { body := map[string]any{"title": "Test"} w := doRequest(t, handler, "POST", "/api/appointments", token, body) @@ -693,7 +828,21 @@ func TestAppointmentCRUD(t *testing.T) { } }) - // List appointments + 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 { @@ -706,7 +855,6 @@ func TestAppointmentCRUD(t *testing.T) { } }) - // Update appointment t.Run("Update", func(t *testing.T) { if appointmentID == "" { t.Skip("no appointment ID") @@ -728,7 +876,6 @@ func TestAppointmentCRUD(t *testing.T) { } }) - // Delete appointment t.Run("Delete", func(t *testing.T) { if appointmentID == "" { t.Skip("no appointment ID") @@ -739,7 +886,6 @@ func TestAppointmentCRUD(t *testing.T) { } }) - // Delete non-existent appointment 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 { @@ -748,34 +894,106 @@ func TestAppointmentCRUD(t *testing.T) { }) } -// ── Dashboard ─────────────────────────────────────────────────────────────── +// ── Note CRUD ─────────────────────────────────────────────────────────────── -func TestDashboard(t *testing.T) { +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") - // Create a case + deadline so dashboard has data - caseBody := map[string]string{ - "case_number": "TEST/DASH/001", - "title": "Dashboard Test Case", - } - w := doRequest(t, handler, "POST", "/api/cases", token, caseBody) - if w.Code != http.StatusCreated { - t.Fatalf("create case: %d: %s", w.Code, w.Body.String()) - } - var caseResp map[string]any - decodeJSON(t, w, &caseResp) - caseID := caseResp["id"].(string) + 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) + 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()) } @@ -789,7 +1007,6 @@ func TestDashboard(t *testing.T) { var dashboard map[string]any decodeJSON(t, w, &dashboard) - // Verify all expected sections exist requiredKeys := []string{"deadline_summary", "case_summary", "upcoming_deadlines", "upcoming_appointments", "recent_activity"} for _, key := range requiredKeys { if _, exists := dashboard[key]; !exists { @@ -797,14 +1014,12 @@ func TestDashboard(t *testing.T) { } } - // Verify case_summary has at least 1 active case 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) } } - // Verify deadline_summary fields 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 { @@ -815,9 +1030,50 @@ func TestDashboard(t *testing.T) { }) } +// ── 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 TestDeadlineCalculator(t *testing.T) { +func TestIntegration_DeadlineCalculator(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() @@ -845,8 +1101,6 @@ func TestDeadlineCalculator(t *testing.T) { if len(deadlines) == 0 { t.Error("expected at least 1 calculated deadline") } - - // Verify each deadline has expected fields for i, dl := range deadlines { m, ok := dl.(map[string]any) if !ok { @@ -891,68 +1145,661 @@ func TestDeadlineCalculator(t *testing.T) { }) } -// ── Proceeding Types & Deadline Rules ─────────────────────────────────────── +// ── Deadline Determination ────────────────────────────────────────────────── -func TestProceedingTypesAndRules(t *testing.T) { +func TestIntegration_Determine(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) + 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 types []any - decodeJSON(t, w, &types) - if len(types) == 0 { - t.Error("expected at least 1 proceeding type") + 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("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("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("GetRuleTree_INF", func(t *testing.T) { - w := doRequest(t, handler, "GET", "/api/deadline-rules/INF", token, nil) + 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 tree []any - decodeJSON(t, w, &tree) - if len(tree) == 0 { - t.Error("expected non-empty rule tree for INF") + 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"]) } }) } -// ── Document Upload ───────────────────────────────────────────────────────── +// ── Reports ───────────────────────────────────────────────────────────────── -func TestDocumentUpload(t *testing.T) { +func TestIntegration_Reports(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() _, userID := setupTestTenant(t, handler) token := createTestJWT(t, userID) - // Create a case first - caseBody := map[string]string{ - "case_number": "TEST/DOC/001", - "title": "Document Test Case", - } - w := doRequest(t, handler, "POST", "/api/cases", token, caseBody) - if w.Code != http.StatusCreated { - t.Fatalf("create case: %d: %s", w.Code, w.Body.String()) - } - var caseResp map[string]any - decodeJSON(t, w, &caseResp) - caseID := caseResp["id"].(string) + 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) @@ -984,9 +1831,7 @@ func TestDocumentUpload(t *testing.T) { // ── AI Endpoints ──────────────────────────────────────────────────────────── -func TestAIExtractDeadlines_NoAPIKey(t *testing.T) { - // AI endpoints are only registered when ANTHROPIC_API_KEY is set. - // Without it, the route should 404. +func TestIntegration_AI_NoAPIKey(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() @@ -1009,7 +1854,7 @@ func TestAIExtractDeadlines_NoAPIKey(t *testing.T) { // ── Critical Path E2E ─────────────────────────────────────────────────────── -func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) { +func TestIntegration_CriticalPath(t *testing.T) { handler, cleanup := testServer(t) defer cleanup() @@ -1034,20 +1879,15 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) { if !ok || caseID == "" { t.Fatalf("create case: missing ID in response: %v", createdCase) } - t.Logf("Created case: %s", caseID) // Step 2: List cases — verify our case is there - req = httptest.NewRequest("GET", "/api/cases", nil) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) - + 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 - json.Unmarshal(w.Body.Bytes(), &caseList) + decodeJSON(t, w, &caseList) total := caseList["total"].(float64) if total < 1 { t.Fatalf("list cases: expected at least 1 case, got %.0f", total) @@ -1056,24 +1896,23 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) { // 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") - deadlineBody := fmt.Sprintf(`{"title":"Klageerwiderung einreichen","due_date":"%s","warning_date":"%s","source":"manual"}`, dueDate, warnDate) - req = httptest.NewRequest("POST", "/api/cases/"+caseID+"/deadlines", bytes.NewBufferString(deadlineBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) - + 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 - json.Unmarshal(w.Body.Bytes(), &createdDeadline) + decodeJSON(t, w, &createdDeadline) deadlineID, ok := createdDeadline["id"].(string) if !ok || deadlineID == "" { t.Fatalf("create deadline: missing ID in response: %v", createdDeadline) } - t.Logf("Created deadline: %s", deadlineID) // Step 4: Create an appointment linked to the case startAt := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) @@ -1088,52 +1927,56 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) { if w.Code != http.StatusCreated { t.Fatalf("create appointment: expected 201, got %d: %s", w.Code, w.Body.String()) } - t.Log("Created appointment linked to case") - // Step 5: Verify deadline appears in case deadlines - req = httptest.NewRequest("GET", "/api/cases/"+caseID+"/deadlines", nil) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) + // 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 - json.Unmarshal(w.Body.Bytes(), &deadlines) + decodeJSON(t, w, &deadlines) if len(deadlines) < 1 { t.Fatalf("list deadlines: expected at least 1, got %d", len(deadlines)) } - // Step 6: Fetch dashboard — should include our case and deadline - req = httptest.NewRequest("GET", "/api/dashboard", nil) - req.Header.Set("Authorization", "Bearer "+token) - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) - + // 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 - json.Unmarshal(w.Body.Bytes(), &dashboard) - - // Verify dashboard has expected sections + 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) } } - // Verify case_summary shows at least 1 active case - if cs, ok := dashboard["case_summary"].(map[string]any); ok { - if active, ok := cs["active_count"].(float64); ok && active < 1 { - t.Errorf("dashboard: expected at least 1 active case, got %.0f", active) - } - } - - // Step 7: Complete the deadline and verify + // 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()) @@ -1144,5 +1987,14 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) { t.Errorf("expected completed, got %v", completedDL["status"]) } - t.Logf("Full critical path passed: auth -> create case -> add deadline -> create appointment -> dashboard -> complete deadline") + // 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") + } }