diff --git a/justfile b/justfile index 0cf37c8a8..b5aba8672 100644 --- a/justfile +++ b/justfile @@ -234,6 +234,18 @@ test *args=("-short ./..."): go test {{ args }} -covermode set -coverprofile=cover.out +# Build the Connect API contract test harness binary +build-connect-harness: + go build -o test/connect-api-contracts/harness/harness ./test/connect-api-contracts/harness/ + +# Run Connect API contract tests (builds harness automatically via setup.ts) +test-connect-contracts: + #!/usr/bin/env bash + set -eou pipefail + {{ _with_debug }} + + cd test/connect-api-contracts && npx vitest run + # Execute Python script tests (licenses, prepare-release, etc.) test-scripts: #!/usr/bin/env bash diff --git a/test/connect-api-contracts/.gitignore b/test/connect-api-contracts/.gitignore new file mode 100644 index 000000000..4a5ff53a4 --- /dev/null +++ b/test/connect-api-contracts/.gitignore @@ -0,0 +1,2 @@ +harness/harness +node_modules/ diff --git a/test/connect-api-contracts/README.md b/test/connect-api-contracts/README.md new file mode 100644 index 000000000..79ab37035 --- /dev/null +++ b/test/connect-api-contracts/README.md @@ -0,0 +1,98 @@ +# Connect API Contract Tests + +Contract tests that validate the HTTP requests Publisher sends **to Posit Connect** and how it parses Connect's responses. These ensure a future TypeScript ConnectClient produces identical behavior to the Go implementation. + +This is fundamentally different from the [Publisher API contract tests](../api-contracts/) — those test Publisher's own API surface, while these test Publisher's role as a **client** of Connect's API. + +## Architecture + +``` +Test code → Go harness → Mock Connect server (Node.js) + POST /test-authentication GET /__api__/v1/user + POST /create-deployment POST /__api__/v1/content + (1:1 per APIClient method) (canned Connect response) +``` + +Two servers are involved: + +1. **Mock Connect server** — A Node.js HTTP server that simulates Connect's API endpoints with canned JSON responses and captures all incoming requests for assertion. +2. **Go harness** — A thin HTTP server that wraps each `APIClient` method as its own endpoint. Each request creates a fresh `ConnectClient` pointed at the mock, calls the target method, and returns the result as JSON. + +The mock exposes control endpoints for tests: +- `GET /__test__/requests` — Read all captured requests +- `DELETE /__test__/requests` — Clear captured requests +- `POST /__test__/response-override` — Set a response override +- `DELETE /__test__/response-overrides` — Clear all response overrides + +## What's tested + +All 15 methods on the Go `APIClient` interface have corresponding test files, mock routes, and fixtures. + +| Endpoint | Connect Path | Harness Endpoint | +|----------|-------------|-----------------| +| `TestAuthentication` | `GET /__api__/v1/user` | `POST /test-authentication` | +| `GetCurrentUser` | `GET /__api__/v1/user` | `POST /get-current-user` | +| `ContentDetails` | `GET /__api__/v1/content/:id` | `POST /content-details` | +| `CreateDeployment` | `POST /__api__/v1/content` | `POST /create-deployment` | +| `UpdateDeployment` | `PATCH /__api__/v1/content/:id` | `POST /update-deployment` | +| `GetEnvVars` | `GET /__api__/v1/content/:id/environment` | `POST /get-env-vars` | +| `SetEnvVars` | `PATCH /__api__/v1/content/:id/environment` | `POST /set-env-vars` | +| `UploadBundle` | `POST /__api__/v1/content/:id/bundles` | `POST /upload-bundle` | +| `DeployBundle` | `POST /__api__/v1/content/:id/deploy` | `POST /deploy-bundle` | +| `WaitForTask` | `GET /__api__/v1/tasks/:id?first=N` | `POST /wait-for-task` | +| `ValidateDeployment` | `GET /content/:id/` | `POST /validate-deployment` | +| `GetIntegrations` | `GET /__api__/v1/oauth/integrations` | `POST /get-integrations` | +| `GetSettings` | 7 endpoints (see below) | `POST /get-settings` | +| `LatestBundleID` | `GET /__api__/v1/content/:id` | `POST /latest-bundle-id` | +| `DownloadBundle` | `GET /__api__/v1/content/:id/bundles/:bid/download` | `POST /download-bundle` | + +`GetSettings` calls 7 endpoints in sequence: `/__api__/v1/user`, `/__api__/server_settings`, `/__api__/server_settings/applications`, `/__api__/server_settings/scheduler[/{appMode}]`, `/__api__/v1/server_settings/python`, `/__api__/v1/server_settings/r`, `/__api__/v1/server_settings/quarto`. + +Each test validates both: +- **Request correctness** — method, path, `Authorization: Key ` header +- **Response parsing** — Publisher correctly transforms Connect's DTO into its internal types + +## Client implementations + +| Client | Description | +|--------|-------------| +| `GoPublisherClient` | Calls the Go harness, which internally calls mock Connect | +| `TypeScriptDirectClient` | Stub for future TS ConnectClient (all methods throw) | + +Set `API_BACKEND=typescript` to run against the TS client once implemented. + +## Running + +```bash +# Run Connect contract tests (harness is built automatically) +just test-connect-contracts + +# Or directly +cd test/connect-api-contracts && npx vitest run + +# Build the harness binary manually +just build-connect-harness + +# Update snapshots +cd test/connect-api-contracts && npx vitest run --update +``` + +## Adding tests + +1. Add a method to the `ConnectContractClient` interface in `src/client.ts` +2. Implement it in both `src/clients/go-publisher-client.ts` and `src/clients/ts-direct-client.ts` +3. Add a handler in the Go harness (`harness/main.go`) +4. Add a route handler in `src/mock-connect-server.ts` with a canned response fixture +5. Create a test file in `src/endpoints/` +6. Use `getClient()` from `src/helpers.ts` to get the appropriate client + +## Fixture files + +- `src/fixtures/connect-responses/` — Canned JSON responses for Connect API endpoints +- `src/fixtures/workspace/` — Minimal project files (used by GetSettings for config) + +## Future expansion + +When the TS ConnectClient is built: +1. Implement `ts-direct-client.ts` to call the TS client directly against the mock +2. Both Go and TS paths validate against the same snapshots and request expectations diff --git a/test/connect-api-contracts/harness/main.go b/test/connect-api-contracts/harness/main.go new file mode 100644 index 000000000..2afb24361 --- /dev/null +++ b/test/connect-api-contracts/harness/main.go @@ -0,0 +1,262 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "net" + "net/http" + "os" + "time" + + "github.com/posit-dev/publisher/internal/accounts" + "github.com/posit-dev/publisher/internal/clients/connect" + "github.com/posit-dev/publisher/internal/config" + "github.com/posit-dev/publisher/internal/contenttypes" + "github.com/posit-dev/publisher/internal/events" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/server_type" + "github.com/posit-dev/publisher/internal/types" + "github.com/posit-dev/publisher/internal/util" + + "github.com/spf13/afero" +) + +var log = logging.NewDiscardLogger() + +// callRequest is the single request body for POST /call. +type callRequest struct { + Method string `json:"method"` + ConnectURL string `json:"connectUrl"` + ApiKey string `json:"apiKey"` + ContentID string `json:"contentId,omitempty"` + BundleID string `json:"bundleId,omitempty"` + TaskID string `json:"taskId,omitempty"` + Body json.RawMessage `json:"body,omitempty"` + Env map[string]string `json:"env,omitempty"` + BundleData string `json:"bundleData,omitempty"` // base64 +} + +// callResponse is returned by every harness call. +type callResponse struct { + Status string `json:"status"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` + CapturedRequests []any `json:"capturedRequests"` +} + +func newClient(connectURL, apiKey string) (connect.APIClient, error) { + account := &accounts.Account{ + URL: connectURL, + ApiKey: apiKey, + ServerType: server_type.ServerTypeConnect, + } + return connect.NewConnectClient(account, 30*time.Second, events.NewNullEmitter(), log) +} + +// clearMockRequests tells the mock server to forget all captured requests. +func clearMockRequests(mockURL string) error { + req, err := http.NewRequest("DELETE", mockURL+"/__test__/requests", nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// fetchCapturedRequests reads captured requests from the mock server. +func fetchCapturedRequests(mockURL string) ([]any, error) { + resp, err := http.Get(mockURL + "/__test__/requests") + if err != nil { + return nil, err + } + defer resp.Body.Close() + var requests []any + if err := json.NewDecoder(resp.Body).Decode(&requests); err != nil { + return nil, err + } + return requests, nil +} + +// dispatch calls the appropriate APIClient method and returns the result. +func dispatch(client connect.APIClient, req *callRequest) (any, error) { + switch req.Method { + case "TestAuthentication": + user, err := client.TestAuthentication(log) + if err != nil { + return map[string]any{"user": nil, "error": map[string]string{"msg": err.Error()}}, err + } + return map[string]any{"user": user, "error": nil}, nil + + case "GetCurrentUser": + return client.GetCurrentUser(log) + + case "CreateDeployment": + var body connect.ConnectContent + if req.Body != nil { + if err := json.Unmarshal(req.Body, &body); err != nil { + return nil, err + } + } + id, err := client.CreateDeployment(&body, log) + if err != nil { + return nil, err + } + return map[string]any{"contentId": id}, nil + + case "ContentDetails": + var body connect.ConnectContent + err := client.ContentDetails(types.ContentID(req.ContentID), &body, log) + if err != nil { + return nil, err + } + return body, nil + + case "UpdateDeployment": + var body connect.ConnectContent + if req.Body != nil { + if err := json.Unmarshal(req.Body, &body); err != nil { + return nil, err + } + } + return nil, client.UpdateDeployment(types.ContentID(req.ContentID), &body, log) + + case "GetEnvVars": + return client.GetEnvVars(types.ContentID(req.ContentID), log) + + case "SetEnvVars": + return nil, client.SetEnvVars(types.ContentID(req.ContentID), config.Environment(req.Env), log) + + case "UploadBundle": + data, err := base64.StdEncoding.DecodeString(req.BundleData) + if err != nil { + return nil, err + } + id, err := client.UploadBundle(types.ContentID(req.ContentID), bytes.NewReader(data), log) + if err != nil { + return nil, err + } + return map[string]any{"bundleId": id}, nil + + case "DeployBundle": + id, err := client.DeployBundle(types.ContentID(req.ContentID), types.BundleID(req.BundleID), log) + if err != nil { + return nil, err + } + return map[string]any{"taskId": id}, nil + + case "WaitForTask": + if err := client.WaitForTask(types.TaskID(req.TaskID), log); err != nil { + return nil, err + } + return map[string]any{"finished": true}, nil + + case "ValidateDeployment": + return nil, client.ValidateDeployment(types.ContentID(req.ContentID), log) + + case "GetIntegrations": + return client.GetIntegrations(log) + + case "GetSettings": + tmpDir, err := os.MkdirTemp("", "harness-settings-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + cfg := &config.Config{Type: contenttypes.ContentType("python-fastapi")} + base := util.NewAbsolutePath(tmpDir, afero.NewOsFs()) + return client.GetSettings(base, cfg, log) + + case "LatestBundleID": + id, err := client.LatestBundleID(types.ContentID(req.ContentID), log) + if err != nil { + return nil, err + } + return map[string]any{"bundleId": id}, nil + + case "DownloadBundle": + data, err := client.DownloadBundle(types.ContentID(req.ContentID), types.BundleID(req.BundleID), log) + if err != nil { + return nil, err + } + return base64.StdEncoding.EncodeToString(data), nil + + default: + return nil, fmt.Errorf("unknown method: %s", req.Method) + } +} + +func handleCall(w http.ResponseWriter, r *http.Request) { + var req callRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeResponse(w, callResponse{Status: "error", Error: err.Error()}) + return + } + + client, err := newClient(req.ConnectURL, req.ApiKey) + if err != nil { + writeResponse(w, callResponse{Status: "error", Error: err.Error()}) + return + } + + // Clear captured requests on the mock before calling the method. + if err := clearMockRequests(req.ConnectURL); err != nil { + writeResponse(w, callResponse{Status: "error", Error: "failed to clear mock: " + err.Error()}) + return + } + + result, err := dispatch(client, &req) + + // Fetch captured requests from the mock after the call. + captured, _ := fetchCapturedRequests(req.ConnectURL) + + resp := callResponse{ + Status: "success", + Result: result, + CapturedRequests: captured, + } + if err != nil { + resp.Status = "error" + resp.Error = err.Error() + } + writeResponse(w, resp) +} + +func writeResponse(w http.ResponseWriter, resp callResponse) { + if resp.CapturedRequests == nil { + resp.CapturedRequests = []any{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func main() { + listen := flag.String("listen", "localhost:0", "Address to listen on") + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("POST /call", handleCall) + + ln, err := net.Listen("tcp", *listen) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err) + os.Exit(1) + } + + fmt.Printf("http://%s\n", ln.Addr().String()) + os.Stdout.Sync() + + if err := http.Serve(ln, mux); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } + + _ = io.Discard // keep import for potential future use +} diff --git a/test/connect-api-contracts/package-lock.json b/test/connect-api-contracts/package-lock.json new file mode 100644 index 000000000..da8363023 --- /dev/null +++ b/test/connect-api-contracts/package-lock.json @@ -0,0 +1,1598 @@ +{ + "name": "connect-api-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-api-contracts", + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/connect-api-contracts/package.json b/test/connect-api-contracts/package.json new file mode 100644 index 000000000..1fec51887 --- /dev/null +++ b/test/connect-api-contracts/package.json @@ -0,0 +1,14 @@ +{ + "name": "connect-api-contracts", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:update": "vitest run --update" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/test/connect-api-contracts/src/client.ts b/test/connect-api-contracts/src/client.ts new file mode 100644 index 000000000..de4bf5828 --- /dev/null +++ b/test/connect-api-contracts/src/client.ts @@ -0,0 +1,103 @@ +import type { CapturedRequest } from "./mock-connect-server"; + +export type ConnectContractStatus = "success" | "error"; + +export interface ConnectContractResult { + status: ConnectContractStatus; + result: T; + capturedRequest: CapturedRequest | null; + capturedRequests?: CapturedRequest[]; +} + +export interface ConnectContractClient { + testAuthentication(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + getCurrentUser(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + createDeployment(params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise; + + contentDetails(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + updateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise; + + getEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + setEnvVars(params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise; + + uploadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise; + + deployBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; + + waitForTask(params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise; + + validateDeployment(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + getIntegrations(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + getSettings(params: { + connectUrl: string; + apiKey: string; + }): Promise; + + latestBundleId(params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise; + + downloadBundle(params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise; +} diff --git a/test/connect-api-contracts/src/clients/go-publisher-client.ts b/test/connect-api-contracts/src/clients/go-publisher-client.ts new file mode 100644 index 000000000..c41dce89a --- /dev/null +++ b/test/connect-api-contracts/src/clients/go-publisher-client.ts @@ -0,0 +1,106 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; + +interface HarnessResponse { + status: "success" | "error"; + result: unknown; + error?: string; + capturedRequests: Array<{ + method: string; + path: string; + headers: Record; + body: string | null; + }>; +} + +export class GoPublisherClient implements ConnectContractClient { + constructor(private apiBase: string) {} + + /** + * Call the Go harness with a method name and params. + * The harness clears mock requests, calls the Go method, fetches captured + * requests, and returns everything in one response. + */ + private async call( + method: string, + params: Record, + ): Promise { + const res = await fetch(`${this.apiBase}/call`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method, ...params }), + }); + const hr: HarnessResponse = await res.json(); + return { + status: hr.status, + result: hr.result, + capturedRequest: hr.capturedRequests.length > 0 ? hr.capturedRequests[0] : null, + capturedRequests: hr.capturedRequests, + }; + } + + testAuthentication(params: { connectUrl: string; apiKey: string }) { + return this.call("TestAuthentication", params); + } + + getCurrentUser(params: { connectUrl: string; apiKey: string }) { + return this.call("GetCurrentUser", params); + } + + createDeployment(params: { connectUrl: string; apiKey: string; body: unknown }) { + return this.call("CreateDeployment", params); + } + + contentDetails(params: { connectUrl: string; apiKey: string; contentId: string }) { + return this.call("ContentDetails", params); + } + + updateDeployment(params: { connectUrl: string; apiKey: string; contentId: string; body: unknown }) { + return this.call("UpdateDeployment", params); + } + + getEnvVars(params: { connectUrl: string; apiKey: string; contentId: string }) { + return this.call("GetEnvVars", params); + } + + setEnvVars(params: { connectUrl: string; apiKey: string; contentId: string; env: Record }) { + return this.call("SetEnvVars", params); + } + + async uploadBundle(params: { connectUrl: string; apiKey: string; contentId: string; bundleData: Uint8Array }) { + const { bundleData, ...rest } = params; + return this.call("UploadBundle", { ...rest, bundleData: Buffer.from(bundleData).toString("base64") }); + } + + deployBundle(params: { connectUrl: string; apiKey: string; contentId: string; bundleId: string }) { + return this.call("DeployBundle", params); + } + + waitForTask(params: { connectUrl: string; apiKey: string; taskId: string }) { + return this.call("WaitForTask", params); + } + + validateDeployment(params: { connectUrl: string; apiKey: string; contentId: string }) { + return this.call("ValidateDeployment", params); + } + + getIntegrations(params: { connectUrl: string; apiKey: string }) { + return this.call("GetIntegrations", params); + } + + getSettings(params: { connectUrl: string; apiKey: string }) { + return this.call("GetSettings", params); + } + + latestBundleId(params: { connectUrl: string; apiKey: string; contentId: string }) { + return this.call("LatestBundleID", params); + } + + async downloadBundle(params: { connectUrl: string; apiKey: string; contentId: string; bundleId: string }) { + const result = await this.call("DownloadBundle", params); + // Decode base64 result back to Uint8Array + if (result.status === "success" && typeof result.result === "string") { + result.result = new Uint8Array(Buffer.from(result.result, "base64")); + } + return result; + } +} diff --git a/test/connect-api-contracts/src/clients/ts-direct-client.ts b/test/connect-api-contracts/src/clients/ts-direct-client.ts new file mode 100644 index 000000000..2abe70aac --- /dev/null +++ b/test/connect-api-contracts/src/clients/ts-direct-client.ts @@ -0,0 +1,129 @@ +import type { ConnectContractClient, ConnectContractResult } from "../client"; + +/** + * Stub client for the future TypeScript ConnectClient implementation. + * Each method will call the TS client directly against the mock Connect server. + * For now, all methods throw "Not implemented yet". + */ +export class TypeScriptDirectClient implements ConnectContractClient { + async testAuthentication(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getCurrentUser(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async createDeployment(_params: { + connectUrl: string; + apiKey: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async contentDetails(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async updateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + body: unknown; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async setEnvVars(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + env: Record; + }): Promise { + throw new Error("Not implemented yet"); + } + + async uploadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleData: Uint8Array; + }): Promise { + throw new Error("Not implemented yet"); + } + + async deployBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async waitForTask(_params: { + connectUrl: string; + apiKey: string; + taskId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async validateDeployment(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getIntegrations(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async getSettings(_params: { + connectUrl: string; + apiKey: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async latestBundleId(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + }): Promise { + throw new Error("Not implemented yet"); + } + + async downloadBundle(_params: { + connectUrl: string; + apiKey: string; + contentId: string; + bundleId: string; + }): Promise { + throw new Error("Not implemented yet"); + } +} diff --git a/test/connect-api-contracts/src/endpoints/authentication.test.ts b/test/connect-api-contracts/src/endpoints/authentication.test.ts new file mode 100644 index 000000000..5663d1fb0 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/authentication.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe("TestAuthentication", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses user fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + }; + + // Publisher maps Connect's UserDTO (12 fields) down to User (5 fields) + expect(body.user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + + it("returns null error on success", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { error: unknown }; + + expect(body.error).toBeNull(); + }); + }); + + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.user).toBeNull(); + }); + + it("returns error for locked user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: true, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("locked"); + expect(body.user).toBeNull(); + }); + + it("returns error for unconfirmed user account", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "publisher", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: false, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("not confirmed"); + expect(body.user).toBeNull(); + }); + + it("returns error for viewer role user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/user$", + status: 200, + body: { + email: "bob@example.com", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + user_role: "viewer", + created_time: "2023-01-01T00:00:00Z", + updated_time: "2023-01-01T00:00:00Z", + active_time: null, + confirmed: true, + locked: false, + guid: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + }, + }); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + const body = result.result as { + user: unknown; + error: { msg: string } | null; + }; + + expect(result.status).toBe("error"); + expect(body.error).not.toBeNull(); + expect(body.error!.msg.toLowerCase()).toContain("permission"); + expect(body.user).toBeNull(); + }); + }); + + describe("snapshot", () => { + it("matches expected response shape", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.testAuthentication({ connectUrl, apiKey }); + + expect(result.result).toMatchInlineSnapshot(` + { + "error": null, + "user": { + "email": "bob@example.com", + "first_name": "Bob", + "id": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "last_name": "Bobberson", + "username": "bob", + }, + } + `); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/content-details.test.ts b/test/connect-api-contracts/src/endpoints/content-details.test.ts new file mode 100644 index 000000000..4a4cf57be --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/content-details.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe("ContentDetails", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses ConnectContent fields from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as Record; + + expect(body.guid).toBe(contentId); + expect(body.name).toBe("my-fastapi-app"); + expect(body.app_mode).toBe("python-fastapi"); + }); + }); + + describe("error handling", () => { + it("returns error for 401 unauthorized response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 401, + body: { code: 3, error: "Key is not valid" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 403 forbidden response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 403, + body: { + code: 4, + error: "You do not have permission to perform this operation", + }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns error for 404 not found response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/content/[^/]+$", + status: 404, + body: { code: 4, error: "Content not found" }, + }); + + const result = await client.contentDetails({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/create-deployment.test.ts b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts new file mode 100644 index 000000000..ff5827d0d --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/create-deployment.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("CreateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/content"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { name: "my-app", title: "My App" }; + const result = await client.createDeployment({ + connectUrl, + apiKey, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + + expect(result.status).toBe("success"); + }); + + it("parses content GUID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.createDeployment({ + connectUrl, + apiKey, + body: {}, + }); + const body = result.result as { contentId: string }; + + expect(body.contentId).toBe(contentId); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts new file mode 100644 index 000000000..8cac46d94 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/deploy-bundle.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("DeployBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "201"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/deploy", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/deploy`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends bundle_id in request body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual({ bundle_id: bundleId }); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses task ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.deployBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const body = result.result as { taskId: string }; + + expect(body.taskId).toBe("task-abc123-def456"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/download-bundle.test.ts b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts new file mode 100644 index 000000000..8e8a052a3 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/download-bundle.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("DownloadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const bundleId = "101"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/bundles/:bid/download", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles/${bundleId}/download`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + + expect(result.status).toBe("success"); + }); + + it("returns raw bytes from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.downloadBundle({ + connectUrl, + apiKey, + contentId, + bundleId, + }); + const data = result.result as Uint8Array; + + expect(data).toBeInstanceOf(Uint8Array); + expect(data.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-current-user.test.ts b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts new file mode 100644 index 000000000..9d92e5f0b --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-current-user.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("GetCurrentUser", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe("/__api__/v1/user"); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses User fields from Connect UserDTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getCurrentUser({ connectUrl, apiKey }); + const user = result.result as { + id: string; + username: string; + first_name: string; + last_name: string; + email: string; + }; + + expect(user).toEqual({ + id: "40d1c1dc-d554-4905-99f1-359517e1a7c0", + username: "bob", + first_name: "Bob", + last_name: "Bobberson", + email: "bob@example.com", + }); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts new file mode 100644 index 000000000..9c8af49ed --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-env-vars.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("GetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("parses environment variable name list", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getEnvVars({ + connectUrl, + apiKey, + contentId, + }); + const envVars = result.result as string[]; + + expect(envVars).toEqual(["DATABASE_URL", "SECRET_KEY", "API_TOKEN"]); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-integrations.test.ts b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts new file mode 100644 index 000000000..390bb2c33 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-integrations.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("GetIntegrations", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/oauth/integrations", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + "/__api__/v1/oauth/integrations", + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + + expect(result.status).toBe("success"); + }); + + it("parses Integration array with expected fields", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.getIntegrations({ connectUrl, apiKey }); + const integrations = result.result as Array<{ + guid: string; + name: string; + description: string; + auth_type: string; + template: string; + config: Record; + created_time: string; + }>; + + expect(integrations).toBeInstanceOf(Array); + expect(integrations.length).toBe(1); + expect(integrations[0].guid).toBe( + "int-guid-1234-5678-abcd-ef0123456789", + ); + expect(integrations[0].name).toBe("My OAuth Integration"); + expect(integrations[0].auth_type).toBe("OAuth2"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/get-settings.test.ts b/test/connect-api-contracts/src/endpoints/get-settings.test.ts new file mode 100644 index 000000000..2c83f44e0 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/get-settings.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, +} from "../helpers"; +import type { CapturedRequest } from "../mock-connect-server"; + +describe("GetSettings", () => { + const apiKey = "test-api-key-12345"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + // Helper: call getSettings and return all captured requests. + async function callAndCapture() { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + const result = await client.getSettings({ connectUrl, apiKey }); + const all = (result.capturedRequests ?? []) as CapturedRequest[]; + return { result, all }; + } + + function filterByPath(requests: CapturedRequest[], pathFragment: string) { + return requests.filter((r) => r.path.includes(pathFragment)); + } + + describe("request correctness", () => { + it("sends GET to /__api__/v1/user", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/v1/user"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings", async () => { + const { all } = await callAndCapture(); + const generalReq = all.find( + (r) => r.path === "/__api__/server_settings", + ); + expect(generalReq).toBeDefined(); + expect(generalReq!.method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/applications", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/server_settings/applications"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/server_settings/scheduler", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/server_settings/scheduler"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/python", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/v1/server_settings/python"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/r", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/v1/server_settings/r"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends GET to /__api__/v1/server_settings/quarto", async () => { + const { all } = await callAndCapture(); + const matched = filterByPath(all, "/__api__/v1/server_settings/quarto"); + expect(matched.length).toBeGreaterThanOrEqual(1); + expect(matched[0].method).toBe("GET"); + }); + + it("sends Authorization header on all 7 requests", async () => { + const { all } = await callAndCapture(); + expect(all.length).toBeGreaterThanOrEqual(7); + + for (const req of all) { + expect(req.headers["authorization"]).toBe(`Key ${apiKey}`); + } + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const { result } = await callAndCapture(); + expect(result.status).toBe("success"); + }); + + it("parses composite settings from all endpoints", async () => { + const { result } = await callAndCapture(); + const settings = result.result as Record; + expect(settings).toBeDefined(); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts new file mode 100644 index 000000000..67e600147 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/latest-bundle-id.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, +} from "../helpers"; + +describe("LatestBundleID", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + + it("extracts bundle_id from content DTO", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.latestBundleId({ + connectUrl, + apiKey, + contentId, + }); + const body = result.result as { bundleId: string }; + + // content-details.json has bundle_id: "101" + expect(body.bundleId).toBe("101"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts new file mode 100644 index 000000000..3fe93ba03 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/set-env-vars.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("SetEnvVars", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id/environment", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/environment`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends env vars as [{name, value}] array body", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db", SECRET: "abc" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + const body = JSON.parse(result.capturedRequest!.body!); + expect(body).toEqual( + expect.arrayContaining([ + { name: "DATABASE_URL", value: "postgres://localhost/db" }, + { name: "SECRET", value: "abc" }, + ]), + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.setEnvVars({ + connectUrl, + apiKey, + contentId, + env: { DATABASE_URL: "postgres://localhost/db" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/update-deployment.test.ts b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts new file mode 100644 index 000000000..64ab310ab --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/update-deployment.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("UpdateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends PATCH to /__api__/v1/content/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("PATCH"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends ConnectContent body as JSON", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const body = { title: "Updated Title", description: "New description" }; + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toContain( + "application/json", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 204 no-body response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.updateDeployment({ + connectUrl, + apiKey, + contentId, + body: { title: "Updated Title" }, + }); + + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts new file mode 100644 index 000000000..c37f85c6d --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/upload-bundle.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getClient, getMockConnectUrl, clearMockRequests, clearMockOverrides } from "../helpers"; + +describe("UploadBundle", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends POST to /__api__/v1/content/:id/bundles", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("POST"); + expect(result.capturedRequest!.path).toBe( + `/__api__/v1/content/${contentId}/bundles`, + ); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + + it("sends Content-Type application/gzip", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["content-type"]).toBe( + "application/gzip", + ); + }); + }); + + describe("response parsing", () => { + it("returns success status", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + + expect(result.status).toBe("success"); + }); + + it("parses bundle ID from response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const bundleData = new Uint8Array([0x1f, 0x8b]); + const result = await client.uploadBundle({ + connectUrl, + apiKey, + contentId, + bundleData, + }); + const body = result.result as { bundleId: string }; + + expect(body.bundleId).toBe("201"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts new file mode 100644 index 000000000..7c2ab1fa0 --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/validate-deployment.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe("ValidateDeployment", () => { + const apiKey = "test-api-key-12345"; + const contentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /content/:id/ (non-API path)", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toBe(`/content/${contentId}/`); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status for 200 response", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("success"); + }); + }); + + describe("error handling", () => { + it("returns error when content responds with 500", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 500, + body: "Internal Server Error", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + expect(result.status).toBe("error"); + }); + + it("returns success when content responds with 404", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/content/[^/]+/$", + status: 404, + body: "Not Found", + contentType: "text/html", + }); + + const result = await client.validateDeployment({ + connectUrl, + apiKey, + contentId, + }); + + // 404 is acceptable — content may not be running yet + expect(result.status).toBe("success"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts new file mode 100644 index 000000000..b3d1f627d --- /dev/null +++ b/test/connect-api-contracts/src/endpoints/wait-for-task.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClient, + getMockConnectUrl, + clearMockRequests, + clearMockOverrides, + setMockResponse, +} from "../helpers"; + +describe("WaitForTask", () => { + const apiKey = "test-api-key-12345"; + const taskId = "task-abc123-def456"; + + beforeEach(async () => { + await clearMockOverrides(); + await clearMockRequests(); + }); + + describe("request correctness", () => { + it("sends GET to /__api__/v1/tasks/:id", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.method).toBe("GET"); + expect(result.capturedRequest!.path).toMatch( + /^\/__api__\/v1\/tasks\/task-abc123-def456/, + ); + }); + + it("includes first query parameter for pagination", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.path).toContain("first="); + }); + + it("sends Authorization header with Key prefix", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.capturedRequest).not.toBeNull(); + expect(result.capturedRequest!.headers["authorization"]).toBe( + `Key ${apiKey}`, + ); + }); + }); + + describe("response parsing", () => { + it("returns success status when task finishes with code 0", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("success"); + }); + + it("returns finished indicator", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + const task = result.result as { finished: boolean }; + + expect(task.finished).toBe(true); + }); + }); + + describe("error handling", () => { + it("returns error when task finishes with non-zero exit code", async () => { + const client = getClient(); + const connectUrl = getMockConnectUrl(); + + await setMockResponse({ + method: "GET", + pathPattern: "^/__api__/v1/tasks/", + status: 200, + body: { + id: "task-abc123-def456", + output: [ + "Building Python application...", + "Bundle requested Python version 3.11.6", + "Error code: python-package-install-failed", + ], + result: null, + finished: true, + code: 1, + error: "Error code: python-package-install-failed", + last: 3, + }, + }); + + const result = await client.waitForTask({ + connectUrl, + apiKey, + taskId, + }); + + expect(result.status).toBe("error"); + }); + }); +}); diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json new file mode 100644 index 000000000..4de212760 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/bundle-upload.json @@ -0,0 +1,20 @@ +{ + "id": "201", + "content_guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "created_time": "2023-06-15T11:00:00Z", + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "active": false, + "size": 4096, + "metadata": { + "source": null, + "source_repo": null, + "source_branch": null, + "source_commit": null, + "archive_md5": "d41d8cd98f00b204e9800998ecf8427e", + "archive_sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + } +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json new file mode 100644 index 000000000..224697a03 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-create.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T10:30:00Z", + "bundle_id": null, + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json new file mode 100644 index 000000000..34e3b0284 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/content-details.json @@ -0,0 +1,33 @@ +{ + "guid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "my-fastapi-app", + "title": "My FastAPI App", + "description": "A sample FastAPI application", + "access_type": "acl", + "connection_timeout": null, + "read_timeout": null, + "init_timeout": null, + "idle_timeout": null, + "max_processes": null, + "min_processes": null, + "max_conns_per_process": null, + "load_factor": null, + "created_time": "2023-06-15T10:30:00Z", + "last_deployed_time": "2023-06-15T12:00:00Z", + "bundle_id": "101", + "app_mode": "python-fastapi", + "content_category": "", + "parameterized": false, + "cluster_name": null, + "image_name": null, + "r_version": null, + "py_version": "3.11.6", + "quarto_version": null, + "run_as": null, + "run_as_current_user": false, + "owner_guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0", + "content_url": "https://connect.example.com/content/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "dashboard_url": "https://connect.example.com/connect/#/apps/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "app_role": "owner", + "id": "42" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json new file mode 100644 index 000000000..a22a89fe8 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/deploy.json @@ -0,0 +1,3 @@ +{ + "task_id": "task-abc123-def456" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/environment.json b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json new file mode 100644 index 000000000..8349928ac --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/environment.json @@ -0,0 +1 @@ +["DATABASE_URL", "SECRET_KEY", "API_TOKEN"] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json new file mode 100644 index 000000000..0d18c6161 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-401.json @@ -0,0 +1,4 @@ +{ + "code": 3, + "error": "Key is not valid" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json new file mode 100644 index 000000000..98317777e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-403.json @@ -0,0 +1,4 @@ +{ + "code": 4, + "error": "You do not have permission to perform this operation" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json new file mode 100644 index 000000000..ec5e0670c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/error-500.json @@ -0,0 +1,4 @@ +{ + "code": 0, + "error": "Internal server error" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json new file mode 100644 index 000000000..a03a88e08 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/integrations.json @@ -0,0 +1,11 @@ +[ + { + "guid": "int-guid-1234-5678-abcd-ef0123456789", + "name": "My OAuth Integration", + "description": "OAuth integration for external service", + "auth_type": "OAuth2", + "template": "custom", + "config": {}, + "created_time": "2023-06-01T09:00:00Z" + } +] diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json new file mode 100644 index 000000000..35dacd30a --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-applications.json @@ -0,0 +1,6 @@ +{ + "access_types": ["acl", "logged_in", "all"], + "run_as": "rstudio-connect", + "run_as_group": "", + "run_as_current_user": false +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json new file mode 100644 index 000000000..7b142a85e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-python.json @@ -0,0 +1,7 @@ +{ + "installations": [ + { "version": "3.11.6", "cluster_name": "", "image_name": "" }, + { "version": "3.10.12", "cluster_name": "", "image_name": "" } + ], + "api_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json new file mode 100644 index 000000000..f0810535f --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-quarto.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "1.4.550", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json new file mode 100644 index 000000000..20c599785 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-r.json @@ -0,0 +1,5 @@ +{ + "installations": [ + { "version": "4.3.1", "cluster_name": "", "image_name": "" } + ] +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json new file mode 100644 index 000000000..338c9d74c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings-scheduler.json @@ -0,0 +1,24 @@ +{ + "min_processes": 0, + "max_processes": 3, + "max_conns_per_process": 20, + "load_factor": 0.5, + "init_timeout": 60, + "idle_timeout": 120, + "min_processes_limit": 0, + "max_processes_limit": 20, + "connection_timeout": 5, + "read_timeout": 30, + "cpu_request": 0.0, + "max_cpu_request": 0.0, + "cpu_limit": 0.0, + "max_cpu_limit": 0.0, + "memory_request": 0, + "max_memory_request": 0, + "memory_limit": 0, + "max_memory_limit": 0, + "amd_gpu_limit": 0, + "max_amd_gpu_limit": 0, + "nvidia_gpu_limit": 0, + "max_nvidia_gpu_limit": 0 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json new file mode 100644 index 000000000..b3805df71 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/server-settings.json @@ -0,0 +1,19 @@ +{ + "license": { + "allow-apis": true, + "current-user-execution": false, + "enable-launcher": false, + "oauth-integrations": true + }, + "runtimes": ["python", "r"], + "git_enabled": true, + "git_available": true, + "execution_type": "native", + "enable_runtime_constraints": false, + "enable_image_management": false, + "default_image_selection_enabled": false, + "default_environment_management_selection": true, + "default_r_environment_management": true, + "default_py_environment_management": true, + "oauth_integrations_enabled": true +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json new file mode 100644 index 000000000..93e10f41d --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-failed.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Error code: python-package-install-failed"], + "result": null, + "finished": true, + "code": 1, + "error": "Error code: python-package-install-failed", + "last": 3 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json new file mode 100644 index 000000000..7ee8a80cc --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/task-finished.json @@ -0,0 +1,9 @@ +{ + "id": "task-abc123-def456", + "output": ["Building Python application...", "Bundle requested Python version 3.11.6", "Launching application...", "Application successfully deployed"], + "result": null, + "finished": true, + "code": 0, + "error": "", + "last": 4 +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json new file mode 100644 index 000000000..40bfb0b2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-locked.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": true, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json new file mode 100644 index 000000000..23c46708e --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-unconfirmed.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": false, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json new file mode 100644 index 000000000..a72b0fc2b --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user-viewer.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "viewer", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/connect-responses/user.json b/test/connect-api-contracts/src/fixtures/connect-responses/user.json new file mode 100644 index 000000000..34778cc95 --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/connect-responses/user.json @@ -0,0 +1,13 @@ +{ + "email": "bob@example.com", + "username": "bob", + "first_name": "Bob", + "last_name": "Bobberson", + "user_role": "publisher", + "created_time": "2023-01-01T00:00:00Z", + "updated_time": "2023-01-01T00:00:00Z", + "active_time": null, + "confirmed": true, + "locked": false, + "guid": "40d1c1dc-d554-4905-99f1-359517e1a7c0" +} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py new file mode 100644 index 000000000..af0afc8cf --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"message": "Hello World"} diff --git a/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt new file mode 100644 index 000000000..97dc7cd8c --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/fastapi-simple/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/test/connect-api-contracts/src/fixtures/workspace/static/index.html b/test/connect-api-contracts/src/fixtures/workspace/static/index.html new file mode 100644 index 000000000..05da19bbd --- /dev/null +++ b/test/connect-api-contracts/src/fixtures/workspace/static/index.html @@ -0,0 +1,9 @@ + + + + Test Static Content + + +

