aa8f7af55d
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 ✓
531 lines
12 KiB
Go
531 lines
12 KiB
Go
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)
|
|
}
|
|
}
|