Files
honeyDueAPI/internal/integration/subscription_is_free_test.go
Trey t 0c86611a10 Add IsFree subscription toggle to bypass all tier limitations
- Add IsFree boolean field to UserSubscription model
- When IsFree is true, user sees limitations_enabled=false regardless of global setting
- CheckLimit() bypasses all limit checks for IsFree users
- Add admin endpoint GET /api/admin/subscriptions/user/:user_id
- Add IsFree toggle to admin user detail page under Subscription card
- Add database migration 004_subscription_is_free
- Add integration tests for IsFree functionality
- Add task kanban categorization documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 18:05:41 -06:00

374 lines
12 KiB
Go

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/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/handlers"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
"gorm.io/gorm"
)
// SubscriptionTestApp holds components for subscription integration testing
type SubscriptionTestApp struct {
DB *gorm.DB
Router *gin.Engine
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
}
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
gin.SetMode(gin.TestMode)
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
// Create repositories
userRepo := repositories.NewUserRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-subscription-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
// Create services
authService := services.NewAuthService(userRepo, cfg)
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
// Create handlers
authHandler := handlers.NewAuthHandler(authService, nil, nil)
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
// Create router
router := gin.New()
// Public routes
auth := router.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := router.Group("/api")
api.Use(authMiddleware.TokenAuth())
{
api.GET("/auth/me", authHandler.CurrentUser)
residences := api.Group("/residences")
{
residences.POST("", residenceHandler.CreateResidence)
}
subscription := api.Group("/subscription")
{
subscription.GET("/", subscriptionHandler.GetSubscription)
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
}
}
return &SubscriptionTestApp{
DB: db,
Router: router,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
}
// Helper to make authenticated requests
func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var reqBody []byte
var err error
if body != nil {
reqBody, err = json.Marshal(body)
require.NoError(t, err)
}
req := httptest.NewRequest(method, path, nil)
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(reqBody))
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Token "+token)
}
w := httptest.NewRecorder()
app.Router.ServeHTTP(w, req)
return w
}
// Helper to register and login a user, returns token and user ID
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, password string) (string, uint) {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
userMap := loginResp["user"].(map[string]interface{})
userID := uint(userMap["id"].(float64))
return token, userID
}
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "freeuser", "free@test.com", "password123")
// Enable global limitations - first delete any existing, then create with enabled
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
settings := &models.SubscriptionSettings{EnableLimitations: true}
err := app.DB.Create(settings).Error
require.NoError(t, err)
// Verify limitations are enabled globally
var verifySettings models.SubscriptionSettings
app.DB.First(&verifySettings)
require.True(t, verifySettings.EnableLimitations, "Global limitations should be enabled")
// ========== Test 1: Normal user sees limitations_enabled=true ==========
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &statusResp)
require.NoError(t, err)
assert.True(t, statusResp["limitations_enabled"].(bool),
"Normal user should see limitations_enabled=true when global setting is enabled")
// ========== Test 2: Set IsFree=true for user ==========
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// Set IsFree=true
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
// ========== Test 3: User with IsFree=true sees limitations_enabled=false ==========
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &statusResp)
require.NoError(t, err)
assert.False(t, statusResp["limitations_enabled"].(bool),
"User with IsFree=true should see limitations_enabled=false regardless of global setting")
}
// TestIntegration_IsFreeBypassesCheckLimit tests that IsFree users can create
// resources beyond their tier limits
func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
_, userID := app.registerAndLogin(t, "limituser", "limit@test.com", "password123")
// Enable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: true}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Set free tier limit to 1 property
one := 1
freeLimits := &models.TierLimits{
Tier: models.TierFree,
PropertiesLimit: &one,
}
app.DB.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{})
app.DB.Create(freeLimits)
// Get user's subscription (should be free tier)
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
require.Equal(t, models.TierFree, sub.Tier)
// ========== Test 1: Normal free user hits limit ==========
// First property should succeed
err = app.SubscriptionService.CheckLimit(userID, "properties")
assert.NoError(t, err, "First property should be allowed")
// Create a property to use up the limit
residence := &models.Residence{
Name: "Test Property",
OwnerID: userID,
}
app.DB.Create(residence)
// Second property should fail
err = app.SubscriptionService.CheckLimit(userID, "properties")
assert.Error(t, err, "Second property should be blocked for normal free user")
assert.Equal(t, services.ErrPropertiesLimitExceeded, err)
// ========== Test 2: Set IsFree=true ==========
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
// ========== Test 3: IsFree user bypasses limit ==========
err = app.SubscriptionService.CheckLimit(userID, "properties")
assert.NoError(t, err, "IsFree user should bypass property limits")
// Should also bypass other limits
err = app.SubscriptionService.CheckLimit(userID, "tasks")
assert.NoError(t, err, "IsFree user should bypass task limits")
err = app.SubscriptionService.CheckLimit(userID, "contractors")
assert.NoError(t, err, "IsFree user should bypass contractor limits")
err = app.SubscriptionService.CheckLimit(userID, "documents")
assert.NoError(t, err, "IsFree user should bypass document limits")
}
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
// the user's subscription tier
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "tieruser", "tier@test.com", "password123")
// Enable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: true}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// ========== Test with Free tier + IsFree ==========
sub.Tier = models.TierFree
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"Free tier user with IsFree should see limitations_enabled=false")
// ========== Test with Pro tier + IsFree ==========
sub.Tier = models.TierPro
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"Pro tier user with IsFree should see limitations_enabled=false")
// ========== Test disabling IsFree restores normal behavior ==========
sub.Tier = models.TierFree
sub.IsFree = false
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.True(t, statusResp["limitations_enabled"].(bool),
"Free tier user without IsFree should see limitations_enabled=true")
}
// TestIntegration_IsFreeWhenGlobalLimitationsDisabled tests that IsFree has no
// effect when global limitations are already disabled
func TestIntegration_IsFreeWhenGlobalLimitationsDisabled(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "globaluser", "global@test.com", "password123")
// Disable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: false}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// ========== Test 1: Without IsFree, limitations are disabled ==========
sub.IsFree = false
app.SubscriptionRepo.Update(sub)
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"When global limitations are disabled, limitations_enabled should be false")
// ========== Test 2: With IsFree, limitations are still disabled ==========
sub.IsFree = true
app.SubscriptionRepo.Update(sub)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"With IsFree and global limitations disabled, limitations_enabled should be false")
// Both cases result in the same outcome - no limitations
err = app.SubscriptionService.CheckLimit(userID, "properties")
assert.NoError(t, err, "Should bypass limits when global limitations are disabled")
}