registry/internal/api/handlers/v0/servers_test.go
adam jones 9d9a02c57f
Database implementation cleanup (#335)
## Motivation and Context

The database logic was getting a bit unwieldy and fragile - especially
because business logic that should really have lived at the registry
service level was being pushed into the persistence level.

This PR tightens the interface, simplifies the code there, as well as
rips out a bunch of mocks etc. we don't need.

## How Has This Been Tested?

Locally everything works

## Breaking Changes

The list endpoint was surprisingly always filtering for isLatest only,
which seems like a bug? I have removed this filter.
2025-09-02 13:37:28 +01:00

395 lines
12 KiB
Go

package v0_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/google/uuid"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"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"
)
func TestServersListEndpoint(t *testing.T) {
testCases := []struct {
name string
queryParams string
setupRegistryService func(service.RegistryService)
expectedStatus int
expectedServers []apiv0.ServerJSON
expectedMeta *v0.Metadata
expectedError string
}{
{
name: "successful list with default parameters",
setupRegistryService: func(registry service.RegistryService) {
// Publish test servers
server1 := apiv0.ServerJSON{
Name: "com.example/test-server-1",
Description: "First test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-1",
Source: "github",
ID: "example/test-server-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
server2 := apiv0.ServerJSON{
Name: "com.example/test-server-2",
Description: "Second test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-2",
Source: "github",
ID: "example/test-server-2",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2)
},
expectedStatus: http.StatusOK,
expectedServers: nil, // Will be verified differently since IDs are dynamic
},
{
name: "successful list with cursor and limit",
queryParams: "?limit=10",
setupRegistryService: func(registry service.RegistryService) {
server := apiv0.ServerJSON{
Name: "com.example/test-server-3",
Description: "Third test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-3",
Source: "github",
ID: "example/test-server-3",
},
VersionDetail: model.VersionDetail{
Version: "1.5.0",
},
}
_, _ = registry.Publish(server)
},
expectedStatus: http.StatusOK,
expectedServers: nil, // Will be verified differently since IDs are dynamic
},
{
name: "successful list with limit capping at 100",
queryParams: "?limit=150",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
},
{
name: "invalid cursor parameter",
queryParams: "?cursor=invalid-uuid",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - non-numeric",
queryParams: "?limit=abc",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - zero",
queryParams: "?limit=0",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - negative",
queryParams: "?limit=-5",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "empty registry returns success",
setupRegistryService: func(_ service.RegistryService) {
// Test empty registry - empty setup
},
expectedStatus: http.StatusOK,
expectedServers: []apiv0.ServerJSON{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
tc.setupRegistryService(registryService)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, registryService)
// Create request
url := "/v0/servers" + tc.queryParams
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
// Serve the request
mux.ServeHTTP(w, req)
// Check status code
assert.Equal(t, tc.expectedStatus, w.Code)
if tc.expectedStatus == http.StatusOK {
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Parse response body
var resp struct {
Servers []apiv0.ServerJSON `json:"servers"`
Metadata *v0.Metadata `json:"metadata,omitempty"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Check the response data
if tc.expectedServers != nil {
assert.Equal(t, tc.expectedServers, resp.Servers)
} else {
// For tests with dynamic data, check structure and count
assert.NotEmpty(t, resp.Servers, "Expected at least one server")
for _, server := range resp.Servers {
assert.NotEmpty(t, server.Name)
assert.NotEmpty(t, server.Description)
assert.NotNil(t, server.Meta)
assert.NotNil(t, server.Meta.IOModelContextProtocolRegistry)
assert.NotEmpty(t, server.Meta.IOModelContextProtocolRegistry.ID)
}
}
// Check metadata if expected
if tc.expectedMeta != nil {
assert.NotNil(t, resp.Metadata, "Expected metadata to be present")
if resp.Metadata != nil {
assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count)
if tc.expectedMeta.NextCursor != "" {
assert.NotEmpty(t, resp.Metadata.NextCursor)
}
}
}
} else if tc.expectedError != "" {
// Check error message for non-200 responses
assert.Contains(t, w.Body.String(), tc.expectedError)
}
// Verify mock expectations
// No expectations to verify with real service
})
}
}
func TestServersDetailEndpoint(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
testServer, err := registryService.Publish(apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
})
assert.NoError(t, err)
testCases := []struct {
name string
serverID string
expectedStatus int
expectedServer *apiv0.ServerJSON
expectedError string
}{
{
name: "successful get server detail",
serverID: testServer.Meta.IOModelContextProtocolRegistry.ID,
expectedStatus: http.StatusOK,
},
{
name: "invalid server ID format",
serverID: "invalid-uuid",
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "validation failed",
},
{
name: "server not found",
serverID: uuid.New().String(),
expectedStatus: http.StatusNotFound,
expectedError: "Server not found",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, registryService)
// Create request
url := "/v0/servers/" + tc.serverID
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
// Serve the request
mux.ServeHTTP(w, req)
// Check status code
assert.Equal(t, tc.expectedStatus, w.Code)
if tc.expectedStatus == http.StatusOK {
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Parse response body
var serverDetailResp apiv0.ServerJSON
err := json.NewDecoder(w.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check that we got a valid response
assert.NotEmpty(t, serverDetailResp.Name)
} else if tc.expectedError != "" {
// Check error message for non-200 responses
assert.Contains(t, w.Body.String(), tc.expectedError)
}
// Verify mock expectations
// No expectations to verify with real service
})
}
}
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
func TestServersEndpointsIntegration(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Test data - publish a server and get its actual ID
testServer := apiv0.ServerJSON{
Name: "com.example/integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
URL: "https://github.com/example/integration-test",
Source: "github",
ID: "example/integration-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
published, err := registryService.Publish(testServer)
assert.NoError(t, err)
assert.NotNil(t, published)
serverID := published.Meta.IOModelContextProtocolRegistry.ID
servers := []apiv0.ServerJSON{*published}
serverDetail := published
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, registryService)
// Create test server
server := httptest.NewServer(mux)
defer server.Close()
// Test list endpoint
t.Run("list servers integration", func(t *testing.T) {
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var listResp struct {
Servers []apiv0.ServerJSON `json:"servers"`
}
err = json.NewDecoder(resp.Body).Decode(&listResp)
assert.NoError(t, err)
// Check the response data (excluding timestamps which will be different)
assert.Len(t, listResp.Servers, len(servers))
if len(listResp.Servers) > 0 {
assert.Equal(t, servers[0].Name, listResp.Servers[0].Name)
assert.Equal(t, servers[0].Description, listResp.Servers[0].Description)
assert.Equal(t, servers[0].Repository, listResp.Servers[0].Repository)
assert.Equal(t, servers[0].VersionDetail, listResp.Servers[0].VersionDetail)
}
})
// Test get server detail endpoint
t.Run("get server detail integration", func(t *testing.T) {
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/v0/servers/"+serverID, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Check content type
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var serverDetailResp apiv0.ServerJSON
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check the response data (excluding timestamps which will be different)
assert.Equal(t, serverDetail.Name, serverDetailResp.Name)
assert.Equal(t, serverDetail.Description, serverDetailResp.Description)
assert.Equal(t, serverDetail.Repository, serverDetailResp.Repository)
assert.Equal(t, serverDetail.VersionDetail, serverDetailResp.VersionDetail)
})
// Verify mock expectations
// No expectations to verify with real service
}