e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
376 lines
13 KiB
Go
376 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/testutil"
|
|
)
|
|
|
|
// setupSubscriptionService creates a SubscriptionService with the given
|
|
// IAP clients (nil means "not configured"). It bypasses NewSubscriptionService
|
|
// which tries to load config from environment.
|
|
func setupSubscriptionService(t *testing.T, appleClient *AppleIAPClient, googleClient *GoogleIAPClient) (*SubscriptionService, *repositories.SubscriptionRepository) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
// Create a test user and subscription record for the test
|
|
user := testutil.CreateTestUser(t, db, "subuser", "subuser@test.com", "password")
|
|
|
|
// Create subscription record so GetOrCreate will find it
|
|
sub := &models.UserSubscription{
|
|
UserID: user.ID,
|
|
Tier: models.TierFree,
|
|
}
|
|
err := db.Create(sub).Error
|
|
require.NoError(t, err)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
appleClient: appleClient,
|
|
googleClient: googleClient,
|
|
}
|
|
|
|
return svc, subscriptionRepo
|
|
}
|
|
|
|
func TestProcessApplePurchase_ClientNil_ReturnsError(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "subuser", "subuser@test.com", "password")
|
|
sub := &models.UserSubscription{UserID: user.ID, Tier: models.TierFree}
|
|
require.NoError(t, db.Create(sub).Error)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
appleClient: nil, // Not configured
|
|
googleClient: nil,
|
|
}
|
|
|
|
_, err := svc.ProcessApplePurchase(context.Background(), user.ID, "fake-receipt", "")
|
|
assert.Error(t, err, "ProcessApplePurchase should return error when Apple IAP client is nil")
|
|
|
|
// Verify user was NOT upgraded to Pro
|
|
updatedSub, err := subscriptionRepo.GetOrCreate(user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier when IAP client is nil")
|
|
}
|
|
|
|
func TestProcessApplePurchase_ValidationFails_DoesNotUpgrade(t *testing.T) {
|
|
// We cannot easily create a real AppleIAPClient that will fail validation
|
|
// in a unit test (it requires real keys and network access).
|
|
// Instead, we test the code path logic:
|
|
// When appleClient is nil, the service must NOT upgrade the user.
|
|
// This is the same as TestProcessApplePurchase_ClientNil_ReturnsError
|
|
// but validates no fallback occurs for the specific case.
|
|
|
|
db := testutil.SetupTestDB(t)
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "subuser2", "subuser2@test.com", "password")
|
|
sub := &models.UserSubscription{UserID: user.ID, Tier: models.TierFree}
|
|
require.NoError(t, db.Create(sub).Error)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
appleClient: nil,
|
|
googleClient: nil,
|
|
}
|
|
|
|
// Neither receipt data nor transaction ID - should still not grant Pro
|
|
_, err := svc.ProcessApplePurchase(context.Background(), user.ID, "", "")
|
|
assert.Error(t, err, "ProcessApplePurchase should return error when client is nil, even with empty data")
|
|
|
|
// Verify no upgrade happened
|
|
updatedSub, err := subscriptionRepo.GetOrCreate(user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier")
|
|
}
|
|
|
|
func TestProcessGooglePurchase_ClientNil_ReturnsError(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "subuser3", "subuser3@test.com", "password")
|
|
sub := &models.UserSubscription{UserID: user.ID, Tier: models.TierFree}
|
|
require.NoError(t, db.Create(sub).Error)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
appleClient: nil,
|
|
googleClient: nil, // Not configured
|
|
}
|
|
|
|
_, err := svc.ProcessGooglePurchase(context.Background(), user.ID, "fake-token", "com.tt.honeyDue.pro.monthly")
|
|
assert.Error(t, err, "ProcessGooglePurchase should return error when Google IAP client is nil")
|
|
|
|
// Verify user was NOT upgraded to Pro
|
|
updatedSub, err := subscriptionRepo.GetOrCreate(user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier when IAP client is nil")
|
|
}
|
|
|
|
func TestProcessGooglePurchase_ValidationFails_DoesNotUpgrade(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
user := testutil.CreateTestUser(t, db, "subuser4", "subuser4@test.com", "password")
|
|
sub := &models.UserSubscription{UserID: user.ID, Tier: models.TierFree}
|
|
require.NoError(t, db.Create(sub).Error)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
appleClient: nil,
|
|
googleClient: nil, // Not configured
|
|
}
|
|
|
|
// With empty token
|
|
_, err := svc.ProcessGooglePurchase(context.Background(), user.ID, "", "")
|
|
assert.Error(t, err, "ProcessGooglePurchase should return error when client is nil")
|
|
|
|
// Verify no upgrade happened
|
|
updatedSub, err := subscriptionRepo.GetOrCreate(user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier")
|
|
}
|
|
|
|
// === GetSubscription ===
|
|
|
|
func TestSubscriptionService_GetSubscription(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
}
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
|
|
|
|
resp, err := svc.GetSubscription(context.Background(), user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "free", resp.Tier)
|
|
assert.False(t, resp.IsPro)
|
|
}
|
|
|
|
func TestSubscriptionService_GetSubscription_ProUser(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
}
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
|
|
|
|
// Create a pro subscription
|
|
future := time.Now().UTC().Add(30 * 24 * time.Hour)
|
|
sub := &models.UserSubscription{
|
|
UserID: user.ID,
|
|
Tier: models.TierPro,
|
|
ExpiresAt: &future,
|
|
Platform: "ios",
|
|
}
|
|
err := db.Create(sub).Error
|
|
require.NoError(t, err)
|
|
|
|
resp, err := svc.GetSubscription(context.Background(), user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "pro", resp.Tier)
|
|
assert.True(t, resp.IsPro)
|
|
assert.True(t, resp.IsActive)
|
|
}
|
|
|
|
// === CancelSubscription ===
|
|
|
|
func TestSubscriptionService_CancelSubscription(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
}
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
|
|
|
|
// Create a pro subscription with auto_renew
|
|
future := time.Now().UTC().Add(30 * 24 * time.Hour)
|
|
sub := &models.UserSubscription{
|
|
UserID: user.ID,
|
|
Tier: models.TierPro,
|
|
ExpiresAt: &future,
|
|
AutoRenew: true,
|
|
}
|
|
err := db.Create(sub).Error
|
|
require.NoError(t, err)
|
|
|
|
resp, err := svc.CancelSubscription(context.Background(), user.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.AutoRenew)
|
|
}
|
|
|
|
func TestIsAlreadyProFromOtherPlatform(t *testing.T) {
|
|
future := time.Now().UTC().Add(30 * 24 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
tier models.SubscriptionTier
|
|
platform string
|
|
expiresAt *time.Time
|
|
trialEnd *time.Time
|
|
requestedPlatform string
|
|
wantConflict bool
|
|
wantPlatform string
|
|
}{
|
|
{
|
|
name: "free user returns no conflict",
|
|
tier: models.TierFree,
|
|
platform: "",
|
|
expiresAt: nil,
|
|
trialEnd: nil,
|
|
requestedPlatform: "stripe",
|
|
wantConflict: false,
|
|
wantPlatform: "",
|
|
},
|
|
{
|
|
name: "pro from ios, requesting ios returns no conflict (same platform)",
|
|
tier: models.TierPro,
|
|
platform: "ios",
|
|
expiresAt: &future,
|
|
trialEnd: nil,
|
|
requestedPlatform: "ios",
|
|
wantConflict: false,
|
|
wantPlatform: "",
|
|
},
|
|
{
|
|
name: "pro from ios, requesting stripe returns conflict",
|
|
tier: models.TierPro,
|
|
platform: "ios",
|
|
expiresAt: &future,
|
|
trialEnd: nil,
|
|
requestedPlatform: "stripe",
|
|
wantConflict: true,
|
|
wantPlatform: "ios",
|
|
},
|
|
{
|
|
name: "pro from stripe, requesting android returns conflict",
|
|
tier: models.TierPro,
|
|
platform: "stripe",
|
|
expiresAt: &future,
|
|
trialEnd: nil,
|
|
requestedPlatform: "android",
|
|
wantConflict: true,
|
|
wantPlatform: "stripe",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(db)
|
|
residenceRepo := repositories.NewResidenceRepository(db)
|
|
taskRepo := repositories.NewTaskRepository(db)
|
|
contractorRepo := repositories.NewContractorRepository(db)
|
|
documentRepo := repositories.NewDocumentRepository(db)
|
|
|
|
svc := &SubscriptionService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
residenceRepo: residenceRepo,
|
|
taskRepo: taskRepo,
|
|
contractorRepo: contractorRepo,
|
|
documentRepo: documentRepo,
|
|
}
|
|
|
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
|
sub := &models.UserSubscription{
|
|
UserID: user.ID,
|
|
Tier: tt.tier,
|
|
Platform: tt.platform,
|
|
ExpiresAt: tt.expiresAt,
|
|
TrialEnd: tt.trialEnd,
|
|
}
|
|
err := db.Create(sub).Error
|
|
require.NoError(t, err)
|
|
|
|
conflict, existingPlatform, err := svc.IsAlreadyProFromOtherPlatform(context.Background(), user.ID, tt.requestedPlatform)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantConflict, conflict)
|
|
assert.Equal(t, tt.wantPlatform, existingPlatform)
|
|
})
|
|
}
|
|
}
|