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>
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/config"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
)
|
|
|
|
func setupRefreshTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = db.AutoMigrate(&models.User{}, &models.UserProfile{}, &models.AuthToken{})
|
|
require.NoError(t, err)
|
|
|
|
return db
|
|
}
|
|
|
|
func createRefreshTestUser(t *testing.T, db *gorm.DB) *models.User {
|
|
t.Helper()
|
|
user := &models.User{
|
|
Username: "refreshtest",
|
|
Email: "refresh@test.com",
|
|
IsActive: true,
|
|
}
|
|
require.NoError(t, user.SetPassword("Password123"))
|
|
require.NoError(t, db.Create(user).Error)
|
|
return user
|
|
}
|
|
|
|
func createTokenWithAge(t *testing.T, db *gorm.DB, userID uint, ageDays int) *models.AuthToken {
|
|
t.Helper()
|
|
token := &models.AuthToken{
|
|
UserID: userID,
|
|
}
|
|
require.NoError(t, db.Create(token).Error)
|
|
|
|
// Backdate the token's Created timestamp after creation to bypass autoCreateTime
|
|
backdated := time.Now().UTC().AddDate(0, 0, -ageDays)
|
|
require.NoError(t, db.Model(token).Update("created", backdated).Error)
|
|
token.Created = backdated
|
|
|
|
return token
|
|
}
|
|
|
|
func newTestAuthService(db *gorm.DB) *AuthService {
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{
|
|
Security: config.SecurityConfig{
|
|
SecretKey: "test-secret",
|
|
TokenExpiryDays: 90,
|
|
TokenRefreshDays: 60,
|
|
},
|
|
}
|
|
return NewAuthService(userRepo, cfg)
|
|
}
|
|
|
|
func TestRefreshToken_FreshToken_ReturnsExisting(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
token := createTokenWithAge(t, db, user.ID, 30) // 30 days old, well within fresh window
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, token.Key, resp.Token, "fresh token should return the same token")
|
|
assert.Contains(t, resp.Message, "still valid")
|
|
}
|
|
|
|
func TestRefreshToken_InRenewalWindow_ReturnsNewToken(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
token := createTokenWithAge(t, db, user.ID, 75) // 75 days old, in renewal window (60-90)
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, token.Key, resp.Token, "should return a new token")
|
|
assert.Contains(t, resp.Message, "refreshed")
|
|
|
|
// Verify old token was deleted
|
|
var count int64
|
|
db.Model(&models.AuthToken{}).Where("key = ?", token.Key).Count(&count)
|
|
assert.Equal(t, int64(0), count, "old token should be deleted")
|
|
|
|
// Verify new token exists in DB
|
|
db.Model(&models.AuthToken{}).Where("key = ?", resp.Token).Count(&count)
|
|
assert.Equal(t, int64(1), count, "new token should exist in DB")
|
|
|
|
// Verify new token belongs to the same user
|
|
var newToken models.AuthToken
|
|
require.NoError(t, db.Where("key = ?", resp.Token).First(&newToken).Error)
|
|
assert.Equal(t, user.ID, newToken.UserID)
|
|
}
|
|
|
|
func TestRefreshToken_ExpiredToken_Returns401(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
token := createTokenWithAge(t, db, user.ID, 91) // 91 days old, past 90-day expiry
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID)
|
|
require.Error(t, err)
|
|
assert.Nil(t, resp)
|
|
assert.Contains(t, err.Error(), "error.token_expired")
|
|
}
|
|
|
|
func TestRefreshToken_AtExactBoundary60Days(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
// Exactly 60 days: token age == refreshDays, so tokenAge < refreshDuration is false,
|
|
// meaning it enters the renewal window
|
|
token := createTokenWithAge(t, db, user.ID, 61)
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, token.Key, resp.Token, "token at 61 days should be refreshed")
|
|
}
|
|
|
|
func TestRefreshToken_InvalidToken_Returns401(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), "nonexistent-token-key", user.ID)
|
|
require.Error(t, err)
|
|
assert.Nil(t, resp)
|
|
assert.Contains(t, err.Error(), "error.invalid_token")
|
|
}
|
|
|
|
func TestRefreshToken_WrongUser_Returns401(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
token := createTokenWithAge(t, db, user.ID, 75)
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
// Try to refresh with a different user ID
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID+999)
|
|
require.Error(t, err)
|
|
assert.Nil(t, resp)
|
|
assert.Contains(t, err.Error(), "error.invalid_token")
|
|
}
|
|
|
|
func TestRefreshToken_FreshTokenAt59Days_ReturnsExisting(t *testing.T) {
|
|
db := setupRefreshTestDB(t)
|
|
user := createRefreshTestUser(t, db)
|
|
token := createTokenWithAge(t, db, user.ID, 59) // 59 days, just under the 60-day threshold
|
|
|
|
svc := newTestAuthService(db)
|
|
|
|
resp, err := svc.RefreshToken(context.Background(), token.Key, user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, token.Key, resp.Token, "token at 59 days should NOT be refreshed")
|
|
}
|