package sync import ( "path/filepath" "testing" "time" "verstak/internal/core/storage" ) func newServiceForTest(t *testing.T) (*Service, func()) { t.Helper() dir := t.TempDir() db, err := storage.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("storage.Open: %v", err) } svc := NewService(db, "test-device-001") return svc, func() { db.Close() } } func TestRecordOp(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() err := svc.RecordOp(EntityNode, "node-001", OpCreate, map[string]interface{}{ "title": "Test Node", "type": "case", }) if err != nil { t.Fatalf("RecordOp: %v", err) } // Verify the op was recorded. ops, err := svc.GetUnpushedOps() if err != nil { t.Fatalf("GetUnpushedOps: %v", err) } if len(ops) != 1 { t.Fatalf("expected 1 unpushed op, got %d", len(ops)) } if ops[0].EntityType != EntityNode { t.Errorf("EntityType = %q, want %q", ops[0].EntityType, EntityNode) } if ops[0].EntityID != "node-001" { t.Errorf("EntityID = %q, want %q", ops[0].EntityID, "node-001") } if ops[0].OpType != OpCreate { t.Errorf("OpType = %q, want %q", ops[0].OpType, OpCreate) } if ops[0].DeviceID != "test-device-001" { t.Errorf("DeviceID = %q, want %q", ops[0].DeviceID, "test-device-001") } } func TestRecordOp_MultipleOps(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() for i := 0; i < 5; i++ { id := string(rune('A' + i)) err := svc.RecordOp(EntityNode, "node-"+id, OpCreate, nil) if err != nil { t.Fatalf("RecordOp %d: %v", i, err) } } ops, err := svc.GetUnpushedOps() if err != nil { t.Fatal(err) } if len(ops) != 5 { t.Errorf("expected 5 unpushed ops, got %d", len(ops)) } } func TestRecordOp_WithNilPayload(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() err := svc.RecordOp(EntityAction, "action-001", OpDelete, nil) if err != nil { t.Fatalf("RecordOp with nil payload: %v", err) } ops, err := svc.GetUnpushedOps() if err != nil { t.Fatal(err) } if len(ops) != 1 { t.Fatalf("expected 1 op, got %d", len(ops)) } } func TestMarkPushed(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() if err := svc.RecordOp(EntityNode, "n1", OpCreate, nil); err != nil { t.Fatal(err) } if err := svc.RecordOp(EntityNode, "n2", OpCreate, nil); err != nil { t.Fatal(err) } ops, err := svc.GetUnpushedOps() if err != nil { t.Fatal(err) } if len(ops) != 2 { t.Fatalf("expected 2 unpushed ops, got %d", len(ops)) } // Mark first as pushed. if err := svc.MarkPushed([]string{ops[0].OpID}); err != nil { t.Fatal(err) } remaining, err := svc.GetUnpushedOps() if err != nil { t.Fatal(err) } if len(remaining) != 1 { t.Errorf("expected 1 remaining unpushed op, got %d", len(remaining)) } if remaining[0].OpID != ops[1].OpID { t.Errorf("remaining op should be the second one") } } func TestMarkPushed_Empty(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() err := svc.MarkPushed([]string{}) if err != nil { t.Errorf("MarkPushed with empty list: %v", err) } } func TestMarkApplied(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() now := time.Now().UTC().Format(time.RFC3339) op := Op{ OpID: "remote-op-001", DeviceID: "other-device", EntityType: EntityNode, EntityID: "node-remote", OpType: OpCreate, PayloadJSON: `{"title":"Remote Node"}`, CreatedAt: now, } if err := svc.RecordRemoteOp(op); err != nil { t.Fatal(err) } if err := svc.MarkApplied([]string{"remote-op-001"}); err != nil { t.Fatal(err) } } func TestGetState_SetState(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() // Initial state should be empty. url, key, seq, lastSync, err := svc.GetState() if err != nil { t.Fatal(err) } if url != "" || key != "" || seq != 0 || lastSync != "" { t.Errorf("expected empty initial state, got url=%q key=%q seq=%d lastSync=%q", url, key, seq, lastSync) } // Set state. if err := svc.SetState("https://sync.example.com", "test-api-key"); err != nil { t.Fatal(err) } url, key, seq, lastSync, err = svc.GetState() if err != nil { t.Fatal(err) } if url != "https://sync.example.com" { t.Errorf("url = %q, want %q", url, "https://sync.example.com") } if key != "test-api-key" { t.Errorf("api_key = %q, want %q", key, "test-api-key") } if seq != 0 { t.Errorf("expected seq=0, got %d", seq) } // lastSync could be empty initially. _ = lastSync } func TestSetState_Upsert(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() if err := svc.SetState("https://first.example.com", "key1"); err != nil { t.Fatal(err) } // Upsert with new values. if err := svc.SetState("https://second.example.com", "key2"); err != nil { t.Fatal(err) } url, key, _, _, err := svc.GetState() if err != nil { t.Fatal(err) } if url != "https://second.example.com" { t.Errorf("url after upsert = %q", url) } if key != "key2" { t.Errorf("key after upsert = %q", key) } } func TestSetLastPullSeq(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() if err := svc.SetState("https://example.com", "key"); err != nil { t.Fatal(err) } if err := svc.SetLastPullSeq(42); err != nil { t.Fatal(err) } _, _, seq, _, err := svc.GetState() if err != nil { t.Fatal(err) } if seq != 42 { t.Errorf("last_pull_seq = %d, want 42", seq) } } func TestGetDeviceID(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() if id := svc.GetDeviceID(); id != "test-device-001" { t.Errorf("GetDeviceID() = %q, want %q", id, "test-device-001") } } func TestRecordRemoteOp_Deduplicate(t *testing.T) { svc, cleanup := newServiceForTest(t) defer cleanup() now := time.Now().UTC().Format(time.RFC3339) op := Op{ OpID: "dup-op", DeviceID: "dev-1", EntityType: EntityNode, EntityID: "node-1", OpType: OpCreate, PayloadJSON: `{}`, CreatedAt: now, } // Insert the same op twice — second should be IGNORE. if err := svc.RecordRemoteOp(op); err != nil { t.Fatal(err) } if err := svc.RecordRemoteOp(op); err != nil { t.Fatal(err) } }