279 lines
6.0 KiB
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)
|
|
}
|
|
}
|