Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/connect-api-contracts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
harness/harness
node_modules/
98 changes: 98 additions & 0 deletions test/connect-api-contracts/README.md
Original file line number Diff line number Diff line change
@@ -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 <apiKey>` 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
262 changes: 262 additions & 0 deletions test/connect-api-contracts/harness/main.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading