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:
@@ -1,6 +1,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -229,3 +230,11 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
|
|||||||
t.Error("Expected habit ID to be returned")
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -132,3 +133,11 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) {
|
|||||||
t.Errorf("Handle() error = %v, want %v", err, customError)
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -537,3 +538,19 @@ func TestMarkHabitHandler_CounterFirstMarkWithNegative(t *testing.T) {
|
|||||||
t.Fatalf("Expected no error, got %v", err)
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -295,3 +296,11 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
|||||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -334,3 +335,19 @@ func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) {
|
|||||||
t.Fatal("Email body is empty")
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -379,3 +380,19 @@ func TestResetPasswordHandler_HashingError(t *testing.T) {
|
|||||||
t.Fatal("Handle() expected error but got nil")
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -137,3 +138,11 @@ func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) {
|
|||||||
t.Errorf("Expected ErrNotFound for missing entry, got %v", err)
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -336,3 +337,11 @@ func TestVerifyEmailHandler_UpdateError(t *testing.T) {
|
|||||||
t.Fatal("Handle() expected error but got nil")
|
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/entities"
|
||||||
"apocapoc-api/internal/domain/value_objects"
|
"apocapoc-api/internal/domain/value_objects"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockHabitRepo struct {
|
type mockHabitRepo struct {
|
||||||
@@ -37,6 +38,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
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 {
|
type mockEntryRepo struct {
|
||||||
entries []*entities.HabitEntry
|
entries []*entities.HabitEntry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package queries
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"apocapoc-api/internal/domain/entities"
|
||||||
"apocapoc-api/internal/domain/repositories"
|
"apocapoc-api/internal/domain/repositories"
|
||||||
"apocapoc-api/internal/domain/value_objects"
|
"apocapoc-api/internal/domain/value_objects"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HabitDTO struct {
|
type HabitDTO struct {
|
||||||
@@ -20,6 +22,12 @@ type HabitDTO struct {
|
|||||||
|
|
||||||
type GetUserHabitsQuery struct {
|
type GetUserHabitsQuery struct {
|
||||||
UserID string
|
UserID string
|
||||||
|
PaginationParams *pagination.Params
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserHabitsResult struct {
|
||||||
|
Habits []HabitDTO
|
||||||
|
Pagination *pagination.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUserHabitsHandler struct {
|
type GetUserHabitsHandler struct {
|
||||||
@@ -32,15 +40,34 @@ func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) {
|
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) (*GetUserHabitsResult, error) {
|
||||||
habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for _, habit := range habits {
|
||||||
result = append(result, HabitDTO{
|
habitDTOs = append(habitDTOs, HabitDTO{
|
||||||
ID: habit.ID,
|
ID: habit.ID,
|
||||||
Name: habit.Name,
|
Name: habit.Name,
|
||||||
Type: habit.Type,
|
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/entities"
|
||||||
"apocapoc-api/internal/domain/value_objects"
|
"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) {
|
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||||
habit1.ID = "habit-1"
|
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 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||||
habit2.ID = "habit-2"
|
habit2.ID = "habit-2"
|
||||||
|
|
||||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
|
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||||
|
|
||||||
handler := NewGetUserHabitsHandler(habitRepo)
|
handler := NewGetUserHabitsHandler(habitRepo)
|
||||||
|
|
||||||
@@ -23,27 +72,31 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
|||||||
UserID: "user-123",
|
UserID: "user-123",
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := handler.Handle(context.Background(), query)
|
result, err := handler.Handle(context.Background(), query)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) != 2 {
|
if len(result.Habits) != 2 {
|
||||||
t.Fatalf("Expected 2 habits, got %d", len(results))
|
t.Fatalf("Expected 2 habits, got %d", len(result.Habits))
|
||||||
}
|
}
|
||||||
|
|
||||||
if results[0].ID != "habit-1" {
|
if result.Habits[0].ID != "habit-1" {
|
||||||
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
|
t.Errorf("Expected first habit ID habit-1, got %s", result.Habits[0].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if results[1].ID != "habit-2" {
|
if result.Habits[1].ID != "habit-2" {
|
||||||
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
|
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) {
|
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
|
||||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
|
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{}}
|
||||||
|
|
||||||
handler := NewGetUserHabitsHandler(habitRepo)
|
handler := NewGetUserHabitsHandler(habitRepo)
|
||||||
|
|
||||||
@@ -51,14 +104,14 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
|
|||||||
UserID: "user-456",
|
UserID: "user-456",
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := handler.Handle(context.Background(), query)
|
result, err := handler.Handle(context.Background(), query)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) != 0 {
|
if len(result.Habits) != 0 {
|
||||||
t.Fatalf("Expected 0 habits, got %d", len(results))
|
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.ID = "habit-1"
|
||||||
habit.TargetValue = &targetValue
|
habit.TargetValue = &targetValue
|
||||||
|
|
||||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit}}
|
||||||
|
|
||||||
handler := NewGetUserHabitsHandler(habitRepo)
|
handler := NewGetUserHabitsHandler(habitRepo)
|
||||||
|
|
||||||
@@ -76,35 +129,151 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
|||||||
UserID: "user-123",
|
UserID: "user-123",
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := handler.Handle(context.Background(), query)
|
result, err := handler.Handle(context.Background(), query)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) != 1 {
|
if len(result.Habits) != 1 {
|
||||||
t.Fatalf("Expected 1 habit, got %d", len(results))
|
t.Fatalf("Expected 1 habit, got %d", len(result.Habits))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := results[0]
|
habitDTO := result.Habits[0]
|
||||||
|
|
||||||
if result.Name != "Drink Water" {
|
if habitDTO.Name != "Drink Water" {
|
||||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
t.Errorf("Expected name 'Drink Water', got %s", habitDTO.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Type != value_objects.HabitTypeValue {
|
if habitDTO.Type != value_objects.HabitTypeValue {
|
||||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
|
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, habitDTO.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Frequency != value_objects.FrequencyDaily {
|
if habitDTO.Frequency != value_objects.FrequencyDaily {
|
||||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
|
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, habitDTO.Frequency)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
if habitDTO.TargetValue == nil || *habitDTO.TargetValue != 5.0 {
|
||||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
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")
|
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
|
package queries
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -204,3 +205,11 @@ func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
|
|||||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
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
|
package queries
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -266,3 +267,19 @@ func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
|
|||||||
t.Error("UserIDs should be different")
|
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"
|
"context"
|
||||||
|
|
||||||
"apocapoc-api/internal/domain/entities"
|
"apocapoc-api/internal/domain/entities"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HabitRepository interface {
|
type HabitRepository interface {
|
||||||
@@ -11,6 +12,8 @@ type HabitRepository interface {
|
|||||||
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
||||||
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||||
FindActiveByUserID(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
|
Update(ctx context.Context, habit *entities.Habit) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apocapoc-api/internal/domain/value_objects"
|
"apocapoc-api/internal/domain/value_objects"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateHabitRequest struct {
|
type CreateHabitRequest struct {
|
||||||
@@ -76,6 +77,11 @@ type UserHabitResponse struct {
|
|||||||
IsNegative bool `json:"is_negative"`
|
IsNegative bool `json:"is_negative"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetUserHabitsResponse struct {
|
||||||
|
Data []UserHabitResponse `json:"data"`
|
||||||
|
Pagination *pagination.Response `json:"pagination,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HabitEntryResponse struct {
|
type HabitEntryResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
HabitID string `json:"habit_id"`
|
HabitID string `json:"habit_id"`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"apocapoc-api/internal/application/queries"
|
"apocapoc-api/internal/application/queries"
|
||||||
"apocapoc-api/internal/i18n"
|
"apocapoc-api/internal/i18n"
|
||||||
"apocapoc-api/internal/shared/errors"
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -108,11 +109,13 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// GetUserHabits godoc
|
// GetUserHabits godoc
|
||||||
// @Summary Get all user habits
|
// @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
|
// @Tags habits
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BearerAuth
|
// @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 401 {object} ErrorResponse
|
||||||
// @Failure 500 {object} ErrorResponse
|
// @Failure 500 {object} ErrorResponse
|
||||||
// @Router /habits [get]
|
// @Router /habits [get]
|
||||||
@@ -127,15 +130,38 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: userID,
|
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 {
|
if err != nil {
|
||||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
|
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]UserHabitResponse, len(habits))
|
habitResponses := make([]UserHabitResponse, len(result.Habits))
|
||||||
for i, habit := range habits {
|
for i, habit := range result.Habits {
|
||||||
response[i] = UserHabitResponse{
|
habitResponses[i] = UserHabitResponse{
|
||||||
ID: habit.ID,
|
ID: habit.ID,
|
||||||
Name: habit.Name,
|
Name: habit.Name,
|
||||||
Type: habit.Type,
|
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)
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
} else {
|
||||||
|
respondJSON(w, http.StatusOK, habitResponses)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHabitByID godoc
|
// GetHabitByID godoc
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"apocapoc-api/internal/domain/entities"
|
"apocapoc-api/internal/domain/entities"
|
||||||
"apocapoc-api/internal/shared/errors"
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -245,3 +246,39 @@ func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
return nil
|
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/entities"
|
||||||
"apocapoc-api/internal/domain/value_objects"
|
"apocapoc-api/internal/domain/value_objects"
|
||||||
"apocapoc-api/internal/shared/errors"
|
"apocapoc-api/internal/shared/errors"
|
||||||
|
"apocapoc-api/internal/shared/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHabitRepositoryCreate(t *testing.T) {
|
func TestHabitRepositoryCreate(t *testing.T) {
|
||||||
@@ -262,3 +263,162 @@ func TestHabitRepositoryArchive(t *testing.T) {
|
|||||||
t.Error("Expected habit to be archived")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user