Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
381 lines
13 KiB
Go
381 lines
13 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/casera-api/internal/apperrors"
|
|
"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"
|
|
"github.com/treytartt/casera-api/internal/validator"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SubscriptionTestApp holds components for subscription integration testing
|
|
type SubscriptionTestApp struct {
|
|
DB *gorm.DB
|
|
Router *echo.Echo
|
|
SubscriptionService *services.SubscriptionService
|
|
SubscriptionRepo *repositories.SubscriptionRepository
|
|
}
|
|
|
|
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
|
|
// Echo does not need test mode
|
|
|
|
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
|
|
e := echo.New()
|
|
e.Validator = validator.NewCustomValidator()
|
|
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
|
|
|
|
// Public routes
|
|
auth := e.Group("/api/auth")
|
|
{
|
|
auth.POST("/register", authHandler.Register)
|
|
auth.POST("/login", authHandler.Login)
|
|
}
|
|
|
|
// Protected routes
|
|
authMiddleware := middleware.NewAuthMiddleware(db, nil)
|
|
api := e.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: e,
|
|
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")
|
|
var appErr *apperrors.AppError
|
|
require.ErrorAs(t, err, &appErr)
|
|
assert.Equal(t, http.StatusForbidden, appErr.Code)
|
|
assert.Equal(t, "error.properties_limit_exceeded", appErr.MessageKey)
|
|
|
|
// ========== 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")
|
|
}
|