/ internal / sync / uploader_test.go
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  }