package handlers import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/breakpilot/billing-service/internal/models" "github.com/gin-gonic/gin" ) func init() { // Set Gin to test mode gin.SetMode(gin.TestMode) } func TestGetPlans_ResponseFormat(t *testing.T) { // Test that GetPlans returns the expected response structure // Since we don't have a real database connection in unit tests, // we test the expected structure and format // Test that default plans are well-formed plans := models.GetDefaultPlans() if len(plans) == 0 { t.Error("Default plans should not be empty") } for _, plan := range plans { // Verify JSON serialization works data, err := json.Marshal(plan) if err != nil { t.Errorf("Failed to marshal plan %s: %v", plan.ID, err) } // Verify we can unmarshal back var decoded models.BillingPlan err = json.Unmarshal(data, &decoded) if err != nil { t.Errorf("Failed to unmarshal plan %s: %v", plan.ID, err) } // Verify key fields if decoded.ID != plan.ID { t.Errorf("Plan ID mismatch: got %s, expected %s", decoded.ID, plan.ID) } } } func TestBillingStatusResponse_Structure(t *testing.T) { // Test the response structure response := models.BillingStatusResponse{ HasSubscription: true, Subscription: &models.SubscriptionInfo{ PlanID: models.PlanStandard, PlanName: "Standard", Status: models.StatusActive, IsTrialing: false, CancelAtPeriodEnd: false, PriceCents: 1990, Currency: "eur", }, TaskUsage: &models.TaskUsageInfo{ TasksAvailable: 85, MaxTasks: 500, InfoText: "Aufgaben verfuegbar: 85 von max. 500", TooltipText: "Aufgaben koennen sich bis zu 5 Monate ansammeln.", }, Entitlements: &models.EntitlementInfo{ Features: []string{"basic_ai", "basic_documents", "templates", "batch_processing"}, MaxTeamMembers: 3, PrioritySupport: false, CustomBranding: false, BatchProcessing: true, CustomTemplates: true, FairUseMode: false, }, AvailablePlans: models.GetDefaultPlans(), } // Test JSON serialization data, err := json.Marshal(response) if err != nil { t.Fatalf("Failed to marshal BillingStatusResponse: %v", err) } // Verify it's valid JSON var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } // Check required fields exist if _, ok := decoded["has_subscription"]; !ok { t.Error("Response should have 'has_subscription' field") } } func TestStartTrialRequest_Validation(t *testing.T) { tests := []struct { name string request models.StartTrialRequest wantError bool }{ { name: "Valid basic plan", request: models.StartTrialRequest{PlanID: models.PlanBasic}, wantError: false, }, { name: "Valid standard plan", request: models.StartTrialRequest{PlanID: models.PlanStandard}, wantError: false, }, { name: "Valid premium plan", request: models.StartTrialRequest{PlanID: models.PlanPremium}, wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test JSON serialization data, err := json.Marshal(tt.request) if err != nil { t.Fatalf("Failed to marshal request: %v", err) } var decoded models.StartTrialRequest err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Failed to unmarshal request: %v", err) } if decoded.PlanID != tt.request.PlanID { t.Errorf("PlanID mismatch: got %s, expected %s", decoded.PlanID, tt.request.PlanID) } }) } } func TestChangePlanRequest_Structure(t *testing.T) { request := models.ChangePlanRequest{ NewPlanID: models.PlanPremium, } data, err := json.Marshal(request) if err != nil { t.Fatalf("Failed to marshal ChangePlanRequest: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["new_plan_id"]; !ok { t.Error("Request should have 'new_plan_id' field") } } func TestStartTrialResponse_Structure(t *testing.T) { response := models.StartTrialResponse{ CheckoutURL: "https://checkout.stripe.com/c/pay/cs_test_123", SessionID: "cs_test_123", } data, err := json.Marshal(response) if err != nil { t.Fatalf("Failed to marshal StartTrialResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["checkout_url"]; !ok { t.Error("Response should have 'checkout_url' field") } if _, ok := decoded["session_id"]; !ok { t.Error("Response should have 'session_id' field") } } func TestCancelSubscriptionResponse_Structure(t *testing.T) { response := models.CancelSubscriptionResponse{ Success: true, Message: "Subscription will be canceled at the end of the billing period", CancelDate: "2025-01-16", ActiveUntil: "2025-01-16", } _, err := json.Marshal(response) if err != nil { t.Fatalf("Failed to marshal CancelSubscriptionResponse: %v", err) } if !response.Success { t.Error("Success should be true") } } func TestCustomerPortalResponse_Structure(t *testing.T) { response := models.CustomerPortalResponse{ PortalURL: "https://billing.stripe.com/p/session/test_123", } data, err := json.Marshal(response) if err != nil { t.Fatalf("Failed to marshal CustomerPortalResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["portal_url"]; !ok { t.Error("Response should have 'portal_url' field") } } func TestEntitlementCheckResponse_Structure(t *testing.T) { tests := []struct { name string response models.EntitlementCheckResponse }{ { name: "Has entitlement", response: models.EntitlementCheckResponse{ HasEntitlement: true, PlanID: models.PlanStandard, }, }, { name: "No entitlement", response: models.EntitlementCheckResponse{ HasEntitlement: false, PlanID: models.PlanBasic, Message: "Feature not available in this plan", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.response) if err != nil { t.Fatalf("Failed to marshal EntitlementCheckResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["has_entitlement"]; !ok { t.Error("Response should have 'has_entitlement' field") } }) } } func TestTrackUsageRequest_Validation(t *testing.T) { tests := []struct { name string request models.TrackUsageRequest valid bool }{ { name: "Valid AI request", request: models.TrackUsageRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", UsageType: "ai_request", Quantity: 1, }, valid: true, }, { name: "Valid document created", request: models.TrackUsageRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", UsageType: "document_created", Quantity: 1, }, valid: true, }, { name: "Multiple quantity", request: models.TrackUsageRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", UsageType: "ai_request", Quantity: 5, }, valid: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.request) if err != nil { t.Fatalf("Failed to marshal TrackUsageRequest: %v", err) } var decoded models.TrackUsageRequest err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Failed to unmarshal TrackUsageRequest: %v", err) } if decoded.UserID != tt.request.UserID { t.Errorf("UserID mismatch: got %s, expected %s", decoded.UserID, tt.request.UserID) } }) } } func TestCheckUsageResponse_Format(t *testing.T) { tests := []struct { name string response models.CheckUsageResponse }{ { name: "Allowed response", response: models.CheckUsageResponse{ Allowed: true, CurrentUsage: 450, Limit: 1500, Remaining: 1050, }, }, { name: "Limit reached", response: models.CheckUsageResponse{ Allowed: false, CurrentUsage: 1500, Limit: 1500, Remaining: 0, Message: "Usage limit reached for ai_request (1500/1500)", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.response) if err != nil { t.Fatalf("Failed to marshal CheckUsageResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["allowed"]; !ok { t.Error("Response should have 'allowed' field") } }) } } func TestConsumeTaskRequest_Format(t *testing.T) { tests := []struct { name string request models.ConsumeTaskRequest }{ { name: "Correction task", request: models.ConsumeTaskRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", TaskType: models.TaskTypeCorrection, }, }, { name: "Letter task", request: models.ConsumeTaskRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", TaskType: models.TaskTypeLetter, }, }, { name: "Batch task", request: models.ConsumeTaskRequest{ UserID: "550e8400-e29b-41d4-a716-446655440000", TaskType: models.TaskTypeBatch, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.request) if err != nil { t.Fatalf("Failed to marshal ConsumeTaskRequest: %v", err) } var decoded models.ConsumeTaskRequest err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Failed to unmarshal ConsumeTaskRequest: %v", err) } if decoded.TaskType != tt.request.TaskType { t.Errorf("TaskType mismatch: got %s, expected %s", decoded.TaskType, tt.request.TaskType) } }) } } func TestConsumeTaskResponse_Format(t *testing.T) { tests := []struct { name string response models.ConsumeTaskResponse }{ { name: "Successful consumption", response: models.ConsumeTaskResponse{ Success: true, TaskID: "task-uuid-123", TasksRemaining: 49, }, }, { name: "Limit reached", response: models.ConsumeTaskResponse{ Success: false, TasksRemaining: 0, Message: "Dein Aufgaben-Kontingent ist aufgebraucht.", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.response) if err != nil { t.Fatalf("Failed to marshal ConsumeTaskResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["success"]; !ok { t.Error("Response should have 'success' field") } if _, ok := decoded["tasks_remaining"]; !ok { t.Error("Response should have 'tasks_remaining' field") } }) } } func TestCheckTaskAllowedResponse_Format(t *testing.T) { tests := []struct { name string response models.CheckTaskAllowedResponse }{ { name: "Task allowed", response: models.CheckTaskAllowedResponse{ Allowed: true, TasksAvailable: 50, MaxTasks: 150, PlanID: models.PlanBasic, }, }, { name: "Task not allowed", response: models.CheckTaskAllowedResponse{ Allowed: false, TasksAvailable: 0, MaxTasks: 150, PlanID: models.PlanBasic, Message: "Dein Aufgaben-Kontingent ist aufgebraucht.", }, }, { name: "Premium Fair Use", response: models.CheckTaskAllowedResponse{ Allowed: true, TasksAvailable: 1000, MaxTasks: 5000, PlanID: models.PlanPremium, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := json.Marshal(tt.response) if err != nil { t.Fatalf("Failed to marshal CheckTaskAllowedResponse: %v", err) } var decoded map[string]interface{} err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("Response is not valid JSON: %v", err) } if _, ok := decoded["allowed"]; !ok { t.Error("Response should have 'allowed' field") } if _, ok := decoded["tasks_available"]; !ok { t.Error("Response should have 'tasks_available' field") } if _, ok := decoded["plan_id"]; !ok { t.Error("Response should have 'plan_id' field") } }) } } // HTTP Handler Tests (without DB) func TestHTTPErrorResponse_Format(t *testing.T) { // Test standard error response format w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) // Simulate an error response c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) if w.Code != http.StatusUnauthorized { t.Errorf("Expected status 401, got %d", w.Code) } var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) if err != nil { t.Fatalf("Failed to parse response: %v", err) } if _, ok := response["error"]; !ok { t.Error("Error response should have 'error' field") } if _, ok := response["message"]; !ok { t.Error("Error response should have 'message' field") } } func TestHTTPSuccessResponse_Format(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) // Simulate a success response c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Operation completed", }) if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) if err != nil { t.Fatalf("Failed to parse response: %v", err) } if response["success"] != true { t.Error("Success response should have success=true") } } func TestRequestParsing_InvalidJSON(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) // Create request with invalid JSON invalidJSON := []byte(`{"plan_id": }`) // Invalid JSON c.Request = httptest.NewRequest("POST", "/test", bytes.NewReader(invalidJSON)) c.Request.Header.Set("Content-Type", "application/json") var req models.StartTrialRequest err := c.ShouldBindJSON(&req) if err == nil { t.Error("Should return error for invalid JSON") } } func TestHTTPHeaders_ContentType(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.JSON(http.StatusOK, gin.H{"test": "value"}) contentType := w.Header().Get("Content-Type") if contentType != "application/json; charset=utf-8" { t.Errorf("Expected JSON content type, got %s", contentType) } }