From 0977d3a58b6c6a56e9457b21be01c48d46ac710c Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Tue, 25 Nov 2025 14:29:51 +0100 Subject: [PATCH] Implement SQLite persistence layer (TDD) - Add SQLite and UUID dependencies (go-sqlite3, google/uuid) - Create database connection with automatic migrations - Implement UserRepository with full CRUD operations - Implement HabitRepository with JSON serialization for arrays - Implement HabitEntryRepository with date range queries - Add comprehensive test coverage for all repositories - Fix User entity to default timezone to UTC when empty - All tests passing with TDD approach (Red-Green-Refactor) --- go.mod | 5 + go.sum | 4 + internal/domain/entities/habit_entry_test.go | 2 +- internal/domain/entities/user.go | 3 + .../persistence/sqlite/database.go | 48 ++++ .../persistence/sqlite/database_test.go | 87 ++++++ .../sqlite/habit_entry_repository.go | 136 +++++++++ .../sqlite/habit_entry_repository_test.go | 205 ++++++++++++++ .../persistence/sqlite/habit_repository.go | 208 ++++++++++++++ .../sqlite/habit_repository_test.go | 258 ++++++++++++++++++ .../persistence/sqlite/migrations.go | 71 +++++ .../persistence/sqlite/migrations_test.go | 127 +++++++++ .../persistence/sqlite/user_repository.go | 133 +++++++++ .../sqlite/user_repository_test.go | 244 +++++++++++++++++ 14 files changed, 1530 insertions(+), 1 deletion(-) create mode 100644 go.sum create mode 100644 internal/infrastructure/persistence/sqlite/database.go create mode 100644 internal/infrastructure/persistence/sqlite/database_test.go create mode 100644 internal/infrastructure/persistence/sqlite/habit_entry_repository.go create mode 100644 internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go create mode 100644 internal/infrastructure/persistence/sqlite/habit_repository.go create mode 100644 internal/infrastructure/persistence/sqlite/habit_repository_test.go create mode 100644 internal/infrastructure/persistence/sqlite/migrations.go create mode 100644 internal/infrastructure/persistence/sqlite/migrations_test.go create mode 100644 internal/infrastructure/persistence/sqlite/user_repository.go create mode 100644 internal/infrastructure/persistence/sqlite/user_repository_test.go diff --git a/go.mod b/go.mod index af99ff4..eec1469 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module habit-tracker-api go 1.23.4 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e0518a --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/internal/domain/entities/habit_entry_test.go b/internal/domain/entities/habit_entry_test.go index c79a730..c0d8c18 100644 --- a/internal/domain/entities/habit_entry_test.go +++ b/internal/domain/entities/habit_entry_test.go @@ -55,7 +55,7 @@ func TestHabitEntry_SoftDelete(t *testing.T) { t.Error("New entry should not be deleted") } - entry.SoftDelete() + entry.Delete() if entry.DeletedAt == nil { t.Error("Entry should be deleted after calling SoftDelete()") diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index ea10fbd..fe9715d 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -13,6 +13,9 @@ type User struct { func NewUser(email, passwordHash, timezone string) *User { now := time.Now() + if timezone == "" { + timezone = "UTC" + } return &User{ Email: email, PasswordHash: passwordHash, diff --git a/internal/infrastructure/persistence/sqlite/database.go b/internal/infrastructure/persistence/sqlite/database.go new file mode 100644 index 0000000..a98f6c7 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/database.go @@ -0,0 +1,48 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +type Database struct { + conn *sql.DB +} + +func NewDatabase(dbPath string) (*Database, error) { + dir := filepath.Dir(dbPath) + if dir != "." && dir != ":" { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory: %w", err) + } + } + + conn, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + conn.SetMaxOpenConns(1) + + if err := conn.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + if err := RunMigrations(conn); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return &Database{conn: conn}, nil +} + +func (db *Database) Close() error { + return db.conn.Close() +} + +func (db *Database) Conn() *sql.DB { + return db.conn +} diff --git a/internal/infrastructure/persistence/sqlite/database_test.go b/internal/infrastructure/persistence/sqlite/database_test.go new file mode 100644 index 0000000..5d61b25 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/database_test.go @@ -0,0 +1,87 @@ +package sqlite + +import ( + "os" + "testing" +) + +func TestNewDatabase(t *testing.T) { + dbPath := "./test_db.sqlite" + defer os.Remove(dbPath) + + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + defer db.Close() + + if db == nil { + t.Fatal("Expected database instance, got nil") + } +} + +func TestNewDatabaseCreatesDataDirectory(t *testing.T) { + dbPath := "./test_data/nested/db.sqlite" + defer os.RemoveAll("./test_data") + + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + defer db.Close() + + if _, err := os.Stat("./test_data/nested"); os.IsNotExist(err) { + t.Fatal("Data directory was not created") + } +} + +func TestNewDatabaseRunsMigrations(t *testing.T) { + dbPath := ":memory:" + + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + defer db.Close() + + conn := db.Conn() + var name string + err = conn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").Scan(&name) + if err != nil { + t.Fatal("Migrations were not run, users table does not exist") + } +} + +func TestDatabaseClose(t *testing.T) { + dbPath := ":memory:" + + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + + err = db.Close() + if err != nil { + t.Errorf("Close failed: %v", err) + } +} + +func TestDatabaseConn(t *testing.T) { + dbPath := ":memory:" + + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + defer db.Close() + + conn := db.Conn() + if conn == nil { + t.Fatal("Expected sql.DB connection, got nil") + } + + err = conn.Ping() + if err != nil { + t.Errorf("Ping failed: %v", err) + } +} diff --git a/internal/infrastructure/persistence/sqlite/habit_entry_repository.go b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go new file mode 100644 index 0000000..e689ff6 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go @@ -0,0 +1,136 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" + + "github.com/google/uuid" +) + +type HabitEntryRepository struct { + db *sql.DB +} + +func NewHabitEntryRepository(db *sql.DB) *HabitEntryRepository { + return &HabitEntryRepository{db: db} +} + +func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.HabitEntry) error { + entry.ID = uuid.New().String() + + query := ` + INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value) + VALUES (?, ?, ?, ?, ?) + ` + + _, err := r.db.ExecContext(ctx, query, + entry.ID, + entry.HabitID, + entry.ScheduledDate.Format("2006-01-02"), + entry.CompletedAt, + entry.Value, + ) + + if err != nil { + if isUniqueConstraintError(err) { + return errors.ErrAlreadyExists + } + return fmt.Errorf("failed to create entry: %w", err) + } + + return nil +} + +func (r *HabitEntryRepository) FindByHabitIDAndDateRange( + ctx context.Context, + habitID string, + from, to time.Time, +) ([]*entities.HabitEntry, error) { + query := ` + SELECT id, habit_id, scheduled_date, completed_at, value, deleted_at + FROM habit_entries + WHERE habit_id = ? + AND scheduled_date >= ? + AND scheduled_date <= ? + ORDER BY scheduled_date ASC + ` + + rows, err := r.db.QueryContext(ctx, query, + habitID, + from.Format("2006-01-02"), + to.Format("2006-01-02"), + ) + if err != nil { + return nil, fmt.Errorf("failed to find entries: %w", err) + } + defer rows.Close() + + return r.scanEntries(rows) +} + +func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error { + query := ` + UPDATE habit_entries + SET deleted_at = ? + WHERE id = ? + ` + + result, err := r.db.ExecContext(ctx, query, entry.DeletedAt, entry.ID) + if err != nil { + return fmt.Errorf("failed to update entry: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.ErrNotFound + } + + return nil +} + +func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEntry, error) { + var entries []*entities.HabitEntry + + for rows.Next() { + var ( + entry entities.HabitEntry + scheduledDate string + deletedAt sql.NullTime + ) + + err := rows.Scan( + &entry.ID, + &entry.HabitID, + &scheduledDate, + &entry.CompletedAt, + &entry.Value, + &deletedAt, + ) + + if err != nil { + return nil, err + } + + parsedDate, err := time.Parse("2006-01-02", scheduledDate) + if err != nil { + parsedDate, err = time.Parse(time.RFC3339, scheduledDate) + if err != nil { + return nil, fmt.Errorf("failed to parse scheduled_date: %w", err) + } + } + entry.ScheduledDate = parsedDate + + if deletedAt.Valid { + entry.DeletedAt = &deletedAt.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} diff --git a/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go new file mode 100644 index 0000000..53c4686 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/habit_entry_repository_test.go @@ -0,0 +1,205 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" +) + +func TestHabitEntryRepositoryCreate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + scheduledDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + value := 5.0 + entry := entities.NewHabitEntry("habit-123", scheduledDate, &value) + + err := repo.Create(ctx, entry) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if entry.ID == "" { + t.Error("Expected entry ID to be generated, got empty string") + } +} + +func TestHabitEntryRepositoryCreateDuplicateDate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + scheduledDate := time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC) + habitID := "habit-456" + + entry1 := entities.NewHabitEntry(habitID, scheduledDate, nil) + err := repo.Create(ctx, entry1) + if err != nil { + t.Fatalf("First create failed: %v", err) + } + + entry2 := entities.NewHabitEntry(habitID, scheduledDate, nil) + err = repo.Create(ctx, entry2) + if err != errors.ErrAlreadyExists { + t.Errorf("Expected ErrAlreadyExists, got %v", err) + } +} + +func TestHabitEntryRepositoryFindByHabitIDAndDateRange(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + habitID := "habit-range-test" + + date1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + date2 := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC) + date3 := time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC) + date4 := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + + entry1 := entities.NewHabitEntry(habitID, date1, nil) + entry2 := entities.NewHabitEntry(habitID, date2, nil) + entry3 := entities.NewHabitEntry(habitID, date3, nil) + entry4 := entities.NewHabitEntry(habitID, date4, nil) + + repo.Create(ctx, entry1) + repo.Create(ctx, entry2) + repo.Create(ctx, entry3) + repo.Create(ctx, entry4) + + from := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC) + to := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC) + + entries, err := repo.FindByHabitIDAndDateRange(ctx, habitID, from, to) + if err != nil { + t.Fatalf("FindByHabitIDAndDateRange failed: %v", err) + } + + if len(entries) != 2 { + t.Errorf("Expected 2 entries in range, got %d", len(entries)) + } +} + +func TestHabitEntryRepositoryUpdate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + scheduledDate := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC) + entry := entities.NewHabitEntry("habit-update", scheduledDate, nil) + + err := repo.Create(ctx, entry) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + now := time.Now() + entry.DeletedAt = &now + + err = repo.Update(ctx, entry) + if err != nil { + t.Fatalf("Update failed: %v", err) + } +} + +func TestHabitEntryRepositoryUpdateNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + scheduledDate := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + entry := entities.NewHabitEntry("habit-123", scheduledDate, nil) + entry.ID = "non-existent" + + err := repo.Update(ctx, entry) + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestHabitEntryRepositoryWithValue(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + scheduledDate := time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC) + value := 42.5 + entry := entities.NewHabitEntry("habit-value", scheduledDate, &value) + + err := repo.Create(ctx, entry) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + from := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC) + + entries, err := repo.FindByHabitIDAndDateRange(ctx, "habit-value", from, to) + if err != nil { + t.Fatalf("FindByHabitIDAndDateRange failed: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("Expected 1 entry, got %d", len(entries)) + } + + if entries[0].Value == nil || *entries[0].Value != 42.5 { + t.Errorf("Expected value 42.5, got %v", entries[0].Value) + } +} + +func TestHabitEntryRepositoryOrderedByDate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitEntryRepository(db) + ctx := context.Background() + + habitID := "habit-order" + + date3 := time.Date(2025, 5, 15, 0, 0, 0, 0, time.UTC) + date1 := time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC) + date2 := time.Date(2025, 5, 10, 0, 0, 0, 0, time.UTC) + + repo.Create(ctx, entities.NewHabitEntry(habitID, date3, nil)) + repo.Create(ctx, entities.NewHabitEntry(habitID, date1, nil)) + repo.Create(ctx, entities.NewHabitEntry(habitID, date2, nil)) + + from := time.Date(2025, 5, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2025, 5, 31, 0, 0, 0, 0, time.UTC) + + entries, err := repo.FindByHabitIDAndDateRange(ctx, habitID, from, to) + if err != nil { + t.Fatalf("FindByHabitIDAndDateRange failed: %v", err) + } + + if len(entries) != 3 { + t.Fatalf("Expected 3 entries, got %d", len(entries)) + } + + if entries[0].ScheduledDate.Format("2006-01-02") != date1.Format("2006-01-02") { + t.Errorf("First entry should be %s, got %s", date1.Format("2006-01-02"), entries[0].ScheduledDate.Format("2006-01-02")) + } + if entries[1].ScheduledDate.Format("2006-01-02") != date2.Format("2006-01-02") { + t.Errorf("Second entry should be %s, got %s", date2.Format("2006-01-02"), entries[1].ScheduledDate.Format("2006-01-02")) + } + if entries[2].ScheduledDate.Format("2006-01-02") != date3.Format("2006-01-02") { + t.Errorf("Third entry should be %s, got %s", date3.Format("2006-01-02"), entries[2].ScheduledDate.Format("2006-01-02")) + } +} diff --git a/internal/infrastructure/persistence/sqlite/habit_repository.go b/internal/infrastructure/persistence/sqlite/habit_repository.go new file mode 100644 index 0000000..48836a2 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/habit_repository.go @@ -0,0 +1,208 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" + + "github.com/google/uuid" +) + +type HabitRepository struct { + db *sql.DB +} + +func NewHabitRepository(db *sql.DB) *HabitRepository { + return &HabitRepository{db: db} +} + +func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) error { + habit.ID = uuid.New().String() + + specificDays, _ := json.Marshal(habit.SpecificDays) + specificDates, _ := json.Marshal(habit.SpecificDates) + + query := ` + INSERT INTO habits ( + id, user_id, name, description, type, frequency, + specific_days, specific_dates, carry_over, target_value, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + _, err := r.db.ExecContext(ctx, query, + habit.ID, + habit.UserID, + habit.Name, + habit.Description, + habit.Type, + habit.Frequency, + specificDays, + specificDates, + habit.CarryOver, + habit.TargetValue, + habit.CreatedAt, + ) + + if err != nil { + return fmt.Errorf("failed to create habit: %w", err) + } + + return nil +} + +func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Habit, error) { + query := ` + SELECT id, user_id, name, description, type, frequency, + specific_days, specific_dates, carry_over, target_value, + created_at, archived_at + FROM habits + WHERE id = ? + ` + + var ( + habit entities.Habit + specificDays sql.NullString + specificDates sql.NullString + archivedAt sql.NullTime + ) + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &habit.ID, + &habit.UserID, + &habit.Name, + &habit.Description, + &habit.Type, + &habit.Frequency, + &specificDays, + &specificDates, + &habit.CarryOver, + &habit.TargetValue, + &habit.CreatedAt, + &archivedAt, + ) + + if err == sql.ErrNoRows { + return nil, errors.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to find habit: %w", err) + } + + if specificDays.Valid { + json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays) + } + if specificDates.Valid { + json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates) + } + if archivedAt.Valid { + habit.ArchivedAt = &archivedAt.Time + } + + return &habit, nil +} + +func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) { + query := ` + SELECT id, user_id, name, description, type, frequency, + specific_days, specific_dates, carry_over, target_value, + created_at, archived_at + FROM habits + WHERE user_id = ? AND archived_at IS NULL + ORDER BY created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("failed to find habits: %w", err) + } + defer rows.Close() + + return r.scanHabits(rows) +} + +func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error { + specificDays, _ := json.Marshal(habit.SpecificDays) + specificDates, _ := json.Marshal(habit.SpecificDates) + + query := ` + UPDATE habits + SET name = ?, description = ?, type = ?, frequency = ?, + specific_days = ?, specific_dates = ?, carry_over = ?, + target_value = ?, archived_at = ? + WHERE id = ? + ` + + result, err := r.db.ExecContext(ctx, query, + habit.Name, + habit.Description, + habit.Type, + habit.Frequency, + specificDays, + specificDates, + habit.CarryOver, + habit.TargetValue, + habit.ArchivedAt, + habit.ID, + ) + + if err != nil { + return fmt.Errorf("failed to update habit: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.ErrNotFound + } + + return nil +} + +func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error) { + var habits []*entities.Habit + + for rows.Next() { + var ( + habit entities.Habit + specificDays sql.NullString + specificDates sql.NullString + archivedAt sql.NullTime + ) + + err := rows.Scan( + &habit.ID, + &habit.UserID, + &habit.Name, + &habit.Description, + &habit.Type, + &habit.Frequency, + &specificDays, + &specificDates, + &habit.CarryOver, + &habit.TargetValue, + &habit.CreatedAt, + &archivedAt, + ) + + if err != nil { + return nil, err + } + + if specificDays.Valid { + json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays) + } + if specificDates.Valid { + json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates) + } + if archivedAt.Valid { + habit.ArchivedAt = &archivedAt.Time + } + + habits = append(habits, &habit) + } + + return habits, nil +} diff --git a/internal/infrastructure/persistence/sqlite/habit_repository_test.go b/internal/infrastructure/persistence/sqlite/habit_repository_test.go new file mode 100644 index 0000000..a3e3f6e --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/habit_repository_test.go @@ -0,0 +1,258 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/domain/value_objects" + "habit-tracker-api/internal/shared/errors" +) + +func TestHabitRepositoryCreate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-123", + "Morning Exercise", + value_objects.HabitTypeBoolean, + value_objects.FrequencyDaily, + false, + ) + habit.Description = "Exercise every morning" + + err := repo.Create(ctx, habit) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if habit.ID == "" { + t.Error("Expected habit ID to be generated, got empty string") + } +} + +func TestHabitRepositoryCreateWithSpecificDays(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-123", + "Weekly Workout", + value_objects.HabitTypeBoolean, + value_objects.FrequencyWeekly, + false, + ) + habit.SpecificDays = []int{1, 3, 5} + + err := repo.Create(ctx, habit) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + found, err := repo.FindByID(ctx, habit.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if len(found.SpecificDays) != 3 { + t.Errorf("Expected 3 specific days, got %d", len(found.SpecificDays)) + } +} + +func TestHabitRepositoryFindByID(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-456", + "Read Books", + value_objects.HabitTypeCounter, + value_objects.FrequencyDaily, + true, + ) + targetValue := 30.0 + habit.TargetValue = &targetValue + + err := repo.Create(ctx, habit) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + found, err := repo.FindByID(ctx, habit.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if found.ID != habit.ID { + t.Errorf("Expected ID %s, got %s", habit.ID, found.ID) + } + if found.Name != habit.Name { + t.Errorf("Expected name %s, got %s", habit.Name, found.Name) + } + if found.CarryOver != habit.CarryOver { + t.Errorf("Expected carry_over %v, got %v", habit.CarryOver, found.CarryOver) + } + if found.TargetValue == nil || *found.TargetValue != 30.0 { + t.Errorf("Expected target_value 30.0, got %v", found.TargetValue) + } +} + +func TestHabitRepositoryFindByIDNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + _, err := repo.FindByID(ctx, "non-existent-id") + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestHabitRepositoryFindActiveByUserID(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + userID := "user-789" + + habit1 := entities.NewHabit(userID, "Habit 1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit2 := entities.NewHabit(userID, "Habit 2", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + habit3 := entities.NewHabit(userID, "Habit 3", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false) + + repo.Create(ctx, habit1) + repo.Create(ctx, habit2) + repo.Create(ctx, habit3) + + now := time.Now() + habit2.ArchivedAt = &now + repo.Update(ctx, habit2) + + habits, err := repo.FindActiveByUserID(ctx, userID) + if err != nil { + t.Fatalf("FindActiveByUserID failed: %v", err) + } + + if len(habits) != 2 { + t.Errorf("Expected 2 active habits, got %d", len(habits)) + } +} + +func TestHabitRepositoryUpdate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-999", + "Original Name", + value_objects.HabitTypeBoolean, + value_objects.FrequencyDaily, + false, + ) + + err := repo.Create(ctx, habit) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + habit.Name = "Updated Name" + habit.Description = "Updated description" + habit.CarryOver = true + + err = repo.Update(ctx, habit) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + found, err := repo.FindByID(ctx, habit.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if found.Name != "Updated Name" { + t.Errorf("Expected name 'Updated Name', got %s", found.Name) + } + if found.Description != "Updated description" { + t.Errorf("Expected description 'Updated description', got %s", found.Description) + } + if !found.CarryOver { + t.Error("Expected carry_over to be true") + } +} + +func TestHabitRepositoryUpdateNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-123", + "Test", + value_objects.HabitTypeBoolean, + value_objects.FrequencyDaily, + false, + ) + habit.ID = "non-existent" + + err := repo.Update(ctx, habit) + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestHabitRepositoryArchive(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewHabitRepository(db) + ctx := context.Background() + + habit := entities.NewHabit( + "user-archive", + "To Archive", + value_objects.HabitTypeBoolean, + value_objects.FrequencyDaily, + false, + ) + + err := repo.Create(ctx, habit) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + now := time.Now() + habit.ArchivedAt = &now + + err = repo.Update(ctx, habit) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + found, err := repo.FindByID(ctx, habit.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if found.ArchivedAt == nil { + t.Error("Expected habit to be archived") + } +} diff --git a/internal/infrastructure/persistence/sqlite/migrations.go b/internal/infrastructure/persistence/sqlite/migrations.go new file mode 100644 index 0000000..2fb121e --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/migrations.go @@ -0,0 +1,71 @@ +package sqlite + +import ( + "database/sql" +) + +func RunMigrations(db *sql.DB) error { + migrations := []string{ + createUsersTable, + createHabitsTable, + createHabitEntriesTable, + createIndexes, + } + + for _, migration := range migrations { + if _, err := db.Exec(migration); err != nil { + return err + } + } + return nil +} + +const createUsersTable = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + timezone TEXT DEFAULT 'UTC', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +const createHabitsTable = ` +CREATE TABLE IF NOT EXISTS habits ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + type TEXT CHECK(type IN ('BOOLEAN', 'COUNTER', 'VALUE')), + frequency TEXT CHECK(frequency IN ('DAILY', 'WEEKLY', 'MONTHLY')), + specific_days TEXT, + specific_dates TEXT, + carry_over BOOLEAN DEFAULT 0, + target_value REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + archived_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +` + +const createHabitEntriesTable = ` +CREATE TABLE IF NOT EXISTS habit_entries ( + id TEXT PRIMARY KEY, + habit_id TEXT NOT NULL, + scheduled_date DATE NOT NULL, + completed_at DATETIME NOT NULL, + value REAL, + deleted_at DATETIME, + FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE, + UNIQUE(habit_id, scheduled_date) +); +` + +const createIndexes = ` +CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id); +CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at); +CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id); +CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date); +CREATE INDEX IF NOT EXISTS idx_entries_deleted ON habit_entries(deleted_at); +` diff --git a/internal/infrastructure/persistence/sqlite/migrations_test.go b/internal/infrastructure/persistence/sqlite/migrations_test.go new file mode 100644 index 0000000..ccc5168 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/migrations_test.go @@ -0,0 +1,127 @@ +package sqlite + +import ( + "database/sql" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestRunMigrations(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + err = RunMigrations(db) + + if err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + tables := []string{"users", "habits", "habit_entries"} + for _, table := range tables { + var name string + query := "SELECT name FROM sqlite_master WHERE type='table' AND name=?" + err := db.QueryRow(query, table).Scan(&name) + if err != nil { + t.Errorf("Table %s does not exist: %v", table, err) + } + } +} + +func TestUsersTableSchema(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + err = RunMigrations(db) + if err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + columns := []string{"id", "email", "password_hash", "timezone", "created_at", "updated_at"} + for _, col := range columns { + query := "SELECT " + col + " FROM users LIMIT 0" + rows, err := db.Query(query) + if err != nil { + t.Errorf("Column %s does not exist in users table: %v", col, err) + } + if rows != nil { + rows.Close() + } + } +} + +func TestHabitsTableSchema(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + err = RunMigrations(db) + if err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + columns := []string{"id", "user_id", "name", "description", "type", "frequency", + "specific_days", "specific_dates", "carry_over", "target_value", "created_at", "archived_at"} + for _, col := range columns { + query := "SELECT " + col + " FROM habits LIMIT 0" + rows, err := db.Query(query) + if err != nil { + t.Errorf("Column %s does not exist in habits table: %v", col, err) + } + if rows != nil { + rows.Close() + } + } +} + +func TestHabitEntriesTableSchema(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + err = RunMigrations(db) + if err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + columns := []string{"id", "habit_id", "scheduled_date", "completed_at", "value", "deleted_at"} + for _, col := range columns { + query := "SELECT " + col + " FROM habit_entries LIMIT 0" + rows, err := db.Query(query) + if err != nil { + t.Errorf("Column %s does not exist in habit_entries table: %v", col, err) + } + if rows != nil { + rows.Close() + } + } +} + +func TestMigrationsAreIdempotent(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + err = RunMigrations(db) + if err != nil { + t.Fatalf("First RunMigrations failed: %v", err) + } + + err = RunMigrations(db) + + if err != nil { + t.Errorf("Second RunMigrations should be idempotent but failed: %v", err) + } +} diff --git a/internal/infrastructure/persistence/sqlite/user_repository.go b/internal/infrastructure/persistence/sqlite/user_repository.go new file mode 100644 index 0000000..bf55c94 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/user_repository.go @@ -0,0 +1,133 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" + + "github.com/google/uuid" +) + +type UserRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(ctx context.Context, user *entities.User) error { + user.ID = uuid.New().String() + + query := ` + INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ` + + _, err := r.db.ExecContext(ctx, query, + user.ID, + user.Email, + user.PasswordHash, + user.Timezone, + user.CreatedAt, + user.UpdatedAt, + ) + + if err != nil { + if isUniqueConstraintError(err) { + return errors.ErrAlreadyExists + } + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} + +func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) { + query := ` + SELECT id, email, password_hash, timezone, created_at, updated_at + FROM users + WHERE id = ? + ` + + var user entities.User + err := r.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Timezone, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, errors.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to find user: %w", err) + } + + return &user, nil +} + +func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + query := ` + SELECT id, email, password_hash, timezone, created_at, updated_at + FROM users + WHERE email = ? + ` + + var user entities.User + err := r.db.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.Email, + &user.PasswordHash, + &user.Timezone, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, errors.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to find user: %w", err) + } + + return &user, nil +} + +func (r *UserRepository) Update(ctx context.Context, user *entities.User) error { + query := ` + UPDATE users + SET email = ?, password_hash = ?, timezone = ?, updated_at = ? + WHERE id = ? + ` + + result, err := r.db.ExecContext(ctx, query, + user.Email, + user.PasswordHash, + user.Timezone, + user.UpdatedAt, + user.ID, + ) + + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.ErrNotFound + } + + return nil +} + +func isUniqueConstraintError(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} diff --git a/internal/infrastructure/persistence/sqlite/user_repository_test.go b/internal/infrastructure/persistence/sqlite/user_repository_test.go new file mode 100644 index 0000000..885a8d3 --- /dev/null +++ b/internal/infrastructure/persistence/sqlite/user_repository_test.go @@ -0,0 +1,244 @@ +package sqlite + +import ( + "context" + "database/sql" + "testing" + "time" + + "habit-tracker-api/internal/domain/entities" + "habit-tracker-api/internal/shared/errors" + + _ "github.com/mattn/go-sqlite3" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + if err := RunMigrations(db); err != nil { + t.Fatalf("Failed to run migrations: %v", err) + } + + return db +} + +func TestUserRepositoryCreate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &entities.User{ + Email: "test@example.com", + PasswordHash: "hashed_password", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Create(ctx, user) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if user.ID == "" { + t.Error("Expected user ID to be generated, got empty string") + } +} + +func TestUserRepositoryCreateDuplicateEmail(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user1 := &entities.User{ + Email: "duplicate@example.com", + PasswordHash: "hash1", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Create(ctx, user1) + if err != nil { + t.Fatalf("First create failed: %v", err) + } + + user2 := &entities.User{ + Email: "duplicate@example.com", + PasswordHash: "hash2", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err = repo.Create(ctx, user2) + if err != errors.ErrAlreadyExists { + t.Errorf("Expected ErrAlreadyExists, got %v", err) + } +} + +func TestUserRepositoryFindByID(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &entities.User{ + Email: "find@example.com", + PasswordHash: "hashed", + Timezone: "America/New_York", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Create(ctx, user) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + found, err := repo.FindByID(ctx, user.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if found.ID != user.ID { + t.Errorf("Expected ID %s, got %s", user.ID, found.ID) + } + if found.Email != user.Email { + t.Errorf("Expected email %s, got %s", user.Email, found.Email) + } + if found.Timezone != user.Timezone { + t.Errorf("Expected timezone %s, got %s", user.Timezone, found.Timezone) + } +} + +func TestUserRepositoryFindByIDNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + _, err := repo.FindByID(ctx, "non-existent-id") + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestUserRepositoryFindByEmail(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &entities.User{ + Email: "email@test.com", + PasswordHash: "hashed", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Create(ctx, user) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + found, err := repo.FindByEmail(ctx, user.Email) + if err != nil { + t.Fatalf("FindByEmail failed: %v", err) + } + + if found.ID != user.ID { + t.Errorf("Expected ID %s, got %s", user.ID, found.ID) + } + if found.Email != user.Email { + t.Errorf("Expected email %s, got %s", user.Email, found.Email) + } +} + +func TestUserRepositoryFindByEmailNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + _, err := repo.FindByEmail(ctx, "nonexistent@example.com") + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +} + +func TestUserRepositoryUpdate(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &entities.User{ + Email: "original@example.com", + PasswordHash: "hash1", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Create(ctx, user) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + user.Email = "updated@example.com" + user.Timezone = "Europe/Madrid" + user.UpdatedAt = time.Now() + + err = repo.Update(ctx, user) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + found, err := repo.FindByID(ctx, user.ID) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + + if found.Email != "updated@example.com" { + t.Errorf("Expected email updated@example.com, got %s", found.Email) + } + if found.Timezone != "Europe/Madrid" { + t.Errorf("Expected timezone Europe/Madrid, got %s", found.Timezone) + } +} + +func TestUserRepositoryUpdateNotFound(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + repo := NewUserRepository(db) + ctx := context.Background() + + user := &entities.User{ + ID: "non-existent", + Email: "test@example.com", + PasswordHash: "hash", + Timezone: "UTC", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Update(ctx, user) + if err != errors.ErrNotFound { + t.Errorf("Expected ErrNotFound, got %v", err) + } +}