(Updated auto-generated PR description from @tadasant / Claude on Feb 22, 2026) ## Motivation and Context Fixes #637 Server lifecycle management (deprecation and yanking) is a standard requirement for package registries. Use cases like rebranding, security vulnerabilities, and end-of-life servers demand the ability to mark servers as deprecated or deleted, with optional messaging to guide consumers toward alternatives. ## Changes ### New API endpoints - **`PATCH /v0/servers/{serverName}/versions/{version}/status`** — Update the lifecycle status of a single server version - **`PATCH /v0/servers/{serverName}/status`** — Update the lifecycle status of all versions of a server in a single transaction Both endpoints accept: ```json { "status": "active|deprecated|deleted", "statusMessage": "Optional message (max 500 chars) explaining the change" } ``` ### New CLI command - **`mcp-publisher status <serverName> [version]`** — Update server lifecycle status from the command line - `--status` (required): `active`, `deprecated`, or `deleted` - `--message`: Optional status message - `--all-versions`: Apply to all versions of the server (replaces the `<version>` argument) ### API response changes `RegistryExtensions` now includes two new fields: - `statusChangedAt` — Timestamp of the last status change - `statusMessage` — Optional free-form message (e.g., deprecation reason, migration guidance) ### List endpoint changes - New `include_deleted` query parameter on `GET /v0/servers` — defaults to `false`, hiding deleted servers from listings - When `updated_since` is provided, `include_deleted` is automatically set to `true` for incremental sync ### Database - Migration `013_add_status_fields.sql`: Adds `status_changed_at` (NOT NULL, initialized from `published_at`) and `status_message` columns to the `servers` table ### Design decisions (from review discussion) - **Dedicated status endpoints** rather than overloading the existing edit endpoint - **Bidirectional status transitions** — all transitions between `active`, `deprecated`, and `deleted` are allowed - **`statusMessage` as free-form text** — no structured `nextName` or `alternativeUrl` fields (per maintainer feedback, to avoid complexity around chaining, trust transfer, etc.) - **Publish permission** grants status change access (not limited to admin/edit) - **Status message auto-cleared** when transitioning back to `active` Good, now I have the full picture. Here's the addendum: ### Consumer-facing API changes The new status fields live inside `_meta["io.modelcontextprotocol.registry/official"]` on every `ServerResponse`. This is the existing `RegistryExtensions` object — two new fields are added alongside the existing ones: **Before:** ```json { "server": { "name": "com.example/my-server", "...": "..." }, "_meta": { "io.modelcontextprotocol.registry/official": { "status": "active", "publishedAt": "2025-06-01T00:00:00Z", "updatedAt": "2025-06-15T00:00:00Z", "isLatest": true } } } ``` **After:** ```json { "server": { "name": "com.example/my-server", "...": "..." }, "_meta": { "io.modelcontextprotocol.registry/official": { "status": "deprecated", "statusChangedAt": "2026-02-01T12:00:00Z", "statusMessage": "Deprecated in favor of com.example/my-server-v2", "publishedAt": "2025-06-01T00:00:00Z", "updatedAt": "2026-02-01T12:00:00Z", "isLatest": true } } } ``` | New field | Type | Present when | |-----------|------|-------------| | `statusChangedAt` | `string` (RFC3339) | Always — initialized to `publishedAt` for existing records | | `statusMessage` | `string` (max 500 chars) | Only when set — `null`/omitted for active servers (auto-cleared on transition to active) | These fields appear on **every endpoint that returns a `ServerResponse`**: `GET /v0/servers`, `GET /v0/servers/{name}`, `GET /v0/servers/{name}/versions/{version}`, and the new PATCH status endpoints. ### List endpoint filtering `GET /v0/servers` gains an `include_deleted` query parameter: | Scenario | Behavior | |----------|----------| | No `include_deleted` param | Deleted servers hidden (default `false`) | | `include_deleted=true` | Deleted servers included in results | | `updated_since` provided | `include_deleted` forced to `true` for incremental sync | ## How Has This Been Tested? - Unit tests for status handler (`status_test.go`) covering status transitions, permission checks, validation errors, and the all-versions endpoint - Unit tests for the CLI command (`status_test.go`) - Integration with existing server/edit handler tests - Database layer tests for the new status update and query methods - Tested locally with `make dev-compose` ## Types of changes - [x] New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Open items from review - [ ] `include_deleted` silent override: When `updated_since` is provided and `include_deleted=false` is explicitly passed, should return a 400 error rather than silently overriding ([thread](https://github.com/modelcontextprotocol/registry/pull/893#discussion_r2100879360)) — agreed upon but may not yet be implemented
770 lines
23 KiB
Go
770 lines
23 KiB
Go
package v0_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
|
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
|
|
"github.com/modelcontextprotocol/registry/internal/config"
|
|
"github.com/modelcontextprotocol/registry/internal/database"
|
|
"github.com/modelcontextprotocol/registry/internal/service"
|
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestListServersEndpoint(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
// Setup test data
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/server-alpha",
|
|
Description: "Alpha test server",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/server-beta",
|
|
Description: "Beta test server",
|
|
Version: "2.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
tests := []struct {
|
|
name string
|
|
queryParams string
|
|
expectedStatus int
|
|
expectedCount int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "list all servers",
|
|
queryParams: "",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "list with limit",
|
|
queryParams: "?limit=1",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "search servers",
|
|
queryParams: "?search=alpha",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "filter latest only",
|
|
queryParams: "?version=latest",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "invalid limit",
|
|
queryParams: "?limit=abc",
|
|
expectedStatus: http.StatusUnprocessableEntity,
|
|
expectedError: "validation failed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers"+tt.queryParams, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, resp.Servers, tt.expectedCount)
|
|
assert.Equal(t, tt.expectedCount, resp.Metadata.Count)
|
|
|
|
// Verify structure
|
|
for _, server := range resp.Servers {
|
|
assert.NotEmpty(t, server.Server.Name)
|
|
assert.NotEmpty(t, server.Server.Description)
|
|
assert.NotNil(t, server.Meta.Official)
|
|
}
|
|
} else if tt.expectedError != "" {
|
|
assert.Contains(t, w.Body.String(), tt.expectedError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetLatestServerVersionEndpoint(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
// Setup test data
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/detail-server",
|
|
Description: "Server for detail testing",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
tests := []struct {
|
|
name string
|
|
serverName string
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "get existing server latest version",
|
|
serverName: "com.example/detail-server",
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "get non-existent server",
|
|
serverName: "com.example/non-existent",
|
|
expectedStatus: http.StatusNotFound,
|
|
expectedError: "Server not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// URL encode the server name
|
|
encodedName := url.PathEscape(tt.serverName)
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions/latest", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.serverName, resp.Server.Name)
|
|
assert.NotNil(t, resp.Meta.Official)
|
|
} else if tt.expectedError != "" {
|
|
assert.Contains(t, w.Body.String(), tt.expectedError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetServerVersionEndpoint(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
serverName := "com.example/version-server"
|
|
|
|
// Setup test data with multiple versions
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Version test server v1",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Version test server v2",
|
|
Version: "2.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Add version with build metadata for URL encoding test
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Version test server with build metadata",
|
|
Version: "1.0.0+20130313144700",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
tests := []struct {
|
|
name string
|
|
serverName string
|
|
version string
|
|
expectedStatus int
|
|
expectedError string
|
|
checkResult func(*testing.T, *apiv0.ServerResponse)
|
|
}{
|
|
{
|
|
name: "get existing version",
|
|
serverName: serverName,
|
|
version: "1.0.0",
|
|
expectedStatus: http.StatusOK,
|
|
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
|
t.Helper()
|
|
assert.Equal(t, "1.0.0", resp.Server.Version)
|
|
assert.Equal(t, "Version test server v1", resp.Server.Description)
|
|
assert.False(t, resp.Meta.Official.IsLatest)
|
|
},
|
|
},
|
|
{
|
|
name: "get latest version",
|
|
serverName: serverName,
|
|
version: "2.0.0",
|
|
expectedStatus: http.StatusOK,
|
|
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
|
t.Helper()
|
|
assert.Equal(t, "2.0.0", resp.Server.Version)
|
|
assert.True(t, resp.Meta.Official.IsLatest)
|
|
},
|
|
},
|
|
{
|
|
name: "get non-existent version",
|
|
serverName: serverName,
|
|
version: "3.0.0",
|
|
expectedStatus: http.StatusNotFound,
|
|
expectedError: "Server not found",
|
|
},
|
|
{
|
|
name: "get non-existent server",
|
|
serverName: "com.example/non-existent",
|
|
version: "1.0.0",
|
|
expectedStatus: http.StatusNotFound,
|
|
expectedError: "Server not found",
|
|
},
|
|
{
|
|
name: "get version with build metadata (URL encoded)",
|
|
serverName: serverName,
|
|
version: "1.0.0+20130313144700",
|
|
expectedStatus: http.StatusOK,
|
|
checkResult: func(t *testing.T, resp *apiv0.ServerResponse) {
|
|
t.Helper()
|
|
assert.Equal(t, "1.0.0+20130313144700", resp.Server.Version)
|
|
assert.Equal(t, "Version test server with build metadata", resp.Server.Description)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// URL encode the server name and version
|
|
encodedName := url.PathEscape(tt.serverName)
|
|
encodedVersion := url.PathEscape(tt.version)
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions/"+encodedVersion, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.serverName, resp.Server.Name)
|
|
assert.Equal(t, tt.version, resp.Server.Version)
|
|
assert.NotNil(t, resp.Meta.Official)
|
|
|
|
if tt.checkResult != nil {
|
|
tt.checkResult(t, &resp)
|
|
}
|
|
} else if tt.expectedError != "" {
|
|
assert.Contains(t, w.Body.String(), tt.expectedError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAllVersionsEndpoint(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
serverName := "com.example/multi-version-server"
|
|
|
|
// Setup test data with multiple versions
|
|
versions := []string{"1.0.0", "1.1.0", "2.0.0"}
|
|
for _, version := range versions {
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Multi-version test server " + version,
|
|
Version: version,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
tests := []struct {
|
|
name string
|
|
serverName string
|
|
expectedStatus int
|
|
expectedCount int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "get all versions of existing server",
|
|
serverName: serverName,
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 3,
|
|
},
|
|
{
|
|
name: "get versions of non-existent server",
|
|
serverName: "com.example/non-existent",
|
|
expectedStatus: http.StatusNotFound,
|
|
expectedError: "Server not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// URL encode the server name
|
|
encodedName := url.PathEscape(tt.serverName)
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, resp.Servers, tt.expectedCount)
|
|
assert.Equal(t, tt.expectedCount, resp.Metadata.Count)
|
|
|
|
// Verify all versions are for the same server
|
|
for _, server := range resp.Servers {
|
|
assert.Equal(t, tt.serverName, server.Server.Name)
|
|
assert.NotNil(t, server.Meta.Official)
|
|
}
|
|
|
|
// Verify all expected versions are present
|
|
versionSet := make(map[string]bool)
|
|
for _, server := range resp.Servers {
|
|
versionSet[server.Server.Version] = true
|
|
}
|
|
for _, expectedVersion := range versions {
|
|
assert.True(t, versionSet[expectedVersion], "Version %s should be present", expectedVersion)
|
|
}
|
|
|
|
// Verify exactly one is marked as latest
|
|
latestCount := 0
|
|
for _, server := range resp.Servers {
|
|
if server.Meta.Official.IsLatest {
|
|
latestCount++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, latestCount, "Exactly one version should be marked as latest")
|
|
} else if tt.expectedError != "" {
|
|
assert.Contains(t, w.Body.String(), tt.expectedError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListServersDeletedFiltering(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
// Setup test data: 2 active servers and 1 deleted server
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/active-server-1",
|
|
Description: "Active server 1",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/active-server-2",
|
|
Description: "Active server 2",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: "com.example/deleted-server",
|
|
Description: "Deleted server",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Delete the third server
|
|
_, err = registryService.UpdateServerStatus(ctx, "com.example/deleted-server", "1.0.0", &service.StatusChangeRequest{
|
|
NewStatus: model.StatusDeleted,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
tests := []struct {
|
|
name string
|
|
queryParams string
|
|
expectedStatus int
|
|
expectedCount int
|
|
checkDeleted bool // whether deleted server should be in results
|
|
}{
|
|
{
|
|
name: "default excludes deleted servers",
|
|
queryParams: "",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
checkDeleted: false,
|
|
},
|
|
{
|
|
name: "include_deleted=false excludes deleted servers",
|
|
queryParams: "?include_deleted=false",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
checkDeleted: false,
|
|
},
|
|
{
|
|
name: "include_deleted=true includes deleted servers",
|
|
queryParams: "?include_deleted=true",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 3,
|
|
checkDeleted: true,
|
|
},
|
|
{
|
|
name: "updated_since always includes deleted servers",
|
|
queryParams: "?updated_since=1990-01-01T00:00:00Z",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 3,
|
|
checkDeleted: true,
|
|
},
|
|
{
|
|
name: "updated_since with include_deleted=false returns error",
|
|
queryParams: "?updated_since=1990-01-01T00:00:00Z&include_deleted=false",
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedCount: 0,
|
|
checkDeleted: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers"+tt.queryParams, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
// Skip response body checks for error responses
|
|
if tt.expectedStatus != http.StatusOK {
|
|
return
|
|
}
|
|
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, resp.Servers, tt.expectedCount)
|
|
|
|
// Check if deleted server is in results
|
|
hasDeleted := false
|
|
for _, server := range resp.Servers {
|
|
if server.Server.Name == "com.example/deleted-server" {
|
|
hasDeleted = true
|
|
assert.Equal(t, model.StatusDeleted, server.Meta.Official.Status)
|
|
}
|
|
}
|
|
assert.Equal(t, tt.checkDeleted, hasDeleted, "Deleted server presence mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServersEndpointEdgeCases(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
// Setup test data with edge case names that comply with constraints
|
|
specialServers := []struct {
|
|
name string
|
|
description string
|
|
version string
|
|
}{
|
|
{"io.dots.and-dashes/server-name", "Server with dots and dashes", "1.0.0"},
|
|
{"com.long-namespace-name/very-long-server-name-here", "Long names", "1.0.0"},
|
|
{"org.test123/server_with_underscores", "Server with underscores", "1.0.0"},
|
|
}
|
|
|
|
for _, server := range specialServers {
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: server.name,
|
|
Description: server.description,
|
|
Version: server.version,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
t.Run("URL encoding edge cases", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
serverName string
|
|
}{
|
|
{"dots and dashes", "io.dots.and-dashes/server-name"},
|
|
{"long server name", "com.long-namespace-name/very-long-server-name-here"},
|
|
{"underscores", "org.test123/server_with_underscores"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Test latest version endpoint
|
|
encodedName := url.PathEscape(tt.serverName)
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions/latest", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp apiv0.ServerResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.serverName, resp.Server.Name)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("query parameter edge cases", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
queryParams string
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{"limit too high", "?limit=1000", http.StatusUnprocessableEntity, "validation failed"},
|
|
{"negative limit", "?limit=-1", http.StatusUnprocessableEntity, "validation failed"},
|
|
{"invalid updated_since format", "?updated_since=invalid", http.StatusBadRequest, "Invalid updated_since format"},
|
|
{"future updated_since", "?updated_since=2030-01-01T00:00:00Z", http.StatusOK, ""},
|
|
{"very old updated_since", "?updated_since=1990-01-01T00:00:00Z", http.StatusOK, ""},
|
|
{"empty search parameter", "?search=", http.StatusOK, ""},
|
|
{"search with special characters", "?search=测试", http.StatusOK, ""},
|
|
{"combined valid parameters", "?search=server&limit=5&version=latest", http.StatusOK, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers"+tt.queryParams, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, resp.Metadata)
|
|
} else if tt.expectedError != "" {
|
|
assert.Contains(t, w.Body.String(), tt.expectedError)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("response structure validation", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
|
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
|
|
// Verify metadata structure
|
|
assert.NotNil(t, resp.Metadata)
|
|
assert.GreaterOrEqual(t, resp.Metadata.Count, 0)
|
|
|
|
// Verify each server has complete structure
|
|
for _, server := range resp.Servers {
|
|
assert.NotEmpty(t, server.Server.Name)
|
|
assert.NotEmpty(t, server.Server.Description)
|
|
assert.NotEmpty(t, server.Server.Version)
|
|
assert.NotNil(t, server.Meta)
|
|
assert.NotNil(t, server.Meta.Official)
|
|
assert.NotZero(t, server.Meta.Official.PublishedAt)
|
|
assert.Contains(t, []model.Status{model.StatusActive, model.StatusDeprecated, model.StatusDeleted}, server.Meta.Official.Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIncludeDeletedOnDetailEndpoints(t *testing.T) {
|
|
ctx := context.Background()
|
|
registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig())
|
|
|
|
serverName := "com.example/deleted-detail-test"
|
|
|
|
// Create server with multiple versions
|
|
_, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Test server v1",
|
|
Version: "1.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{
|
|
Schema: model.CurrentSchemaURL,
|
|
Name: serverName,
|
|
Description: "Test server v2",
|
|
Version: "2.0.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Delete version 1.0.0
|
|
_, err = registryService.UpdateServerStatus(ctx, serverName, "1.0.0", &service.StatusChangeRequest{
|
|
NewStatus: model.StatusDeleted,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create API
|
|
mux := http.NewServeMux()
|
|
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
|
v0.RegisterServersEndpoints(api, "/v0", registryService)
|
|
|
|
encodedName := url.PathEscape(serverName)
|
|
|
|
t.Run("detail endpoint", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
queryParams string
|
|
expectedStatus int
|
|
}{
|
|
{
|
|
name: "deleted version without include_deleted returns 404",
|
|
version: "1.0.0",
|
|
queryParams: "",
|
|
expectedStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "deleted version with include_deleted=false returns 404",
|
|
version: "1.0.0",
|
|
queryParams: "?include_deleted=false",
|
|
expectedStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "deleted version with include_deleted=true returns 200",
|
|
version: "1.0.0",
|
|
queryParams: "?include_deleted=true",
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "active version without include_deleted returns 200",
|
|
version: "2.0.0",
|
|
queryParams: "",
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
encodedVersion := url.PathEscape(tt.version)
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions/"+encodedVersion+tt.queryParams, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
if tt.expectedStatus == http.StatusOK {
|
|
var resp apiv0.ServerResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.version, resp.Server.Version)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("version history endpoint", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
queryParams string
|
|
expectedStatus int
|
|
expectedCount int
|
|
}{
|
|
{
|
|
name: "without include_deleted excludes deleted versions",
|
|
queryParams: "",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "include_deleted=false excludes deleted versions",
|
|
queryParams: "?include_deleted=false",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "include_deleted=true includes deleted versions",
|
|
queryParams: "?include_deleted=true",
|
|
expectedStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/servers/"+encodedName+"/versions"+tt.queryParams, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, w.Code)
|
|
|
|
var resp apiv0.ServerListResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
assert.NoError(t, err)
|
|
assert.Len(t, resp.Servers, tt.expectedCount)
|
|
})
|
|
}
|
|
})
|
|
}
|