package store_test import ( "context" "errors" "testing" "time" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) func mustSyncStore(t *testing.T) *store.SyncStore { t.Helper() db, err := store.Open(":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) return store.NewSyncStore(db) } func TestSyncPullNormal(t *testing.T) { s := mustSyncStore(t) ctx := context.Background() e1 := &domain.Entry{ID: "e1", DayKey: "2026-04-01", UpdatedAt: time.Now().UnixMilli()} e2 := &domain.Entry{ID: "e2", DayKey: "2026-04-02", UpdatedAt: time.Now().UnixMilli()} if err := s.LogEntry(ctx, e1); err != nil { t.Fatal(err) } if err := s.LogEntry(ctx, e2); err != nil { t.Fatal(err) } changes, ver, err := s.Pull(ctx, 0) if err != nil { t.Fatalf("Pull: %v", err) } if len(changes) != 2 { t.Fatalf("expected 2 changes, got %d", len(changes)) } if ver != 2 { t.Fatalf("expected server_version=2, got %d", ver) } // Incremental pull: since=1 should return only e2. changes2, ver2, err := s.Pull(ctx, 1) if err != nil { t.Fatal(err) } if len(changes2) != 1 || changes2[0].EntityID != "e2" { t.Fatalf("expected [e2], got %+v", changes2) } if ver2 != 2 { t.Fatalf("expected ver=2, got %d", ver2) } } func TestSyncPruneStaleClient(t *testing.T) { s := mustSyncStore(t) ctx := context.Background() // Log two entries then prune all of them (zero TTL). e1 := &domain.Entry{ID: "e1", DayKey: "2026-01-01", UpdatedAt: time.Now().UnixMilli()} e2 := &domain.Entry{ID: "e2", DayKey: "2026-01-02", UpdatedAt: time.Now().UnixMilli()} if err := s.LogEntry(ctx, e1); err != nil { t.Fatal(err) } if err := s.LogEntry(ctx, e2); err != nil { t.Fatal(err) } // Prune with -1ms TTL → cutoff is 1ms in the future, so all rows are pruned. if err := s.Prune(ctx, -time.Millisecond); err != nil { t.Fatalf("Prune: %v", err) } // A stale client (since=0) should get ErrSyncStale. _, _, err := s.Pull(ctx, 0) if !errors.Is(err, store.ErrSyncStale) { t.Fatalf("expected ErrSyncStale, got %v", err) } } func TestSyncPruneNoRows(t *testing.T) { s := mustSyncStore(t) ctx := context.Background() // Prune on empty log is a no-op. if err := s.Prune(ctx, 30*24*time.Hour); err != nil { t.Fatalf("Prune on empty log: %v", err) } changes, ver, err := s.Pull(ctx, 0) if err != nil { t.Fatalf("Pull: %v", err) } if len(changes) != 0 { t.Fatalf("expected 0 changes, got %d", len(changes)) } if ver != 0 { t.Fatalf("expected ver=0, got %d", ver) } } func TestSyncClientAheadOfMarker(t *testing.T) { s := mustSyncStore(t) ctx := context.Background() // Log two entries, prune all, then log a third. e1 := &domain.Entry{ID: "e1", DayKey: "2026-01-01", UpdatedAt: time.Now().UnixMilli()} e2 := &domain.Entry{ID: "e2", DayKey: "2026-01-02", UpdatedAt: time.Now().UnixMilli()} if err := s.LogEntry(ctx, e1); err != nil { t.Fatal(err) } if err := s.LogEntry(ctx, e2); err != nil { t.Fatal(err) } if err := s.Prune(ctx, -time.Millisecond); err != nil { t.Fatal(err) } // Marker is at version 2. Log a new entry → version 3. e3 := &domain.Entry{ID: "e3", DayKey: "2026-04-01", UpdatedAt: time.Now().UnixMilli()} if err := s.LogEntry(ctx, e3); err != nil { t.Fatal(err) } // A client with since=2 is past the marker — should get only e3. changes, ver, err := s.Pull(ctx, 2) if err != nil { t.Fatalf("expected no error for up-to-date client, got %v", err) } if len(changes) != 1 || changes[0].EntityID != "e3" { t.Fatalf("expected [e3], got %+v", changes) } if ver != 3 { t.Fatalf("expected ver=3, got %d", ver) } }