diff --git a/web/service/client.go b/web/service/client.go index 1109da6c..9086f80b 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -24,6 +24,35 @@ type ClientWithAttachments struct { Traffic *xray.ClientTraffic `json:"traffic,omitempty"` } +// MarshalJSON is required because model.ClientRecord defines its own +// MarshalJSON. Go promotes the embedded method to the outer struct, so without +// this the encoder would call ClientRecord.MarshalJSON for the whole value and +// silently drop InboundIds and Traffic from the API response. +func (c ClientWithAttachments) MarshalJSON() ([]byte, error) { + rec, err := json.Marshal(c.ClientRecord) + if err != nil { + return nil, err + } + extras := struct { + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` + }{InboundIds: c.InboundIds, Traffic: c.Traffic} + extra, err := json.Marshal(extras) + if err != nil { + return nil, err + } + if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 { + return rec, nil + } + out := make([]byte, 0, len(rec)+len(extra)) + out = append(out, rec[:len(rec)-1]...) + if len(rec) > 2 { + out = append(out, ',') + } + out = append(out, extra[1:]...) + return out, nil +} + func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string { if rec == nil { return "" diff --git a/web/service/client_test.go b/web/service/client_test.go new file mode 100644 index 00000000..2cf8b219 --- /dev/null +++ b/web/service/client_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/xray" +) + +func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) { + c := ClientWithAttachments{ + ClientRecord: model.ClientRecord{Id: 1, Email: "alice@example.com"}, + InboundIds: []int{3, 5}, + Traffic: &xray.ClientTraffic{Email: "alice@example.com", Up: 1024, Down: 4096, Enable: true}, + } + out, err := json.Marshal(c) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if parsed["email"] != "alice@example.com" { + t.Errorf("expected ClientRecord fields to survive, got %v", parsed) + } + ids, ok := parsed["inboundIds"].([]any) + if !ok { + t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out) + } + if len(ids) != 2 { + t.Errorf("expected 2 inbound ids, got %d", len(ids)) + } + if _, ok := parsed["traffic"].(map[string]any); !ok { + t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"]) + } +} + +func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) { + c := ClientWithAttachments{ + ClientRecord: model.ClientRecord{Id: 1, Email: "bob@example.com"}, + InboundIds: nil, + } + out, err := json.Marshal(c) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if _, present := parsed["traffic"]; present { + t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"]) + } + if _, present := parsed["inboundIds"]; !present { + t.Errorf("expected inboundIds key to always be present, got %s", out) + } +}