168 lines
4.5 KiB
Go
168 lines
4.5 KiB
Go
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 client with an existing sync position behind the marker is stale.
|
|
_, _, err := s.Pull(ctx, 1)
|
|
if !errors.Is(err, store.ErrSyncStale) {
|
|
t.Fatalf("expected ErrSyncStale, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSyncBootstrapAfterPruneReturnsLatestData(t *testing.T) {
|
|
s := mustSyncStore(t)
|
|
ctx := context.Background()
|
|
|
|
old := &domain.Entry{ID: "old", DayKey: "2026-01-01", UpdatedAt: time.Now().Add(-31 * 24 * time.Hour).UnixMilli()}
|
|
latest := &domain.Entry{ID: "latest", DayKey: "2026-02-01", UpdatedAt: time.Now().UnixMilli()}
|
|
if err := s.LogEntry(ctx, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Prune(ctx, -time.Millisecond); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.LogEntry(ctx, latest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A zero-version client has no history to reconcile. It should receive
|
|
// current data rather than being rejected because older history was pruned.
|
|
changes, _, err := s.Pull(ctx, 0)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap should return latest data after pruning, got %v", err)
|
|
}
|
|
if len(changes) != 1 || changes[0].EntityID != "latest" {
|
|
t.Fatalf("expected only latest data, got %+v", changes)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|