Files
sub2api/backend/internal/pkg/requestmodel/requestmodel_test.go
T

258 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package requestmodel
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
)
func TestFromBodyJSONModel(t *testing.T) {
if got := FromBody("application/json", []byte(`{"model":"gpt-5.4"}`)); got != "gpt-5.4" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyJSONSessionModel(t *testing.T) {
if got := FromBody("application/json", []byte(`{"session":{"model":"gpt-live"},"sdp":"v=0"}`)); got != "gpt-live" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyJSONModelWinsOverSession(t *testing.T) {
body := []byte(`{"model":"top-level","session":{"model":"nested"}}`)
if got := FromBody("application/json", body); got != "top-level" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyJSONBlankModelFallsThrough(t *testing.T) {
if got := FromBody("application/json", []byte(`{"model":" "}`)); got != "" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyMultipartModelField(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("prompt", "draw")
_ = writer.WriteField("model", "gpt-image-1")
_ = writer.Close()
if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyMultipartSessionField(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("sdp", "v=0")
_ = writer.WriteField("session", `{"model":"live-alias"}`)
_ = writer.WriteField("media", "file-part") // 字段名不匹配,应被跳过
_ = writer.Close()
if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "live-alias" {
t.Fatalf("got %q", got)
}
}
func TestFromBodySkipsFilePartsBeforeModelField(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "input.png")
if err != nil {
t.Fatalf("create form file: %v", err)
}
_, _ = part.Write(bytes.Repeat([]byte{0}, 4096))
_ = writer.WriteField("model", "gpt-image-1")
_ = writer.Close()
if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" {
t.Fatalf("got %q", got)
}
}
func TestFromBodyNoModelReturnsEmpty(t *testing.T) {
if got := FromBody("application/json", []byte(`{"input":"hi"}`)); got != "" {
t.Fatalf("got %q", got)
}
if got := FromBody("text/plain", []byte("plain")); got != "" {
t.Fatalf("got %q", got)
}
}
func TestResetRequestBodyAllowsRereadWithoutCopy(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":"hi"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
ResetRequestBody(req, body)
got, err := httputil.ReadRequestBodyWithPrealloc(req)
if err != nil {
t.Fatalf("reread: %v", err)
}
if &got[0] != &body[0] {
t.Fatalf("expected zero-copy reread of the same slice")
}
if req.ContentLength != int64(len(body)) {
t.Fatalf("content length mismatch: %d", req.ContentLength)
}
if req.Header.Get("Content-Length") != strconv.Itoa(len(body)) {
t.Fatalf("content-length header mismatch: %q", req.Header.Get("Content-Length"))
}
}
func TestResetRequestBodyNilRequestIsNoop(t *testing.T) {
ResetRequestBody(nil, []byte("x"))
}
func TestFromBodyForRouteLivePrefersSessionModel(t *testing.T) {
body := []byte(`{"model":"top-level","session":{"model":"session-model"},"sdp":"v=0"}`)
if got := FromBodyForRoute("/v1/live", "application/json", body); got != "session-model" {
t.Fatalf("live route should prefer session.model, got %q", got)
}
if got := FromBodyForRoute("/backend-api/codex/realtime/calls", "application/json", body); got != "session-model" {
t.Fatalf("codex realtime route should prefer session.model, got %q", got)
}
if got := FromBodyForRoute("/live", "application/json", body); got != "session-model" {
t.Fatalf("root live alias should prefer session.model, got %q", got)
}
}
func TestFromBodyForRouteNonLivePrefersTopLevelModel(t *testing.T) {
body := []byte(`{"model":"top-level","session":{"model":"session-model"}}`)
if got := FromBodyForRoute("/v1/messages", "application/json", body); got != "top-level" {
t.Fatalf("non-live route should prefer top-level model, got %q", got)
}
if got := FromBodyForRoute("/v1/responses", "application/json", body); got != "top-level" {
t.Fatalf("responses route should prefer top-level model, got %q", got)
}
}
func TestFromBodyForRouteFallsBackWhenPreferredFieldMissing(t *testing.T) {
if got := FromBodyForRoute("/v1/live", "application/json", []byte(`{"model":"top-level"}`)); got != "top-level" {
t.Fatalf("live without session.model should fall back to model, got %q", got)
}
if got := FromBodyForRoute("/v1/messages", "application/json", []byte(`{"session":{"model":"session-model"}}`)); got != "session-model" {
t.Fatalf("non-live without model should fall back to session.model, got %q", got)
}
}
func TestFromBodyForRouteMultipartSessionFirstForLive(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", "top-level")
_ = writer.WriteField("session", `{"model":"session-model"}`)
_ = writer.Close()
contentType := writer.FormDataContentType()
if got := FromBodyForRoute("/v1/live", contentType, body.Bytes()); got != "session-model" {
t.Fatalf("live multipart should prefer session field, got %q", got)
}
if got := FromBodyForRoute("/v1/messages", contentType, body.Bytes()); got != "top-level" {
t.Fatalf("non-live multipart should prefer model field, got %q", got)
}
}
func TestIsLiveRequestRoute(t *testing.T) {
for _, path := range []string{"/v1/live", "/live", "/backend-api/codex/realtime/calls"} {
if !IsLiveRequestRoute(path) {
t.Fatalf("%s should be a live route", path)
}
}
for _, path := range []string{"/v1/messages", "/v1/responses", "/realtime", ""} {
if IsLiveRequestRoute(path) {
t.Fatalf("%s should not be a live route", path)
}
}
}
// gjson 的部分扫描会在 multipart 字节流里误匹配 session JSON 内的 model;
// 声明为 multipart 的请求体必须走 multipart 解析。
func TestFromBodyMultipartContentTypeSkipsJSONScan(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", "top-level")
_ = writer.WriteField("session", `{"model":"session-model"}`)
_ = writer.Close()
if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "top-level" {
t.Fatalf("multipart body should use the model field, got %q", got)
}
if got := FromBodyForRoute("/v1/live", writer.FormDataContentType(), body.Bytes()); got != "session-model" {
t.Fatalf("live multipart should use the session field, got %q", got)
}
}
func TestFromBodyCandidatesIncludesCaseVariantsAndDuplicates(t *testing.T) {
// encoding/json 绑定对键名大小写不敏感、重复键取末值;gjson 取首个。
// 候选集必须包含全部出现。
got := FromBodyCandidates("", "application/json", []byte(`{"Model":"caps-model"}`))
if len(got) != 1 || got[0] != "caps-model" {
t.Fatalf("case-variant key must be a candidate, got %#v", got)
}
got = FromBodyCandidates("", "application/json", []byte(`{"model":"first","model":"last"}`))
if len(got) != 2 || got[0] != "first" || got[1] != "last" {
t.Fatalf("duplicate keys must both be candidates, got %#v", got)
}
}
func TestFromBodyCandidatesSessionVariants(t *testing.T) {
body := []byte(`{"Session":{"Model":"s-caps"},"session":{"model":"s-lower"},"model":"top"}`)
// 非 Live 入口:顶层候选。
got := FromBodyCandidates("", "application/json", body)
if len(got) != 1 || got[0] != "top" {
t.Fatalf("non-live should use top-level candidates, got %#v", got)
}
// Live 入口:session 候选(含大小写变体与重复)。
got = FromBodyCandidates("/v1/live", "application/json", body)
if len(got) != 2 || got[0] != "s-caps" || got[1] != "s-lower" {
t.Fatalf("live should collect all session candidates, got %#v", got)
}
}
func TestFromBodyCandidatesMultipartDuplicateFields(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", "gpt-image-1")
_ = writer.WriteField("model", "gpt-image-1.5")
_ = writer.WriteField("session", `{"model":"live-a"}`)
_ = writer.WriteField("session", `{"model":"live-b"}`)
_ = writer.Close()
models, sessions := multipartModelCandidates(writer.FormDataContentType(), body.Bytes())
if len(models) != 2 || models[0] != "gpt-image-1" || models[1] != "gpt-image-1.5" {
t.Fatalf("duplicate model fields must all be candidates, got %#v", models)
}
if len(sessions) != 2 {
t.Fatalf("duplicate session fields must all be captured, got %#v", sessions)
}
got := FromBodyCandidates("", writer.FormDataContentType(), body.Bytes())
if len(got) != 2 || got[0] != "gpt-image-1" || got[1] != "gpt-image-1.5" {
t.Fatalf("non-live multipart candidates mismatch, got %#v", got)
}
got = FromBodyCandidates("/v1/live", writer.FormDataContentType(), body.Bytes())
if len(got) != 2 || got[0] != "live-a" || got[1] != "live-b" {
t.Fatalf("live multipart session candidates mismatch, got %#v", got)
}
}
func TestFromBodyForRouteReturnsFirstCandidate(t *testing.T) {
got := FromBodyForRoute("", "application/json", []byte(`{"model":"first","model":"last"}`))
if got != "first" {
t.Fatalf("dispatch helper keeps gjson-first semantics, got %q", got)
}
}