diff --git a/apps/carrier-connector/internal/analytics/service.go b/apps/carrier-connector/internal/analytics/service.go index 5ba5648..66af19d 100644 --- a/apps/carrier-connector/internal/analytics/service.go +++ b/apps/carrier-connector/internal/analytics/service.go @@ -135,7 +135,7 @@ func (s *Service) getUsageMetrics(ctx context.Context, filter *AnalyticsFilter) return metrics, nil } -func (s *Service) getCarrierMetrics(ctx context.Context, filter *AnalyticsFilter) (CarrierMetrics, error) { +func (s *Service) getCarrierMetrics(ctx context.Context, _ *AnalyticsFilter) (CarrierMetrics, error) { metrics := CarrierMetrics{ ByCarrier: make(map[string]CarrierStat), FailuresByReason: make(map[string]int64), @@ -151,14 +151,14 @@ func (s *Service) getCarrierMetrics(ctx context.Context, filter *AnalyticsFilter return metrics, nil } -func (s *Service) getGeoMetrics(ctx context.Context, filter *AnalyticsFilter) (GeoMetrics, error) { +func (s *Service) getGeoMetrics(_ context.Context, _ *AnalyticsFilter) (GeoMetrics, error) { metrics := GeoMetrics{ RevenueByContinent: make(map[string]float64), } return metrics, nil } -func (s *Service) getPerformanceStats(ctx context.Context, filter *AnalyticsFilter) (PerformanceStats, error) { +func (s *Service) getPerformanceStats(_ context.Context, _ *AnalyticsFilter) (PerformanceStats, error) { return PerformanceStats{ Uptime: 99.9, ErrorRate: 0.1, diff --git a/apps/carrier-connector/internal/currency/services/rateplan_core.go b/apps/carrier-connector/internal/currency/services/rateplan_core.go deleted file mode 100644 index 7bdf709..0000000 --- a/apps/carrier-connector/internal/currency/services/rateplan_core.go +++ /dev/null @@ -1,208 +0,0 @@ -package services - -import ( - "context" - "fmt" - "time" - - "github.com/sirupsen/logrus" - - "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" - "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" -) - -// RatePlanCurrencyIntegrator integrates currency system with rate plans -type RatePlanCurrencyIntegrator struct { - billingService currency.BillingService - exchangeService currency.ExchangeRateService - ratePlanService rateplan.Service - logger *logrus.Logger - baseCurrency string -} - -// NewRatePlanCurrencyIntegrator creates a new rate plan currency integrator -func NewRatePlanCurrencyIntegrator( - billingService currency.BillingService, - exchangeService currency.ExchangeRateService, - ratePlanService rateplan.Service, - logger *logrus.Logger, - baseCurrency string, -) *RatePlanCurrencyIntegrator { - return &RatePlanCurrencyIntegrator{ - billingService: billingService, - exchangeService: exchangeService, - ratePlanService: ratePlanService, - logger: logger, - baseCurrency: baseCurrency, - } -} - -// SubscribeToPlanWithCurrency subscribes to a rate plan with currency conversion -func (rpci *RatePlanCurrencyIntegrator) SubscribeToPlanWithCurrency(ctx context.Context, profileID string, planID string, targetCurrency string) (*rateplan.RatePlanSubscription, error) { - // Get the rate plan - plan, err := rpci.ratePlanService.GetRatePlan(ctx, planID) - if err != nil { - return nil, fmt.Errorf("failed to get rate plan: %w", err) - } - - // Convert price to requested currency if needed - subscriptionPrice := plan.BasePrice - exchangeRate := 1.0 - - if targetCurrency != plan.Currency { - conversion, err := rpci.billingService.ConvertAmount(ctx, ¤cy.CurrencyConversionRequest{ - Amount: plan.BasePrice, - FromCurrency: plan.Currency, - ToCurrency: targetCurrency, - }) - if err != nil { - rpci.logger.WithError(err).Error("Failed to convert rate plan price") - return nil, fmt.Errorf("currency conversion failed: %w", err) - } - subscriptionPrice = conversion.ConvertedAmount - exchangeRate = conversion.ExchangeRate - } - - // Create subscription with currency information - subscription := &rateplan.RatePlanSubscription{ - ProfileID: profileID, - RatePlanID: planID, - Status: rateplan.SubscriptionStatusActive, - StartedAt: time.Now(), - Metadata: map[string]any{ - "original_currency": plan.Currency, - "subscription_currency": targetCurrency, - "original_price": plan.BasePrice, - "subscription_price": subscriptionPrice, - "exchange_rate": exchangeRate, - }, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - // Create subscription request - subscribeReq := &rateplan.SubscribeRequest{ - ProfileID: profileID, - RatePlanID: planID, - AutoRenew: true, - Metadata: subscription.Metadata, - } - - createdSubscription, err := rpci.ratePlanService.SubscribeToPlan(ctx, subscribeReq) - if err != nil { - return nil, fmt.Errorf("failed to create subscription: %w", err) - } - - // Process initial billing - billingReq := ¤cy.BillingRequest{ - ProfileID: profileID, - SubscriptionID: createdSubscription.ID, - Amount: subscriptionPrice, - Currency: targetCurrency, - Description: fmt.Sprintf("Initial subscription to %s", plan.Name), - BillingDate: time.Now(), - } - - _, err = rpci.billingService.ProcessBilling(ctx, billingReq) - if err != nil { - rpci.logger.WithError(err).Error("Failed to process initial billing") - // Don't fail the subscription if billing fails, but log it - } - - rpci.logger.WithFields(logrus.Fields{ - "profile_id": profileID, - "plan_id": planID, - "currency": targetCurrency, - "subscription_id": createdSubscription.ID, - }).Info("Rate plan subscription created with currency support") - - return createdSubscription, nil -} - -// CalculatePlanCostInCurrency calculates the cost of a rate plan in a specific currency -func (rpci *RatePlanCurrencyIntegrator) CalculatePlanCostInCurrency(ctx context.Context, planID string, targetCurrency string, usageData *rateplan.RatePlanUsage) (*currency.BillingSummary, error) { - // Get the rate plan - plan, err := rpci.ratePlanService.GetRatePlan(ctx, planID) - if err != nil { - return nil, fmt.Errorf("failed to get rate plan: %w", err) - } - - // Calculate base cost - baseCost := plan.BasePrice - - // Add overage costs if usage data is provided - if usageData != nil { - overageCost, err := rpci.calculateOverageCost(ctx, plan, usageData) - if err != nil { - rpci.logger.WithError(err).Warn("Failed to calculate overage cost") - } else { - baseCost += overageCost - } - } - - // Convert to requested currency - convertedCost := baseCost - exchangeRate := 1.0 - - if targetCurrency != plan.Currency { - conversion, err := rpci.exchangeService.ConvertAmount(ctx, baseCost, plan.Currency, targetCurrency) - if err != nil { - return nil, fmt.Errorf("currency conversion failed: %w", err) - } - convertedCost = conversion.ConvertedAmount - exchangeRate = conversion.ExchangeRate - } - - // Create billing summary - summary := ¤cy.BillingSummary{ - ProfileID: usageData.ProfileID, - TotalAmount: convertedCost, - Currency: targetCurrency, - BaseTotalAmount: baseCost, - BaseCurrency: plan.Currency, - TransactionCount: 1, - FromDate: time.Now().AddDate(0, -1, 0), - ToDate: time.Now(), - Breakdown: map[string]any{ - "plan_id": planID, - "plan_name": plan.Name, - "base_cost": plan.BasePrice, - "overage_cost": baseCost - plan.BasePrice, - "exchange_rate": exchangeRate, - "original_currency": plan.Currency, - }, - } - - return summary, nil -} - -// calculateOverageCost calculates overage costs for usage -func (rpci *RatePlanCurrencyIntegrator) calculateOverageCost(ctx context.Context, plan *rateplan.RatePlan, usage *rateplan.RatePlanUsage) (float64, error) { - overageCost := 0.0 - - // Calculate data overage - if plan.DataAllowance != nil && usage.DataUsed > plan.DataAllowance.Amount { - dataOverage := usage.DataUsed - plan.DataAllowance.Amount - if plan.OverageRates != nil { - overageCost += float64(dataOverage) * plan.OverageRates.DataRate - } - } - - // Calculate voice overage - if plan.VoiceAllowance != nil && usage.VoiceUsed > plan.VoiceAllowance.Minutes { - voiceOverage := usage.VoiceUsed - plan.VoiceAllowance.Minutes - if plan.OverageRates != nil { - overageCost += float64(voiceOverage) * plan.OverageRates.VoiceRate - } - } - - // Calculate SMS overage - if plan.SMSAllowance != nil && usage.SMSUsed > plan.SMSAllowance.Messages { - smsOverage := usage.SMSUsed - plan.SMSAllowance.Messages - if plan.OverageRates != nil { - overageCost += float64(smsOverage) * plan.OverageRates.SMSRate - } - } - - return overageCost, nil -} diff --git a/apps/carrier-connector/internal/smdp/selection_test_ml.go b/apps/carrier-connector/internal/smdp/selection_test_ml.go index ed3575f..982c460 100644 --- a/apps/carrier-connector/internal/smdp/selection_test_ml.go +++ b/apps/carrier-connector/internal/smdp/selection_test_ml.go @@ -38,11 +38,9 @@ func TestMachineLearningModel(t *testing.T) { // Check that weights have been optimized optimizedWeights := mlModel.GetOptimizedWeights() - originalWeights := WeightVector{Performance: 0.3, Reliability: 0.3, Cost: 0.2, Region: 0.1, Capability: 0.1} - // Weights should have changed from original - if optimizedWeights.Performance == originalWeights.Performance && - optimizedWeights.Reliability == originalWeights.Reliability { + // Weights should have changed from original defaults (0.3, 0.3, 0.2, 0.1, 0.1) + if optimizedWeights.Performance == 0.3 && optimizedWeights.Reliability == 0.3 { t.Error("Expected weights to change after learning") } diff --git a/apps/carrier-connector/tests/e2e/subscriber_journey_test.go b/apps/carrier-connector/tests/e2e/subscriber_journey_test.go new file mode 100644 index 0000000..389a6aa --- /dev/null +++ b/apps/carrier-connector/tests/e2e/subscriber_journey_test.go @@ -0,0 +1,744 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// SubscriberJourneyTestSuite tests end-to-end subscriber journey +type SubscriberJourneyTestSuite struct { + suite.Suite + db *gorm.DB + router *gin.Engine + logger *logrus.Logger +} + +// SetupSuite sets up the test suite +func (suite *SubscriberJourneyTestSuite) SetupSuite() { + // Setup in-memory database for testing + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(suite.T(), err) + + // Auto-migrate tables + err = db.AutoMigrate( + &repository.RatePlanUsage{}, + &repository.RatePlanSubscription{}, + ) + require.NoError(suite.T(), err) + + suite.db = db + suite.logger = logrus.New() + suite.logger.SetLevel(logrus.ErrorLevel) + + // Setup router with full application routes + gin.SetMode(gin.TestMode) + suite.router = gin.New() + suite.setupRoutes() +} + +func (suite *SubscriberJourneyTestSuite) setupRoutes() { + api := suite.router.Group("/api/v1") + + // Profile management + profiles := api.Group("/profiles") + { + profiles.POST("", suite.createProfile) + profiles.GET("/:id", suite.getProfile) + profiles.PUT("/:id/activate", suite.activateProfile) + } + + // Rate plan management + rateplans := api.Group("/rateplans") + { + rateplans.POST("", suite.createRatePlan) + rateplans.GET("", suite.listRatePlans) + rateplans.GET("/:id", suite.getRatePlan) + } + + // Subscription management + subscriptions := api.Group("/subscriptions") + { + subscriptions.POST("", suite.createSubscription) + subscriptions.GET("/:id", suite.getSubscription) + subscriptions.PUT("/:id/cancel", suite.cancelSubscription) + subscriptions.POST("/:id/usage", suite.recordUsage) + subscriptions.GET("/:id/usage", suite.getUsage) + } + + // Billing + billing := api.Group("/billing") + { + billing.POST("/charge", suite.processCharge) + billing.GET("/invoice/:subscription_id", suite.getInvoice) + } + + // Analytics + analytics := api.Group("/analytics") + { + analytics.GET("/dashboard", suite.getDashboard) + analytics.GET("/revenue", suite.getRevenue) + } +} + +// TestCompleteSubscriberJourney tests the full subscriber lifecycle +func (suite *SubscriberJourneyTestSuite) TestCompleteSubscriberJourney() { + // Step 1: Create a rate plan + ratePlanReq := map[string]interface{}{ + "name": "Premium Data Plan", + "description": "Unlimited data with voice and SMS", + "currency": "USD", + "base_price": 29.99, + "billing_cycle": "monthly", + "features": []string{"unlimited_data", "unlimited_voice", "unlimited_sms"}, + } + + reqBody, _ := json.Marshal(ratePlanReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rateplans", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var ratePlan map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &ratePlan) + require.NoError(suite.T(), err) + ratePlanID := fmt.Sprintf("%v", ratePlan["id"]) + + // Step 2: Create a subscriber profile + profileReq := map[string]interface{}{ + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "phone": "+1234567890", + "country": "US", + "status": "pending", + "metadata": map[string]interface{}{ + "source": "web_signup", + }, + } + + reqBody, _ = json.Marshal(profileReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var profile map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &profile) + require.NoError(suite.T(), err) + profileID := fmt.Sprintf("%v", profile["id"]) + + // Step 3: Activate the profile + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/profiles/%s/activate", profileID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + // Step 4: Subscribe to a rate plan + subReq := map[string]interface{}{ + "profile_id": profileID, + "rate_plan_id": ratePlanID, + "auto_renew": true, + "payment_method": "credit_card", + "billing_address": map[string]interface{}{ + "street": "123 Main St", + "city": "New York", + "state": "NY", + "zip": "10001", + "country": "US", + }, + } + + reqBody, _ = json.Marshal(subReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var subscription repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &subscription) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), profileID, subscription.ProfileID) + assert.Equal(suite.T(), ratePlanID, subscription.RatePlanID) + assert.Equal(suite.T(), repository.SubscriptionStatus("active"), subscription.Status) + + subscriptionID := subscription.ID + + // Step 5: Record usage for the subscription + usageReq := map[string]interface{}{ + "data_used": int64(5120), // 5GB in MB + "voice_used": int64(450), // 450 minutes + "sms_used": int64(25), // 25 SMS + "timestamp": time.Now().Format(time.RFC3339), + } + + reqBody, _ = json.Marshal(usageReq) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/subscriptions/%s/usage", subscriptionID), bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + // Step 6: Get usage history + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/subscriptions/%s/usage", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var usageList []repository.RatePlanUsage + err = json.Unmarshal(w.Body.Bytes(), &usageList) + require.NoError(suite.T(), err) + assert.Greater(suite.T(), len(usageList), 0) + + // Step 7: Process billing charge + chargeReq := map[string]interface{}{ + "subscription_id": subscriptionID, + "amount": 29.99, + "currency": "USD", + "description": "Monthly subscription fee", + "charge_type": "recurring", + } + + reqBody, _ = json.Marshal(chargeReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/billing/charge", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + // Step 8: Get invoice + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/billing/invoice/%s", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var invoice map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &invoice) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), subscriptionID, fmt.Sprintf("%v", invoice["subscription_id"])) + assert.Equal(suite.T(), 29.99, invoice["total_amount"]) + + // Step 9: Check analytics dashboard + req = httptest.NewRequest(http.MethodGet, "/api/v1/analytics/dashboard", nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var dashboard map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &dashboard) + require.NoError(suite.T(), err) + assert.Contains(suite.T(), dashboard, "total_subscribers") + assert.Contains(suite.T(), dashboard, "monthly_revenue") + assert.Contains(suite.T(), dashboard, "active_subscriptions") + + // Step 10: Cancel subscription (end of journey) + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/subscriptions/%s/cancel", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + // Verify subscription is cancelled + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/subscriptions/%s", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var cancelledSub repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &cancelledSub) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), repository.SubscriptionStatus("cancelled"), cancelledSub.Status) +} + +// TestMultiSubscriptionJourney tests subscriber with multiple subscriptions +func (suite *SubscriberJourneyTestSuite) TestMultiSubscriptionJourney() { + // Create multiple rate plans + ratePlans := []map[string]interface{}{ + { + "name": "Basic Plan", + "base_price": 9.99, + "currency": "USD", + "features": []string{"1gb_data", "100min_voice"}, + }, + { + "name": "Premium Plan", + "base_price": 29.99, + "currency": "USD", + "features": []string{"unlimited_data", "unlimited_voice"}, + }, + } + + ratePlanIDs := []string{} + for _, rp := range ratePlans { + reqBody, _ := json.Marshal(rp) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rateplans", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var ratePlan map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &ratePlan) + require.NoError(suite.T(), err) + ratePlanIDs = append(ratePlanIDs, fmt.Sprintf("%v", ratePlan["id"])) + } + + // Create profile + profileReq := map[string]interface{}{ + "first_name": "Jane", + "last_name": "Smith", + "email": "jane.smith@example.com", + "country": "US", + } + + reqBody, _ := json.Marshal(profileReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + var profile map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &profile) + require.NoError(suite.T(), err) + profileID := fmt.Sprintf("%v", profile["id"]) + + // Activate profile + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/profiles/%s/activate", profileID), nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusOK, w.Code) + + // Subscribe to multiple plans + subscriptionIDs := []string{} + for i, ratePlanID := range ratePlanIDs { + subReq := map[string]interface{}{ + "profile_id": profileID, + "rate_plan_id": ratePlanID, + "auto_renew": true, + "metadata": map[string]interface{}{ + "priority": i + 1, + }, + } + + reqBody, _ = json.Marshal(subReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var subscription repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &subscription) + require.NoError(suite.T(), err) + subscriptionIDs = append(subscriptionIDs, subscription.ID) + } + + // Verify all subscriptions are active + for _, subID := range subscriptionIDs { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/subscriptions/%s", subID), nil) + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var sub repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &sub) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), repository.SubscriptionStatus("active"), sub.Status) + } + + // Record usage on primary subscription + usageReq := map[string]interface{}{ + "data_used": int64(2048), + "voice_used": int64(150), + "sms_used": int64(10), + } + + reqBody, _ = json.Marshal(usageReq) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/subscriptions/%s/usage", subscriptionIDs[0]), bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + // Check analytics reflects multiple subscriptions + req = httptest.NewRequest(http.MethodGet, "/api/v1/analytics/dashboard", nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var dashboard map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &dashboard) + require.NoError(suite.T(), err) + + // Should have at least 1 active subscriber + totalSubs := dashboard["total_subscribers"].(int64) + assert.Greater(suite.T(), totalSubs, int64(0)) +} + +// TestFailedJourneyHandling tests error scenarios in subscriber journey +func (suite *SubscriberJourneyTestSuite) TestFailedJourneyHandling() { + // Test subscription without active profile + subReq := map[string]interface{}{ + "profile_id": "non-existent-profile", + "rate_plan_id": "non-existent-plan", + "auto_renew": true, + } + + reqBody, _ := json.Marshal(subReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) + + var errorResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &errorResp) + require.NoError(suite.T(), err) + assert.Contains(suite.T(), errorResp, "error") + + // Test usage recording for non-existent subscription + usageReq := map[string]interface{}{ + "data_used": int64(100), + "voice_used": int64(10), + "sms_used": int64(5), + } + + reqBody, _ = json.Marshal(usageReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions/non-existent/usage", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusNotFound, w.Code) + + // Test billing for cancelled subscription + // First create and cancel a subscription + profileReq := map[string]interface{}{ + "email": "test@example.com", + "country": "US", + } + + reqBody, _ = json.Marshal(profileReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/profiles", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + var profile map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &profile) + require.NoError(suite.T(), err) + profileID := fmt.Sprintf("%v", profile["id"]) + + // Activate + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/profiles/%s/activate", profileID), nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + // Create rate plan + rpReq := map[string]interface{}{ + "name": "Test Plan", + "currency": "USD", + "base_price": 10.0, + } + reqBody, _ = json.Marshal(rpReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/rateplans", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + var ratePlan map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &ratePlan) + require.NoError(suite.T(), err) + ratePlanID := fmt.Sprintf("%v", ratePlan["id"]) + + // Subscribe + subReq = map[string]interface{}{ + "profile_id": profileID, + "rate_plan_id": ratePlanID, + "auto_renew": true, + } + reqBody, _ = json.Marshal(subReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + var subscription repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &subscription) + require.NoError(suite.T(), err) + + // Cancel subscription + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/subscriptions/%s/cancel", subscription.ID), nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + // Try to bill cancelled subscription + chargeReq := map[string]interface{}{ + "subscription_id": subscription.ID, + "amount": 10.0, + "currency": "USD", + } + reqBody, _ = json.Marshal(chargeReq) + req = httptest.NewRequest(http.MethodPost, "/api/v1/billing/charge", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) +} + +// Handler implementations (simplified for testing) +func (suite *SubscriberJourneyTestSuite) createProfile(c *gin.Context) { + profile := map[string]interface{}{ + "id": fmt.Sprintf("profile-%d", time.Now().UnixNano()), + "status": "pending", + "created_at": time.Now(), + } + + // Add request fields + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for k, v := range req { + profile[k] = v + } + + c.JSON(http.StatusCreated, profile) +} + +func (suite *SubscriberJourneyTestSuite) getProfile(c *gin.Context) { + profile := map[string]interface{}{ + "id": c.Param("id"), + "status": "active", + } + c.JSON(http.StatusOK, profile) +} + +func (suite *SubscriberJourneyTestSuite) activateProfile(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "activated"}) +} + +func (suite *SubscriberJourneyTestSuite) createRatePlan(c *gin.Context) { + ratePlan := map[string]interface{}{ + "id": fmt.Sprintf("plan-%d", time.Now().UnixNano()), + "created_at": time.Now(), + } + + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for k, v := range req { + ratePlan[k] = v + } + + c.JSON(http.StatusCreated, ratePlan) +} + +func (suite *SubscriberJourneyTestSuite) listRatePlans(c *gin.Context) { + plans := []map[string]interface{}{ + { + "id": "plan-1", + "name": "Basic Plan", + "price": 9.99, + }, + { + "id": "plan-2", + "name": "Premium Plan", + "price": 29.99, + }, + } + c.JSON(http.StatusOK, plans) +} + +func (suite *SubscriberJourneyTestSuite) getRatePlan(c *gin.Context) { + ratePlan := map[string]interface{}{ + "id": c.Param("id"), + "name": "Test Plan", + } + c.JSON(http.StatusOK, ratePlan) +} + +func (suite *SubscriberJourneyTestSuite) createSubscription(c *gin.Context) { + var subscription repository.RatePlanSubscription + if err := c.ShouldBindJSON(&subscription); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + subscription.ID = fmt.Sprintf("sub-%d", time.Now().UnixNano()) + subscription.Status = repository.SubscriptionStatus("active") + subscription.StartedAt = time.Now() + subscription.CreatedAt = time.Now() + subscription.UpdatedAt = time.Now() + subscription.CurrentCycle = time.Now() + + c.JSON(http.StatusCreated, subscription) +} + +func (suite *SubscriberJourneyTestSuite) getSubscription(c *gin.Context) { + var subscription repository.RatePlanSubscription + if err := suite.db.First(&subscription, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"}) + return + } + + c.JSON(http.StatusOK, subscription) +} + +func (suite *SubscriberJourneyTestSuite) cancelSubscription(c *gin.Context) { + if err := suite.db.Model(&repository.RatePlanSubscription{}).Where("id = ?", c.Param("id")).Updates(map[string]interface{}{ + "status": "cancelled", + "ended_at": time.Now(), + "updated_at": time.Now(), + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) +} + +func (suite *SubscriberJourneyTestSuite) recordUsage(c *gin.Context) { + var usage repository.RatePlanUsage + if err := c.ShouldBindJSON(&usage); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + usage.ID = fmt.Sprintf("usage-%d", time.Now().UnixNano()) + usage.RatePlanID = "test-plan" + usage.LastUpdated = time.Now() + + if err := suite.db.Create(&usage).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, usage) +} + +func (suite *SubscriberJourneyTestSuite) getUsage(c *gin.Context) { + var usage []repository.RatePlanUsage + if err := suite.db.Where("rate_plan_id = ?", "test-plan").Find(&usage).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, usage) +} + +func (suite *SubscriberJourneyTestSuite) processCharge(c *gin.Context) { + var charge map[string]interface{} + if err := c.ShouldBindJSON(&charge); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + charge["id"] = fmt.Sprintf("charge-%d", time.Now().UnixNano()) + charge["status"] = "completed" + charge["processed_at"] = time.Now() + + c.JSON(http.StatusOK, charge) +} + +func (suite *SubscriberJourneyTestSuite) getInvoice(c *gin.Context) { + invoice := map[string]interface{}{ + "subscription_id": c.Param("subscription_id"), + "total_amount": 29.99, + "currency": "USD", + "status": "paid", + "issued_at": time.Now(), + } + c.JSON(http.StatusOK, invoice) +} + +func (suite *SubscriberJourneyTestSuite) getDashboard(c *gin.Context) { + dashboard := map[string]interface{}{ + "total_subscribers": int64(100), + "active_subscriptions": int64(95), + "monthly_revenue": 2999.0, + "churn_rate": 0.05, + "generated_at": time.Now(), + } + c.JSON(http.StatusOK, dashboard) +} + +func (suite *SubscriberJourneyTestSuite) getRevenue(c *gin.Context) { + revenue := map[string]interface{}{ + "total_revenue": 50000.0, + "monthly_revenue": 5000.0, + "revenue_by_plan": map[string]float64{ + "basic": 1500.0, + "premium": 3500.0, + }, + "currency": "USD", + } + c.JSON(http.StatusOK, revenue) +} + +// TearDownSuite cleans up the test suite +func (suite *SubscriberJourneyTestSuite) TearDownSuite() { + sqlDB, err := suite.db.DB() + if err == nil { + sqlDB.Close() + } +} + +// TestSubscriberJourneySuite runs the E2E test suite +func TestSubscriberJourneySuite(t *testing.T) { + suite.Run(t, new(SubscriberJourneyTestSuite)) +} diff --git a/apps/carrier-connector/tests/integration/api_integration_test.go b/apps/carrier-connector/tests/integration/api_integration_test.go new file mode 100644 index 0000000..fd52f58 --- /dev/null +++ b/apps/carrier-connector/tests/integration/api_integration_test.go @@ -0,0 +1,440 @@ +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// APIIntegrationTestSuite tests API integration +type APIIntegrationTestSuite struct { + suite.Suite + db *gorm.DB + router *gin.Engine + logger *logrus.Logger +} + +// SetupSuite sets up the test suite +func (suite *APIIntegrationTestSuite) SetupSuite() { + // Setup in-memory database for testing + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(suite.T(), err) + + // Auto-migrate tables + err = db.AutoMigrate( + &repository.RatePlanUsage{}, + &repository.RatePlanSubscription{}, + ) + require.NoError(suite.T(), err) + + suite.db = db + suite.logger = logrus.New() + suite.logger.SetLevel(logrus.ErrorLevel) + + // Setup router + gin.SetMode(gin.TestMode) + suite.router = gin.New() + suite.setupRoutes() +} + +func (suite *APIIntegrationTestSuite) setupRoutes() { + api := suite.router.Group("/api/v1") + + // Rate plan usage routes + usage := api.Group("/usage") + { + usage.POST("", suite.createUsage) + usage.GET("/:id", suite.getUsage) + usage.GET("", suite.listUsage) + } + + // Subscription routes + subscriptions := api.Group("/subscriptions") + { + subscriptions.POST("", suite.createSubscription) + subscriptions.GET("/:id", suite.getSubscription) + subscriptions.PUT("/:id/cancel", suite.cancelSubscription) + } + + // Health check + api.GET("/health", suite.healthCheck) +} + +// TestRatePlanUsageLifecycle tests usage tracking +func (suite *APIIntegrationTestSuite) TestRatePlanUsageLifecycle() { + // 1. Create a rate plan usage record + usageReq := map[string]interface{}{ + "rate_plan_id": "plan-123", + "profile_id": "profile-456", + "cycle_start": "2026-01-01T00:00:00Z", + "cycle_end": "2026-01-31T23:59:59Z", + "data_used": int64(1024), + "voice_used": int64(300), + "sms_used": int64(50), + } + + reqBody, _ := json.Marshal(usageReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/usage", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var usage repository.RatePlanUsage + err := json.Unmarshal(w.Body.Bytes(), &usage) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), "plan-123", usage.RatePlanID) + assert.Equal(suite.T(), "profile-456", usage.ProfileID) + assert.Equal(suite.T(), int64(1024), usage.DataUsed) + + usageID := usage.ID + + // 2. Get the usage record + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/usage/%s", usageID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var retrievedUsage repository.RatePlanUsage + err = json.Unmarshal(w.Body.Bytes(), &retrievedUsage) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), usageID, retrievedUsage.ID) + + // 3. List usage records + req = httptest.NewRequest(http.MethodGet, "/api/v1/usage", nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var usageList []repository.RatePlanUsage + err = json.Unmarshal(w.Body.Bytes(), &usageList) + require.NoError(suite.T(), err) + assert.Greater(suite.T(), len(usageList), 0) +} + +// TestSubscriptionLifecycle tests subscription management +func (suite *APIIntegrationTestSuite) TestSubscriptionLifecycle() { + // 1. Create a subscription + subReq := map[string]interface{}{ + "profile_id": "profile-789", + "rate_plan_id": "plan-abc", + "status": "active", + "billing_cycle": "monthly", + "next_billing_date": "2026-02-01T00:00:00Z", + "auto_renew": true, + "applied_discounts": []string{"new-user-10"}, + } + + reqBody, _ := json.Marshal(subReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var subscription repository.RatePlanSubscription + err := json.Unmarshal(w.Body.Bytes(), &subscription) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), "profile-789", subscription.ProfileID) + assert.Equal(suite.T(), "plan-abc", subscription.RatePlanID) + assert.Equal(suite.T(), repository.SubscriptionStatus("active"), subscription.Status) + + subscriptionID := subscription.ID + + // 2. Get the subscription + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/subscriptions/%s", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var retrievedSubscription repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &retrievedSubscription) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), subscriptionID, retrievedSubscription.ID) + + // 3. Cancel the subscription + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/subscriptions/%s/cancel", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + // 4. Verify subscription is cancelled + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/subscriptions/%s", subscriptionID), nil) + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var cancelledSubscription repository.RatePlanSubscription + err = json.Unmarshal(w.Body.Bytes(), &cancelledSubscription) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), repository.SubscriptionStatus("cancelled"), cancelledSubscription.Status) +} + +// TestHealthCheck tests the health endpoint +func (suite *APIIntegrationTestSuite) TestHealthCheck() { + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var health map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &health) + require.NoError(suite.T(), err) + assert.Equal(suite.T(), "healthy", health["status"]) + assert.Contains(suite.T(), health, "timestamp") +} + +// TestErrorHandling tests API error handling +func (suite *APIIntegrationTestSuite) TestErrorHandling() { + // Test getting non-existent usage + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/non-existent", nil) + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusNotFound, w.Code) + + var errorResponse map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + require.NoError(suite.T(), err) + assert.Contains(suite.T(), errorResponse, "error") + + // Test invalid request body + reqBody := []byte("{invalid json}") + req = httptest.NewRequest(http.MethodPost, "/api/v1/usage", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) +} + +// TestConcurrentRequests tests concurrent API requests +func (suite *APIIntegrationTestSuite) TestConcurrentRequests() { + // Create some test data first + usage := repository.RatePlanUsage{ + RatePlanID: "test-plan", + ProfileID: "test-profile", + CycleStart: time.Now(), + CycleEnd: time.Now().Add(30 * 24 * time.Hour), + DataUsed: 100, + VoiceUsed: 50, + SMSUsed: 10, + } + err := suite.db.Create(&usage).Error + require.NoError(suite.T(), err) + + // Test concurrent requests + const numGoroutines = 10 + const numRequests = 100 + + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + for j := 0; j < numRequests/numGoroutines; j++ { + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage", nil) + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + errChan <- fmt.Errorf("unexpected status code: %d", w.Code) + return + } + } + errChan <- nil + }() + } + + // Wait for all goroutines to complete + for i := 0; i < numGoroutines; i++ { + err := <-errChan + assert.NoError(suite.T(), err) + } +} + +// TestRatePlanUsageValidation tests input validation +func (suite *APIIntegrationTestSuite) TestRatePlanUsageValidation() { + // Test missing required fields + invalidReq := map[string]interface{}{ + "profile_id": "profile-123", + // Missing rate_plan_id + } + + reqBody, _ := json.Marshal(invalidReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/usage", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) + + var errorResponse map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + require.NoError(suite.T(), err) + assert.Contains(suite.T(), errorResponse, "error") + + // Test invalid data types + invalidReq2 := map[string]interface{}{ + "rate_plan_id": "plan-123", + "profile_id": "profile-123", + "data_used": "not-a-number", // Should be int64 + } + + reqBody, _ = json.Marshal(invalidReq2) + req = httptest.NewRequest(http.MethodPost, "/api/v1/usage", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + suite.router.ServeHTTP(w, req) + + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) +} + +// Handler implementations +func (suite *APIIntegrationTestSuite) createUsage(c *gin.Context) { + var usage repository.RatePlanUsage + if err := c.ShouldBindJSON(&usage); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Generate ID if not provided + if usage.ID == "" { + usage.ID = fmt.Sprintf("usage-%d", time.Now().UnixNano()) + } + + // Set timestamps + now := time.Now() + usage.LastUpdated = now + + if err := suite.db.Create(&usage).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, usage) +} + +func (suite *APIIntegrationTestSuite) getUsage(c *gin.Context) { + var usage repository.RatePlanUsage + if err := suite.db.First(&usage, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "usage record not found"}) + return + } + + c.JSON(http.StatusOK, usage) +} + +func (suite *APIIntegrationTestSuite) listUsage(c *gin.Context) { + var usage []repository.RatePlanUsage + if err := suite.db.Find(&usage).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, usage) +} + +func (suite *APIIntegrationTestSuite) createSubscription(c *gin.Context) { + var subscription repository.RatePlanSubscription + if err := c.ShouldBindJSON(&subscription); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Generate ID if not provided + if subscription.ID == "" { + subscription.ID = fmt.Sprintf("sub-%d", time.Now().UnixNano()) + } + + // Set timestamps + now := time.Now() + subscription.StartedAt = now + subscription.CreatedAt = now + subscription.UpdatedAt = now + subscription.CurrentCycle = now + + if err := suite.db.Create(&subscription).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, subscription) +} + +func (suite *APIIntegrationTestSuite) getSubscription(c *gin.Context) { + var subscription repository.RatePlanSubscription + if err := suite.db.First(&subscription, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"}) + return + } + + c.JSON(http.StatusOK, subscription) +} + +func (suite *APIIntegrationTestSuite) cancelSubscription(c *gin.Context) { + if err := suite.db.Model(&repository.RatePlanSubscription{}).Where("id = ?", c.Param("id")).Updates(map[string]interface{}{ + "status": "cancelled", + "ended_at": time.Now(), + "updated_at": time.Now(), + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) +} + +func (suite *APIIntegrationTestSuite) healthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now(), + "version": "1.0.0", + }) +} + +// TearDownSuite cleans up the test suite +func (suite *APIIntegrationTestSuite) TearDownSuite() { + sqlDB, err := suite.db.DB() + if err == nil { + sqlDB.Close() + } +} + +// TestAPIIntegrationSuite runs the integration test suite +func TestAPIIntegrationSuite(t *testing.T) { + suite.Run(t, new(APIIntegrationTestSuite)) +} diff --git a/apps/carrier-connector/tests/integration/carrier_api_test.go b/apps/carrier-connector/tests/integration/carrier_api_test.go new file mode 100644 index 0000000..7c79de5 --- /dev/null +++ b/apps/carrier-connector/tests/integration/carrier_api_test.go @@ -0,0 +1,189 @@ +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// CarrierAPITestSuite tests carrier API integration +type CarrierAPITestSuite struct { + suite.Suite + db *gorm.DB + router *gin.Engine +} + +func (suite *CarrierAPITestSuite) SetupSuite() { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(suite.T(), err) + + err = db.AutoMigrate(&repository.RatePlan{}, &repository.RatePlanSubscription{}, &repository.RatePlanUsage{}) + require.NoError(suite.T(), err) + + suite.db = db + gin.SetMode(gin.TestMode) + suite.router = gin.New() + suite.setupRoutes() +} + +func (suite *CarrierAPITestSuite) setupRoutes() { + api := suite.router.Group("/api/v1") + api.POST("/rateplans", suite.createRatePlan) + api.GET("/rateplans", suite.listRatePlans) + api.GET("/rateplans/:id", suite.getRatePlan) + api.POST("/subscriptions", suite.createSubscription) + api.GET("/subscriptions/:id", suite.getSubscription) + api.POST("/usage", suite.recordUsage) + api.GET("/health", suite.healthCheck) +} + +func (suite *CarrierAPITestSuite) TestRatePlanLifecycle() { + plan := map[string]interface{}{ + "name": "Test Plan", "description": "Test", "carrier_id": "carrier-1", + "region": "US", "base_price": 10.0, "currency": "USD", "is_active": true, + } + body, _ := json.Marshal(plan) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rateplans", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var created repository.RatePlan + json.Unmarshal(w.Body.Bytes(), &created) + assert.Equal(suite.T(), "Test Plan", created.Name) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/rateplans/"+created.ID, nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusOK, w.Code) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/rateplans", nil) + w = httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusOK, w.Code) +} + +func (suite *CarrierAPITestSuite) TestSubscriptionLifecycle() { + plan := &repository.RatePlan{ID: "plan-1", Name: "Plan", CarrierID: "c1", BasePrice: 10, Currency: "USD", IsActive: true} + suite.db.Create(plan) + + sub := map[string]interface{}{"profile_id": "profile-1", "rate_plan_id": "plan-1", "auto_renew": true} + body, _ := json.Marshal(sub) + req := httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var created repository.RatePlanSubscription + json.Unmarshal(w.Body.Bytes(), &created) + assert.Equal(suite.T(), "profile-1", created.ProfileID) +} + +func (suite *CarrierAPITestSuite) TestUsageRecording() { + usage := map[string]interface{}{"profile_id": "p1", "rate_plan_id": "rp1", "data_used": 100, "voice_used": 10, "sms_used": 5} + body, _ := json.Marshal(usage) + req := httptest.NewRequest(http.MethodPost, "/api/v1/usage", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusCreated, w.Code) +} + +func (suite *CarrierAPITestSuite) TestHealthCheck() { + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + suite.router.ServeHTTP(w, req) + assert.Equal(suite.T(), http.StatusOK, w.Code) +} + +func (suite *CarrierAPITestSuite) createRatePlan(c *gin.Context) { + var plan repository.RatePlan + if err := c.ShouldBindJSON(&plan); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + plan.ID = "rp-" + time.Now().Format("20060102150405") + plan.CreatedAt = time.Now() + plan.UpdatedAt = time.Now() + suite.db.Create(&plan) + c.JSON(http.StatusCreated, plan) +} + +func (suite *CarrierAPITestSuite) listRatePlans(c *gin.Context) { + var plans []repository.RatePlan + suite.db.Find(&plans) + c.JSON(http.StatusOK, plans) +} + +func (suite *CarrierAPITestSuite) getRatePlan(c *gin.Context) { + var plan repository.RatePlan + if err := suite.db.First(&plan, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, plan) +} + +func (suite *CarrierAPITestSuite) createSubscription(c *gin.Context) { + var sub repository.RatePlanSubscription + if err := c.ShouldBindJSON(&sub); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + sub.ID = "sub-" + time.Now().Format("20060102150405") + sub.Status = repository.SubscriptionStatusActive + sub.StartedAt = time.Now() + sub.CreatedAt = time.Now() + sub.UpdatedAt = time.Now() + suite.db.Create(&sub) + c.JSON(http.StatusCreated, sub) +} + +func (suite *CarrierAPITestSuite) getSubscription(c *gin.Context) { + var sub repository.RatePlanSubscription + if err := suite.db.First(&sub, "id = ?", c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, sub) +} + +func (suite *CarrierAPITestSuite) recordUsage(c *gin.Context) { + var usage repository.RatePlanUsage + if err := c.ShouldBindJSON(&usage); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + usage.ID = "usage-" + time.Now().Format("20060102150405") + usage.LastUpdated = time.Now() + suite.db.Create(&usage) + c.JSON(http.StatusCreated, usage) +} + +func (suite *CarrierAPITestSuite) healthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "healthy", "timestamp": time.Now()}) +} + +func (suite *CarrierAPITestSuite) TearDownSuite() { + if db, err := suite.db.DB(); err == nil { + db.Close() + } +} + +func TestCarrierAPITestSuite(t *testing.T) { + suite.Run(t, new(CarrierAPITestSuite)) +}