Files
Ascently/sync/sync_test.go
Atridad Lahiji 8c4a78ad50
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m32s
2.2.0 - Final Builds
2025-10-18 23:02:31 -06:00

502 lines
17 KiB
Go

package main
import (
"path/filepath"
"testing"
"time"
)
// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect
func TestDeltaSyncDeletedItemResurrection(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
// Initial state: Server has one gym, one problem, one session with 8 attempts
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: gymID,
Name: "Test Gym",
SupportedClimbTypes: []string{"BOULDER"},
DifficultySystems: []string{"V"},
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Problems: []BackupProblem{
{
ID: problemID,
GymID: gymID,
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Sessions: []BackupClimbSession{
{
ID: sessionID,
GymID: gymID,
Date: now.Format("2006-01-02"),
Status: "completed",
CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
},
},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client 1 syncs - gets all data
client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339)
deltaRequest1 := DeltaSyncRequest{
LastSyncTime: client1LastSync,
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Simulate delta sync for client 1
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
if len(serverBackup.Sessions) != 1 {
t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 8 {
t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts))
}
// Client 1 deletes the session locally
deleteTime := now.Format(time.RFC3339)
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: deleteTime},
}
// Also track attempt deletions
for _, attempt := range initialBackup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: deleteTime,
})
}
// Client 1 syncs deletion
deltaRequest2 := DeltaSyncRequest{
LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: deletions,
}
// Server processes deletion
serverBackup, _ = server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Verify deletions were applied on server
serverBackup, _ = server.loadData()
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) != 9 {
t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems))
}
// Client does local reset and pulls from server
deltaRequest3 := DeltaSyncRequest{
LastSyncTime: time.Time{}.Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
serverBackup, _ = server.loadData()
clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime)
// Build response
response := DeltaSyncResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Build deleted item map
deletedItemMap := make(map[string]bool)
for _, item := range serverBackup.DeletedItems {
key := item.Type + ":" + item.ID
deletedItemMap[key] = true
}
// Filter sessions (excluding deleted)
for _, session := range serverBackup.Sessions {
if deletedItemMap["session:"+session.ID] {
continue
}
sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt)
if sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
}
}
// Filter attempts (excluding deleted)
for _, attempt := range serverBackup.Attempts {
if deletedItemMap["attempt:"+attempt.ID] {
continue
}
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)
}
}
// Send deletion records
for _, deletion := range serverBackup.DeletedItems {
deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt)
if deletionTime.After(clientLastSync) {
response.DeletedItems = append(response.DeletedItems, deletion)
}
}
if len(response.Sessions) != 0 {
t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions))
}
if len(response.Attempts) != 0 {
t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts))
}
if len(response.DeletedItems) < 9 {
t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems))
}
}
// TestDeltaSyncAttemptCount verifies all attempts are preserved
func TestDeltaSyncAttemptCount(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
// Create session with 8 attempts
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts at different times
baseTime := now.Add(-30 * time.Minute)
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client syncs with lastSyncTime BEFORE all attempts were created
clientLastSync := baseTime.Add(-1 * time.Hour)
serverBackup, _ := server.loadData()
// Count attempts that should be returned
attemptCount := 0
for _, attempt := range serverBackup.Attempts {
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
attemptCount++
}
}
if attemptCount != 8 {
t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount)
}
}
// TestTombstoneCleanup verifies old deletion records are cleaned up
func TestTombstoneCleanup(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
oldDeletion := DeletedItem{
ID: "old-item",
Type: "session",
DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old
}
recentDeletion := DeletedItem{
ID: "recent-item",
Type: "session",
DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old
}
existing := []DeletedItem{oldDeletion}
updates := []DeletedItem{recentDeletion}
merged := server.mergeDeletedItems(existing, updates)
// Old deletion should be cleaned up, only recent one remains
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged))
}
if len(merged) > 0 && merged[0].ID != "recent-item" {
t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID)
}
}
// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled
func TestMergeDeletedItemsDeduplication(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
deletion1 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
}
deletion2 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Format(time.RFC3339), // Newer timestamp
}
existing := []DeletedItem{deletion1}
updates := []DeletedItem{deletion2}
merged := server.mergeDeletedItems(existing, updates)
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record, got %d", len(merged))
}
if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt {
t.Errorf("Expected newer deletion timestamp to be kept")
}
}
// TestApplyDeletions verifies deletions are applied correctly
func TestApplyDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}},
DeletedItems: []DeletedItem{},
}
deletions := []DeletedItem{
{ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)},
{ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)},
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts))
}
if len(backup.Gyms) != 1 {
t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms))
}
if len(backup.Problems) != 1 {
t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems))
}
}
// TestCascadingDeletions verifies related items are handled properly
func TestCascadingDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
sessionID := "session-1"
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add multiple attempts for the session
for i := 0; i < 5; i++ {
backup.Attempts = append(backup.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
})
}
// Delete session - attempts should also be tracked as deleted
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)},
}
for _, attempt := range backup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: now.Format(time.RFC3339),
})
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts))
}
}
// TestFullSyncAfterReset verifies the reported user scenario
func TestFullSyncAfterReset(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
// Initial sync with data
initialData := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
for i := 0; i < 8; i++ {
initialData.Attempts = append(initialData.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: "session-1",
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
server.saveData(initialData)
// Client deletes everything and syncs
deletions := []DeletedItem{
{ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
}
for i := 0; i < 8; i++ {
deletions = append(deletions, DeletedItem{
ID: "attempt-" + string(rune('1'+i)),
Type: "attempt",
DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339),
})
}
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Client does local reset and pulls from server
serverBackup, _ = server.loadData()
if len(serverBackup.Gyms) != 0 {
t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms))
}
if len(serverBackup.Problems) != 0 {
t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems))
}
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) == 0 {
t.Errorf("Expected deletion records, got 0")
}
}