feat: implement offline sync endpoints with Last-Write-Wins strategy

Add comprehensive offline synchronization support for habits and entries:

## Infrastructure (Phase 1)
- Add UpdatedAt and DeletedAt timestamps to Habit and HabitEntry entities
- Implement soft delete with Delete(), Touch(), and IsDeleted() methods
- Create SQL migration with optimized composite indexes for sync queries
- Add GetChangesSince() and SoftDelete() to both repositories
- Update all Find* methods to exclude soft-deleted records
- 13 comprehensive TDD tests for sync repository methods

## HTTP Endpoints (Phase 2)
- GET /api/v1/sync/changes: retrieve all changes since timestamp
- POST /api/v1/sync/batch: apply client changes with conflict resolution
- Implement Last-Write-Wins strategy using UpdatedAt timestamps
- Add authentication and rate limiting (100 req/min)
- Validate user ownership for all sync operations
- 9 tests for sync handlers (3 queries + 6 commands)

## Technical Details
- Composite indexes: (user_id, updated_at) for optimal query performance
- No pagination: atomic sync operations for data consistency
- Upsert behavior: create resources if not found on server
- DTOs with full entity state including timestamps
- Swagger documentation updated for new endpoints

All 220+ tests passing ✓
This commit is contained in:
2025-12-12 00:11:46 +01:00
parent 1aedc2b69a
commit aa8f7af55d
25 changed files with 4554 additions and 87 deletions
@@ -7,6 +7,7 @@ import (
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
"github.com/google/uuid"
@@ -24,8 +25,8 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
entry.ID = uuid.New().String()
query := `
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
VALUES (?, ?, ?, ?, ?)
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
@@ -34,6 +35,7 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
entry.ScheduledDate.Format("2006-01-02"),
entry.CompletedAt,
entry.Value,
entry.UpdatedAt,
)
if err != nil {
@@ -52,11 +54,12 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
from, to time.Time,
) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
FROM habit_entries
WHERE habit_id = ?
AND scheduled_date >= ?
AND scheduled_date <= ?
AND deleted_at IS NULL
ORDER BY scheduled_date ASC
`
@@ -74,13 +77,15 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
}
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
entry.UpdatedAt = time.Now()
query := `
UPDATE habit_entries
SET value = ?, completed_at = ?
WHERE id = ?
SET value = ?, completed_at = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.UpdatedAt, entry.ID)
if err != nil {
return fmt.Errorf("failed to update entry: %w", err)
}
@@ -100,6 +105,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
var (
entry entities.HabitEntry
scheduledDate string
updatedAt sql.NullTime
deletedAt sql.NullTime
)
err := rows.Scan(
@@ -108,6 +115,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
&scheduledDate,
&entry.CompletedAt,
&entry.Value,
&updatedAt,
&deletedAt,
)
if err != nil {
@@ -123,6 +132,13 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
}
entry.ScheduledDate = parsedDate
if updatedAt.Valid {
entry.UpdatedAt = updatedAt.Time
}
if deletedAt.Valid {
entry.DeletedAt = &deletedAt.Time
}
entries = append(entries, &entry)
}
@@ -131,14 +147,16 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
FROM habit_entries
WHERE id = ?
WHERE id = ? AND deleted_at IS NULL
`
var (
entry entities.HabitEntry
scheduledDate string
updatedAt sql.NullTime
deletedAt sql.NullTime
)
err := r.db.QueryRowContext(ctx, query, id).Scan(
@@ -147,6 +165,8 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
&scheduledDate,
&entry.CompletedAt,
&entry.Value,
&updatedAt,
&deletedAt,
)
if err == sql.ErrNoRows {
@@ -165,14 +185,21 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
}
entry.ScheduledDate = parsedDate
if updatedAt.Valid {
entry.UpdatedAt = updatedAt.Time
}
if deletedAt.Valid {
entry.DeletedAt = &deletedAt.Time
}
return &entry, nil
}
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
FROM habit_entries
WHERE habit_id = ?
WHERE habit_id = ? AND deleted_at IS NULL
ORDER BY scheduled_date DESC
`
@@ -187,10 +214,10 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
query := `
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
FROM habit_entries he
INNER JOIN habits h ON he.habit_id = h.id
WHERE h.user_id = ?
WHERE h.user_id = ? AND he.deleted_at IS NULL
ORDER BY he.scheduled_date DESC
`
@@ -205,10 +232,11 @@ func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string)
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
FROM habit_entries
WHERE habit_id = ?
AND scheduled_date < ?
AND deleted_at IS NULL
ORDER BY scheduled_date DESC
`
@@ -236,3 +264,128 @@ func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
return nil
}
func (r *HabitEntryRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
changes := &repositories.HabitEntryChanges{
Created: []*entities.HabitEntry{},
Updated: []*entities.HabitEntry{},
Deleted: []string{},
}
query := `
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
FROM habit_entries he
INNER JOIN habits h ON he.habit_id = h.id
WHERE h.user_id = ?
AND he.updated_at > ?
AND he.deleted_at IS NULL
ORDER BY he.updated_at ASC
`
rows, err := r.db.QueryContext(ctx, query, userID, since)
if err != nil {
return nil, fmt.Errorf("failed to query habit entry changes: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
entry entities.HabitEntry
scheduledDate string
updatedAt sql.NullTime
deletedAt sql.NullTime
)
err := rows.Scan(
&entry.ID,
&entry.HabitID,
&scheduledDate,
&entry.CompletedAt,
&entry.Value,
&updatedAt,
&deletedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan habit entry: %w", 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 updatedAt.Valid {
entry.UpdatedAt = updatedAt.Time
}
if deletedAt.Valid {
entry.DeletedAt = &deletedAt.Time
}
if entry.CompletedAt.After(since) {
changes.Created = append(changes.Created, &entry)
} else {
changes.Updated = append(changes.Updated, &entry)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating habit entries: %w", err)
}
queryDeleted := `
SELECT he.id
FROM habit_entries he
INNER JOIN habits h ON he.habit_id = h.id
WHERE h.user_id = ?
AND he.deleted_at IS NOT NULL
AND he.deleted_at > ?
ORDER BY he.deleted_at ASC
`
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
if err != nil {
return nil, fmt.Errorf("failed to query deleted habit entries: %w", err)
}
defer rowsDeleted.Close()
for rowsDeleted.Next() {
var id string
if err := rowsDeleted.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan deleted habit entry id: %w", err)
}
changes.Deleted = append(changes.Deleted, id)
}
if err := rowsDeleted.Err(); err != nil {
return nil, fmt.Errorf("error iterating deleted habit entries: %w", err)
}
return changes, nil
}
func (r *HabitEntryRepository) SoftDelete(ctx context.Context, id string) error {
now := time.Now()
query := `
UPDATE habit_entries
SET deleted_at = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, now, now, id)
if err != nil {
return fmt.Errorf("failed to soft delete habit entry: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
@@ -31,8 +32,8 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
query := `
INSERT INTO habits (
id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
specific_days, specific_dates, carry_over, is_negative, target_value, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
@@ -48,6 +49,7 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
habit.IsNegative,
habit.TargetValue,
habit.CreatedAt,
habit.UpdatedAt,
)
if err != nil {
@@ -61,16 +63,18 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE id = ?
WHERE id = ? AND deleted_at IS NULL
`
var (
habit entities.Habit
specificDays sql.NullString
specificDates sql.NullString
updatedAt sql.NullTime
archivedAt sql.NullTime
deletedAt sql.NullTime
)
err := r.db.QueryRowContext(ctx, query, id).Scan(
@@ -86,7 +90,9 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
&habit.IsNegative,
&habit.TargetValue,
&habit.CreatedAt,
&updatedAt,
&archivedAt,
&deletedAt,
)
if err == sql.ErrNoRows {
@@ -96,15 +102,22 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
return nil, fmt.Errorf("failed to find habit: %w", err)
}
if updatedAt.Valid {
habit.UpdatedAt = updatedAt.Time
}
if archivedAt.Valid {
habit.ArchivedAt = &archivedAt.Time
}
if deletedAt.Valid {
habit.DeletedAt = &deletedAt.Time
}
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
}
@@ -113,9 +126,9 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE user_id = ? AND archived_at IS NULL
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
ORDER BY created_at DESC
`
@@ -129,6 +142,8 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
}
func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error {
habit.Touch()
specificDays, _ := json.Marshal(habit.SpecificDays)
specificDates, _ := json.Marshal(habit.SpecificDates)
@@ -136,8 +151,8 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
UPDATE habits
SET name = ?, description = ?, type = ?, frequency = ?,
specific_days = ?, specific_dates = ?, carry_over = ?, is_negative = ?,
target_value = ?, archived_at = ?
WHERE id = ?
target_value = ?, archived_at = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query,
@@ -151,6 +166,7 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
habit.IsNegative,
habit.TargetValue,
habit.ArchivedAt,
habit.UpdatedAt,
habit.ID,
)
@@ -174,7 +190,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
habit entities.Habit
specificDays sql.NullString
specificDates sql.NullString
updatedAt sql.NullTime
archivedAt sql.NullTime
deletedAt sql.NullTime
)
err := rows.Scan(
@@ -190,7 +208,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
&habit.IsNegative,
&habit.TargetValue,
&habit.CreatedAt,
&updatedAt,
&archivedAt,
&deletedAt,
)
if err != nil {
@@ -203,9 +223,15 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
if specificDates.Valid {
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
}
if updatedAt.Valid {
habit.UpdatedAt = updatedAt.Time
}
if archivedAt.Valid {
habit.ArchivedAt = &archivedAt.Time
}
if deletedAt.Valid {
habit.DeletedAt = &deletedAt.Time
}
habits = append(habits, &habit)
}
@@ -217,9 +243,9 @@ func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*e
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE user_id = ?
WHERE user_id = ? AND deleted_at IS NULL
ORDER BY created_at DESC
`
@@ -252,9 +278,9 @@ func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context,
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE user_id = ? AND archived_at IS NULL
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`
@@ -272,7 +298,7 @@ func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string
query := `
SELECT COUNT(*)
FROM habits
WHERE user_id = ? AND archived_at IS NULL
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
`
var count int
@@ -288,13 +314,16 @@ func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID strin
baseQuery := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, archived_at
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE user_id = ?`
args := []interface{}{userID}
conditions := []string{}
// Always exclude soft deleted
conditions = append(conditions, "deleted_at IS NULL")
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at IS NULL")
}
@@ -341,6 +370,9 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
args := []interface{}{userID}
conditions := []string{}
// Always exclude soft deleted
conditions = append(conditions, "deleted_at IS NULL")
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at IS NULL")
}
@@ -373,3 +405,141 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
return count, nil
}
func (r *HabitRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
changes := &repositories.HabitChanges{
Created: []*entities.Habit{},
Updated: []*entities.Habit{},
Deleted: []string{},
}
// Get created and updated habits (not deleted)
query := `
SELECT id, user_id, name, description, type, frequency,
specific_days, specific_dates, carry_over, is_negative, target_value,
created_at, updated_at, archived_at, deleted_at
FROM habits
WHERE user_id = ?
AND updated_at > ?
AND deleted_at IS NULL
ORDER BY updated_at ASC
`
rows, err := r.db.QueryContext(ctx, query, userID, since)
if err != nil {
return nil, fmt.Errorf("failed to query habits changes: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
habit entities.Habit
specificDays sql.NullString
specificDates sql.NullString
updatedAt sql.NullTime
archivedAt sql.NullTime
deletedAt sql.NullTime
)
err := rows.Scan(
&habit.ID,
&habit.UserID,
&habit.Name,
&habit.Description,
&habit.Type,
&habit.Frequency,
&specificDays,
&specificDates,
&habit.CarryOver,
&habit.IsNegative,
&habit.TargetValue,
&habit.CreatedAt,
&updatedAt,
&archivedAt,
&deletedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan habit: %w", err)
}
if specificDays.Valid {
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
}
if specificDates.Valid {
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
}
if updatedAt.Valid {
habit.UpdatedAt = updatedAt.Time
}
if archivedAt.Valid {
habit.ArchivedAt = &archivedAt.Time
}
if deletedAt.Valid {
habit.DeletedAt = &deletedAt.Time
}
// Classify as created or updated based on when it was created
if habit.CreatedAt.After(since) {
changes.Created = append(changes.Created, &habit)
} else {
changes.Updated = append(changes.Updated, &habit)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating habits: %w", err)
}
// Get deleted habits
queryDeleted := `
SELECT id
FROM habits
WHERE user_id = ?
AND deleted_at IS NOT NULL
AND deleted_at > ?
ORDER BY deleted_at ASC
`
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
if err != nil {
return nil, fmt.Errorf("failed to query deleted habits: %w", err)
}
defer rowsDeleted.Close()
for rowsDeleted.Next() {
var id string
if err := rowsDeleted.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan deleted habit id: %w", err)
}
changes.Deleted = append(changes.Deleted, id)
}
if err := rowsDeleted.Err(); err != nil {
return nil, fmt.Errorf("error iterating deleted habits: %w", err)
}
return changes, nil
}
func (r *HabitRepository) SoftDelete(ctx context.Context, id string) error {
now := time.Now()
query := `
UPDATE habits
SET deleted_at = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, now, now, id)
if err != nil {
return fmt.Errorf("failed to soft delete habit: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
@@ -0,0 +1,530 @@
package sqlite
import (
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
)
func TestHabitRepository_GetChangesSince_EmptyWhenNoChanges(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Crear hábito inicial
habit := entities.NewHabit(
userID,
"Initial Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Timestamp después de la creación
time.Sleep(10 * time.Millisecond)
since := time.Now()
// No hay cambios después de 'since'
changes, err := repo.GetChangesSince(ctx, userID, since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
if len(changes.Created) != 0 {
t.Errorf("Expected 0 created habits, got %d", len(changes.Created))
}
if len(changes.Updated) != 0 {
t.Errorf("Expected 0 updated habits, got %d", len(changes.Updated))
}
if len(changes.Deleted) != 0 {
t.Errorf("Expected 0 deleted habits, got %d", len(changes.Deleted))
}
}
func TestHabitRepository_GetChangesSince_ReturnsCreatedHabits(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Timestamp de referencia
since := time.Now()
time.Sleep(10 * time.Millisecond)
// Crear hábito DESPUÉS de 'since'
habit := entities.NewHabit(
userID,
"New Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Obtener cambios
changes, err := repo.GetChangesSince(ctx, userID, since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
if len(changes.Created) != 1 {
t.Fatalf("Expected 1 created habit, got %d", len(changes.Created))
}
if changes.Created[0].Name != "New Habit" {
t.Errorf("Expected habit name 'New Habit', got '%s'", changes.Created[0].Name)
}
if changes.Created[0].ID != habit.ID {
t.Errorf("Expected habit ID '%s', got '%s'", habit.ID, changes.Created[0].ID)
}
}
func TestHabitRepository_GetChangesSince_ReturnsUpdatedHabits(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Crear hábito inicial
habit := entities.NewHabit(
userID,
"Original Name",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Timestamp de referencia
time.Sleep(10 * time.Millisecond)
since := time.Now()
time.Sleep(10 * time.Millisecond)
// Actualizar hábito DESPUÉS de 'since'
habit.Name = "Updated Name"
err = repo.Update(ctx, habit)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Obtener cambios
changes, err := repo.GetChangesSince(ctx, userID, since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
if len(changes.Updated) != 1 {
t.Fatalf("Expected 1 updated habit, got %d", len(changes.Updated))
}
if changes.Updated[0].Name != "Updated Name" {
t.Errorf("Expected updated name 'Updated Name', got '%s'", changes.Updated[0].Name)
}
if len(changes.Created) != 0 {
t.Errorf("Expected 0 created habits (should be in Updated), got %d", len(changes.Created))
}
}
func TestHabitRepository_GetChangesSince_ReturnsDeletedHabits(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Crear hábito
habit := entities.NewHabit(
userID,
"To Delete",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
habitID := habit.ID
// Timestamp de referencia
time.Sleep(10 * time.Millisecond)
since := time.Now()
time.Sleep(10 * time.Millisecond)
// Soft delete DESPUÉS de 'since'
err = repo.SoftDelete(ctx, habitID)
if err != nil {
t.Fatalf("SoftDelete failed: %v", err)
}
// Obtener cambios
changes, err := repo.GetChangesSince(ctx, userID, since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
if len(changes.Deleted) != 1 {
t.Fatalf("Expected 1 deleted habit, got %d", len(changes.Deleted))
}
if changes.Deleted[0] != habitID {
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitID, changes.Deleted[0])
}
}
func TestHabitRepository_GetChangesSince_CombinedChanges(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Crear hábito inicial (antes de 'since')
habitOld := entities.NewHabit(
userID,
"Old Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habitOld)
// Timestamp de referencia
time.Sleep(10 * time.Millisecond)
since := time.Now()
time.Sleep(10 * time.Millisecond)
// DESPUÉS de 'since':
// 1. Crear nuevo hábito
habitNew := entities.NewHabit(
userID,
"New Habit",
value_objects.HabitTypeCounter,
value_objects.FrequencyWeekly,
false,
false,
)
habitNew.SpecificDays = []int{1, 3, 5}
repo.Create(ctx, habitNew)
// 2. Actualizar hábito existente
habitOld.Name = "Old Habit Updated"
repo.Update(ctx, habitOld)
// 3. Crear y eliminar otro hábito
habitToDelete := entities.NewHabit(
userID,
"To Delete",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habitToDelete)
repo.SoftDelete(ctx, habitToDelete.ID)
// Obtener cambios
changes, err := repo.GetChangesSince(ctx, userID, since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
// Verificar creados (habitNew, NO habitToDelete porque fue eliminado)
if len(changes.Created) != 1 {
t.Errorf("Expected 1 created habit, got %d", len(changes.Created))
}
if len(changes.Created) > 0 && changes.Created[0].Name != "New Habit" {
t.Errorf("Expected created habit name 'New Habit', got '%s'", changes.Created[0].Name)
}
// Verificar actualizados
if len(changes.Updated) != 1 {
t.Errorf("Expected 1 updated habit, got %d", len(changes.Updated))
}
if len(changes.Updated) > 0 && changes.Updated[0].Name != "Old Habit Updated" {
t.Errorf("Expected updated habit name 'Old Habit Updated', got '%s'", changes.Updated[0].Name)
}
// Verificar eliminados
if len(changes.Deleted) != 1 {
t.Errorf("Expected 1 deleted habit, got %d", len(changes.Deleted))
}
if len(changes.Deleted) > 0 && changes.Deleted[0] != habitToDelete.ID {
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitToDelete.ID, changes.Deleted[0])
}
}
func TestHabitRepository_GetChangesSince_OnlyReturnsUserHabits(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
since := time.Now()
time.Sleep(10 * time.Millisecond)
// Crear hábitos de diferentes usuarios
habitUser1 := entities.NewHabit(
"user-1",
"User 1 Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habitUser1)
habitUser2 := entities.NewHabit(
"user-2",
"User 2 Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habitUser2)
// Obtener cambios solo de user-1
changes, err := repo.GetChangesSince(ctx, "user-1", since)
if err != nil {
t.Fatalf("GetChangesSince failed: %v", err)
}
if len(changes.Created) != 1 {
t.Fatalf("Expected 1 created habit for user-1, got %d", len(changes.Created))
}
if changes.Created[0].UserID != "user-1" {
t.Errorf("Expected user ID 'user-1', got '%s'", changes.Created[0].UserID)
}
}
func TestHabitRepository_SoftDelete_MarksAsDeleted(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
// Crear hábito
habit := entities.NewHabit(
"user-123",
"To Delete",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit)
// Soft delete
err := repo.SoftDelete(ctx, habit.ID)
if err != nil {
t.Fatalf("SoftDelete failed: %v", err)
}
// El hábito NO debe aparecer en FindByID (porque está eliminado)
found, err := repo.FindByID(ctx, habit.ID)
if err == nil {
t.Error("Expected error when finding soft-deleted habit, got nil")
}
if found != nil {
t.Error("Expected nil habit when soft-deleted, got habit")
}
}
func TestHabitRepository_SoftDelete_NotFoundError(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
// Intentar eliminar hábito inexistente
err := repo.SoftDelete(ctx, "non-existent-id")
if err == nil {
t.Error("Expected error when deleting non-existent habit, got nil")
}
}
func TestHabitRepository_SoftDelete_CannotDeleteTwice(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
// Crear hábito
habit := entities.NewHabit(
"user-123",
"To Delete",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit)
// Primera eliminación
err := repo.SoftDelete(ctx, habit.ID)
if err != nil {
t.Fatalf("First SoftDelete failed: %v", err)
}
// Segunda eliminación debe fallar
err = repo.SoftDelete(ctx, habit.ID)
if err == nil {
t.Error("Expected error when deleting already deleted habit, got nil")
}
}
func TestHabitRepository_Update_UpdatesUpdatedAt(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
// Crear hábito
habit := entities.NewHabit(
"user-123",
"Original",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit)
originalUpdatedAt := habit.UpdatedAt
time.Sleep(10 * time.Millisecond)
// Actualizar
habit.Name = "Updated"
err := repo.Update(ctx, habit)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Verificar que UpdatedAt cambió
if !habit.UpdatedAt.After(originalUpdatedAt) {
t.Errorf("Expected UpdatedAt to be updated, but it wasn't. Original: %v, Current: %v",
originalUpdatedAt, habit.UpdatedAt)
}
}
func TestHabitRepository_Create_SetsUpdatedAt(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
// Crear hábito
habit := entities.NewHabit(
"user-123",
"New Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Verificar que UpdatedAt está seteado
if habit.UpdatedAt.IsZero() {
t.Error("Expected UpdatedAt to be set, got zero value")
}
// UpdatedAt debe ser igual a CreatedAt al crear
if !habit.UpdatedAt.Equal(habit.CreatedAt) {
t.Errorf("Expected UpdatedAt to equal CreatedAt on creation. UpdatedAt: %v, CreatedAt: %v",
habit.UpdatedAt, habit.CreatedAt)
}
}
func TestHabitRepository_FindActiveByUserID_ExcludesSoftDeleted(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewHabitRepository(db)
ctx := context.Background()
userID := "user-123"
// Crear 2 hábitos
habit1 := entities.NewHabit(
userID,
"Active Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit1)
habit2 := entities.NewHabit(
userID,
"Deleted Habit",
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
repo.Create(ctx, habit2)
// Soft delete uno
repo.SoftDelete(ctx, habit2.ID)
// FindActiveByUserID debe devolver solo el activo
activeHabits, err := repo.FindActiveByUserID(ctx, userID)
if err != nil {
t.Fatalf("FindActiveByUserID failed: %v", err)
}
if len(activeHabits) != 1 {
t.Fatalf("Expected 1 active habit, got %d", len(activeHabits))
}
if activeHabits[0].Name != "Active Habit" {
t.Errorf("Expected 'Active Habit', got '%s'", activeHabits[0].Name)
}
}
@@ -29,6 +29,10 @@ func RunMigrations(db *sql.DB) error {
return err
}
if err := addSyncColumns(db); err != nil {
return err
}
return nil
}
@@ -85,6 +89,98 @@ func columnExists(db *sql.DB, table, column string) (bool, error) {
return count > 0, nil
}
func indexExists(db *sql.DB, indexName string) (bool, error) {
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
var count int
err := db.QueryRow(query, indexName).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func addSyncColumns(db *sql.DB) error {
// Columns to add to habits table
habitColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
}
for _, col := range habitColumns {
exists, err := columnExists(db, "habits", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with created_at for existing records
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at: %w", err)
}
// Columns to add to habit_entries table
entryColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
}
for _, col := range entryColumns {
exists, err := columnExists(db, "habit_entries", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with completed_at for existing entries
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
}
// Create indexes for sync queries
indexes := []struct {
name string
definition string
}{
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
}
for _, idx := range indexes {
exists, err := indexExists(db, idx.name)
if err != nil {
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
}
if !exists {
if _, err := db.Exec(idx.definition); err != nil {
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
}
}
}
return nil
}
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,