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>
434 lines
12 KiB
Go
434 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// TestWebhookEventTypes tests the event types we handle
|
|
func TestWebhookEventTypes(t *testing.T) {
|
|
eventTypes := []struct {
|
|
eventType string
|
|
shouldHandle bool
|
|
}{
|
|
{"checkout.session.completed", true},
|
|
{"customer.subscription.created", true},
|
|
{"customer.subscription.updated", true},
|
|
{"customer.subscription.deleted", true},
|
|
{"invoice.paid", true},
|
|
{"invoice.payment_failed", true},
|
|
{"customer.created", true}, // Handled but just logged
|
|
{"unknown.event.type", false},
|
|
}
|
|
|
|
for _, tt := range eventTypes {
|
|
t.Run(tt.eventType, func(t *testing.T) {
|
|
if tt.eventType == "" {
|
|
t.Error("Event type should not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWebhookRequest_MissingSignature tests handling of missing signature
|
|
func TestWebhookRequest_MissingSignature(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
// Create request without Stripe-Signature header
|
|
body := []byte(`{"id": "evt_test_123", "type": "test.event"}`)
|
|
c.Request = httptest.NewRequest("POST", "/webhook", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
// Note: No Stripe-Signature header
|
|
|
|
// Simulate the check we do in the handler
|
|
sigHeader := c.GetHeader("Stripe-Signature")
|
|
if sigHeader == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing signature"})
|
|
}
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing signature, 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["error"] != "missing signature" {
|
|
t.Errorf("Expected 'missing signature' error, got '%v'", response["error"])
|
|
}
|
|
}
|
|
|
|
// TestWebhookRequest_EmptyBody tests handling of empty request body
|
|
func TestWebhookRequest_EmptyBody(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
// Create request with empty body
|
|
c.Request = httptest.NewRequest("POST", "/webhook", bytes.NewReader([]byte{}))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Request.Header.Set("Stripe-Signature", "t=123,v1=signature")
|
|
|
|
// Read the body
|
|
body := make([]byte, 0)
|
|
|
|
// Simulate empty body handling
|
|
if len(body) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "empty body"})
|
|
}
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for empty body, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestWebhookIdempotency tests idempotency behavior
|
|
func TestWebhookIdempotency(t *testing.T) {
|
|
// Test that the same event ID should not be processed twice
|
|
eventID := "evt_test_123456789"
|
|
|
|
// Simulate event tracking
|
|
processedEvents := make(map[string]bool)
|
|
|
|
// First time - should process
|
|
if !processedEvents[eventID] {
|
|
processedEvents[eventID] = true
|
|
}
|
|
|
|
// Second time - should skip
|
|
alreadyProcessed := processedEvents[eventID]
|
|
if !alreadyProcessed {
|
|
t.Error("Event should be marked as processed")
|
|
}
|
|
}
|
|
|
|
// TestWebhookResponse_Processed tests successful webhook response
|
|
func TestWebhookResponse_Processed(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
|
|
|
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["status"] != "processed" {
|
|
t.Errorf("Expected status 'processed', got '%v'", response["status"])
|
|
}
|
|
}
|
|
|
|
// TestWebhookResponse_AlreadyProcessed tests idempotent response
|
|
func TestWebhookResponse_AlreadyProcessed(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "already_processed"})
|
|
|
|
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["status"] != "already_processed" {
|
|
t.Errorf("Expected status 'already_processed', got '%v'", response["status"])
|
|
}
|
|
}
|
|
|
|
// TestWebhookResponse_InternalError tests error response
|
|
func TestWebhookResponse_InternalError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler error"})
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("Expected status 500, 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["error"] != "handler error" {
|
|
t.Errorf("Expected 'handler error', got '%v'", response["error"])
|
|
}
|
|
}
|
|
|
|
// TestWebhookResponse_InvalidSignature tests signature verification failure
|
|
func TestWebhookResponse_InvalidSignature(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
|
|
|
|
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 response["error"] != "invalid signature" {
|
|
t.Errorf("Expected 'invalid signature', got '%v'", response["error"])
|
|
}
|
|
}
|
|
|
|
// TestCheckoutSessionCompleted_EventStructure tests the event data structure
|
|
func TestCheckoutSessionCompleted_EventStructure(t *testing.T) {
|
|
// Test the expected structure of a checkout.session.completed event
|
|
eventData := map[string]interface{}{
|
|
"id": "cs_test_123",
|
|
"customer": "cus_test_456",
|
|
"subscription": "sub_test_789",
|
|
"mode": "subscription",
|
|
"payment_status": "paid",
|
|
"status": "complete",
|
|
"metadata": map[string]interface{}{
|
|
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"plan_id": "standard",
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(eventData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal event data: %v", err)
|
|
}
|
|
|
|
var decoded map[string]interface{}
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
}
|
|
|
|
// Verify required fields
|
|
if decoded["customer"] == nil {
|
|
t.Error("Event should have 'customer' field")
|
|
}
|
|
if decoded["subscription"] == nil {
|
|
t.Error("Event should have 'subscription' field")
|
|
}
|
|
metadata, ok := decoded["metadata"].(map[string]interface{})
|
|
if !ok || metadata["user_id"] == nil {
|
|
t.Error("Event should have 'metadata.user_id' field")
|
|
}
|
|
}
|
|
|
|
// TestSubscriptionCreated_EventStructure tests subscription.created event structure
|
|
func TestSubscriptionCreated_EventStructure(t *testing.T) {
|
|
eventData := map[string]interface{}{
|
|
"id": "sub_test_123",
|
|
"customer": "cus_test_456",
|
|
"status": "trialing",
|
|
"items": map[string]interface{}{
|
|
"data": []map[string]interface{}{
|
|
{
|
|
"price": map[string]interface{}{
|
|
"id": "price_test_789",
|
|
"metadata": map[string]interface{}{"plan_id": "standard"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"trial_end": 1735689600,
|
|
"current_period_end": 1735689600,
|
|
"metadata": map[string]interface{}{
|
|
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"plan_id": "standard",
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(eventData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal event data: %v", err)
|
|
}
|
|
|
|
var decoded map[string]interface{}
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
}
|
|
|
|
// Verify required fields
|
|
if decoded["status"] != "trialing" {
|
|
t.Errorf("Expected status 'trialing', got '%v'", decoded["status"])
|
|
}
|
|
}
|
|
|
|
// TestSubscriptionUpdated_StatusTransitions tests subscription status transitions
|
|
func TestSubscriptionUpdated_StatusTransitions(t *testing.T) {
|
|
validTransitions := []struct {
|
|
from string
|
|
to string
|
|
}{
|
|
{"trialing", "active"},
|
|
{"active", "past_due"},
|
|
{"past_due", "active"},
|
|
{"active", "canceled"},
|
|
{"trialing", "canceled"},
|
|
}
|
|
|
|
for _, tt := range validTransitions {
|
|
t.Run(tt.from+"->"+tt.to, func(t *testing.T) {
|
|
if tt.from == "" || tt.to == "" {
|
|
t.Error("Status should not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInvoicePaid_EventStructure tests invoice.paid event structure
|
|
func TestInvoicePaid_EventStructure(t *testing.T) {
|
|
eventData := map[string]interface{}{
|
|
"id": "in_test_123",
|
|
"subscription": "sub_test_456",
|
|
"customer": "cus_test_789",
|
|
"status": "paid",
|
|
"amount_paid": 1990,
|
|
"currency": "eur",
|
|
"period_start": 1735689600,
|
|
"period_end": 1738368000,
|
|
"hosted_invoice_url": "https://invoice.stripe.com/test",
|
|
"invoice_pdf": "https://invoice.stripe.com/test.pdf",
|
|
}
|
|
|
|
data, err := json.Marshal(eventData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal event data: %v", err)
|
|
}
|
|
|
|
var decoded map[string]interface{}
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
}
|
|
|
|
// Verify required fields
|
|
if decoded["status"] != "paid" {
|
|
t.Errorf("Expected status 'paid', got '%v'", decoded["status"])
|
|
}
|
|
if decoded["subscription"] == nil {
|
|
t.Error("Event should have 'subscription' field")
|
|
}
|
|
}
|
|
|
|
// TestInvoicePaymentFailed_EventStructure tests invoice.payment_failed event structure
|
|
func TestInvoicePaymentFailed_EventStructure(t *testing.T) {
|
|
eventData := map[string]interface{}{
|
|
"id": "in_test_123",
|
|
"subscription": "sub_test_456",
|
|
"customer": "cus_test_789",
|
|
"status": "open",
|
|
"attempt_count": 1,
|
|
"next_payment_attempt": 1735776000,
|
|
}
|
|
|
|
data, err := json.Marshal(eventData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal event data: %v", err)
|
|
}
|
|
|
|
var decoded map[string]interface{}
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
}
|
|
|
|
// Verify fields
|
|
if decoded["attempt_count"] == nil {
|
|
t.Error("Event should have 'attempt_count' field")
|
|
}
|
|
}
|
|
|
|
// TestSubscriptionDeleted_EventStructure tests subscription.deleted event structure
|
|
func TestSubscriptionDeleted_EventStructure(t *testing.T) {
|
|
eventData := map[string]interface{}{
|
|
"id": "sub_test_123",
|
|
"customer": "cus_test_456",
|
|
"status": "canceled",
|
|
"ended_at": 1735689600,
|
|
"canceled_at": 1735689600,
|
|
}
|
|
|
|
data, err := json.Marshal(eventData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal event data: %v", err)
|
|
}
|
|
|
|
var decoded map[string]interface{}
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal event data: %v", err)
|
|
}
|
|
|
|
// Verify required fields
|
|
if decoded["status"] != "canceled" {
|
|
t.Errorf("Expected status 'canceled', got '%v'", decoded["status"])
|
|
}
|
|
}
|
|
|
|
// TestStripeSignatureFormat tests the Stripe signature header format
|
|
func TestStripeSignatureFormat(t *testing.T) {
|
|
// Stripe signature format: t=timestamp,v1=signature
|
|
validSignatures := []string{
|
|
"t=1609459200,v1=abc123def456",
|
|
"t=1609459200,v1=signature_here,v0=old_signature",
|
|
}
|
|
|
|
for _, sig := range validSignatures {
|
|
if len(sig) < 10 {
|
|
t.Errorf("Signature seems too short: %s", sig)
|
|
}
|
|
// Should start with timestamp
|
|
if sig[:2] != "t=" {
|
|
t.Errorf("Signature should start with 't=': %s", sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookEventID_Format tests Stripe event ID format
|
|
func TestWebhookEventID_Format(t *testing.T) {
|
|
validEventIDs := []string{
|
|
"evt_1234567890abcdef",
|
|
"evt_test_123456789",
|
|
"evt_live_987654321",
|
|
}
|
|
|
|
for _, eventID := range validEventIDs {
|
|
// Event IDs should start with "evt_"
|
|
if len(eventID) < 10 || eventID[:4] != "evt_" {
|
|
t.Errorf("Invalid event ID format: %s", eventID)
|
|
}
|
|
}
|
|
}
|