mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
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.
This commit is contained in:
80
docker-compose.integration.yml
Normal file
80
docker-compose.integration.yml
Normal file
@@ -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:
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# 1. PostgreSQL Database
|
# 1. PostgreSQL Database
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: ez-postgres
|
container_name: ez-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB:-ezapi}
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -22,6 +26,8 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: ez-redis
|
container_name: ez-redis
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -39,13 +45,13 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ez-api
|
container_name: ez-api
|
||||||
ports:
|
ports:
|
||||||
- "${EZ_API_PORT}:${EZ_API_PORT}"
|
- "${EZ_API_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
EZ_API_PORT: ${EZ_API_PORT}
|
EZ_API_PORT: ${EZ_API_PORT:-8080}
|
||||||
EZ_PG_DSN: ${EZ_PG_DSN}
|
EZ_PG_DSN: ${EZ_PG_DSN:-host=postgres user=postgres password=postgres dbname=ezapi port=5432 sslmode=disable}
|
||||||
EZ_REDIS_ADDR: ${EZ_REDIS_ADDR}
|
EZ_REDIS_ADDR: ${EZ_REDIS_ADDR:-redis:6379}
|
||||||
EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD}
|
EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD}
|
||||||
EZ_REDIS_DB: ${EZ_REDIS_DB}
|
EZ_REDIS_DB: ${EZ_REDIS_DB:-0}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -61,12 +67,12 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ez-balancer
|
container_name: ez-balancer
|
||||||
ports:
|
ports:
|
||||||
- "${EZ_BALANCER_PORT}:${EZ_BALANCER_PORT}"
|
- "${EZ_BALANCER_PORT:-8081}:8081"
|
||||||
environment:
|
environment:
|
||||||
EZ_BALANCER_PORT: ${EZ_BALANCER_PORT}
|
EZ_BALANCER_PORT: ${EZ_BALANCER_PORT:-8081}
|
||||||
EZ_REDIS_ADDR: ${EZ_REDIS_ADDR}
|
EZ_REDIS_ADDR: ${EZ_REDIS_ADDR:-redis:6379}
|
||||||
EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD}
|
EZ_REDIS_PASSWORD: ${EZ_REDIS_PASSWORD}
|
||||||
EZ_REDIS_DB: ${EZ_REDIS_DB}
|
EZ_REDIS_DB: ${EZ_REDIS_DB:-0}
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -79,4 +85,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
ez-network:
|
ez-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
3
integration/go.mod
Normal file
3
integration/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/ez-api/integration
|
||||||
|
|
||||||
|
go 1.24.5
|
||||||
172
integration/integration_test.go
Normal file
172
integration/integration_test.go
Normal 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
|
||||||
|
}
|
||||||
13
integration/mock-upstream/Dockerfile
Normal file
13
integration/mock-upstream/Dockerfile
Normal 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"]
|
||||||
51
integration/mock-upstream/main.go
Normal file
51
integration/mock-upstream/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
integration_test.sh
Executable file
55
integration_test.sh
Executable 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}"
|
||||||
Reference in New Issue
Block a user