package backendrpc import ( "bufio" "context" "encoding/json" "errors" "net" "testing" "time" ) type testRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } type testEnvelope struct { OK bool `json:"ok"` Status string `json:"status"` Data any `json:"data,omitempty"` Error any `json:"error,omitempty"` RequestID string `json:"request_id"` } type testResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *JSONRPCError `json:"error,omitempty"` } func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client { t.Helper() client := New("bat.sock") client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { clientConn, serverConn := net.Pipe() go func(conn net.Conn) { defer conn.Close() line, err := bufio.NewReader(conn).ReadBytes('\n') if err != nil { return } var req testRequest if err := json.Unmarshal(line, &req); err != nil { return } resp := handler(t, req) if len(resp.ID) == 0 { resp.ID = req.ID } if resp.JSONRPC == "" { resp.JSONRPC = jsonRPCVersion } _ = json.NewEncoder(conn).Encode(resp) }(serverConn) return clientConn, nil } return client } func TestResourceRepairQueuesTask(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "resource.repair" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: true, Status: "accepted", RequestID: "req-test-1", Data: map[string]any{ "task_id": "task-1", "kind": "resource.repair", }, }, } }) task, err := client.ResourceRepair(context.Background()) if err != nil { t.Fatalf("ResourceRepair error: %v", err) } if task.TaskID != "task-1" || task.Kind != "resource.repair" { t.Fatalf("unexpected task: %#v", task) } } func TestDaemonRestartSendsControlMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "daemon.restart" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: true, Status: "accepted", RequestID: "req-test-restart", Data: map[string]any{ "command": "restart", "status": "accepted", "message": "restart accepted", "state_dir": "/tmp/bat-pid", "socket_path": "/tmp/bat-pid/bat.sock", "controller_pid": 4242, }, }, } }) ack, err := client.DaemonRestart(context.Background()) if err != nil { t.Fatalf("DaemonRestart error: %v", err) } if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 { t.Fatalf("unexpected ack: %#v", ack) } } func TestParseTextUnitsSendsQuery(t *testing.T) { pathID := int64(7) classID := 114 client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "parse.text_units" { t.Fatalf("method = %s", req.Method) } var params TextUnitQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID { t.Fatalf("params = %#v", params) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-parse", Data: map[string]any{ "available": true, "entries": []any{}, }, }, } }) raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{ Offset: 3, Limit: 5, Destination: "*Table*", PathID: &pathID, ClassID: &classID, }) if err != nil { t.Fatalf("ParseTextUnits error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "unityfs.patch_field" { t.Fatalf("method = %s", req.Method) } var params UnityFSFieldPatchParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` { t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement)) } return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-unityfs", Data: map[string]any{ "command": "unityfs.patch_field", "status": "completed", }, }, } }) raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{ BundlePath: "/tmp/source.bundle", SerializedFilePath: "CAB-test", PathID: 1, FieldPath: "m_Name", Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`), TargetPath: "/tmp/target.bundle", }) if err != nil { t.Fatalf("UnityFSPatchField error: %v", err) } if !json.Valid(raw) { t.Fatalf("invalid raw JSON: %s", string(raw)) } } func TestResourceListSendsPagination(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "resource.list" { t.Fatalf("method = %s", req.Method) } var params pageParam if err := json.Unmarshal(req.Params, ¶ms); err != nil { t.Fatalf("decode params: %v", err) } if params.Offset != 2 || params.Limit != 1 { t.Fatalf("params = %#v", params) } bytes := uint64(42) return testResponse{ Result: testEnvelope{ OK: true, Status: "ok", RequestID: "req-test-2", Data: map[string]any{ "available": true, "resource_root": "/tmp/resources/current", "manifest_version": 1, "total_entries": 3, "offset": 2, "limit": 1, "entries": []map[string]any{ { "url": "https://example.invalid/a.zip", "destination": "a.zip", "bytes": bytes, "blake3": "abc", }, }, }, }, } }) page, err := client.ResourceList(context.Background(), 2, 1) if err != nil { t.Fatalf("ResourceList error: %v", err) } if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 { t.Fatalf("unexpected page: %#v", page) } if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 { t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes) } } func TestApplicationErrorReturnsAPIError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "task.status" { t.Fatalf("method = %s", req.Method) } return testResponse{ Result: testEnvelope{ OK: false, Status: "error", Error: APIError{ Code: "BAT-ERR-700004", Kind: "task_not_found", Domain: "rpc", Location: "task.status", Message: "任务不存在:missing", Retryable: false, }, RequestID: "req-test-3", }, } }) _, err := client.TaskStatus(context.Background(), "missing") var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError, got %T %v", err, err) } if apiErr.Code != "BAT-ERR-700004" { t.Fatalf("code = %s", apiErr.Code) } } func TestTransportErrorReturnsJSONRPCError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { return testResponse{ Error: &JSONRPCError{ Code: -32700, Message: "parse error", }, } }) _, err := client.DaemonStatus(context.Background()) var rpcErr *JSONRPCError if !errors.As(err, &rpcErr) { t.Fatalf("expected JSONRPCError, got %T %v", err, err) } if rpcErr.Code != -32700 { t.Fatalf("code = %d", rpcErr.Code) } } func TestContextDeadlineIsApplied(t *testing.T) { client := New("bat.sock") client.Timeout = time.Millisecond client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { <-ctx.Done() return nil, ctx.Err() } ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) defer cancel() _, err := client.DaemonStatus(ctx) if err == nil { t.Fatal("expected dial error") } }