Files
goyco/internal/e2e/common.go

913 lines
27 KiB
Go

package e2e
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"goyco/internal/config"
"goyco/internal/database"
"goyco/internal/handlers"
"goyco/internal/middleware"
"goyco/internal/repositories"
"goyco/internal/server"
"goyco/internal/services"
"goyco/internal/testutils"
"github.com/golang-jwt/jwt/v5"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type (
APIResponse = testutils.APIResponse
LoginResponse = testutils.LoginResponse
PostResponse = testutils.PostResponse
PostsListResponse = testutils.PostsListResponse
Post = testutils.Post
VoteResponse = testutils.VoteResponse
TestUser = testutils.TestUser
TestPost = testutils.TestPost
HealthResponse = testutils.HealthResponse
MetricsResponse = testutils.MetricsResponse
UserResponse = testutils.UserResponse
ProfileResponse = testutils.ProfileResponse
AccountDeletionResponse = testutils.AccountDeletionResponse
)
type AuthenticatedClient struct {
*testutils.AuthenticatedClient
}
type testContext struct {
server *IntegrationTestServer
client *http.Client
baseURL string
}
type IntegrationTestServer struct {
DB *gorm.DB
Server *httptest.Server
baseURL string
transport http.RoundTripper
closeFunc func()
UserRepo repositories.UserRepository
PostRepo repositories.PostRepository
VoteRepo repositories.VoteRepository
RefreshTokenRepo *repositories.RefreshTokenRepository
AuthService handlers.AuthServiceInterface
VoteService *services.VoteService
AuthHandler *handlers.AuthHandler
PostHandler *handlers.PostHandler
VoteHandler *handlers.VoteHandler
UserHandler *handlers.UserHandler
APIHandler *handlers.APIHandler
EmailSender *testutils.MockEmailSender
}
func (server *IntegrationTestServer) BaseURL() string {
return server.baseURL
}
func (server *IntegrationTestServer) NewHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: server.transport,
}
}
func (server *IntegrationTestServer) Cleanup() {
if server.closeFunc != nil {
server.closeFunc()
}
if server.DB != nil {
sqlDB, err := server.DB.DB()
if err == nil {
_ = sqlDB.Close()
}
}
}
type inMemoryRoundTripper struct {
handler http.Handler
}
func newInMemoryRoundTripper(handler http.Handler) http.RoundTripper {
return &inMemoryRoundTripper{handler: handler}
}
func (rt *inMemoryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt == nil || rt.handler == nil {
return nil, fmt.Errorf("in-memory round tripper not initialized")
}
var bodyBytes []byte
if req.Body != nil && req.Body != http.NoBody {
defer req.Body.Close()
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
}
if len(bodyBytes) > 0 {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
} else {
req.Body = http.NoBody
}
clonedReq := req.Clone(req.Context())
if len(bodyBytes) > 0 {
clonedReq.Body = io.NopCloser(bytes.NewReader(bodyBytes))
} else {
clonedReq.Body = http.NoBody
}
clonedReq.RequestURI = clonedReq.URL.RequestURI()
recorder := httptest.NewRecorder()
rt.handler.ServeHTTP(recorder, clonedReq)
resp := recorder.Result()
return resp, nil
}
var (
sanitizeIdentifierRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
)
func findWorkspaceRoot() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
root := wd
for {
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
return root
}
parent := filepath.Dir(root)
if parent == root {
return wd
}
root = parent
}
}
func sanitizeIdentifier(value string) string {
sanitized := sanitizeIdentifierRegex.ReplaceAllString(value, "_")
sanitized = strings.Trim(sanitized, "_")
if sanitized == "" {
fallback := fmt.Sprintf("test_%d", time.Now().UnixNano())
sanitized = sanitizeIdentifierRegex.ReplaceAllString(fallback, "_")
sanitized = strings.Trim(sanitized, "_")
}
return sanitized
}
func uniqueTestID(t *testing.T) string {
rawID := fmt.Sprintf("%s_%d", t.Name(), time.Now().UnixNano())
return sanitizeIdentifier(rawID)
}
func getTestFilePrefix(t *testing.T) string {
testName := t.Name()
parts := strings.Split(testName, "/")
if len(parts) > 0 {
filePart := parts[len(parts)-1]
if idx := strings.Index(filePart, "_"); idx > 0 {
return filePart[:idx]
}
return strings.ToLower(strings.TrimPrefix(filePart, "TestE2E_"))
}
return "test"
}
func uniqueUsername(t *testing.T, prefix string) string {
filePrefix := getTestFilePrefix(t)
fullPrefix := fmt.Sprintf("%s_%s", filePrefix, prefix)
username := fmt.Sprintf("%s_%s", fullPrefix, uniqueTestID(t))
if len(username) > 50 {
maxIDLength := 50 - len(fullPrefix) - 1
if maxIDLength < 0 {
maxIDLength = 0
}
testID := uniqueTestID(t)
if len(testID) > maxIDLength {
testID = testID[:maxIDLength]
}
username = fmt.Sprintf("%s_%s", fullPrefix, testID)
if len(username) > 50 {
username = username[:50]
}
}
return username
}
func uniqueEmail(t *testing.T, prefix string) string {
return fmt.Sprintf("%s_%s@example.com", prefix, uniqueTestID(t))
}
type TestFixtures struct {
VerifiedUser *TestUser
UnverifiedUser *TestUser
LockedUser *TestUser
PostWithVotes *TestPost
PostNoVotes *TestPost
}
func getResponseReader(resp *http.Response) (io.Reader, func(), error) {
var reader io.Reader = resp.Body
var cleanup func() = func() {}
if resp.Header.Get("Content-Encoding") == "gzip" {
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
reader = gzReader
cleanup = func() { _ = gzReader.Close() }
}
return reader, cleanup, nil
}
func tokenHash(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
func retryOnRateLimit(t *testing.T, maxRetries int, operation func() int) int {
t.Helper()
for attempt := 0; attempt < maxRetries; attempt++ {
statusCode := operation()
if statusCode != http.StatusTooManyRequests {
return statusCode
}
if attempt < maxRetries-1 {
backoff := time.Duration(attempt+1) * 50 * time.Millisecond
time.Sleep(backoff)
}
}
return http.StatusTooManyRequests
}
func skipIfRateLimited(t *testing.T, statusCode int, reason string) {
if statusCode == http.StatusTooManyRequests {
t.Skipf("Skipping %s: rate limited", reason)
}
}
func setupTestContext(t *testing.T) *testContext {
t.Helper()
server := setupIntegrationTestServer(t)
t.Cleanup(func() {
server.Cleanup()
})
return &testContext{
server: server,
client: server.NewHTTPClient(),
baseURL: server.BaseURL(),
}
}
func setupTestContextWithAuthRateLimit(t *testing.T, authLimit int) *testContext {
t.Helper()
server := setupIntegrationTestServerWithAuthRateLimit(t, authLimit)
t.Cleanup(func() {
server.Cleanup()
})
return &testContext{
server: server,
client: server.NewHTTPClient(),
baseURL: server.BaseURL(),
}
}
func (ctx *testContext) createUserWithCleanup(t *testing.T, prefix, password string) *TestUser {
t.Helper()
if password == "" {
password = "Password123!"
}
user := testutils.CreateE2ETestUser(t, ctx.server.UserRepo, uniqueUsername(t, prefix), uniqueEmail(t, prefix), password)
t.Cleanup(func() {
if user != nil {
ctx.server.UserRepo.Delete(user.ID)
}
})
return user
}
func (ctx *testContext) createMultipleUsersWithCleanup(t *testing.T, count int, prefix, password string) []*TestUser {
t.Helper()
if password == "" {
password = "Password123!"
}
var users []*TestUser
for i := range count {
userPrefix := fmt.Sprintf("%s%d", prefix, i+1)
user := testutils.CreateE2ETestUser(t, ctx.server.UserRepo, uniqueUsername(t, userPrefix), uniqueEmail(t, userPrefix), password)
users = append(users, user)
}
t.Cleanup(func() {
for _, user := range users {
if user != nil {
ctx.server.UserRepo.Delete(user.ID)
}
}
})
return users
}
func (ctx *testContext) createUserAndLogin(t *testing.T, prefix, password string) (*TestUser, *AuthenticatedClient) {
t.Helper()
user := ctx.createUserWithCleanup(t, prefix, password)
client := ctx.loginUser(t, user.Username, user.Password)
return user, client
}
func (ctx *testContext) doLoginRequest(t *testing.T, username, password, ipAddress string) (*http.Response, error) {
t.Helper()
loginData := map[string]string{
"username": username,
"password": password,
}
body, err := json.Marshal(loginData)
if err != nil {
return nil, fmt.Errorf("marshal login data: %w", err)
}
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create login request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
testutils.WithStandardHeaders(req)
req.Header.Set("X-Forwarded-For", ipAddress)
return ctx.client.Do(req)
}
func (ctx *testContext) doRequest(t *testing.T, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
t.Helper()
builder := testutils.NewRequestBuilder(method, ctx.baseURL+path)
if body != nil {
builder = builder.WithBody(body)
}
for k, v := range headers {
builder = builder.WithHeader(k, v)
}
req, err := builder.Build()
if err != nil {
return nil, err
}
return ctx.client.Do(req)
}
func (ctx *testContext) doRequestExpectStatus(t *testing.T, method, path string, expectedStatus int, body io.Reader, headers map[string]string) int {
t.Helper()
resp, err := ctx.doRequest(t, method, path, body, headers)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
bodyBytes := make([]byte, 1024)
n, _ := resp.Body.Read(bodyBytes)
t.Fatalf("expected status %d (or 429), got %d. Body: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
}
return resp.StatusCode
}
func decodeJSONResponse(t *testing.T, resp *http.Response, target any) {
t.Helper()
reader, cleanup, err := getResponseReader(resp)
if err != nil {
t.Fatalf("failed to get response reader: %v", err)
}
defer cleanup()
if err := json.NewDecoder(reader).Decode(target); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
}
func (ctx *testContext) loginUser(t *testing.T, username, password string) *AuthenticatedClient {
t.Helper()
return ctx.loginUserWithIP(t, username, password, testutils.GenerateTestIP())
}
func (ctx *testContext) loginUserWithIP(t *testing.T, username, password, ipAddress string) *AuthenticatedClient {
t.Helper()
resp, err := ctx.doLoginRequest(t, username, password, ipAddress)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes := make([]byte, 1024)
n, _ := resp.Body.Read(bodyBytes)
t.Fatalf("login failed with status %d. Response: %s", resp.StatusCode, string(bodyBytes[:n]))
}
var loginResp testutils.LoginResponse
decodeJSONResponse(t, resp, &loginResp)
if !loginResp.Success {
t.Fatalf("login response indicates failure: %s", loginResp.Message)
}
accessToken := loginResp.Data.AccessToken
if accessToken == "" {
accessToken = loginResp.Data.Token
}
if accessToken == "" {
t.Fatalf("login response missing access token")
}
return &AuthenticatedClient{
AuthenticatedClient: &testutils.AuthenticatedClient{
Client: ctx.client,
Token: accessToken,
RefreshToken: loginResp.Data.RefreshToken,
BaseURL: ctx.baseURL,
},
}
}
func (ctx *testContext) loginUserSafe(t *testing.T, username, password string) (*AuthenticatedClient, error) {
t.Helper()
authClient, err := testutils.LoginUserSafe(ctx.client, ctx.baseURL, username, password)
if err != nil {
return nil, err
}
return &AuthenticatedClient{AuthenticatedClient: authClient}, nil
}
func (ctx *testContext) loginExpectStatus(t *testing.T, username, password string, expectedStatus int) int {
t.Helper()
return ctx.loginExpectStatusWithIP(t, username, password, expectedStatus, testutils.GenerateTestIP())
}
func (ctx *testContext) loginExpectStatusWithIP(t *testing.T, username, password string, expectedStatus int, ipAddress string) int {
t.Helper()
resp, err := ctx.doLoginRequest(t, username, password, ipAddress)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
bodyBytes := make([]byte, 1024)
n, _ := resp.Body.Read(bodyBytes)
t.Fatalf("expected login status %d (or 429), got %d. Body: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
}
return resp.StatusCode
}
func (ctx *testContext) confirmEmail(t *testing.T, token string) {
t.Helper()
if token == "" {
t.Fatalf("confirmation token must not be empty")
}
confirmURL := fmt.Sprintf("%s/api/auth/confirm?token=%s", ctx.baseURL, url.QueryEscape(token))
req, err := http.NewRequest("GET", confirmURL, nil)
if err != nil {
t.Fatalf("failed to build confirmation request: %v", err)
}
testutils.WithStandardHeaders(req)
resp, err := ctx.client.Do(req)
if err != nil {
t.Fatalf("confirmation request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected confirmation to return 200, got %d. Body: %s", resp.StatusCode, string(body))
}
}
func (ctx *testContext) registerUserExpectStatus(t *testing.T, username, email, password string) int {
t.Helper()
registerData := map[string]string{
"username": username,
"email": email,
"password": password,
}
body, err := json.Marshal(registerData)
if err != nil {
t.Fatalf("failed to marshal register data: %v", err)
}
headers := map[string]string{"Content-Type": "application/json"}
return ctx.doRequestExpectStatus(t, "POST", "/api/auth/register", http.StatusCreated, bytes.NewReader(body), headers)
}
func (ctx *testContext) makeRequestWithToken(t *testing.T, token string) int {
t.Helper()
headers := make(map[string]string)
if token != "" {
headers["Authorization"] = "Bearer " + token
}
resp, err := ctx.doRequest(t, "GET", "/api/auth/me", nil, headers)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
return resp.StatusCode
}
func (ctx *testContext) requestAccountDeletionExpectStatus(t *testing.T, token string, expectedStatus int) (int, *AccountDeletionResponse) {
t.Helper()
headers := map[string]string{"Authorization": "Bearer " + token}
resp, err := ctx.doRequest(t, "DELETE", "/api/auth/account", nil, headers)
if err != nil {
t.Fatalf("failed to make account deletion request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
bodyBytes := make([]byte, 1024)
n, _ := resp.Body.Read(bodyBytes)
t.Fatalf("expected account deletion status %d (or 429), got %d. Response: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
}
if resp.StatusCode == http.StatusOK {
var deletionResponse AccountDeletionResponse
decodeJSONResponse(t, resp, &deletionResponse)
return resp.StatusCode, &deletionResponse
}
return resp.StatusCode, nil
}
func generateTestToken(t *testing.T, user *database.User, cfg *config.JWTConfig, opts ...func(*testutils.TokenClaims, *config.JWTConfig) string) string {
var secret string
return testutils.GenerateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
secret = jwtCfg.Secret
for _, opt := range opts {
if opt != nil {
secret = opt(claims, jwtCfg)
}
}
return secret
})
}
func generateExpiredToken(t *testing.T, user *database.User, cfg *config.JWTConfig) string {
return generateTestToken(t, user, cfg, testutils.WithExpiredToken)
}
func generateTamperedToken(t *testing.T, user *database.User, cfg *config.JWTConfig) string {
return generateTestToken(t, user, cfg, testutils.WithTamperedSecret)
}
func generateTokenWithSessionVersion(t *testing.T, user *database.User, cfg *config.JWTConfig, sessionVersion uint) string {
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
claims.SessionVersion = sessionVersion
return jwtCfg.Secret
})
}
func generateTokenWithType(t *testing.T, user *database.User, cfg *config.JWTConfig, tokenType string) string {
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
claims.TokenType = tokenType
return jwtCfg.Secret
})
}
func generateTokenWithExpiration(t *testing.T, user *database.User, cfg *config.JWTConfig, expiration time.Duration) string {
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
now := time.Now()
claims.IssuedAt = jwt.NewNumericDate(now)
claims.ExpiresAt = jwt.NewNumericDate(now.Add(expiration))
return jwtCfg.Secret
})
}
type serverConfig struct {
authLimit int
}
func setupIntegrationTestServer(t *testing.T) *IntegrationTestServer {
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: 50000})
}
func setupIntegrationTestServerWithAuthRateLimit(t *testing.T, authLimit int) *IntegrationTestServer {
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: authLimit})
}
func setupDatabase(t *testing.T) *gorm.DB {
t.Helper()
uniqueID := fmt.Sprintf("%s_%d", sanitizeIdentifier(t.Name()), time.Now().UnixNano())
dbName := "file:memdb_" + uniqueID + "?mode=memory&cache=shared&_journal_mode=WAL&_synchronous=NORMAL"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("failed to connect to in-memory database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("failed to access underlying database: %v", err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
err = db.AutoMigrate(
&database.User{},
&database.Post{},
&database.Vote{},
&database.AccountDeletionRequest{},
&database.RefreshToken{},
)
if err != nil {
t.Fatalf("failed to migrate database: %v", err)
}
if execErr := db.Exec("PRAGMA busy_timeout = 5000").Error; execErr != nil {
t.Fatalf("failed to configure busy timeout: %v", execErr)
}
if execErr := db.Exec("PRAGMA foreign_keys = ON").Error; execErr != nil {
t.Fatalf("failed to enable foreign keys: %v", execErr)
}
return db
}
func setupRepositories(db *gorm.DB) (repositories.UserRepository, repositories.PostRepository, repositories.VoteRepository, repositories.AccountDeletionRepository, *repositories.RefreshTokenRepository) {
return repositories.NewUserRepository(db),
repositories.NewPostRepository(db),
repositories.NewVoteRepository(db),
repositories.NewAccountDeletionRepository(db),
repositories.NewRefreshTokenRepository(db)
}
func setupServices(cfg *config.Config, userRepo repositories.UserRepository, postRepo repositories.PostRepository, deletionRepo repositories.AccountDeletionRepository, refreshTokenRepo *repositories.RefreshTokenRepository, emailSender *testutils.MockEmailSender, voteRepo repositories.VoteRepository, db *gorm.DB) (handlers.AuthServiceInterface, *services.VoteService, error) {
authService, err := services.NewAuthFacadeForTest(cfg, userRepo, postRepo, deletionRepo, refreshTokenRepo, emailSender)
if err != nil {
return nil, nil, err
}
voteService := services.NewVoteService(voteRepo, postRepo, db)
return authService, voteService, nil
}
func setupHandlers(authService handlers.AuthServiceInterface, userRepo repositories.UserRepository, postRepo repositories.PostRepository, voteService *services.VoteService, cfg *config.Config) (*handlers.AuthHandler, *handlers.PostHandler, *handlers.VoteHandler, *handlers.UserHandler, *handlers.APIHandler) {
return handlers.NewAuthHandler(authService, userRepo),
handlers.NewPostHandler(postRepo, nil, voteService),
handlers.NewVoteHandler(voteService),
handlers.NewUserHandler(userRepo, authService),
handlers.NewAPIHandler(cfg, postRepo, userRepo, voteService)
}
func setupRouter(authHandler *handlers.AuthHandler, postHandler *handlers.PostHandler, voteHandler *handlers.VoteHandler, userHandler *handlers.UserHandler, apiHandler *handlers.APIHandler, authService handlers.AuthServiceInterface, cfg *config.Config) http.Handler {
return server.NewRouter(server.RouterConfig{
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
APIHandler: apiHandler,
AuthService: authService,
PageHandler: nil,
StaticDir: findWorkspaceRoot() + "/internal/static/",
Debug: false,
DisableCache: true,
DisableCompression: true,
RateLimitConfig: cfg.RateLimit,
})
}
func setupIntegrationTestServerWithConfig(t *testing.T, serverCfg serverConfig) *IntegrationTestServer {
t.Helper()
originalTrustProxy := middleware.TrustProxyHeaders
middleware.TrustProxyHeaders = true
t.Cleanup(func() {
middleware.TrustProxyHeaders = originalTrustProxy
})
db := setupDatabase(t)
userRepo, postRepo, voteRepo, deletionRepo, refreshTokenRepo := setupRepositories(db)
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-for-testing-purposes-only",
Expiration: 24,
RefreshExpiration: 168,
Issuer: "goyco",
Audience: "goyco-users",
},
App: config.AppConfig{
BaseURL: "http://localhost:8080",
BcryptCost: 10,
},
RateLimit: config.RateLimitConfig{
AuthLimit: serverCfg.authLimit,
GeneralLimit: 10000,
HealthLimit: 10000,
MetricsLimit: 10000,
TrustProxyHeaders: true,
},
}
mockEmailSender := &testutils.MockEmailSender{}
authService, voteService, err := setupServices(cfg, userRepo, postRepo, deletionRepo, refreshTokenRepo, mockEmailSender, voteRepo, db)
if err != nil {
t.Fatalf("failed to create auth service: %v", err)
}
authHandler, postHandler, voteHandler, userHandler, apiHandler := setupHandlers(authService, userRepo, postRepo, voteService, cfg)
router := setupRouter(authHandler, postHandler, voteHandler, userHandler, apiHandler, authService, cfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
var (
httpServer *httptest.Server
baseURL string
transport http.RoundTripper
closeFunc func()
)
if err == nil {
httpServer = httptest.NewUnstartedServer(router)
httpServer.Listener = listener
httpServer.Start()
baseURL = httpServer.URL
transport = nil
closeFunc = func() {
httpServer.Close()
}
} else {
t.Logf("falling back to in-memory http server: %v", err)
baseURL = "http://inmemory.goyco"
transport = newInMemoryRoundTripper(router)
closeFunc = func() {}
}
return &IntegrationTestServer{
DB: db,
Server: httpServer,
baseURL: baseURL,
transport: transport,
closeFunc: closeFunc,
UserRepo: userRepo,
PostRepo: postRepo,
VoteRepo: voteRepo,
RefreshTokenRepo: refreshTokenRepo,
AuthService: authService,
VoteService: voteService,
AuthHandler: authHandler,
PostHandler: postHandler,
VoteHandler: voteHandler,
UserHandler: userHandler,
APIHandler: apiHandler,
EmailSender: mockEmailSender,
}
}
func (ctx *testContext) resendVerification(t *testing.T, email string) int {
t.Helper()
return testutils.ResendVerificationEmail(t, ctx.client, ctx.baseURL, email)
}
func (ctx *testContext) createTestFixtures(t *testing.T) *TestFixtures {
t.Helper()
fixtures := &TestFixtures{}
fixtures.VerifiedUser = ctx.createUserWithCleanup(t, "verified", "Password123!")
ctx.confirmEmail(t, ctx.server.EmailSender.VerificationToken())
fixtures.UnverifiedUser = ctx.createUserWithCleanup(t, "unverified", "Password123!")
lockedUser := ctx.createUserWithCleanup(t, "locked", "Password123!")
ctx.server.UserRepo.Update(&database.User{
ID: lockedUser.ID,
Locked: true,
})
fixtures.LockedUser = lockedUser
client := ctx.loginUser(t, fixtures.VerifiedUser.Username, fixtures.VerifiedUser.Password)
fixtures.PostWithVotes = client.CreatePost(t, "Post With Votes", "https://example.com/votes", "Content")
client.VoteOnPost(t, fixtures.PostWithVotes.ID, "up")
client2 := ctx.loginUser(t, fixtures.VerifiedUser.Username, fixtures.VerifiedUser.Password)
client2.VoteOnPost(t, fixtures.PostWithVotes.ID, "up")
fixtures.PostNoVotes = client.CreatePost(t, "Post No Votes", "https://example.com/novotes", "Content")
return fixtures
}
func (ctx *testContext) waitForCondition(t *testing.T, condition func() bool, timeout time.Duration) bool {
t.Helper()
ctxTimeout, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctxTimeout.Done():
return false
case <-ticker.C:
if condition() {
return true
}
}
}
}
func (ctx *testContext) assertEventually(t *testing.T, assertion func() bool, timeout time.Duration) {
t.Helper()
if !ctx.waitForCondition(t, assertion, timeout) {
t.Errorf("Assertion failed after %v timeout", timeout)
}
}
var (
assertPostInList = testutils.AssertPostInList
getHealth = testutils.GetHealth
getMetrics = testutils.GetMetrics
assertVoteData = testutils.AssertVoteData
)
func assertUserResponse(t *testing.T, resp *ProfileResponse, expectedUser *TestUser) {
t.Helper()
if resp == nil {
t.Fatalf("Expected user response, got nil")
}
if !resp.Success {
t.Errorf("Expected user response success=true, got false: %s", resp.Message)
}
if resp.Data.ID != expectedUser.ID {
t.Errorf("Expected user ID %d, got %d", expectedUser.ID, resp.Data.ID)
}
if resp.Data.Username != expectedUser.Username {
t.Errorf("Expected username '%s', got '%s'", expectedUser.Username, resp.Data.Username)
}
if resp.Data.Email != expectedUser.Email {
t.Errorf("Expected email '%s', got '%s'", expectedUser.Email, resp.Data.Email)
}
if resp.Data.CreatedAt == "" {
t.Errorf("Expected user CreatedAt to be set")
}
if resp.Data.UpdatedAt == "" {
t.Errorf("Expected user UpdatedAt to be set")
}
validateTimestamp(t, resp.Data.CreatedAt, "CreatedAt")
validateTimestamp(t, resp.Data.UpdatedAt, "UpdatedAt")
}
func assertVoteResponse(t *testing.T, resp *VoteResponse, expectedType string) {
t.Helper()
if resp == nil {
t.Fatalf("Expected vote response, got nil")
}
if !resp.Success {
t.Errorf("Expected vote response success=true, got false: %s", resp.Message)
}
if resp.Data == nil {
t.Errorf("Expected vote data to be present")
return
}
voteData, ok := resp.Data.(map[string]any)
if !ok {
return
}
if voteType, exists := voteData["type"]; exists {
if voteTypeStr, ok := voteType.(string); ok && voteTypeStr != expectedType {
t.Errorf("Expected vote type '%s', got '%s'", expectedType, voteTypeStr)
}
}
}
func validateTimestamp(t *testing.T, timestampStr, fieldName string) {
t.Helper()
if timestampStr == "" {
t.Errorf("Expected %s to be set", fieldName)
return
}
_, err := time.Parse(time.RFC3339, timestampStr)
if err != nil {
_, err = time.Parse("2006-01-02T15:04:05Z07:00", timestampStr)
if err != nil {
t.Errorf("Invalid timestamp format for %s: '%s'", fieldName, timestampStr)
}
}
}