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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user