Add pagination support to GET /api/v1/habits endpoint

- Create pagination package with Params and Response structs
- Add FindActiveByUserIDWithPagination and CountActiveByUserID methods to HabitRepository interface
- Implement pagination in SQLite repository using LIMIT and OFFSET
- Update GetUserHabitsHandler to support optional pagination parameters
- Modify HTTP endpoint to parse 'page' and 'page_size' query params (default: page=1, page_size=50, max=100)
- Add GetUserHabitsResponse DTO with pagination metadata
- Maintain backward compatibility - endpoint works with and without pagination params
- Add comprehensive tests for pagination logic in repository, handler, and pagination package
- Update all mock repositories to implement new pagination methods
This commit is contained in:
2025-12-01 23:53:00 +01:00
parent f780c69806
commit 38e640c617
21 changed files with 774 additions and 41 deletions
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -229,3 +230,11 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
t.Error("Expected habit ID to be returned")
}
}
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -132,3 +133,11 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) {
t.Errorf("Handle() error = %v, want %v", err, customError)
}
}
func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -537,3 +538,19 @@ func TestMarkHabitHandler_CounterFirstMarkWithNegative(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
}
func (m *mockEntryRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"errors"
"testing"
@@ -295,3 +296,11 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
t.Errorf("expected ErrRegistrationClosed, got %v", err)
}
}
func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -334,3 +335,19 @@ func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) {
t.Fatal("Email body is empty")
}
}
func (m *mockRequestResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -379,3 +380,19 @@ func TestResetPasswordHandler_HashingError(t *testing.T) {
t.Fatal("Handle() expected error but got nil")
}
}
func (m *mockPasswordResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockPasswordResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -186,3 +187,11 @@ func TestRevokeAllTokensHandler_Handle(t *testing.T) {
})
}
}
func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -137,3 +138,11 @@ func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) {
t.Errorf("Expected ErrNotFound for missing entry, got %v", err)
}
}
func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -336,3 +337,11 @@ func TestVerifyEmailHandler_UpdateError(t *testing.T) {
t.Fatal("Handle() expected error but got nil")
}
}
func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -7,6 +7,7 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type mockHabitRepo struct {
@@ -37,6 +38,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
return nil
}
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
type mockEntryRepo struct {
entries []*entities.HabitEntry
}
@@ -3,8 +3,10 @@ package queries
import (
"context"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type HabitDTO struct {
@@ -20,6 +22,12 @@ type HabitDTO struct {
type GetUserHabitsQuery struct {
UserID string
PaginationParams *pagination.Params
}
type GetUserHabitsResult struct {
Habits []HabitDTO
Pagination *pagination.Response
}
type GetUserHabitsHandler struct {
@@ -32,15 +40,34 @@ func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHab
}
}
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) {
habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID)
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) (*GetUserHabitsResult, error) {
var habits []*entities.Habit
var paginationResponse *pagination.Response
var err error
if query.PaginationParams != nil {
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
if err != nil {
return nil, err
}
var result []HabitDTO
totalItems, err := h.habitRepo.CountActiveByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
response := pagination.NewResponse(*query.PaginationParams, totalItems)
paginationResponse = &response
} else {
habits, err = h.habitRepo.FindActiveByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
}
var habitDTOs []HabitDTO
for _, habit := range habits {
result = append(result, HabitDTO{
habitDTOs = append(habitDTOs, HabitDTO{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
@@ -52,5 +79,8 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
})
}
return result, nil
return &GetUserHabitsResult{
Habits: habitDTOs,
Pagination: paginationResponse,
}, nil
}
@@ -6,8 +6,57 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type mockGetUserHabitsRepo struct {
habits []*entities.Habit
}
func (m *mockGetUserHabitsRepo) Create(ctx context.Context, habit *entities.Habit) error {
return nil
}
func (m *mockGetUserHabitsRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
return nil, nil
}
func (m *mockGetUserHabitsRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockGetUserHabitsRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
return m.habits, nil
}
func (m *mockGetUserHabitsRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
offset := params.Offset()
limit := params.Limit()
if offset >= len(m.habits) {
return []*entities.Habit{}, nil
}
end := offset + limit
if end > len(m.habits) {
end = len(m.habits)
}
return m.habits[offset:end], nil
}
func (m *mockGetUserHabitsRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return len(m.habits), nil
}
func (m *mockGetUserHabitsRepo) Update(ctx context.Context, habit *entities.Habit) error {
return nil
}
func (m *mockGetUserHabitsRepo) Delete(ctx context.Context, id string) error {
return nil
}
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit1.ID = "habit-1"
@@ -15,7 +64,7 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
habit2.ID = "habit-2"
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2}}
handler := NewGetUserHabitsHandler(habitRepo)
@@ -23,27 +72,31 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
UserID: "user-123",
}
results, err := handler.Handle(context.Background(), query)
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(results) != 2 {
t.Fatalf("Expected 2 habits, got %d", len(results))
if len(result.Habits) != 2 {
t.Fatalf("Expected 2 habits, got %d", len(result.Habits))
}
if results[0].ID != "habit-1" {
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
if result.Habits[0].ID != "habit-1" {
t.Errorf("Expected first habit ID habit-1, got %s", result.Habits[0].ID)
}
if results[1].ID != "habit-2" {
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
if result.Habits[1].ID != "habit-2" {
t.Errorf("Expected second habit ID habit-2, got %s", result.Habits[1].ID)
}
if result.Pagination != nil {
t.Error("Expected no pagination when not requested")
}
}
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{}}
handler := NewGetUserHabitsHandler(habitRepo)
@@ -51,14 +104,14 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
UserID: "user-456",
}
results, err := handler.Handle(context.Background(), query)
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(results) != 0 {
t.Fatalf("Expected 0 habits, got %d", len(results))
if len(result.Habits) != 0 {
t.Fatalf("Expected 0 habits, got %d", len(result.Habits))
}
}
@@ -68,7 +121,7 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
habit.ID = "habit-1"
habit.TargetValue = &targetValue
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit}}
handler := NewGetUserHabitsHandler(habitRepo)
@@ -76,35 +129,151 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
UserID: "user-123",
}
results, err := handler.Handle(context.Background(), query)
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(results) != 1 {
t.Fatalf("Expected 1 habit, got %d", len(results))
if len(result.Habits) != 1 {
t.Fatalf("Expected 1 habit, got %d", len(result.Habits))
}
result := results[0]
habitDTO := result.Habits[0]
if result.Name != "Drink Water" {
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
if habitDTO.Name != "Drink Water" {
t.Errorf("Expected name 'Drink Water', got %s", habitDTO.Name)
}
if result.Type != value_objects.HabitTypeValue {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
if habitDTO.Type != value_objects.HabitTypeValue {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, habitDTO.Type)
}
if result.Frequency != value_objects.FrequencyDaily {
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
if habitDTO.Frequency != value_objects.FrequencyDaily {
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, habitDTO.Frequency)
}
if result.TargetValue == nil || *result.TargetValue != 5.0 {
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
if habitDTO.TargetValue == nil || *habitDTO.TargetValue != 5.0 {
t.Errorf("Expected target value 5.0, got %v", habitDTO.TargetValue)
}
if !result.CarryOver {
if !habitDTO.CarryOver {
t.Error("Expected carry over to be true")
}
}
func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
var habits []*entities.Habit
for i := 1; i <= 10; i++ {
habit := entities.NewHabit("user-123", "Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit.ID = "habit-" + string(rune(i+'0'))
habits = append(habits, habit)
}
habitRepo := &mockGetUserHabitsRepo{habits: habits}
handler := NewGetUserHabitsHandler(habitRepo)
t.Run("FirstPage", func(t *testing.T) {
params := pagination.NewParams(1, 5)
query := GetUserHabitsQuery{
UserID: "user-123",
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 5 {
t.Errorf("Expected 5 habits on first page, got %d", len(result.Habits))
}
if result.Pagination == nil {
t.Fatal("Expected pagination metadata")
}
if result.Pagination.Page != 1 {
t.Errorf("Expected page 1, got %d", result.Pagination.Page)
}
if result.Pagination.PageSize != 5 {
t.Errorf("Expected page_size 5, got %d", result.Pagination.PageSize)
}
if result.Pagination.TotalItems != 10 {
t.Errorf("Expected total_items 10, got %d", result.Pagination.TotalItems)
}
if result.Pagination.TotalPages != 2 {
t.Errorf("Expected total_pages 2, got %d", result.Pagination.TotalPages)
}
})
t.Run("SecondPage", func(t *testing.T) {
params := pagination.NewParams(2, 5)
query := GetUserHabitsQuery{
UserID: "user-123",
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 5 {
t.Errorf("Expected 5 habits on second page, got %d", len(result.Habits))
}
if result.Pagination.Page != 2 {
t.Errorf("Expected page 2, got %d", result.Pagination.Page)
}
})
t.Run("PageBeyondTotal", func(t *testing.T) {
params := pagination.NewParams(10, 5)
query := GetUserHabitsQuery{
UserID: "user-123",
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 0 {
t.Errorf("Expected 0 habits beyond total, got %d", len(result.Habits))
}
})
t.Run("CustomPageSize", func(t *testing.T) {
params := pagination.NewParams(1, 3)
query := GetUserHabitsQuery{
UserID: "user-123",
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 3 {
t.Errorf("Expected 3 habits with page_size=3, got %d", len(result.Habits))
}
if result.Pagination.PageSize != 3 {
t.Errorf("Expected page_size 3, got %d", result.Pagination.PageSize)
}
if result.Pagination.TotalPages != 4 {
t.Errorf("Expected total_pages 4 (10 items / 3 per page), got %d", result.Pagination.TotalPages)
}
})
}
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -204,3 +205,11 @@ func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
}
}
func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -266,3 +267,19 @@ func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
t.Error("UserIDs should be different")
}
}
func (m *mockRefreshTokenRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/pagination"
)
type HabitRepository interface {
@@ -11,6 +12,8 @@ type HabitRepository interface {
FindByID(ctx context.Context, id string) (*entities.Habit, error)
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error)
CountActiveByUserID(ctx context.Context, userID string) (int, error)
Update(ctx context.Context, habit *entities.Habit) error
Delete(ctx context.Context, id string) error
}
+6
View File
@@ -4,6 +4,7 @@ import (
"time"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type CreateHabitRequest struct {
@@ -76,6 +77,11 @@ type UserHabitResponse struct {
IsNegative bool `json:"is_negative"`
}
type GetUserHabitsResponse struct {
Data []UserHabitResponse `json:"data"`
Pagination *pagination.Response `json:"pagination,omitempty"`
}
type HabitEntryResponse struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
+40 -6
View File
@@ -11,6 +11,7 @@ import (
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination"
"github.com/go-chi/chi/v5"
)
@@ -108,11 +109,13 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
// GetUserHabits godoc
// @Summary Get all user habits
// @Description Get all active habits for the authenticated user
// @Description Get all active habits for the authenticated user with optional pagination
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Success 200 {array} UserHabitResponse
// @Param page query int false "Page number (default: 1)"
// @Param page_size query int false "Page size (default: 50, max: 100)"
// @Success 200 {object} GetUserHabitsResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /habits [get]
@@ -127,15 +130,38 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
UserID: userID,
}
habits, err := h.getUserHabitsHandler.Handle(r.Context(), query)
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
if pageStr != "" || pageSizeStr != "" {
page := 1
pageSize := 50
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if pageSizeStr != "" {
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
pageSize = ps
}
}
params := pagination.NewParams(page, pageSize)
query.PaginationParams = &params
}
result, err := h.getUserHabitsHandler.Handle(r.Context(), query)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
return
}
response := make([]UserHabitResponse, len(habits))
for i, habit := range habits {
response[i] = UserHabitResponse{
habitResponses := make([]UserHabitResponse, len(result.Habits))
for i, habit := range result.Habits {
habitResponses[i] = UserHabitResponse{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
@@ -147,7 +173,15 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
}
}
if result.Pagination != nil {
response := GetUserHabitsResponse{
Data: habitResponses,
Pagination: result.Pagination,
}
respondJSON(w, http.StatusOK, response)
} else {
respondJSON(w, http.StatusOK, habitResponses)
}
}
// GetHabitByID godoc
@@ -8,6 +8,7 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination"
"github.com/google/uuid"
)
@@ -245,3 +246,39 @@ func (r *HabitRepository) Delete(ctx context.Context, id string) error {
return nil
}
func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
FROM habits
WHERE user_id = ? AND archived_at IS NULL
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`
rows, err := r.db.QueryContext(ctx, query, userID, params.Limit(), params.Offset())
if err != nil {
return nil, fmt.Errorf("failed to find habits: %w", err)
}
defer rows.Close()
return r.scanHabits(rows)
}
func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
query := `
SELECT COUNT(*)
FROM habits
WHERE user_id = ? AND archived_at IS NULL
`
var count int
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count habits: %w", err)
}
return count, nil
}
@@ -8,6 +8,7 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination"
)
func TestHabitRepositoryCreate(t *testing.T) {
@@ -262,3 +263,162 @@ func TestHabitRepositoryArchive(t *testing.T) {
t.Error("Expected habit to be archived")
}
}
func TestHabitRepositoryFindActiveByUserIDWithPagination(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-pagination-test"
for i := 1; i <= 10; i++ {
habit := entities.NewHabit(
userID,
"Habit "+string(rune(i+'0')),
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
time.Sleep(1 * time.Millisecond)
}
now := time.Now()
allHabits, _ := repo.FindActiveByUserID(ctx, userID)
allHabits[0].ArchivedAt = &now
repo.Update(ctx, allHabits[0])
t.Run("FirstPage", func(t *testing.T) {
params := pagination.NewParams(1, 5)
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
if err != nil {
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
}
if len(habits) != 5 {
t.Errorf("Expected 5 habits on first page, got %d", len(habits))
}
})
t.Run("SecondPage", func(t *testing.T) {
params := pagination.NewParams(2, 5)
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
if err != nil {
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
}
if len(habits) != 4 {
t.Errorf("Expected 4 habits on second page (9 total active), got %d", len(habits))
}
})
t.Run("PageBeyondTotal", func(t *testing.T) {
params := pagination.NewParams(10, 5)
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
if err != nil {
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
}
if len(habits) != 0 {
t.Errorf("Expected 0 habits beyond total pages, got %d", len(habits))
}
})
t.Run("CustomPageSize", func(t *testing.T) {
params := pagination.NewParams(1, 3)
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
if err != nil {
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
}
if len(habits) != 3 {
t.Errorf("Expected 3 habits with page_size=3, got %d", len(habits))
}
})
t.Run("ExcludesArchived", func(t *testing.T) {
params := pagination.NewParams(1, 20)
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
if err != nil {
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
}
if len(habits) != 9 {
t.Errorf("Expected 9 active habits (1 archived), got %d", len(habits))
}
for _, habit := range habits {
if habit.ArchivedAt != nil {
t.Error("Expected no archived habits in results")
}
}
})
}
func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-count-test"
t.Run("NoHabits", func(t *testing.T) {
count, err := repo.CountActiveByUserID(ctx, "non-existent-user")
if err != nil {
t.Fatalf("CountActiveByUserID failed: %v", err)
}
if count != 0 {
t.Errorf("Expected count 0 for non-existent user, got %d", count)
}
})
for i := 1; i <= 7; i++ {
habit := entities.NewHabit(
userID,
"Habit "+string(rune(i+'0')),
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit)
}
t.Run("AllActive", func(t *testing.T) {
count, err := repo.CountActiveByUserID(ctx, userID)
if err != nil {
t.Fatalf("CountActiveByUserID failed: %v", err)
}
if count != 7 {
t.Errorf("Expected count 7, got %d", count)
}
})
t.Run("WithArchived", func(t *testing.T) {
habits, _ := repo.FindActiveByUserID(ctx, userID)
now := time.Now()
habits[0].ArchivedAt = &now
habits[1].ArchivedAt = &now
repo.Update(ctx, habits[0])
repo.Update(ctx, habits[1])
count, err := repo.CountActiveByUserID(ctx, userID)
if err != nil {
t.Fatalf("CountActiveByUserID failed: %v", err)
}
if count != 5 {
t.Errorf("Expected count 5 (7 total - 2 archived), got %d", count)
}
})
}
+54
View File
@@ -0,0 +1,54 @@
package pagination
type Params struct {
Page int
PageSize int
}
func NewParams(page, pageSize int) Params {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
return Params{
Page: page,
PageSize: pageSize,
}
}
func (p Params) Offset() int {
return (p.Page - 1) * p.PageSize
}
func (p Params) Limit() int {
return p.PageSize
}
type Response struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalItems int `json:"total_items"`
TotalPages int `json:"total_pages"`
}
func NewResponse(params Params, totalItems int) Response {
totalPages := totalItems / params.PageSize
if totalItems%params.PageSize > 0 {
totalPages++
}
if totalPages < 1 {
totalPages = 1
}
return Response{
Page: params.Page,
PageSize: params.PageSize,
TotalItems: totalItems,
TotalPages: totalPages,
}
}
@@ -0,0 +1,100 @@
package pagination
import "testing"
func TestNewParams(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
expectedPage int
expectedSize int
}{
{"valid params", 1, 20, 1, 20},
{"valid params page 2", 2, 50, 2, 50},
{"page less than 1 defaults to 1", 0, 20, 1, 20},
{"negative page defaults to 1", -5, 20, 1, 20},
{"pageSize less than 1 defaults to 50", 1, 0, 1, 50},
{"negative pageSize defaults to 50", 1, -10, 1, 50},
{"pageSize greater than 100 caps at 100", 1, 200, 1, 100},
{"page 100", 1, 101, 1, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := NewParams(tt.page, tt.pageSize)
if params.Page != tt.expectedPage {
t.Errorf("Page = %d, want %d", params.Page, tt.expectedPage)
}
if params.PageSize != tt.expectedSize {
t.Errorf("PageSize = %d, want %d", params.PageSize, tt.expectedSize)
}
})
}
}
func TestParamsOffset(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
expectedOffset int
}{
{"first page", 1, 20, 0},
{"second page", 2, 20, 20},
{"third page", 3, 20, 40},
{"page 10 size 50", 10, 50, 450},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := Params{Page: tt.page, PageSize: tt.pageSize}
offset := params.Offset()
if offset != tt.expectedOffset {
t.Errorf("Offset() = %d, want %d", offset, tt.expectedOffset)
}
})
}
}
func TestParamsLimit(t *testing.T) {
params := Params{Page: 1, PageSize: 25}
if params.Limit() != 25 {
t.Errorf("Limit() = %d, want 25", params.Limit())
}
}
func TestNewResponse(t *testing.T) {
tests := []struct {
name string
params Params
totalItems int
expectedPages int
expectedTotal int
}{
{"exact division", Params{1, 20}, 100, 5, 100},
{"with remainder", Params{1, 20}, 105, 6, 105},
{"less than page size", Params{1, 20}, 15, 1, 15},
{"zero items", Params{1, 20}, 0, 1, 0},
{"one item", Params{1, 20}, 1, 1, 1},
{"large dataset", Params{1, 50}, 1000, 20, 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := NewResponse(tt.params, tt.totalItems)
if response.Page != tt.params.Page {
t.Errorf("Page = %d, want %d", response.Page, tt.params.Page)
}
if response.PageSize != tt.params.PageSize {
t.Errorf("PageSize = %d, want %d", response.PageSize, tt.params.PageSize)
}
if response.TotalPages != tt.expectedPages {
t.Errorf("TotalPages = %d, want %d", response.TotalPages, tt.expectedPages)
}
if response.TotalItems != tt.expectedTotal {
t.Errorf("TotalItems = %d, want %d", response.TotalItems, tt.expectedTotal)
}
})
}
}