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") } }