- Structured logging: replace log.* with log/slog JSON output across backend - Request logger middleware: logs method, path, status, duration for all non-health requests - Rate limiting: token bucket (5 req/min, burst 10) on AI endpoints (/api/ai/*) - Integration tests: full critical path test (auth -> create case -> add deadline -> dashboard) - Seed demo data: 1 tenant, 5 cases with deadlines/appointments/parties/events - docker-compose.yml: add all required env vars (DATABASE_URL, SUPABASE_*, ANTHROPIC_API_KEY) - .env.example: document all env vars including DATABASE_URL and CalDAV note
274 lines
8.7 KiB
Go
274 lines
8.7 KiB
Go
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"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// testServer sets up the full router with a real DB connection.
|
|
// Requires DATABASE_URL and SUPABASE_JWT_SECRET env vars.
|
|
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 = "test-jwt-secret-for-integration-tests"
|
|
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)
|
|
handler := router.New(database, authMW, cfg, nil)
|
|
|
|
return handler, func() { 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 = "test-jwt-secret-for-integration-tests"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// setupTestTenant creates a temporary tenant and user_tenant for testing.
|
|
// Returns tenantID and userID. Cleans up on test completion.
|
|
func setupTestTenant(t *testing.T, handler 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 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 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
|
|
}
|
|
|
|
func TestHealthEndpoint(t *testing.T) {
|
|
handler, cleanup := testServer(t)
|
|
defer cleanup()
|
|
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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)
|
|
if resp["status"] != "ok" {
|
|
t.Fatalf("expected status ok, got %v", resp)
|
|
}
|
|
}
|
|
|
|
func TestCriticalPath_CreateCase_AddDeadline_Dashboard(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]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &createdCase)
|
|
caseID, ok := createdCase["id"].(string)
|
|
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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list cases: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var caseList map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &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")
|
|
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)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create deadline: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var createdDeadline map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &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: 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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list deadlines: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var deadlines []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &deadlines)
|
|
if len(deadlines) < 1 {
|
|
t.Fatalf("list deadlines: expected at least 1, got %d", len(deadlines))
|
|
}
|
|
|
|
// Step 5: 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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("dashboard: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var dashboard map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &dashboard)
|
|
|
|
// Verify dashboard has expected sections
|
|
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]interface{}); ok {
|
|
if active, ok := cs["active"].(float64); ok && active < 1 {
|
|
t.Errorf("dashboard: expected at least 1 active case, got %.0f", active)
|
|
}
|
|
}
|
|
|
|
t.Logf("Full critical path passed: auth -> create case -> add deadline -> dashboard")
|
|
}
|
|
|
|
func TestUnauthenticatedAccess(t *testing.T) {
|
|
handler, cleanup := testServer(t)
|
|
defer cleanup()
|
|
|
|
// Accessing API without token should return 401
|
|
req := httptest.NewRequest("GET", "/api/cases", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|