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)
This commit is contained in:
2025-11-25 14:29:51 +01:00
parent 64bfe80806
commit 0977d3a58b
14 changed files with 1530 additions and 1 deletions
+5
View File
@@ -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
)
+4
View File
@@ -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=
+1 -1
View File
@@ -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()")
+3
View File
@@ -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,
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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"))
}
}
@@ -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
}
@@ -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")
}
}
@@ -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);
`
@@ -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)
}
}
@@ -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")
}
@@ -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)
}
}