diff --git a/internal/application/commands/create_habit_test.go b/internal/application/commands/create_habit_test.go index 54df206..dee83d5 100644 --- a/internal/application/commands/create_habit_test.go +++ b/internal/application/commands/create_habit_test.go @@ -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 +} diff --git a/internal/application/commands/delete_user_test.go b/internal/application/commands/delete_user_test.go index 3f1ad0b..aabfe32 100644 --- a/internal/application/commands/delete_user_test.go +++ b/internal/application/commands/delete_user_test.go @@ -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 +} diff --git a/internal/application/commands/mark_habit_test.go b/internal/application/commands/mark_habit_test.go index 002d4d6..d05d67d 100644 --- a/internal/application/commands/mark_habit_test.go +++ b/internal/application/commands/mark_habit_test.go @@ -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 +} diff --git a/internal/application/commands/register_user_test.go b/internal/application/commands/register_user_test.go index 1b26814..ad73d1a 100644 --- a/internal/application/commands/register_user_test.go +++ b/internal/application/commands/register_user_test.go @@ -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 +} diff --git a/internal/application/commands/request_password_reset_test.go b/internal/application/commands/request_password_reset_test.go index d1c7329..2d07e21 100644 --- a/internal/application/commands/request_password_reset_test.go +++ b/internal/application/commands/request_password_reset_test.go @@ -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 +} diff --git a/internal/application/commands/reset_password_test.go b/internal/application/commands/reset_password_test.go index 52610f5..029b909 100644 --- a/internal/application/commands/reset_password_test.go +++ b/internal/application/commands/reset_password_test.go @@ -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 +} diff --git a/internal/application/commands/revoke_token_test.go b/internal/application/commands/revoke_token_test.go index 8e131f4..95fce51 100644 --- a/internal/application/commands/revoke_token_test.go +++ b/internal/application/commands/revoke_token_test.go @@ -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 +} diff --git a/internal/application/commands/unmark_habit_test.go b/internal/application/commands/unmark_habit_test.go index 5cedd38..eb91fea 100644 --- a/internal/application/commands/unmark_habit_test.go +++ b/internal/application/commands/unmark_habit_test.go @@ -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 +} diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go index 9c8c877..9700c2d 100644 --- a/internal/application/commands/verify_email_test.go +++ b/internal/application/commands/verify_email_test.go @@ -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 +} diff --git a/internal/application/queries/get_todays_habits_test.go b/internal/application/queries/get_todays_habits_test.go index dce552e..8c7fbc3 100644 --- a/internal/application/queries/get_todays_habits_test.go +++ b/internal/application/queries/get_todays_habits_test.go @@ -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 } diff --git a/internal/application/queries/get_user_habits.go b/internal/application/queries/get_user_habits.go index b4930d7..c3094e2 100644 --- a/internal/application/queries/get_user_habits.go +++ b/internal/application/queries/get_user_habits.go @@ -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 } diff --git a/internal/application/queries/get_user_habits_test.go b/internal/application/queries/get_user_habits_test.go index 49684ba..6e6034d 100644 --- a/internal/application/queries/get_user_habits_test.go +++ b/internal/application/queries/get_user_habits_test.go @@ -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) + } + }) +} diff --git a/internal/application/queries/login_user_test.go b/internal/application/queries/login_user_test.go index e538de2..ea2845a 100644 --- a/internal/application/queries/login_user_test.go +++ b/internal/application/queries/login_user_test.go @@ -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 +} diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index a82531c..141497d 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -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 +} diff --git a/internal/domain/repositories/habit_repository.go b/internal/domain/repositories/habit_repository.go index df52b1b..ea1be25 100644 --- a/internal/domain/repositories/habit_repository.go +++ b/internal/domain/repositories/habit_repository.go @@ -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 } diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index de098b2..d7c0d42 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -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"` diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index c4d61a3..35f8c3b 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -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 = ¶ms + } + + 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) { } } - respondJSON(w, http.StatusOK, response) + if result.Pagination != nil { + response := GetUserHabitsResponse{ + Data: habitResponses, + Pagination: result.Pagination, + } + respondJSON(w, http.StatusOK, response) + } else { + respondJSON(w, http.StatusOK, habitResponses) + } } // GetHabitByID godoc diff --git a/internal/infrastructure/persistence/sqlite/habit_repository.go b/internal/infrastructure/persistence/sqlite/habit_repository.go index 2bbe89a..78427ee 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository.go @@ -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 +} diff --git a/internal/infrastructure/persistence/sqlite/habit_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_repository_test.go index acf245b..efc3891 100644 --- a/internal/infrastructure/persistence/sqlite/habit_repository_test.go +++ b/internal/infrastructure/persistence/sqlite/habit_repository_test.go @@ -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) + } + }) +} diff --git a/internal/shared/pagination/pagination.go b/internal/shared/pagination/pagination.go new file mode 100644 index 0000000..760ee23 --- /dev/null +++ b/internal/shared/pagination/pagination.go @@ -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, + } +} diff --git a/internal/shared/pagination/pagination_test.go b/internal/shared/pagination/pagination_test.go new file mode 100644 index 0000000..1564b33 --- /dev/null +++ b/internal/shared/pagination/pagination_test.go @@ -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) + } + }) + } +}