Sync bug fixes across the board!
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
module ascently-sync
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/joho/godotenv v1.5.1
|
||||
|
||||
2
sync/go.sum
Normal file
2
sync/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
124
sync/main.go
124
sync/main.go
@@ -11,16 +11,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const VERSION = "2.4.0"
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
const VERSION = "2.5.0"
|
||||
|
||||
type ClimbDataBackup struct {
|
||||
ExportedAt string `json:"exportedAt"`
|
||||
@@ -41,11 +36,12 @@ type DeltaSyncRequest struct {
|
||||
}
|
||||
|
||||
type DeltaSyncResponse struct {
|
||||
ServerTime string `json:"serverTime"`
|
||||
Gyms []BackupGym `json:"gyms"`
|
||||
Problems []BackupProblem `json:"problems"`
|
||||
Sessions []BackupClimbSession `json:"sessions"`
|
||||
Attempts []BackupAttempt `json:"attempts"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
RequestFullSync bool `json:"requestFullSync,omitempty"`
|
||||
Gyms []BackupGym `json:"gyms"`
|
||||
Problems []BackupProblem `json:"problems"`
|
||||
Sessions []BackupClimbSession `json:"sessions"`
|
||||
Attempts []BackupAttempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type BackupGym struct {
|
||||
@@ -282,6 +278,81 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SyncServer) cleanupTombstones(backup *ClimbDataBackup) {
|
||||
cutoffTime := time.Now().UTC().Add(-90 * 24 * time.Hour)
|
||||
log.Printf("Cleaning up tombstones older than %s", cutoffTime.Format(time.RFC3339))
|
||||
|
||||
// Gyms
|
||||
activeGyms := make([]BackupGym, 0, len(backup.Gyms))
|
||||
for _, item := range backup.Gyms {
|
||||
if !item.IsDeleted {
|
||||
activeGyms = append(activeGyms, item)
|
||||
continue
|
||||
}
|
||||
updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt)
|
||||
if err == nil && updatedAt.After(cutoffTime) {
|
||||
activeGyms = append(activeGyms, item)
|
||||
} else {
|
||||
log.Printf("Pruning deleted gym: %s", item.ID)
|
||||
}
|
||||
}
|
||||
backup.Gyms = activeGyms
|
||||
|
||||
// Problems
|
||||
activeProblems := make([]BackupProblem, 0, len(backup.Problems))
|
||||
for _, item := range backup.Problems {
|
||||
if !item.IsDeleted {
|
||||
activeProblems = append(activeProblems, item)
|
||||
continue
|
||||
}
|
||||
updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt)
|
||||
if err == nil && updatedAt.After(cutoffTime) {
|
||||
activeProblems = append(activeProblems, item)
|
||||
} else {
|
||||
log.Printf("Pruning deleted problem: %s", item.ID)
|
||||
}
|
||||
}
|
||||
backup.Problems = activeProblems
|
||||
|
||||
// Sessions
|
||||
activeSessions := make([]BackupClimbSession, 0, len(backup.Sessions))
|
||||
for _, item := range backup.Sessions {
|
||||
if !item.IsDeleted {
|
||||
activeSessions = append(activeSessions, item)
|
||||
continue
|
||||
}
|
||||
updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt)
|
||||
if err == nil && updatedAt.After(cutoffTime) {
|
||||
activeSessions = append(activeSessions, item)
|
||||
} else {
|
||||
log.Printf("Pruning deleted session: %s", item.ID)
|
||||
}
|
||||
}
|
||||
backup.Sessions = activeSessions
|
||||
|
||||
// Attempts
|
||||
activeAttempts := make([]BackupAttempt, 0, len(backup.Attempts))
|
||||
for _, item := range backup.Attempts {
|
||||
if !item.IsDeleted {
|
||||
activeAttempts = append(activeAttempts, item)
|
||||
continue
|
||||
}
|
||||
|
||||
timeStr := item.CreatedAt
|
||||
if item.UpdatedAt != nil {
|
||||
timeStr = *item.UpdatedAt
|
||||
}
|
||||
|
||||
updatedAt, err := time.Parse(time.RFC3339, timeStr)
|
||||
if err == nil && updatedAt.After(cutoffTime) {
|
||||
activeAttempts = append(activeAttempts, item)
|
||||
} else {
|
||||
log.Printf("Pruning deleted attempt: %s", item.ID)
|
||||
}
|
||||
}
|
||||
backup.Attempts = activeAttempts
|
||||
}
|
||||
|
||||
func (s *SyncServer) saveData(backup *ClimbDataBackup) error {
|
||||
backup.ExportedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
@@ -339,6 +410,8 @@ func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.cleanupTombstones(&backup)
|
||||
|
||||
if err := s.saveData(&backup); err != nil {
|
||||
log.Printf("Failed to save data: %v", err)
|
||||
http.Error(w, "Failed to save data", http.StatusInternalServerError)
|
||||
@@ -476,14 +549,33 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
clientLastSyncCheck, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
|
||||
isServerEmpty := len(serverBackup.Gyms) == 0 && len(serverBackup.Problems) == 0 &&
|
||||
len(serverBackup.Sessions) == 0 && len(serverBackup.Attempts) == 0
|
||||
|
||||
if err == nil && !clientLastSyncCheck.IsZero() && isServerEmpty {
|
||||
log.Printf("Server is empty but client has sync history. Requesting full sync.")
|
||||
response := DeltaSyncResponse{
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
RequestFullSync: true,
|
||||
Gyms: []BackupGym{},
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge client changes into server data
|
||||
// Note: We no longer need separate deletion handling as IsDeleted is part of the struct
|
||||
// and handled by standard merge logic (latest timestamp wins)
|
||||
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
|
||||
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
|
||||
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
|
||||
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
|
||||
|
||||
s.cleanupTombstones(serverBackup)
|
||||
|
||||
// Save merged data
|
||||
if err := s.saveData(serverBackup); err != nil {
|
||||
log.Printf("Failed to save data: %v", err)
|
||||
@@ -565,7 +657,9 @@ func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
authToken := os.Getenv("AUTH_TOKEN")
|
||||
print(authToken)
|
||||
if authToken == "" {
|
||||
log.Fatal("AUTH_TOKEN environment variable is required")
|
||||
}
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user