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>
This commit is contained in:
373
internal/integration/subscription_is_free_test.go
Normal file
373
internal/integration/subscription_is_free_test.go
Normal file
@@ -0,0 +1,373 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user