From 6e7e15cf4a13a78bcd7ad8cded0d16f4ff14528b Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 12 Apr 2026 21:52:29 +0700 Subject: [PATCH] test(contracts): complete P1 contract tests with schemas and policy - Add config.get, skills.list WS contract tests - Add /v1/providers HTTP contract test - Add JSON schemas for ws_connect, ws_chat_send, http_chat_completions - Document breaking change policy --- tests/contracts/BREAKING_CHANGE_POLICY.md | 61 +++++++++++++++++++ tests/contracts/http_api/models_test.go | 26 ++++++++ .../schemas/http_chat_completions.json | 40 ++++++++++++ tests/contracts/schemas/ws_chat_send.json | 11 ++++ tests/contracts/schemas/ws_connect.json | 21 +++++++ tests/contracts/ws_methods/config_get_test.go | 19 ++++++ .../contracts/ws_methods/skills_list_test.go | 33 ++++++++++ 7 files changed, 211 insertions(+) create mode 100644 tests/contracts/BREAKING_CHANGE_POLICY.md create mode 100644 tests/contracts/http_api/models_test.go create mode 100644 tests/contracts/schemas/http_chat_completions.json create mode 100644 tests/contracts/schemas/ws_chat_send.json create mode 100644 tests/contracts/schemas/ws_connect.json create mode 100644 tests/contracts/ws_methods/config_get_test.go create mode 100644 tests/contracts/ws_methods/skills_list_test.go diff --git a/tests/contracts/BREAKING_CHANGE_POLICY.md b/tests/contracts/BREAKING_CHANGE_POLICY.md new file mode 100644 index 00000000..b507e1e6 --- /dev/null +++ b/tests/contracts/BREAKING_CHANGE_POLICY.md @@ -0,0 +1,61 @@ +# Contract Breaking Change Policy + +## What is a Breaking Change? + +A breaking change is any modification to the API response schema that could cause existing clients to fail: + +| Change Type | Breaking? | Example | +|------------|-----------|---------| +| Remove required field | YES | `{id, name}` → `{id}` | +| Change field type | YES | `"count": 5` → `"count": "5"` | +| Rename field | YES | `user_id` → `userId` | +| Add new required field | YES | Client must send new field | +| Add optional field | NO | Backward compatible | +| Add new enum value | MAYBE | If client validates strictly | + +## Contract Test Categories + +### P0 - Critical (WS Core) +- `connect` - Session establishment +- `chat.send` - Message handling + +### P1 - High (Data APIs) +- `agents.list`, `sessions.list`, `skills.list` +- `/v1/chat/completions` - OpenAI compatibility +- `/v1/agents` - Agent management + +### P2 - Medium (Config/Admin) +- `config.get`, `config.apply` +- `/v1/providers` + +## Breaking Change Process + +1. **Detect**: Contract test fails in CI +2. **Evaluate**: Is this intentional or accidental? +3. **If Intentional**: + - Bump protocol version in `pkg/protocol/version.go` + - Update JSON schemas in `tests/contracts/schemas/` + - Update contract tests + - Document in CHANGELOG.md +4. **If Accidental**: Fix the regression + +## Running Contract Tests + +```bash +# Set environment variables +export CONTRACT_TEST_WS_URL="ws://localhost:8080/ws" +export CONTRACT_TEST_HTTP_URL="http://localhost:8080" +export CONTRACT_TEST_TOKEN="your-test-token" + +# Run all contract tests +go test -tags integration ./tests/contracts/... +``` + +## Schema Files + +JSON Schema definitions in `tests/contracts/schemas/`: +- `ws_connect.json` - WS connect response +- `ws_chat_send.json` - WS chat.send response +- `http_chat_completions.json` - OpenAI-compatible response + +These schemas serve as documentation and can be used for automated validation. diff --git a/tests/contracts/http_api/models_test.go b/tests/contracts/http_api/models_test.go new file mode 100644 index 00000000..8d2b62f9 --- /dev/null +++ b/tests/contracts/http_api/models_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package http_api + +import "testing" + +// CONTRACT: /v1/providers response MUST include providers array. +func TestContract_HTTP_ProvidersList(t *testing.T) { + baseURL, token := getTestServer(t) + client := newHTTPClient(baseURL, token) + + resp := client.get(t, "/v1/providers") + + assertField(t, resp, "providers", "array") + + providers, ok := resp["providers"].([]any) + if !ok || len(providers) == 0 { + t.Log("No providers - skipping field checks") + return + } + + provider := providers[0].(map[string]any) + assertField(t, provider, "id", "string") + assertField(t, provider, "name", "string") + assertField(t, provider, "type", "string") +} diff --git a/tests/contracts/schemas/http_chat_completions.json b/tests/contracts/schemas/http_chat_completions.json new file mode 100644 index 00000000..e1747c7f --- /dev/null +++ b/tests/contracts/schemas/http_chat_completions.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "POST /v1/chat/completions response (OpenAI-compatible)", + "type": "object", + "required": ["id", "object", "created", "model", "choices"], + "properties": { + "id": { "type": "string" }, + "object": { "type": "string", "const": "chat.completion" }, + "created": { "type": "number" }, + "model": { "type": "string" }, + "choices": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["index", "message", "finish_reason"], + "properties": { + "index": { "type": "number" }, + "message": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { "type": "string" }, + "content": { "type": "string" } + } + }, + "finish_reason": { "type": "string" } + } + } + }, + "usage": { + "type": "object", + "properties": { + "prompt_tokens": { "type": "number" }, + "completion_tokens": { "type": "number" }, + "total_tokens": { "type": "number" } + } + } + } +} diff --git a/tests/contracts/schemas/ws_chat_send.json b/tests/contracts/schemas/ws_chat_send.json new file mode 100644 index 00000000..e9e88400 --- /dev/null +++ b/tests/contracts/schemas/ws_chat_send.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WS chat.send response", + "type": "object", + "required": ["message_id", "content", "role"], + "properties": { + "message_id": { "type": "string" }, + "content": { "type": "string" }, + "role": { "type": "string", "enum": ["assistant", "user", "system"] } + } +} diff --git a/tests/contracts/schemas/ws_connect.json b/tests/contracts/schemas/ws_connect.json new file mode 100644 index 00000000..be387904 --- /dev/null +++ b/tests/contracts/schemas/ws_connect.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WS connect response", + "type": "object", + "required": ["protocol", "role", "user_id", "tenant_id", "is_owner", "server"], + "properties": { + "protocol": { "type": "number" }, + "role": { "type": "string", "enum": ["admin", "operator", "viewer"] }, + "user_id": { "type": "string" }, + "tenant_id": { "type": "string" }, + "is_owner": { "type": "boolean" }, + "server": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "const": "goclaw" }, + "version": { "type": "string" } + } + } + } +} diff --git a/tests/contracts/ws_methods/config_get_test.go b/tests/contracts/ws_methods/config_get_test.go new file mode 100644 index 00000000..3fb2850f --- /dev/null +++ b/tests/contracts/ws_methods/config_get_test.go @@ -0,0 +1,19 @@ +//go:build integration + +package ws_methods + +import "testing" + +// CONTRACT: config.get response MUST include config, hash, path. +// Requires owner + master scope. +func TestContract_WS_ConfigGet(t *testing.T) { + wsURL, _ := getTestServer(t) + client, _ := connect(t, wsURL, nil) + + resp := client.send(t, "config.get", map[string]any{}) + + // Required fields + assertField(t, resp, "config", "object") + assertField(t, resp, "hash", "string") + assertField(t, resp, "path", "string") +} diff --git a/tests/contracts/ws_methods/skills_list_test.go b/tests/contracts/ws_methods/skills_list_test.go new file mode 100644 index 00000000..06b2104c --- /dev/null +++ b/tests/contracts/ws_methods/skills_list_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package ws_methods + +import "testing" + +// CONTRACT: skills.list response MUST include skills array. +func TestContract_WS_SkillsList(t *testing.T) { + wsURL, _ := getTestServer(t) + client, _ := connect(t, wsURL, nil) + + resp := client.send(t, "skills.list", map[string]any{}) + + // Response must have skills array + assertField(t, resp, "skills", "array") + + // If skills exist, verify each has required fields + skills, ok := resp["skills"].([]any) + if !ok || len(skills) == 0 { + t.Log("No skills returned - skipping field checks") + return + } + + skill, ok := skills[0].(map[string]any) + if !ok { + t.Error("CONTRACT VIOLATION: skills[0] is not an object") + return + } + + // Required skill fields + assertField(t, skill, "name", "string") + assertField(t, skill, "description", "string") +}