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>
327 lines
8.3 KiB
Go
327 lines
8.3 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/breakpilot/billing-service/internal/models"
|
|
)
|
|
|
|
func TestSubscriptionStatus_Transitions(t *testing.T) {
|
|
// Test valid subscription status values
|
|
validStatuses := []models.SubscriptionStatus{
|
|
models.StatusTrialing,
|
|
models.StatusActive,
|
|
models.StatusPastDue,
|
|
models.StatusCanceled,
|
|
models.StatusExpired,
|
|
}
|
|
|
|
for _, status := range validStatuses {
|
|
if status == "" {
|
|
t.Errorf("Status should not be empty")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanID_ValidValues(t *testing.T) {
|
|
validPlanIDs := []models.PlanID{
|
|
models.PlanBasic,
|
|
models.PlanStandard,
|
|
models.PlanPremium,
|
|
}
|
|
|
|
expected := []string{"basic", "standard", "premium"}
|
|
|
|
for i, planID := range validPlanIDs {
|
|
if string(planID) != expected[i] {
|
|
t.Errorf("PlanID should be '%s', got '%s'", expected[i], planID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanFeatures_JSONSerialization(t *testing.T) {
|
|
features := models.PlanFeatures{
|
|
MonthlyTaskAllowance: 100,
|
|
MaxTaskBalance: 500,
|
|
FeatureFlags: []string{"basic_ai", "templates"},
|
|
MaxTeamMembers: 3,
|
|
PrioritySupport: false,
|
|
CustomBranding: false,
|
|
BatchProcessing: true,
|
|
CustomTemplates: true,
|
|
FairUseMode: false,
|
|
}
|
|
|
|
// Test JSON serialization
|
|
data, err := json.Marshal(features)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal PlanFeatures: %v", err)
|
|
}
|
|
|
|
// Test JSON deserialization
|
|
var decoded models.PlanFeatures
|
|
err = json.Unmarshal(data, &decoded)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal PlanFeatures: %v", err)
|
|
}
|
|
|
|
// Verify fields
|
|
if decoded.MonthlyTaskAllowance != features.MonthlyTaskAllowance {
|
|
t.Errorf("MonthlyTaskAllowance mismatch: got %d, expected %d",
|
|
decoded.MonthlyTaskAllowance, features.MonthlyTaskAllowance)
|
|
}
|
|
if decoded.MaxTaskBalance != features.MaxTaskBalance {
|
|
t.Errorf("MaxTaskBalance mismatch: got %d, expected %d",
|
|
decoded.MaxTaskBalance, features.MaxTaskBalance)
|
|
}
|
|
if decoded.BatchProcessing != features.BatchProcessing {
|
|
t.Errorf("BatchProcessing mismatch: got %v, expected %v",
|
|
decoded.BatchProcessing, features.BatchProcessing)
|
|
}
|
|
}
|
|
|
|
func TestBillingPlan_DefaultPlansAreValid(t *testing.T) {
|
|
plans := models.GetDefaultPlans()
|
|
|
|
if len(plans) != 3 {
|
|
t.Fatalf("Expected 3 default plans, got %d", len(plans))
|
|
}
|
|
|
|
// Verify all plans have required fields
|
|
for _, plan := range plans {
|
|
if plan.ID == "" {
|
|
t.Errorf("Plan ID should not be empty")
|
|
}
|
|
if plan.Name == "" {
|
|
t.Errorf("Plan '%s' should have a name", plan.ID)
|
|
}
|
|
if plan.Description == "" {
|
|
t.Errorf("Plan '%s' should have a description", plan.ID)
|
|
}
|
|
if plan.PriceCents <= 0 {
|
|
t.Errorf("Plan '%s' should have a positive price, got %d", plan.ID, plan.PriceCents)
|
|
}
|
|
if plan.Currency != "eur" {
|
|
t.Errorf("Plan '%s' currency should be 'eur', got '%s'", plan.ID, plan.Currency)
|
|
}
|
|
if plan.Interval != "month" {
|
|
t.Errorf("Plan '%s' interval should be 'month', got '%s'", plan.ID, plan.Interval)
|
|
}
|
|
if !plan.IsActive {
|
|
t.Errorf("Plan '%s' should be active", plan.ID)
|
|
}
|
|
if plan.SortOrder <= 0 {
|
|
t.Errorf("Plan '%s' should have a positive sort order, got %d", plan.ID, plan.SortOrder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBillingPlan_TaskAllowanceProgression(t *testing.T) {
|
|
plans := models.GetDefaultPlans()
|
|
|
|
// Basic should have lowest allowance
|
|
basic := plans[0]
|
|
standard := plans[1]
|
|
premium := plans[2]
|
|
|
|
if basic.Features.MonthlyTaskAllowance >= standard.Features.MonthlyTaskAllowance {
|
|
t.Error("Standard plan should have more tasks than Basic")
|
|
}
|
|
|
|
if standard.Features.MonthlyTaskAllowance >= premium.Features.MonthlyTaskAllowance {
|
|
t.Error("Premium plan should have more tasks than Standard")
|
|
}
|
|
}
|
|
|
|
func TestBillingPlan_PriceProgression(t *testing.T) {
|
|
plans := models.GetDefaultPlans()
|
|
|
|
// Prices should increase with each tier
|
|
if plans[0].PriceCents >= plans[1].PriceCents {
|
|
t.Error("Standard should cost more than Basic")
|
|
}
|
|
if plans[1].PriceCents >= plans[2].PriceCents {
|
|
t.Error("Premium should cost more than Standard")
|
|
}
|
|
}
|
|
|
|
func TestBillingPlan_FairUseModeOnlyForPremium(t *testing.T) {
|
|
plans := models.GetDefaultPlans()
|
|
|
|
for _, plan := range plans {
|
|
if plan.ID == models.PlanPremium {
|
|
if !plan.Features.FairUseMode {
|
|
t.Error("Premium plan should have FairUseMode enabled")
|
|
}
|
|
} else {
|
|
if plan.Features.FairUseMode {
|
|
t.Errorf("Plan '%s' should not have FairUseMode enabled", plan.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBillingPlan_MaxTaskBalanceCalculation(t *testing.T) {
|
|
plans := models.GetDefaultPlans()
|
|
|
|
for _, plan := range plans {
|
|
expected := plan.Features.MonthlyTaskAllowance * models.CarryoverMonthsCap
|
|
if plan.Features.MaxTaskBalance != expected {
|
|
t.Errorf("Plan '%s' MaxTaskBalance should be %d (allowance * 5), got %d",
|
|
plan.ID, expected, plan.Features.MaxTaskBalance)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditLogJSON_Marshaling(t *testing.T) {
|
|
// Test that audit log values can be properly serialized
|
|
oldValue := map[string]interface{}{
|
|
"plan_id": "basic",
|
|
"status": "active",
|
|
}
|
|
|
|
newValue := map[string]interface{}{
|
|
"plan_id": "standard",
|
|
"status": "active",
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"reason": "upgrade",
|
|
}
|
|
|
|
// Marshal all values
|
|
oldJSON, err := json.Marshal(oldValue)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal oldValue: %v", err)
|
|
}
|
|
|
|
newJSON, err := json.Marshal(newValue)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal newValue: %v", err)
|
|
}
|
|
|
|
metaJSON, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal metadata: %v", err)
|
|
}
|
|
|
|
// Verify non-empty
|
|
if len(oldJSON) == 0 || len(newJSON) == 0 || len(metaJSON) == 0 {
|
|
t.Error("JSON outputs should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionTrialCalculation(t *testing.T) {
|
|
// Test trial days calculation logic
|
|
trialDays := 7
|
|
|
|
if trialDays <= 0 {
|
|
t.Error("Trial days should be positive")
|
|
}
|
|
|
|
if trialDays > 30 {
|
|
t.Error("Trial days should not exceed 30")
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionInfo_TrialingStatus(t *testing.T) {
|
|
info := models.SubscriptionInfo{
|
|
PlanID: models.PlanBasic,
|
|
PlanName: "Basic",
|
|
Status: models.StatusTrialing,
|
|
IsTrialing: true,
|
|
TrialDaysLeft: 5,
|
|
CancelAtPeriodEnd: false,
|
|
PriceCents: 990,
|
|
Currency: "eur",
|
|
}
|
|
|
|
if !info.IsTrialing {
|
|
t.Error("Should be trialing")
|
|
}
|
|
if info.Status != models.StatusTrialing {
|
|
t.Errorf("Status should be 'trialing', got '%s'", info.Status)
|
|
}
|
|
if info.TrialDaysLeft <= 0 {
|
|
t.Error("TrialDaysLeft should be positive during trial")
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionInfo_ActiveStatus(t *testing.T) {
|
|
info := models.SubscriptionInfo{
|
|
PlanID: models.PlanStandard,
|
|
PlanName: "Standard",
|
|
Status: models.StatusActive,
|
|
IsTrialing: false,
|
|
TrialDaysLeft: 0,
|
|
CancelAtPeriodEnd: false,
|
|
PriceCents: 1990,
|
|
Currency: "eur",
|
|
}
|
|
|
|
if info.IsTrialing {
|
|
t.Error("Should not be trialing")
|
|
}
|
|
if info.Status != models.StatusActive {
|
|
t.Errorf("Status should be 'active', got '%s'", info.Status)
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionInfo_CanceledStatus(t *testing.T) {
|
|
info := models.SubscriptionInfo{
|
|
PlanID: models.PlanStandard,
|
|
PlanName: "Standard",
|
|
Status: models.StatusActive,
|
|
IsTrialing: false,
|
|
CancelAtPeriodEnd: true, // Scheduled for cancellation
|
|
PriceCents: 1990,
|
|
Currency: "eur",
|
|
}
|
|
|
|
if !info.CancelAtPeriodEnd {
|
|
t.Error("CancelAtPeriodEnd should be true")
|
|
}
|
|
// Status remains active until period end
|
|
if info.Status != models.StatusActive {
|
|
t.Errorf("Status should still be 'active', got '%s'", info.Status)
|
|
}
|
|
}
|
|
|
|
func TestWebhookEventTypes(t *testing.T) {
|
|
// Test common Stripe webhook event types we handle
|
|
eventTypes := []string{
|
|
"checkout.session.completed",
|
|
"customer.subscription.created",
|
|
"customer.subscription.updated",
|
|
"customer.subscription.deleted",
|
|
"invoice.paid",
|
|
"invoice.payment_failed",
|
|
}
|
|
|
|
for _, eventType := range eventTypes {
|
|
if eventType == "" {
|
|
t.Error("Event type should not be empty")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIdempotencyKey_Format(t *testing.T) {
|
|
// Test that we can handle Stripe event IDs
|
|
sampleEventIDs := []string{
|
|
"evt_1234567890abcdef",
|
|
"evt_test_abc123xyz789",
|
|
"evt_live_real_event_id",
|
|
}
|
|
|
|
for _, eventID := range sampleEventIDs {
|
|
if len(eventID) < 10 {
|
|
t.Errorf("Event ID '%s' seems too short", eventID)
|
|
}
|
|
// Stripe event IDs typically start with "evt_"
|
|
if eventID[:4] != "evt_" {
|
|
t.Errorf("Event ID '%s' should start with 'evt_'", eventID)
|
|
}
|
|
}
|
|
}
|