237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestJsonRawMessageToString(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data json.RawMessage
|
|
want string
|
|
}{
|
|
{
|
|
name: "object",
|
|
data: json.RawMessage(`{"city":"Paris","days":0,"strict":false}`),
|
|
want: `{"city":"Paris","days":0,"strict":false}`,
|
|
},
|
|
{
|
|
name: "string",
|
|
data: json.RawMessage(`"{\"city\":\"Paris\",\"days\":0,\"strict\":false}"`),
|
|
want: `{"city":"Paris","days":0,"strict":false}`,
|
|
},
|
|
{
|
|
name: "null",
|
|
data: json.RawMessage(`null`),
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty",
|
|
data: nil,
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.Equal(t, tt.want, JsonRawMessageToString(tt.data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeJsonWithValidation(t *testing.T) {
|
|
type request struct {
|
|
Code string `json:"code" binding:"required"`
|
|
}
|
|
for _, test := range []struct {
|
|
name, body string
|
|
validationError bool
|
|
decodeError bool
|
|
}{
|
|
{name: "valid", body: `{"code":"123456"}`},
|
|
{name: "missing required field", body: `{}`, validationError: true},
|
|
{name: "empty required field", body: `{"code":""}`, validationError: true},
|
|
{name: "malformed JSON", body: `{"code":`, decodeError: true},
|
|
{name: "wrong field type", body: `{"code":123456}`, decodeError: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var value request
|
|
err := DecodeJsonWithValidation(strings.NewReader(test.body), &value)
|
|
if test.validationError {
|
|
var validationErrors validator.ValidationErrors
|
|
require.ErrorAs(t, err, &validationErrors)
|
|
require.Len(t, validationErrors, 1)
|
|
assert.Equal(t, "Code", validationErrors[0].Field())
|
|
assert.Equal(t, "required", validationErrors[0].Tag())
|
|
return
|
|
}
|
|
if test.decodeError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "123456", value.Code)
|
|
})
|
|
}
|
|
// Existing decoding callers opt into validation explicitly.
|
|
var unvalidated request
|
|
require.NoError(t, DecodeJson(strings.NewReader(`{}`), &unvalidated))
|
|
}
|
|
|
|
// TestHostJSONCodecConformance runs through the codec injected by common's
|
|
// init() and locks the encoding semantics the relay DTOs depend on. A future
|
|
// engine swap in hostJSONCodec must keep every case here green.
|
|
func TestHostJSONCodecConformance(t *testing.T) {
|
|
type embedded struct {
|
|
Content any `json:"content"`
|
|
}
|
|
type shadowed struct {
|
|
embedded
|
|
Content any `json:"content,omitempty"`
|
|
}
|
|
type anyFields struct {
|
|
Nil any `json:"nil,omitempty"`
|
|
Str any `json:"str,omitempty"`
|
|
Int any `json:"int,omitempty"`
|
|
Bool any `json:"bool,omitempty"`
|
|
}
|
|
type rawFields struct {
|
|
Obj json.RawMessage `json:"obj"`
|
|
Arr json.RawMessage `json:"arr"`
|
|
Nested json.RawMessage `json:"nested"`
|
|
Str json.RawMessage `json:"str"`
|
|
}
|
|
type numberField struct {
|
|
N json.Number `json:"n"`
|
|
}
|
|
type pointerZeros struct {
|
|
Count *int `json:"count,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
zero := 0
|
|
off := false
|
|
|
|
t.Run("marshal", func(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
in any
|
|
want string
|
|
}{
|
|
{
|
|
name: "shallowest field shadows embedded content and nil is omitted",
|
|
in: shadowed{embedded: embedded{Content: "inner"}},
|
|
want: `{}`,
|
|
},
|
|
{
|
|
name: "shallowest field keeps empty string content",
|
|
in: shadowed{embedded: embedded{Content: "inner"}, Content: ""},
|
|
want: `{"content":""}`,
|
|
},
|
|
{
|
|
name: "omitempty on any drops nil but keeps zero values",
|
|
in: anyFields{Str: "", Int: 0, Bool: false},
|
|
want: `{"str":"","int":0,"bool":false}`,
|
|
},
|
|
{
|
|
name: "map keys are sorted",
|
|
in: map[string]any{"z": 1, "a": 2, "m": 3},
|
|
want: `{"a":2,"m":3,"z":1}`,
|
|
},
|
|
{
|
|
name: "html characters are escaped",
|
|
in: map[string]string{"s": `<a href="x">&</a>`},
|
|
want: `{"s":"\u003ca href=\"x\"\u003e\u0026\u003c/a\u003e"}`,
|
|
},
|
|
{
|
|
name: "explicit pointer zeros are kept",
|
|
in: pointerZeros{Count: &zero, Enabled: &off},
|
|
want: `{"count":0,"enabled":false}`,
|
|
},
|
|
{
|
|
name: "nil pointers are omitted",
|
|
in: pointerZeros{},
|
|
want: `{}`,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
encoded, err := Marshal(tt.in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, string(encoded))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("raw message passthrough", func(t *testing.T) {
|
|
input := `{"obj":{},"arr":[],"nested":{"k":[1,2]},"str":"x"}`
|
|
var value rawFields
|
|
require.NoError(t, UnmarshalJsonStr(input, &value))
|
|
assert.Equal(t, `{}`, string(value.Obj))
|
|
assert.Equal(t, `[]`, string(value.Arr))
|
|
assert.Equal(t, `{"k":[1,2]}`, string(value.Nested))
|
|
assert.Equal(t, `"x"`, string(value.Str))
|
|
encoded, err := Marshal(value)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, input, string(encoded))
|
|
})
|
|
|
|
t.Run("json.Number keeps large integers exact", func(t *testing.T) {
|
|
input := `{"n":18446744073686646784}`
|
|
var value numberField
|
|
require.NoError(t, Unmarshal([]byte(input), &value))
|
|
assert.Equal(t, json.Number("18446744073686646784"), value.N)
|
|
encoded, err := Marshal(value)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, input, string(encoded))
|
|
})
|
|
|
|
t.Run("explicit zeros survive unmarshal into pointers", func(t *testing.T) {
|
|
var value pointerZeros
|
|
require.NoError(t, Unmarshal([]byte(`{"count":0,"enabled":false}`), &value))
|
|
require.NotNil(t, value.Count)
|
|
require.NotNil(t, value.Enabled)
|
|
assert.Equal(t, 0, *value.Count)
|
|
assert.False(t, *value.Enabled)
|
|
|
|
var absent pointerZeros
|
|
require.NoError(t, Unmarshal([]byte(`{}`), &absent))
|
|
assert.Nil(t, absent.Count)
|
|
assert.Nil(t, absent.Enabled)
|
|
})
|
|
|
|
t.Run("relaykit DTO round trip", func(t *testing.T) {
|
|
raw := []byte(`{
|
|
"model":"kimi-k3",
|
|
"messages":[
|
|
{"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]},
|
|
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]}
|
|
]
|
|
}`)
|
|
var req dto.GeneralOpenAIRequest
|
|
require.NoError(t, Unmarshal(raw, &req))
|
|
encoded, err := Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
messages := gjson.GetBytes(encoded, "messages").Array()
|
|
require.Len(t, messages, 2)
|
|
|
|
// Kimi K3 dynamic tool loading: tools survive and no content key is emitted.
|
|
assert.Equal(t, "system", messages[0].Get("role").String())
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "messages.0.tools").Raw, messages[0].Get("tools").Raw)
|
|
assert.False(t, messages[0].Get("content").Exists())
|
|
|
|
// Assistant tool-call replay still carries an explicit "content": null.
|
|
assistantContent := messages[1].Get("content")
|
|
assert.True(t, assistantContent.Exists())
|
|
assert.Equal(t, gjson.Null, assistantContent.Type)
|
|
assert.JSONEq(t, gjson.GetBytes(raw, "messages.1.tool_calls").Raw, messages[1].Get("tool_calls").Raw)
|
|
})
|
|
}
|