Hello from contract tests

+ + diff --git a/test/connect-api-contracts/src/helpers.ts b/test/connect-api-contracts/src/helpers.ts new file mode 100644 index 000000000..c8db0388d --- /dev/null +++ b/test/connect-api-contracts/src/helpers.ts @@ -0,0 +1,71 @@ +import type { ConnectContractClient } from "./client"; +import type { CapturedRequest } from "./mock-connect-server"; +import { GoPublisherClient } from "./clients/go-publisher-client"; +import { TypeScriptDirectClient } from "./clients/ts-direct-client"; + +let _client: ConnectContractClient | null = null; + +export function getClient(): ConnectContractClient { + if (_client) return _client; + + const clientType = process.env.__CLIENT_TYPE ?? "go"; + if (clientType === "go") { + const apiBase = process.env.API_BASE; + if (!apiBase) { + throw new Error( + "API_BASE not set. Is the global setup running correctly?", + ); + } + _client = new GoPublisherClient(apiBase); + } else { + _client = new TypeScriptDirectClient(); + } + return _client; +} + +export function getMockConnectUrl(): string { + const url = process.env.MOCK_CONNECT_URL; + if (!url) { + throw new Error( + "MOCK_CONNECT_URL not set. Is the global setup running correctly?", + ); + } + return url; +} + +export async function clearMockRequests(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/requests`, { method: "DELETE" }); +} + +export async function setMockResponse(override: { + method: string; + pathPattern: string; + status: number; + body?: unknown; + contentType?: string; +}): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-override`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(override), + }); +} + +export async function clearMockOverrides(): Promise { + const mockUrl = getMockConnectUrl(); + await fetch(`${mockUrl}/__test__/response-overrides`, { method: "DELETE" }); +} + +export async function getMockRequests( + pathFilter?: string, +): Promise { + const mockUrl = getMockConnectUrl(); + const res = await fetch(`${mockUrl}/__test__/requests`); + const requests: CapturedRequest[] = await res.json(); + if (pathFilter) { + return requests.filter((r) => r.path.includes(pathFilter)); + } + return requests; +} diff --git a/test/connect-api-contracts/src/mock-connect-server.ts b/test/connect-api-contracts/src/mock-connect-server.ts new file mode 100644 index 000000000..44d05c472 --- /dev/null +++ b/test/connect-api-contracts/src/mock-connect-server.ts @@ -0,0 +1,335 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface CapturedRequest { + method: string; + path: string; + headers: Record; + body: string | null; +} + +interface RouteHandler { + method: string; + pattern: RegExp; + status: number; + response: unknown; // JSON object, string, Buffer, or null (for no-body responses) + contentType?: string; // defaults to "application/json" +} + +const FIXTURES_DIR = resolve(__dirname, "fixtures", "connect-responses"); + +function loadFixture(name: string): unknown { + const content = readFileSync(resolve(FIXTURES_DIR, name), "utf-8"); + return JSON.parse(content); +} + +// Minimal valid gzip stream (empty gzip file) +const DUMMY_GZIP_BYTES = Buffer.from([ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +export class MockConnectServer { + private server: ReturnType | null = null; + private captured: CapturedRequest[] = []; + private routes: RouteHandler[] = []; + private overrides: RouteHandler[] = []; + private _port = 0; + + constructor() { + this.registerDefaultRoutes(); + } + + get port(): number { + return this._port; + } + + get url(): string { + return `http://localhost:${this._port}`; + } + + private registerDefaultRoutes(): void { + // Routes are matched by first match, so more specific patterns must come first. + + // --- Authentication & User --- + + // GET /__api__/v1/user — TestAuthentication, GetCurrentUser + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/user$/, + status: 200, + response: loadFixture("user.json"), + }); + + // --- OAuth Integrations --- + + // GET /__api__/v1/oauth/integrations — GetIntegrations + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/oauth\/integrations$/, + status: 200, + response: loadFixture("integrations.json"), + }); + + // --- Content (specific sub-resources first, then generic) --- + + // GET /__api__/v1/content/:id/bundles/:bid/download — DownloadBundle + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles\/[^/]+\/download$/, + status: 200, + response: DUMMY_GZIP_BYTES, + contentType: "application/gzip", + }); + + // POST /__api__/v1/content/:id/bundles — UploadBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/bundles$/, + status: 200, + response: loadFixture("bundle-upload.json"), + }); + + // GET /__api__/v1/content/:id/environment — GetEnvVars + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 200, + response: loadFixture("environment.json"), + }); + + // PATCH /__api__/v1/content/:id/environment — SetEnvVars + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+\/environment$/, + status: 204, + response: null, + }); + + // POST /__api__/v1/content/:id/deploy — DeployBundle + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content\/[^/]+\/deploy$/, + status: 200, + response: loadFixture("deploy.json"), + }); + + // POST /__api__/v1/content — CreateDeployment + this.routes.push({ + method: "POST", + pattern: /^\/__api__\/v1\/content$/, + status: 200, + response: loadFixture("content-create.json"), + }); + + // PATCH /__api__/v1/content/:id — UpdateDeployment + this.routes.push({ + method: "PATCH", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 204, + response: null, + }); + + // GET /__api__/v1/content/:id — ContentDetails, LatestBundleID + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/content\/[^/]+$/, + status: 200, + response: loadFixture("content-details.json"), + }); + + // --- Tasks --- + + // GET /__api__/v1/tasks/:id — WaitForTask (always returns finished) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/tasks\/[^?]+/, + status: 200, + response: loadFixture("task-finished.json"), + }); + + // --- Server Settings --- + + // GET /__api__/server_settings/applications — GetSettings (applications) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/applications$/, + status: 200, + response: loadFixture("server-settings-applications.json"), + }); + + // GET /__api__/server_settings/scheduler[/{appMode}] — GetSettings (scheduler) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings\/scheduler/, + status: 200, + response: loadFixture("server-settings-scheduler.json"), + }); + + // GET /__api__/server_settings — GetSettings (general) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/server_settings$/, + status: 200, + response: loadFixture("server-settings.json"), + }); + + // GET /__api__/v1/server_settings/python — GetSettings (python) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/python$/, + status: 200, + response: loadFixture("server-settings-python.json"), + }); + + // GET /__api__/v1/server_settings/r — GetSettings (r) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/r$/, + status: 200, + response: loadFixture("server-settings-r.json"), + }); + + // GET /__api__/v1/server_settings/quarto — GetSettings (quarto) + this.routes.push({ + method: "GET", + pattern: /^\/__api__\/v1\/server_settings\/quarto$/, + status: 200, + response: loadFixture("server-settings-quarto.json"), + }); + + // --- Content Validation (non-API path) --- + + // GET /content/:id/ — ValidateDeployment + this.routes.push({ + method: "GET", + pattern: /^\/content\/[^/]+\/$/, + status: 200, + response: "OK", + contentType: "text/html", + }); + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.listen(0, "localhost", () => { + const addr = this.server!.address(); + if (addr && typeof addr === "object") { + this._port = addr.port; + } + resolve(); + }); + this.server.on("error", reject); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const method = req.method ?? "GET"; + const path = req.url ?? "/"; + + // Control endpoint: GET captured requests + if (method === "GET" && path === "/__test__/requests") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(this.captured)); + return; + } + + // Control endpoint: clear captured requests + if (method === "DELETE" && path === "/__test__/requests") { + this.captured = []; + res.writeHead(204); + res.end(); + return; + } + + // Control endpoint: register a response override + if (method === "POST" && path === "/__test__/response-override") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + this.overrides.push({ + method: body.method, + pattern: new RegExp(body.pathPattern), + status: body.status, + response: body.body ?? null, + contentType: body.contentType, + }); + res.writeHead(204); + res.end(); + }); + return; + } + + // Control endpoint: clear all response overrides + if (method === "DELETE" && path === "/__test__/response-overrides") { + this.overrides = []; + res.writeHead(204); + res.end(); + return; + } + + // Collect request body, then capture and route + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const bodyStr = chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null; + + // Flatten headers to Record + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(", "); + } + } + + // Capture the request + this.captured.push({ method, path, headers, body: bodyStr }); + + // Find matching route (overrides take priority over default routes) + const route = + this.overrides.find( + (r) => r.method === method && r.pattern.test(path), + ) ?? + this.routes.find( + (r) => r.method === method && r.pattern.test(path), + ); + + if (route) { + const contentType = route.contentType ?? "application/json"; + + if (route.response === null || route.response === undefined) { + // No-body response (e.g. 204) + res.writeHead(route.status); + res.end(); + } else if (Buffer.isBuffer(route.response)) { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else if (typeof route.response === "string") { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(route.response); + } else { + res.writeHead(route.status, { "Content-Type": contentType }); + res.end(JSON.stringify(route.response)); + } + } else { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + } +} diff --git a/test/connect-api-contracts/src/setup.ts b/test/connect-api-contracts/src/setup.ts new file mode 100644 index 000000000..cfc337307 --- /dev/null +++ b/test/connect-api-contracts/src/setup.ts @@ -0,0 +1,115 @@ +import { execSync, spawn, type ChildProcess } from "node:child_process"; +import { resolve } from "node:path"; +import type { GlobalSetupContext } from "vitest/node"; +import { MockConnectServer } from "./mock-connect-server"; + +const REPO_ROOT = resolve(__dirname, "..", "..", ".."); +const HARNESS_DIR = resolve(__dirname, "..", "harness"); + +let harnessProcess: ChildProcess | null = null; +let mockServer: MockConnectServer | null = null; + +function buildHarness(): string { + const binaryPath = resolve(HARNESS_DIR, "harness"); + execSync( + `go build -o ${binaryPath} ./test/connect-api-contracts/harness/`, + { + cwd: REPO_ROOT, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return binaryPath; +} + +export async function setup({ provide }: GlobalSetupContext) { + // 1. Start mock Connect server + mockServer = new MockConnectServer(); + await mockServer.start(); + process.env.MOCK_CONNECT_URL = mockServer.url; + + console.log(`[setup] Mock Connect server running at ${mockServer.url}`); + + const backend = process.env.API_BACKEND ?? "go"; + + if (backend === "go") { + // 2. Build the harness binary + console.log("[setup] Building harness binary..."); + const binaryPath = buildHarness(); + console.log(`[setup] Harness built at ${binaryPath}`); + + // 3. Spawn the harness + harnessProcess = spawn(binaryPath, ["--listen", "localhost:0"], { + stdio: ["ignore", "pipe", "pipe"], + }); + + // 4. Capture the URL from stdout + const apiBase = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for harness URL on stdout")); + }, 15_000); + + let buffer = ""; + harnessProcess!.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("http://")) { + clearTimeout(timeout); + resolve(trimmed.replace(/\/$/, "")); + return; + } + } + }); + + harnessProcess!.stderr!.on("data", (chunk: Buffer) => { + process.stderr.write(`[harness stderr] ${chunk.toString()}`); + }); + + harnessProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn harness: ${err.message}`)); + }); + + harnessProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Harness exited with code ${code}`)); + } + }); + }); + + process.env.API_BASE = apiBase; + process.env.__CLIENT_TYPE = "go"; + + console.log(`[setup] Harness running at ${apiBase}`); + } else { + process.env.__CLIENT_TYPE = "typescript"; + console.log(`[setup] Using TypeScript direct client`); + } +} + +export async function teardown() { + if (harnessProcess) { + harnessProcess.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + harnessProcess?.kill("SIGKILL"); + resolve(); + }, 5_000); + harnessProcess!.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + harnessProcess = null; + } + + if (mockServer) { + await mockServer.stop(); + mockServer = null; + } + + console.log("[teardown] Servers stopped"); +} diff --git a/test/connect-api-contracts/tsconfig.json b/test/connect-api-contracts/tsconfig.json new file mode 100644 index 000000000..e676e1152 --- /dev/null +++ b/test/connect-api-contracts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/test/connect-api-contracts/vitest.config.ts b/test/connect-api-contracts/vitest.config.ts new file mode 100644 index 000000000..484c251db --- /dev/null +++ b/test/connect-api-contracts/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["src/setup.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + include: ["src/endpoints/**/*.test.ts"], + // All test files share a single mock server, so they must run sequentially + // to avoid interfering with each other's captured requests and overrides. + fileParallelism: false, + }, +});