test(integration): reorganize test structure and add runner script

- Move integration tests and mock upstream resources from `integration/` to `test/` directory.
- Add `test/integration.sh` script to orchestrate environment setup and test execution.
- Update build context in `docker-compose.integration.yml` to match new structure.
- Add documentation for local development and integration testing workflows in README.
This commit is contained in:
zenfun
2025-12-02 15:56:17 +08:00
parent a0f13d55c1
commit d147f3ab04
8 changed files with 41 additions and 1 deletions

3
test/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/ez-api/integration
go 1.24.5

20
test/integration.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
# Runs integration tests inside the ez-api repo (builds docker-compose + go test).
REPO_ROOT=$(cd -- "$(dirname -- "$0")"/.. && pwd)
compose_file="$REPO_ROOT/docker-compose.integration.yml"
echo "[integration] bringing up services..."
docker compose -f "$compose_file" up --build -d --wait
cleanup() {
echo "[integration] tearing down services..."
docker compose -f "$compose_file" down -v
}
trap cleanup EXIT
echo "[integration] running go test with integration tag..."
cd "$REPO_ROOT/test"
go test -tags=integration ./...

172
test/integration_test.go Normal file
View File

@@ -0,0 +1,172 @@
//go:build integration
package integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type providerResp struct {
ID uint `json:"ID"`
}
type keyResp struct {
ID uint `json:"ID"`
Group string `json:"group"`
}
type modelResp struct {
ID uint `json:"ID"`
Name string `json:"name"`
}
func TestEndToEnd(t *testing.T) {
apiBase := getenv("E2E_EZAPI_URL", "http://localhost:8080")
balancerBase := getenv("E2E_BALANCER_URL", "http://localhost:8081")
client := &http.Client{Timeout: 5 * time.Second}
// 1) create provider pointing to mock upstream inside compose network
prov := map[string]string{
"name": "mock",
"type": "mock",
"base_url": "http://mock-upstream:8082",
"api_key": "mock-upstream-key",
"group": "default",
}
_ = postJSON(t, client, apiBase+"/providers", prov, new(providerResp))
// 2) create model metadata
modelPayload := map[string]interface{}{
"name": "mock-model",
"context_window": 2048,
"cost_per_token": 0.0,
}
_ = postJSON(t, client, apiBase+"/models", modelPayload, new(modelResp))
// 3) create key bound to provider
keyPayload := map[string]interface{}{
"group": "default",
"key_secret": "sk-integration",
"status": "active",
"weight": 10,
"balance": 100,
}
postJSON(t, client, apiBase+"/keys", keyPayload, new(keyResp))
// 4) wait for balancer to refresh snapshot
waitFor(t, 15*time.Second, func() error {
models := fetchModels(t, client, balancerBase)
if len(models) == 0 {
return fmt.Errorf("no models yet")
}
found := false
for _, m := range models {
if m == "mock-model" {
found = true
}
}
if !found {
return fmt.Errorf("mock-model not visible")
}
return nil
})
// 5) call chat completions through balancer
body := map[string]interface{}{
"model": "mock-model",
"messages": []map[string]string{
{"role": "user", "content": "hi"},
},
}
reqBody, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, balancerBase+"/v1/chat/completions", bytes.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+keyPayload["key_secret"].(string))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var respBody map[string]interface{}
data, _ := io.ReadAll(resp.Body)
require.NoError(t, json.Unmarshal(data, &respBody))
require.Equal(t, "chat.completion", respBody["object"])
}
func fetchModels(t *testing.T, client *http.Client, balancerBase string) []string {
req, err := http.NewRequest(http.MethodGet, balancerBase+"/v1/models", nil)
require.NoError(t, err)
// No auth required? our balancer requires auth; use test token
req.Header.Set("Authorization", "Bearer sk-integration")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
data, _ := io.ReadAll(resp.Body)
require.NoError(t, json.Unmarshal(data, &payload))
out := make([]string, 0, len(payload.Data))
for _, m := range payload.Data {
out = append(out, m.ID)
}
return out
}
func waitFor(t *testing.T, timeout time.Duration, fn func() error) {
deadline := time.Now().Add(timeout)
for {
if err := fn(); err == nil {
return
}
if time.Now().After(deadline) {
require.NoError(t, fn())
}
time.Sleep(500 * time.Millisecond)
}
}
func postJSON[T any](t *testing.T, client *http.Client, url string, body interface{}, out *T) *T {
b, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
data, _ := io.ReadAll(resp.Body)
if len(data) > 0 {
require.NoError(t, json.Unmarshal(data, out))
}
return out
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

55
test/integration_test.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
echo "Starting Integration Test..."
# 1. Wait for services
echo "Waiting for services to be ready..."
sleep 10 # Simple wait, in real world use health checks
# 2. Create Key via Control Plane (ez-api)
echo "Creating Key via ez-api..."
KEY_SECRET="sk-test-integration-$(date +%s)"
# Note: Keys route by group now; omitting group defaults to "default".
RESPONSE=$(curl -s -X POST http://localhost:8080/keys \
-H "Content-Type: application/json" \
-d "{\"key_secret\": \"$KEY_SECRET\", \"balance\": 100.0, \"group\": \"default\"}")
echo "Create Key Response: $RESPONSE"
if [[ $RESPONSE == *"KeySecret"* ]]; then
echo -e "${GREEN}Key Created Successfully${NC}"
else
echo -e "${RED}Failed to Create Key${NC}"
exit 1
fi
# 3. Verify Auth via Data Plane (balancer)
echo "Verifying Auth via balancer..."
# Note: We are hitting /v1/chat/completions. Since we don't have a real upstream,
# we expect 502 Bad Gateway (if upstream is down) or 404/200 from upstream.
# But definitely NOT 401 Unauthorized.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8081/v1/chat/completions \
-H "Authorization: Bearer $KEY_SECRET" \
-H "Content-Type: application/json" \
-d "{\"model\": \"gpt-3.5-turbo\", \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]}")
echo "Balancer HTTP Code: $HTTP_CODE"
if [[ "$HTTP_CODE" == "401" ]]; then
echo -e "${RED}Auth Failed: Got 401 Unauthorized${NC}"
exit 1
elif [[ "$HTTP_CODE" == "502" || "$HTTP_CODE" == "200" || "$HTTP_CODE" == "404" ]]; then
# 502 means Auth passed but upstream failed (expected in test env without real internet/upstream)
echo -e "${GREEN}Auth Passed (Upstream response: $HTTP_CODE)${NC}"
else
echo -e "${RED}Unexpected Response: $HTTP_CODE${NC}"
exit 1
fi
echo -e "${GREEN}Integration Test Passed!${NC}"

View File

@@ -0,0 +1,13 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go mod init mockupstream && go mod tidy && go build -o mock-upstream main.go
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/mock-upstream .
EXPOSE 8082
CMD ["./mock-upstream"]

View File

@@ -0,0 +1,51 @@
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"id": "mock-completion",
"object": "chat.completion",
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]string{
"role": "assistant",
"content": "hello from mock",
},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"object": "list",
"data": []map[string]string{
{"id": "mock-model", "object": "model", "owned_by": "mock"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
log.Println("mock-upstream listening on :8082")
if err := http.ListenAndServe(":8082", mux); err != nil {
log.Fatalf("mock-upstream failed: %v", err)
}
}