first commit
This commit is contained in:
306
tests/async/validation_test.go
Normal file
306
tests/async/validation_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package async
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// streamEndpoints lists async endpoints that reject stream=true in the JSON body.
|
||||
// Speech uses stream_format instead and is tested separately.
|
||||
// image_edits and image_variations are multipart-only endpoints; their stream field
|
||||
// is a multipart form value — not a JSON body field — so they are not listed here.
|
||||
var streamEndpoints = []struct {
|
||||
name string
|
||||
submitPath string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
name: "text_completions",
|
||||
submitPath: "/v1/async/completions",
|
||||
body: map[string]any{
|
||||
"model": modelFor("ASYNC_TEXT_COMPLETION_MODEL"),
|
||||
"prompt": "Hello",
|
||||
"stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chat_completions",
|
||||
submitPath: "/v1/async/chat/completions",
|
||||
body: map[string]any{
|
||||
"model": modelFor("ASYNC_CHAT_COMPLETION_MODEL"),
|
||||
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
||||
"stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "responses",
|
||||
submitPath: "/v1/async/responses",
|
||||
body: map[string]any{
|
||||
"model": modelFor("ASYNC_RESPONSES_MODEL"),
|
||||
"input": "Hello",
|
||||
"stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image_generations",
|
||||
submitPath: "/v1/async/images/generations",
|
||||
body: map[string]any{
|
||||
"model": modelFor("ASYNC_IMAGE_GEN_MODEL"),
|
||||
"prompt": "A circle",
|
||||
"stream": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// TestValidation_StreamRejected_Returns400 confirms that stream=true is rejected
|
||||
// with 400 before any job is created. Runs in both global and VK modes because the
|
||||
// stream check happens before VK resolution.
|
||||
func TestValidation_StreamRejected_Returns400(t *testing.T) {
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
for _, ep := range streamEndpoints {
|
||||
t.Run(ep.name, func(t *testing.T) {
|
||||
code, _, body := submitJSON(t, ep.submitPath, ep.body, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for stream=true on %s, got %d: %s",
|
||||
ep.submitPath, code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_SpeechStreamFormatRejected_Returns400 confirms that the speech
|
||||
// endpoint rejects stream_format=sse with 400.
|
||||
func TestValidation_SpeechStreamFormatRejected_Returns400(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": modelFor("ASYNC_SPEECH_MODEL"),
|
||||
"input": "Hello",
|
||||
"voice": "alloy",
|
||||
"stream_format": "sse",
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, raw := submitJSON(t, "/v1/async/audio/speech", body, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for stream_format=sse on speech, got %d: %s", code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_MalformedJSON_Returns400 verifies that sending malformed JSON to any
|
||||
// async JSON endpoint returns 400 before a job is created.
|
||||
func TestValidation_MalformedJSON_Returns400(t *testing.T) {
|
||||
jsonEndpoints := []endpointCase{}
|
||||
for _, ec := range endpointCases() {
|
||||
if ec.multipart == nil {
|
||||
jsonEndpoints = append(jsonEndpoints, ec)
|
||||
}
|
||||
}
|
||||
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
for _, ec := range jsonEndpoints {
|
||||
t.Run(ec.name, func(t *testing.T) {
|
||||
code, body := submitRaw(t, ec.submitPath, []byte(`{invalid json`),
|
||||
"application/json", mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for malformed JSON on %s, got %d: %s",
|
||||
ec.submitPath, code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_TranscriptionStreamRejected_Returns400 confirms that the transcription
|
||||
// endpoint rejects stream=true (sent as a multipart field) with 400.
|
||||
func TestValidation_TranscriptionStreamRejected_Returns400(t *testing.T) {
|
||||
mp := &multipartCase{
|
||||
fields: map[string]string{
|
||||
"model": modelFor("ASYNC_TRANSCRIPTION_MODEL"),
|
||||
"stream": "true",
|
||||
},
|
||||
files: map[string]fileFixture{
|
||||
"file": {filename: "sample.mp3", data: sampleAudio()},
|
||||
},
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, body := submitMultipart(t, "/v1/async/audio/transcriptions", mp, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for stream=true on transcription, got %d: %s", code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_MissingModel_Returns400 verifies that submitting without a model field
|
||||
// is rejected with 400 across all JSON endpoints.
|
||||
func TestValidation_MissingModel_Returns400(t *testing.T) {
|
||||
missingModelCases := []struct {
|
||||
name string
|
||||
path string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
"chat_completions",
|
||||
"/v1/async/chat/completions",
|
||||
map[string]any{"messages": []map[string]any{{"role": "user", "content": "Hello"}}},
|
||||
},
|
||||
{
|
||||
"text_completions",
|
||||
"/v1/async/completions",
|
||||
map[string]any{"prompt": "Hello"},
|
||||
},
|
||||
{
|
||||
"embeddings",
|
||||
"/v1/async/embeddings",
|
||||
map[string]any{"input": "Hello"},
|
||||
},
|
||||
{
|
||||
"responses",
|
||||
"/v1/async/responses",
|
||||
map[string]any{"input": "Hello"},
|
||||
},
|
||||
{
|
||||
"speech",
|
||||
"/v1/async/audio/speech",
|
||||
map[string]any{"input": "Hello", "voice": "alloy"},
|
||||
},
|
||||
{
|
||||
"rerank",
|
||||
"/v1/async/rerank",
|
||||
map[string]any{
|
||||
"query": "test",
|
||||
"documents": []map[string]any{{"text": "test document"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ocr",
|
||||
"/v1/async/ocr",
|
||||
map[string]any{
|
||||
"document": map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": envOr("ASYNC_OCR_IMAGE_URL", "https://pestworldcdn-dcf2a8gbggazaghf.z01.azurefd.net/media/561791/carpenter-ant4.jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
for _, mc := range missingModelCases {
|
||||
t.Run(mc.name, func(t *testing.T) {
|
||||
code, _, body := submitJSON(t, mc.path, mc.body, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing model on %s, got %d: %s", mc.path, code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_ImageEditStreamRejected_Returns400 confirms that the image edit endpoint
|
||||
// rejects stream=true (sent as a multipart form field) with 400. This requires a complete
|
||||
// valid multipart body because stream validation runs after successful form parsing.
|
||||
func TestValidation_ImageEditStreamRejected_Returns400(t *testing.T) {
|
||||
mp := &multipartCase{
|
||||
fields: map[string]string{
|
||||
"model": modelFor("ASYNC_IMAGE_EDIT_MODEL"),
|
||||
"prompt": "Make it blue",
|
||||
"stream": "true",
|
||||
},
|
||||
files: map[string]fileFixture{
|
||||
"image": {filename: "image.png", data: samplePNG()},
|
||||
},
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, body := submitMultipart(t, "/v1/async/images/edits", mp, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for stream=true on image edits, got %d: %s", code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_Transcription_MissingFile_Returns400 verifies that a transcription request
|
||||
// without the required audio file is rejected with 400 at the multipart parse stage.
|
||||
func TestValidation_Transcription_MissingFile_Returns400(t *testing.T) {
|
||||
mp := &multipartCase{
|
||||
fields: map[string]string{
|
||||
"model": modelFor("ASYNC_TRANSCRIPTION_MODEL"),
|
||||
},
|
||||
// no "file" entry
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, body := submitMultipart(t, "/v1/async/audio/transcriptions", mp, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing audio file on transcription, got %d: %s", code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_ImageEdit_MissingImage_Returns400 verifies that an image edit request
|
||||
// without the required image file is rejected with 400.
|
||||
func TestValidation_ImageEdit_MissingImage_Returns400(t *testing.T) {
|
||||
mp := &multipartCase{
|
||||
fields: map[string]string{
|
||||
"model": modelFor("ASYNC_IMAGE_EDIT_MODEL"),
|
||||
"prompt": "Make it blue",
|
||||
},
|
||||
// no "image" file entry
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, body := submitMultipart(t, "/v1/async/images/edits", mp, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing image file on image edits, got %d: %s", code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidation_ImageVariation_MissingImage_Returns400 verifies that an image variation
|
||||
// request without the required image file is rejected with 400.
|
||||
func TestValidation_ImageVariation_MissingImage_Returns400(t *testing.T) {
|
||||
mp := &multipartCase{
|
||||
fields: map[string]string{
|
||||
"model": modelFor("ASYNC_IMAGE_VARIATION_MODEL"),
|
||||
},
|
||||
// no "image" file entry
|
||||
}
|
||||
for _, mode := range testModes() {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
code, _, body := submitMultipart(t, "/v1/async/images/variations", mp, mode.headers)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing image file on image variations, got %d: %s", code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP_WrongMethod_Rejected verifies that POST on a poll-only path does not return
|
||||
// a success status code. The converse (GET on a submit path) is not checked here
|
||||
// because the server's UI layer intercepts bare GET requests on /v1/async/* paths
|
||||
// before the async router is reached.
|
||||
func TestHTTP_WrongMethod_Rejected(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.BaseURL+"/v1/async/chat/completions/00000000-0000-0000-0000-000000000000", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/async/chat/completions/{id} failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST on poll path returned %d, expected 404 or 405", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user