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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user