verstak/internal/core/sync/sync_test.go

279 lines
6.0 KiB
Go

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