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:
@@ -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 {
|
||||
@@ -19,7 +21,13 @@ type HabitDTO struct {
|
||||
}
|
||||
|
||||
type GetUserHabitsQuery struct {
|
||||
UserID string
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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
|
||||
}
|
||||
|
||||
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 result []HabitDTO
|
||||
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: ¶ms,
|
||||
}
|
||||
|
||||
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: ¶ms,
|
||||
}
|
||||
|
||||
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: ¶ms,
|
||||
}
|
||||
|
||||
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: ¶ms,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user