uploader_test.go
1 package sync 2 3 import ( 4 "context" 5 "encoding/json" 6 "net/http" 7 "net/http/httptest" 8 "os" 9 "path/filepath" 10 "testing" 11 "time" 12 13 "github.com/Kocoro-lab/ShanClaw/internal/client" 14 ) 15 16 func TestDryRunUploader_WritesOutboxAndAcceptsAll(t *testing.T) { 17 dir := t.TempDir() 18 u := &DryRunUploader{OutboxDir: dir, Now: func() time.Time { 19 return time.Date(2026, 4, 19, 3, 0, 0, 0, time.UTC) 20 }} 21 22 batch := client.SyncBatchRequest{ 23 ClientVersion: "shanclaw/test", 24 SyncAt: time.Date(2026, 4, 19, 3, 0, 0, 0, time.UTC), 25 Sessions: []client.SessionEnvelope{ 26 {AgentName: "", Session: json.RawMessage(`{"id":"a"}`)}, 27 {AgentName: "ops-bot", Session: json.RawMessage(`{"id":"b"}`)}, 28 }, 29 } 30 31 resp, err := u.Send(context.Background(), batch) 32 if err != nil { 33 t.Fatalf("Send: %v", err) 34 } 35 if len(resp.Accepted) != 2 { 36 t.Errorf("expected all sessions accepted in dry-run, got %d", len(resp.Accepted)) 37 } 38 if len(resp.Rejected) != 0 { 39 t.Errorf("expected no rejections, got %d", len(resp.Rejected)) 40 } 41 42 entries, err := os.ReadDir(dir) 43 if err != nil || len(entries) != 1 { 44 t.Fatalf("expected exactly one outbox file, got %v err=%v", entries, err) 45 } 46 body, _ := os.ReadFile(filepath.Join(dir, entries[0].Name())) 47 if !json.Valid(body) { 48 t.Errorf("outbox file is not valid JSON: %s", body) 49 } 50 } 51 52 func TestNormalizeResponse_UnknownAcceptedIDDropped(t *testing.T) { 53 batch := client.SyncBatchRequest{ 54 Sessions: []client.SessionEnvelope{ 55 {Session: json.RawMessage(`{"id":"a"}`)}, 56 }, 57 } 58 raw := client.SyncBatchResponse{ 59 Accepted: []string{"a", "ghost"}, 60 } 61 out := normalizeResponse(batch, raw) 62 if len(out.Accepted) != 1 || out.Accepted[0] != "a" { 63 t.Errorf("expected only [a]; got %v", out.Accepted) 64 } 65 } 66 67 func TestCloudUploader_EmptyResponseIsTransportError(t *testing.T) { 68 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 69 w.WriteHeader(200) 70 _, _ = w.Write([]byte(`{}`)) 71 })) 72 defer srv.Close() 73 74 u := &CloudUploader{Client: client.NewGatewayClient(srv.URL, "k")} 75 _, err := u.Send(context.Background(), client.SyncBatchRequest{ 76 Sessions: []client.SessionEnvelope{ 77 {Session: json.RawMessage(`{"id":"a"}`)}, 78 }, 79 }) 80 if err == nil { 81 t.Fatalf("empty 200 response with non-empty batch must be a transport error, got nil") 82 } 83 } 84 85 func TestNormalizeResponse_DuplicatesDeduped(t *testing.T) { 86 batch := client.SyncBatchRequest{ 87 Sessions: []client.SessionEnvelope{ 88 {Session: json.RawMessage(`{"id":"a"}`)}, 89 {Session: json.RawMessage(`{"id":"b"}`)}, 90 }, 91 } 92 raw := client.SyncBatchResponse{ 93 Accepted: []string{"a", "a", "b"}, 94 Rejected: []client.RejectedEntry{ 95 {ID: "b", Reason: "x"}, // b is in BOTH lists 96 }, 97 } 98 out := normalizeResponse(batch, raw) 99 if len(out.Accepted) != 1 || out.Accepted[0] != "a" { 100 t.Errorf("Accepted should dedupe to [a]; got %v", out.Accepted) 101 } 102 if len(out.Rejected) != 1 || out.Rejected[0].ID != "b" { 103 t.Errorf("b should be in Rejected; got %v", out.Rejected) 104 } 105 if out.Rejected[0].Reason != "cloud_inconsistent_response" { 106 t.Errorf("conflict reason: got %q, want cloud_inconsistent_response", out.Rejected[0].Reason) 107 } 108 }