diff --git a/internal/infrastructure/http/auth_integration_test.go b/internal/infrastructure/http/auth_integration_test.go index 9f71b05..7072866 100644 --- a/internal/infrastructure/http/auth_integration_test.go +++ b/internal/infrastructure/http/auth_integration_test.go @@ -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) + } + }) +} diff --git a/internal/infrastructure/http/ratelimit_integration_test.go b/internal/infrastructure/http/ratelimit_integration_test.go new file mode 100644 index 0000000..c52995f --- /dev/null +++ b/internal/infrastructure/http/ratelimit_integration_test.go @@ -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") + } + }) +} diff --git a/internal/infrastructure/http/stats_integration_test.go b/internal/infrastructure/http/stats_integration_test.go new file mode 100644 index 0000000..888c5be --- /dev/null +++ b/internal/infrastructure/http/stats_integration_test.go @@ -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) + } + }) +}