Complete CRUD operations for habits
Implement all missing endpoints for full habit management:
- GET /api/v1/habits - List all user habits
- GET /api/v1/habits/{id} - Get specific habit
- PUT /api/v1/habits/{id} - Update habit
- DELETE /api/v1/habits/{id} - Archive habit (soft delete)
- GET /api/v1/habits/{id}/entries - Get habit entry history
- DELETE /api/v1/habits/{id}/entries/{date} - Unmark habit (soft delete entry)
All endpoints include:
- TDD approach with comprehensive test coverage
- JWT authentication and ownership validation
- Proper error handling (404, 403, 400, 500)
- Clean architecture with separated commands/queries
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type GetHabitByIDQuery struct {
|
||||
HabitID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
type GetHabitByIDHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
}
|
||||
|
||||
func NewGetHabitByIDHandler(habitRepo repositories.HabitRepository) *GetHabitByIDHandler {
|
||||
return &GetHabitByIDHandler{
|
||||
habitRepo: habitRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetHabitByIDHandler) Handle(ctx context.Context, query GetHabitByIDQuery) (*HabitDTO, error) {
|
||||
habit, err := h.habitRepo.FindByID(ctx, query.HabitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if habit.UserID != query.UserID {
|
||||
return nil, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
return &HabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: string(habit.Type),
|
||||
Frequency: string(habit.Frequency),
|
||||
TargetValue: habit.TargetValue,
|
||||
CarryOver: habit.CarryOver,
|
||||
SpecificDays: habit.SpecificDays,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockHabitRepoWithFindByID struct {
|
||||
mockHabitRepo
|
||||
habitToReturn *entities.Habit
|
||||
errorToReturn error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoWithFindByID) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
if m.errorToReturn != nil {
|
||||
return nil, m.errorToReturn
|
||||
}
|
||||
return m.habitToReturn, nil
|
||||
}
|
||||
|
||||
func TestGetHabitByIDHandler_ReturnsHabitSuccessfully(t *testing.T) {
|
||||
targetValue := 5.0
|
||||
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true)
|
||||
habit.ID = "habit-1"
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
habitToReturn: habit,
|
||||
}
|
||||
|
||||
handler := NewGetHabitByIDHandler(habitRepo)
|
||||
|
||||
query := GetHabitByIDQuery{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.ID != "habit-1" {
|
||||
t.Errorf("Expected ID habit-1, got %s", result.ID)
|
||||
}
|
||||
|
||||
if result.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
||||
}
|
||||
|
||||
if result.Type != string(value_objects.HabitTypeQuantity) {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type)
|
||||
}
|
||||
|
||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHabitByIDHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
errorToReturn: errors.ErrNotFound,
|
||||
}
|
||||
|
||||
handler := NewGetHabitByIDHandler(habitRepo)
|
||||
|
||||
query := GetHabitByIDQuery{
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHabitByIDHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
|
||||
habit.ID = "habit-1"
|
||||
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
habitToReturn: habit,
|
||||
}
|
||||
|
||||
handler := NewGetHabitByIDHandler(habitRepo)
|
||||
|
||||
query := GetHabitByIDQuery{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != errors.ErrUnauthorized {
|
||||
t.Errorf("Expected ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitEntryDTO struct {
|
||||
ID string
|
||||
HabitID string
|
||||
ScheduledDate time.Time
|
||||
CompletedAt time.Time
|
||||
Value *float64
|
||||
}
|
||||
|
||||
type GetHabitEntriesQuery struct {
|
||||
HabitID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
type GetHabitEntriesHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewGetHabitEntriesHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *GetHabitEntriesHandler {
|
||||
return &GetHabitEntriesHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetHabitEntriesHandler) Handle(ctx context.Context, query GetHabitEntriesQuery) ([]HabitEntryDTO, error) {
|
||||
// Verify habit exists and user owns it
|
||||
habit, err := h.habitRepo.FindByID(ctx, query.HabitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if habit.UserID != query.UserID {
|
||||
return nil, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
// Get all entries for the habit
|
||||
entries, err := h.entryRepo.FindByHabitID(ctx, query.HabitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []HabitEntryDTO
|
||||
for _, entry := range entries {
|
||||
// Filter out deleted entries
|
||||
if entry.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, HabitEntryDTO{
|
||||
ID: entry.ID,
|
||||
HabitID: entry.HabitID,
|
||||
ScheduledDate: entry.ScheduledDate,
|
||||
CompletedAt: entry.CompletedAt,
|
||||
Value: entry.Value,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockEntryRepoWithFindByHabitID struct {
|
||||
mockEntryRepo
|
||||
entries []*entities.HabitEntry
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoWithFindByHabitID) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return m.entries, nil
|
||||
}
|
||||
|
||||
func TestGetHabitEntriesHandler_ReturnsEntriesSuccessfully(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
|
||||
habit.ID = "habit-1"
|
||||
|
||||
date1 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
date2 := time.Date(2025, 1, 16, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
entry1 := entities.NewHabitEntry("habit-1", date1, nil)
|
||||
entry1.ID = "entry-1"
|
||||
|
||||
entry2 := entities.NewHabitEntry("habit-1", date2, nil)
|
||||
entry2.ID = "entry-2"
|
||||
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
habitToReturn: habit,
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoWithFindByHabitID{
|
||||
entries: []*entities.HabitEntry{entry1, entry2},
|
||||
}
|
||||
|
||||
handler := NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetHabitEntriesQuery{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, 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 entries, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].ID != "entry-1" {
|
||||
t.Errorf("Expected first entry ID entry-1, got %s", results[0].ID)
|
||||
}
|
||||
|
||||
if results[1].ID != "entry-2" {
|
||||
t.Errorf("Expected second entry ID entry-2, got %s", results[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHabitEntriesHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
errorToReturn: errors.ErrNotFound,
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoWithFindByHabitID{}
|
||||
|
||||
handler := NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetHabitEntriesQuery{
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHabitEntriesHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
|
||||
habit.ID = "habit-1"
|
||||
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
habitToReturn: habit,
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoWithFindByHabitID{}
|
||||
|
||||
handler := NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetHabitEntriesQuery{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != errors.ErrUnauthorized {
|
||||
t.Errorf("Expected ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHabitEntriesHandler_FiltersDeletedEntries(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
|
||||
habit.ID = "habit-1"
|
||||
|
||||
date1 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
date2 := time.Date(2025, 1, 16, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
entry1 := entities.NewHabitEntry("habit-1", date1, nil)
|
||||
entry1.ID = "entry-1"
|
||||
|
||||
entry2 := entities.NewHabitEntry("habit-1", date2, nil)
|
||||
entry2.ID = "entry-2"
|
||||
now := time.Now()
|
||||
entry2.DeletedAt = &now // This one is deleted
|
||||
|
||||
habitRepo := &mockHabitRepoWithFindByID{
|
||||
habitToReturn: habit,
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoWithFindByHabitID{
|
||||
entries: []*entities.HabitEntry{entry1, entry2},
|
||||
}
|
||||
|
||||
handler := NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetHabitEntriesQuery{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should only return the non-deleted entry
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 active entry, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].ID != "entry-1" {
|
||||
t.Errorf("Expected entry-1 (non-deleted), got %s", results[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
)
|
||||
|
||||
type HabitDTO struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Frequency string
|
||||
TargetValue *float64
|
||||
CarryOver bool
|
||||
SpecificDays []int
|
||||
}
|
||||
|
||||
type GetUserHabitsQuery struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
type GetUserHabitsHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
}
|
||||
|
||||
func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHabitsHandler {
|
||||
return &GetUserHabitsHandler{
|
||||
habitRepo: habitRepo,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var result []HabitDTO
|
||||
for _, habit := range habits {
|
||||
result = append(result, HabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: string(habit.Type),
|
||||
Frequency: string(habit.Frequency),
|
||||
TargetValue: habit.TargetValue,
|
||||
CarryOver: habit.CarryOver,
|
||||
SpecificDays: habit.SpecificDays,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
|
||||
habit1.ID = "habit-1"
|
||||
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, 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 results[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
|
||||
}
|
||||
|
||||
if results[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-456",
|
||||
}
|
||||
|
||||
results, 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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
targetValue := 5.0
|
||||
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true)
|
||||
habit.ID = "habit-1"
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, 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))
|
||||
}
|
||||
|
||||
result := results[0]
|
||||
|
||||
if result.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
||||
}
|
||||
|
||||
if result.Type != string(value_objects.HabitTypeQuantity) {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type)
|
||||
}
|
||||
|
||||
if result.Frequency != string(value_objects.FrequencyDaily) {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
|
||||
}
|
||||
|
||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
||||
}
|
||||
|
||||
if !result.CarryOver {
|
||||
t.Error("Expected carry over to be true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user