From f56f9215d8c6f460005a097cdef0f5a71ae08296 Mon Sep 17 00:00:00 2001 From: zenfun Date: Tue, 2 Dec 2025 14:47:01 +0800 Subject: [PATCH] test(integration): add e2e testing suite and infrastructure Introduces a comprehensive integration testing setup and local development environment. Changes include: - Add `docker-compose.yml` for local stack orchestration. - Add `docker-compose.integration.yml` for the integration test environment. - Create `mock-upstream` service to simulate external LLM provider responses. - Implement Go-based end-to-end tests verifying control plane configuration and data plane routing. - Add `integration_test.sh` for quick connectivity verification. --- docker-compose.integration.yml | 80 +++++++++++++ docker-compose.yml | 32 +++-- integration/go.mod | 3 + integration/integration_test.go | 172 +++++++++++++++++++++++++++ integration/mock-upstream/Dockerfile | 13 ++ integration/mock-upstream/main.go | 51 ++++++++ integration_test.sh | 55 +++++++++ 7 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 docker-compose.integration.yml create mode 100644 integration/go.mod create mode 100644 integration/integration_test.go create mode 100644 integration/mock-upstream/Dockerfile create mode 100644 integration/mock-upstream/main.go create mode 100755 integration_test.sh diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml new file mode 100644 index 0000000..add61b7 --- /dev/null +++ b/docker-compose.integration.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ezapi + ports: + - "5434:5432" + volumes: + - pgdata-int:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + volumes: + - redisdata-int:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + mock-upstream: + build: + context: ./integration/mock-upstream + dockerfile: Dockerfile + ports: + - "8082:8082" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8082/health"] + interval: 5s + timeout: 5s + retries: 5 + + ez-api: + build: + context: . + dockerfile: Dockerfile + environment: + EZ_API_PORT: 8080 + EZ_PG_DSN: host=postgres user=postgres password=postgres dbname=ezapi port=5432 sslmode=disable + EZ_REDIS_ADDR: redis:6379 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + balancer: + build: + context: ../balancer + dockerfile: Dockerfile + environment: + EZ_BALANCER_PORT: 8081 + EZ_REDIS_ADDR: redis:6379 + EZ_BALANCER_REFRESH_SECONDS: 2 + ports: + - "8081:8081" + depends_on: + redis: + condition: service_healthy + ez-api: + condition: service_started + mock-upstream: + condition: service_healthy + +volumes: + pgdata-int: + redisdata-int: diff --git a/docker-compose.yml b/docker-compose.yml index 065f608..cc99432 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,16 @@ +version: "3.8" + services: # 1. PostgreSQL Database postgres: image: postgres:15-alpine container_name: ez-postgres environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-ezapi} + ports: + - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -22,6 +26,8 @@ services: image: redis:7-alpine container_name: ez-redis command: redis-server --appendonly yes + ports: + - "6379:6379" volumes: - redis_data:/data healthcheck: @@ -39,13 +45,13 @@ services: dockerfile: Dockerfile container_name: ez-api ports: - - "${EZ_API_PORT}:${EZ_API_PORT}" + - "${EZ_API_PORT:-8080}:8080" environment: - EZ_API_PORT: ${EZ_API_PORT} - EZ_PG_DSN: ${EZ_PG_DSN} - EZ_REDIS_ADDR: ${EZ_REDIS_ADDR} + EZ_API_PORT: ${EZ_API_PORT:-8080} + EZ_PG_DSN: ${EZ_PG_DSN:-host=postgres user=postgres password=postgres dbname=ezapi port=5432 sslmode=disable} + EZ_REDIS_ADDR: ${EZ_REDIS_ADDR:-redis:6379} EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD} - EZ_REDIS_DB: ${EZ_REDIS_DB} + EZ_REDIS_DB: ${EZ_REDIS_DB:-0} depends_on: postgres: condition: service_healthy @@ -61,12 +67,12 @@ services: dockerfile: Dockerfile container_name: ez-balancer ports: - - "${EZ_BALANCER_PORT}:${EZ_BALANCER_PORT}" + - "${EZ_BALANCER_PORT:-8081}:8081" environment: - EZ_BALANCER_PORT: ${EZ_BALANCER_PORT} - EZ_REDIS_ADDR: ${EZ_REDIS_ADDR} + EZ_BALANCER_PORT: ${EZ_BALANCER_PORT:-8081} + EZ_REDIS_ADDR: ${EZ_REDIS_ADDR:-redis:6379} EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD} - EZ_REDIS_DB: ${EZ_REDIS_DB} + EZ_REDIS_DB: ${EZ_REDIS_DB:-0} depends_on: redis: condition: service_healthy @@ -79,4 +85,4 @@ volumes: networks: ez-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/integration/go.mod b/integration/go.mod new file mode 100644 index 0000000..598719c --- /dev/null +++ b/integration/go.mod @@ -0,0 +1,3 @@ +module github.com/ez-api/integration + +go 1.24.5 diff --git a/integration/integration_test.go b/integration/integration_test.go new file mode 100644 index 0000000..6954ba1 --- /dev/null +++ b/integration/integration_test.go @@ -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 +} diff --git a/integration/mock-upstream/Dockerfile b/integration/mock-upstream/Dockerfile new file mode 100644 index 0000000..28f7154 --- /dev/null +++ b/integration/mock-upstream/Dockerfile @@ -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"] diff --git a/integration/mock-upstream/main.go b/integration/mock-upstream/main.go new file mode 100644 index 0000000..8a7e7eb --- /dev/null +++ b/integration/mock-upstream/main.go @@ -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) + } +} diff --git a/integration_test.sh b/integration_test.sh new file mode 100755 index 0000000..49bbabb --- /dev/null +++ b/integration_test.sh @@ -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}"