Add integration tests for auth refresh flow, rate limiting, and statistics

- Add refresh token flow tests including token rotation and invalidation
- Add rate limiting integration tests
- Add statistics endpoint integration tests
This commit is contained in:
2025-12-05 01:24:22 +01:00
parent e9e7e9dbac
commit 1aedc2b69a
3 changed files with 319 additions and 0 deletions
@@ -131,3 +131,94 @@ func TestAuthFlow(t *testing.T) {
}
})
}
func TestRefreshTokenFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
t.Run("Complete refresh token flow", func(t *testing.T) {
registerBody := RegisterRequest{
Email: "refresh@example.com",
Password: "Password123!",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
loginBody := LoginRequest{
Email: "refresh@example.com",
Password: "Password123!",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
var loginResp AuthResponse
decodeResponse(t, rr, &loginResp)
if loginResp.RefreshToken == "" {
t.Fatal("Expected refresh token in login response")
}
refreshReq := map[string]string{
"refresh_token": loginResp.RefreshToken,
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
}
var refreshResp AuthResponse
decodeResponse(t, rr, &refreshResp)
if refreshResp.Token == "" {
t.Error("Expected new access token in refresh response")
}
if refreshResp.RefreshToken == "" {
t.Error("Expected new refresh token in refresh response")
}
})
t.Run("Refresh with invalid token", func(t *testing.T) {
refreshReq := map[string]string{
"refresh_token": "invalid-token",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", rr.Code)
}
})
t.Run("Logout invalidates refresh token", func(t *testing.T) {
registerBody := RegisterRequest{
Email: "logout@example.com",
Password: "Password123!",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
loginBody := LoginRequest{
Email: "logout@example.com",
Password: "Password123!",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
var loginResp AuthResponse
decodeResponse(t, rr, &loginResp)
logoutReq := map[string]string{
"refresh_token": loginResp.RefreshToken,
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/logout", logoutReq, loginResp.Token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200 for logout, got %d", rr.Code)
}
refreshReq := map[string]string{
"refresh_token": loginResp.RefreshToken,
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401 when using logged out token, got %d", rr.Code)
}
})
}
@@ -0,0 +1,69 @@
package http
import (
"net/http"
"testing"
)
func TestGlobalRateLimiting(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "ratelimit@example.com", "Password123!")
t.Run("Request within rate limit succeeds", func(t *testing.T) {
for i := 0; i < 10; i++ {
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits", nil, token)
if rr.Code == http.StatusTooManyRequests {
t.Errorf("Request %d hit rate limit unexpectedly", i+1)
break
}
}
})
}
func TestPasswordResetRateLimiting(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
registerBody := RegisterRequest{
Email: "resetlimit@example.com",
Password: "Password123!",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
t.Run("Email-based rate limit for password reset", func(t *testing.T) {
resetReq := map[string]string{
"email": "resetlimit@example.com",
}
for i := 0; i < 3; i++ {
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
if rr.Code == http.StatusTooManyRequests {
t.Fatalf("Request %d hit rate limit too early (limit is 3)", i+1)
}
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
if rr.Code != http.StatusTooManyRequests {
t.Errorf("Expected status 429 after 4th request, got %d", rr.Code)
}
})
t.Run("Different emails have separate rate limits", func(t *testing.T) {
registerBody2 := RegisterRequest{
Email: "resetlimit2@example.com",
Password: "Password123!",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody2, "")
resetReq := map[string]string{
"email": "resetlimit2@example.com",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
if rr.Code == http.StatusTooManyRequests {
t.Error("Different email should not be affected by previous email's rate limit")
}
})
}
@@ -0,0 +1,159 @@
package http
import (
"net/http"
"testing"
"time"
"apocapoc-api/internal/application/queries"
)
func TestHabitStatsFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Meditation",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
t.Run("Stats for new habit should be zero", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 0 {
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
}
})
today := time.Now().UTC().Format("2006-01-02")
t.Run("Stats after marking habit once", func(t *testing.T) {
markReq := MarkHabitRequest{
ScheduledDate: today,
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 1 {
t.Errorf("Expected current streak of 1, got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 1 {
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
}
})
t.Run("Stats after unmarking habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to unmark habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
}
})
}
func TestHabitUpdateAffectsStats(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Running",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
today := time.Now().UTC().Format("2006-01-02")
markReq := MarkHabitRequest{
ScheduledDate: today,
}
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
t.Run("Stats remain after updating habit name", func(t *testing.T) {
updateReq := UpdateHabitRequest{
Name: "Morning Running",
}
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to update habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
}
})
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to archive habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
}
})
}