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

Implemented comprehensive filtering capabilities for the habits list endpoint:
- Filter by type (BOOLEAN, COUNTER, VALUE)
- Filter by frequency (DAILY, WEEKLY, MONTHLY)
- Filter by archived status
- Text search in habit name and description
- All filters can be combined
- Filters work with pagination

Technical changes:
- Added FilterParams to GetUserHabitsQuery
- Created HabitFilter struct in repository interface
- Implemented dynamic SQL query building in SQLite repository
- Updated HTTP handler to parse filter query parameters
- Added comprehensive tests for repository and handler filtering
- Updated all test mocks with new filter methods
This commit is contained in:
2025-12-02 01:02:39 +01:00
parent 38e640c617
commit 935f742ac9
18 changed files with 662 additions and 2 deletions
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -238,3 +239,11 @@ func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, us
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -141,3 +142,11 @@ func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Contex
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockDeleteUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockDeleteUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -554,3 +555,19 @@ func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Cont
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockEntryRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"errors"
@@ -304,3 +305,11 @@ func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, use
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -351,3 +352,19 @@ func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRequestResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockRequestResetUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -396,3 +397,19 @@ func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockPasswordResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockPasswordResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockResetPasswordUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockResetPasswordUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -195,3 +196,11 @@ func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Cont
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRefreshTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -146,3 +147,11 @@ func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Co
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -345,3 +346,11 @@ func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.C
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockVerifyEmailUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockVerifyEmailUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"context"
"testing"
"time"
@@ -324,3 +325,11 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) {
t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results))
}
}
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -20,9 +20,17 @@ type HabitDTO struct {
SpecificDays []int
}
type FilterParams struct {
Type *value_objects.HabitType
Frequency *value_objects.Frequency
IncludeArchived bool
Search string
}
type GetUserHabitsQuery struct {
UserID string
PaginationParams *pagination.Params
FilterParams *FilterParams
}
type GetUserHabitsResult struct {
@@ -45,7 +53,29 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
var paginationResponse *pagination.Response
var err error
if query.PaginationParams != nil {
if query.FilterParams != nil {
filter := repositories.HabitFilter{
Type: query.FilterParams.Type,
Frequency: query.FilterParams.Frequency,
IncludeArchived: query.FilterParams.IncludeArchived,
Search: query.FilterParams.Search,
}
habits, err = h.habitRepo.FindByUserIDFiltered(ctx, query.UserID, filter, query.PaginationParams)
if err != nil {
return nil, err
}
if query.PaginationParams != nil {
totalItems, err := h.habitRepo.CountByUserIDFiltered(ctx, query.UserID, filter)
if err != nil {
return nil, err
}
response := pagination.NewResponse(*query.PaginationParams, totalItems)
paginationResponse = &response
}
} else if query.PaginationParams != nil {
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
if err != nil {
return nil, err
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"context"
"testing"
@@ -277,3 +278,142 @@ func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
}
})
}
func (m *mockGetUserHabitsRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
var filtered []*entities.Habit
for _, habit := range m.habits {
if filter.Type != nil && habit.Type != *filter.Type {
continue
}
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
continue
}
if !filter.IncludeArchived && habit.ArchivedAt != nil {
continue
}
filtered = append(filtered, habit)
}
if paginationParams != nil {
offset := paginationParams.Offset()
limit := paginationParams.Limit()
if offset >= len(filtered) {
return []*entities.Habit{}, nil
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end], nil
}
return filtered, nil
}
func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
count := 0
for _, habit := range m.habits {
if filter.Type != nil && habit.Type != *filter.Type {
continue
}
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
continue
}
if !filter.IncludeArchived && habit.ArchivedAt != nil {
continue
}
count++
}
return count, nil
}
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit1.ID = "habit-1"
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
habit2.ID = "habit-2"
habit3 := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
habit3.ID = "habit-3"
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2, habit3}}
handler := NewGetUserHabitsHandler(habitRepo)
t.Run("FilterByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Type: &habitType,
},
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 1 {
t.Errorf("Expected 1 BOOLEAN habit, got %d", len(result.Habits))
}
if result.Habits[0].Type != value_objects.HabitTypeBoolean {
t.Errorf("Expected BOOLEAN type, got %s", result.Habits[0].Type)
}
})
t.Run("FilterByFrequency", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Frequency: &frequency,
},
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 2 {
t.Errorf("Expected 2 DAILY habits, got %d", len(result.Habits))
}
})
t.Run("FilterWithPagination", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
params := pagination.NewParams(1, 1)
query := GetUserHabitsQuery{
UserID: "user-123",
FilterParams: &FilterParams{
Frequency: &frequency,
},
PaginationParams: &params,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits) != 1 {
t.Errorf("Expected 1 habit on first page, got %d", len(result.Habits))
}
if result.Pagination == nil {
t.Fatal("Expected pagination metadata")
}
if result.Pagination.TotalItems != 2 {
t.Errorf("Expected 2 total DAILY habits, got %d", result.Pagination.TotalItems)
}
})
}
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -213,3 +214,11 @@ func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockLoginUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockLoginUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,7 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
@@ -283,3 +284,19 @@ func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx cont
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRefreshTokenRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockUserRepositoryForRefresh) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}