Files
honeyDueAPI/internal/integration/subscription_is_free_test.go
T
Trey t d74cfeee62
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
feat(subscription): temporarily disable subscription gating
Subscriptions aren't a shipping feature for now. Make
GET /api/subscription/status/ return a "limitations disabled" / pro-tier
stub at the top of the function with no DB or Redis work:

  - tier="pro"
  - is_active=true
  - limitations_enabled=false  (master kill switch in SubscriptionHelper.kt;
                                every canCreate* check short-circuits true)
  - usage=0 across the board
  - limits map present with empty entries (all-nil = unlimited per the KMM
    model convention) so client tier-lookups don't NPE

The original implementation is preserved verbatim as the unexported
getSubscriptionStatusFromDB method. Re-enabling is a one-line change:
swap GetSubscriptionStatus's body to call s.getSubscriptionStatusFromDB.

Two integration tests in subscription_is_free_test.go assert the original
"limitations actually apply based on settings/IsFree" behavior. They now
t.Skip with the same TEMPORARILY DISABLED marker pointing back to the
service comment. CheckLimit-based tests in the same file still pass
because that codepath is unchanged.

Perf side effect: POST/GET on this route drops to ~1ms (just JSON marshal),
removing 4-5 serial Neon RTTs from every cold call. Was the slowest endpoint
in the live dashboard (~213ms p95 / ~480ms after the pod roll).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:07:06 -05:00

400 lines
14 KiB
Go

package integration
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
"github.com/treytartt/honeydue-api/internal/validator"
)
// SubscriptionTestApp holds components for subscription integration testing
type SubscriptionTestApp struct {
DB *gorm.DB
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
// the in-process tokenStore instead of calling the real Kratos session endpoint.
func (app *SubscriptionTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
// registerAndLogin creates a user directly in the DB and returns a fake token
// and user ID. No HTTP register/login calls are made.
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, _ string) (string, uint) {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok, user.ID
}
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
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, true)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
app := &SubscriptionTestApp{
DB: db,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
tokenStore: make(map[string]*models.User),
}
// Create router with fake auth middleware
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
e.Use(middleware.TimezoneMiddleware())
// Protected routes
api := e.Group("/api")
api.Use(app.fakeAuthMiddleware())
{
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)
}
}
app.Router = e
return app
}
// 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
}
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus now returns
// a limitations_enabled=false stub for everyone, so the assertion that a
// normal user sees limitations_enabled=true (per EnableLimitations setting)
// no longer holds. Remove this skip when GetSubscriptionStatus is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
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(context.Background(), 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(context.Background(), 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(context.Background(), userID, "properties")
assert.NoError(t, err, "IsFree user should bypass property limits")
// Should also bypass other limits
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "tasks")
assert.NoError(t, err, "IsFree user should bypass task limits")
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "contractors")
assert.NoError(t, err, "IsFree user should bypass contractor limits")
err = app.SubscriptionService.CheckLimit(context.Background(), 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) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus is stubbed,
// so the Pro+!IsFree case (which would normally return limitations_enabled=true)
// is no longer reachable. Remove this skip when the feature is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
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(context.Background(), userID, "properties")
assert.NoError(t, err, "Should bypass limits when global limitations are disabled")
}