Files
bifrost/tests/async/integration_route_test.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

175 lines
6.2 KiB
Go

package async
import (
"encoding/json"
"maps"
"net/http"
"os"
"strings"
"testing"
)
// Integration route tests verify that x-bf-async and x-bf-async-id headers work on
// provider integration routes. These routes apply a provider-specific response converter,
// so the envelope differs from /v1/async/* endpoints:
//
// Submit (x-bf-async: true) → HTTP 200 (not 202)
// Retrieve (x-bf-async-id: <id>) → HTTP 200 for any job state
//
// Optional env:
//
// BIFROST_INTEGRATION_PATH — override the default /openai/v1/responses
// BIFROST_INTEGRATION_MODEL — model string; defaults to ASYNC_RESPONSES_MODEL default
//
// Note: only routes with AsyncResponsesResponseConverter support x-bf-async.
// AsyncChatResponseConverter is not implemented on any route — the Responses API
// path (/openai/v1/responses) is the only integration route that supports async.
func integrationPath() string {
return envOr("BIFROST_INTEGRATION_PATH", "/openai/v1/responses")
}
func integrationModel() string {
if v := os.Getenv("BIFROST_INTEGRATION_MODEL"); v != "" {
return v
}
return modelFor("ASYNC_RESPONSES_MODEL")
}
// assert4xx fails the test unless code is a 4xx client error, catching 5xx regressions.
func assert4xx(t *testing.T, code int, body []byte) {
t.Helper()
if code < 400 || code >= 500 {
t.Fatalf("expected 4xx, got %d: %s", code, body)
}
}
// integrationJobID extracts the job UUID from an integration route response body.
// All integration converters preserve the async job ID in the top-level "id" field.
func integrationJobID(t *testing.T, body []byte) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return ""
}
if id, ok := m["id"].(string); ok {
return id
}
return ""
}
// pollIntegration POSTs to an integration path with x-bf-async-id header to retrieve a job.
// Integration routes use the same POST method for both submit and retrieve.
func pollIntegration(t *testing.T, path, jobID string, headers map[string]string) (int, []byte) {
t.Helper()
h := make(map[string]string, len(headers)+1)
maps.Copy(h, headers)
h["x-bf-async-id"] = jobID
code, body := submitRaw(t, path, []byte("{}"), "application/json", h)
return code, body
}
// integrationSubmitBody returns a minimal Responses API body for the integration path.
func integrationSubmitBody() map[string]any {
return map[string]any{
"model": integrationModel(),
"input": "Say hello in one word.",
}
}
// TestIntegration_AsyncCreate_Returns200WithJobID submits a chat request via an integration
// route with x-bf-async header and confirms the response is 200 OK with a job UUID.
// Integration routes return 200 (not 202) because the response passes through the
// provider-specific converter before being sent.
func TestIntegration_AsyncCreate_Returns200WithJobID(t *testing.T) {
for _, mode := range testModes() {
t.Run(mode.name, func(t *testing.T) {
headers := withRawHeader(mode.headers, "x-bf-async", "true")
code, _, body := submitJSON(t, integrationPath(), integrationSubmitBody(), headers)
if code != http.StatusOK {
t.Fatalf("expected 200 from integration async submit, got %d: %s", code, body)
}
jobID := integrationJobID(t, body)
if jobID == "" {
t.Fatalf("no job id in integration route response: %s", body)
}
parts := strings.Split(jobID, "-")
if len(parts) != 5 || len(parts[0]) != 8 || len(parts[1]) != 4 ||
len(parts[2]) != 4 || len(parts[3]) != 4 || len(parts[4]) != 12 {
t.Errorf("id %q does not look like a UUID", jobID)
}
})
}
}
// TestIntegration_AsyncRetrieve_Returns200 submits an async job on an integration route
// and polls it via x-bf-async-id header, confirming retrieve also returns 200 OK.
func TestIntegration_AsyncRetrieve_Returns200(t *testing.T) {
for _, mode := range testModes() {
t.Run(mode.name, func(t *testing.T) {
headers := withRawHeader(mode.headers, "x-bf-async", "true")
code, _, body := submitJSON(t, integrationPath(), integrationSubmitBody(), headers)
if code != http.StatusOK {
t.Fatalf("submit failed with %d: %s", code, body)
}
jobID := integrationJobID(t, body)
if jobID == "" {
t.Fatalf("no job id in submit response: %s", body)
}
pollCode, pollBody := pollIntegration(t, integrationPath(), jobID, mode.headers)
if pollCode != http.StatusOK {
t.Errorf("expected 200 on integration retrieve, got %d: %s", pollCode, pollBody)
}
})
}
}
// TestIntegration_AsyncRetrieve_NonExistentJob_Returns4xx polls an integration route with
// a fake job ID and confirms a non-success status code is returned.
func TestIntegration_AsyncRetrieve_NonExistentJob_Returns4xx(t *testing.T) {
const fakeID = "00000000-0000-0000-0000-000000000000"
for _, mode := range testModes() {
t.Run(mode.name, func(t *testing.T) {
code, body := pollIntegration(t, integrationPath(), fakeID, mode.headers)
assert4xx(t, code, body)
})
}
}
// TestIntegration_AsyncCreate_StreamRejected confirms that submitting a streaming request
// via x-bf-async is rejected — streaming and async are mutually exclusive.
func TestIntegration_AsyncCreate_StreamRejected(t *testing.T) {
streamBody := map[string]any{
"model": integrationModel(),
"input": "Hello",
"stream": true,
}
for _, mode := range testModes() {
t.Run(mode.name, func(t *testing.T) {
headers := withRawHeader(mode.headers, "x-bf-async", "true")
code, _, body := submitJSON(t, integrationPath(), streamBody, headers)
assert4xx(t, code, body)
})
}
}
// TestIntegration_VKScope_DifferentKey_Returns4xx submits an async job on an integration
// route with VK1 and retrieves with VK2, confirming VK isolation works on integration routes.
func TestIntegration_VKScope_DifferentKey_Returns4xx(t *testing.T) {
if cfg.VK == "" || cfg.AltVK == "" {
t.Skip("both BIFROST_VK and BIFROST_ALT_VK must be set")
}
headers := withRawHeader(vkHeaders(cfg.VK), "x-bf-async", "true")
code, _, body := submitJSON(t, integrationPath(), integrationSubmitBody(), headers)
if code != http.StatusOK {
t.Fatalf("submit failed with %d: %s", code, body)
}
jobID := integrationJobID(t, body)
if jobID == "" {
t.Fatalf("no job id in submit response: %s", body)
}
pollCode, pollBody := pollIntegration(t, integrationPath(), jobID, vkHeaders(cfg.AltVK))
assert4xx(t, pollCode, pollBody)
}