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
@@ -6,6 +6,7 @@ import (
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination"
@@ -422,3 +423,213 @@ func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
}
})
}
func TestHabitRepositoryFindByUserIDFiltered(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-filter-test"
habit1 := entities.NewHabit(userID, "Morning Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
habit1.Description = "Daily morning workout"
repo.Create(ctx, habit1)
time.Sleep(1 * time.Millisecond)
habit2 := entities.NewHabit(userID, "Read Books", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
habit2.Description = "Read at least 3 books per week"
repo.Create(ctx, habit2)
time.Sleep(1 * time.Millisecond)
habit3 := entities.NewHabit(userID, "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
habit3.Description = "Drink 2 liters of water daily"
repo.Create(ctx, habit3)
time.Sleep(1 * time.Millisecond)
habit4 := entities.NewHabit(userID, "Weekly Run", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
repo.Create(ctx, habit4)
time.Sleep(1 * time.Millisecond)
now := time.Now()
habit4.ArchivedAt = &now
repo.Update(ctx, habit4)
t.Run("FilterByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 active BOOLEAN habit, got %d", len(habits))
}
if habits[0].Type != value_objects.HabitTypeBoolean {
t.Errorf("Expected BOOLEAN type, got %s", habits[0].Type)
}
})
t.Run("FilterByFrequency", func(t *testing.T) {
frequency := value_objects.FrequencyDaily
filter := repositories.HabitFilter{
Frequency: &frequency,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 2 {
t.Errorf("Expected 2 DAILY habits, got %d", len(habits))
}
})
t.Run("FilterIncludeArchived", func(t *testing.T) {
filter := repositories.HabitFilter{
IncludeArchived: true,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 4 {
t.Errorf("Expected 4 habits (including archived), got %d", len(habits))
}
})
t.Run("FilterBySearch", func(t *testing.T) {
filter := repositories.HabitFilter{
Search: "Exercise",
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 habit matching 'Exercise', got %d", len(habits))
}
if habits[0].Name != "Morning Exercise" {
t.Errorf("Expected 'Morning Exercise', got %s", habits[0].Name)
}
})
t.Run("FilterBySearchInDescription", func(t *testing.T) {
filter := repositories.HabitFilter{
Search: "books",
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 habit matching 'books' in description, got %d", len(habits))
}
})
t.Run("CombineFilters", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
frequency := value_objects.FrequencyWeekly
filter := repositories.HabitFilter{
Type: &habitType,
Frequency: &frequency,
IncludeArchived: true,
}
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 1 {
t.Errorf("Expected 1 BOOLEAN WEEKLY habit (archived), got %d", len(habits))
}
if habits[0].Name != "Weekly Run" {
t.Errorf("Expected 'Weekly Run', got %s", habits[0].Name)
}
})
t.Run("WithPagination", func(t *testing.T) {
filter := repositories.HabitFilter{}
params := pagination.NewParams(1, 2)
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, &params)
if err != nil {
t.Fatalf("FindByUserIDFiltered failed: %v", err)
}
if len(habits) != 2 {
t.Errorf("Expected 2 habits on first page, got %d", len(habits))
}
})
}
func TestHabitRepositoryCountByUserIDFiltered(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-count-filter-test"
habit1 := entities.NewHabit(userID, "Test1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
repo.Create(ctx, habit1)
habit2 := entities.NewHabit(userID, "Test2", value_objects.HabitTypeCounter, value_objects.FrequencyDaily, false, false)
repo.Create(ctx, habit2)
habit3 := entities.NewHabit(userID, "Test3", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
now := time.Now()
habit3.ArchivedAt = &now
repo.Create(ctx, habit3)
repo.Update(ctx, habit3)
t.Run("CountByType", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
}
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
if err != nil {
t.Fatalf("CountByUserIDFiltered failed: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 active BOOLEAN habit, got %d", count)
}
})
t.Run("CountWithArchived", func(t *testing.T) {
habitType := value_objects.HabitTypeBoolean
filter := repositories.HabitFilter{
Type: &habitType,
IncludeArchived: true,
}
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
if err != nil {
t.Fatalf("CountByUserIDFiltered failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 BOOLEAN habits (including archived), got %d", count)
}
})
}