A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
613 lines
15 KiB
Go
613 lines
15 KiB
Go
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)
|
|
}
|
|
}
|