Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ go.work.sum
bin/basecamp
bin/basecamp-*
/basecamp
e2e/recorder/recorder

# GoReleaser output
dist/
Expand Down
65 changes: 60 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,58 @@ test: check-toolchain
test-e2e: build
./e2e/run.sh

# Record cassettes for happy-path replay tests.
# Required env vars:
# BASECAMP_RECORD_TOKEN - valid API token for the target server
# BASECAMP_RECORD_TARGET - API base URL (e.g. http://3.basecampapi.localhost:4001)
# BASECAMP_RECORD_ACCOUNT - account ID baked into cassette URL paths
# BASECAMP_RECORD_PROJECT - project ID for project-scoped commands
.PHONY: record-cassettes
record-cassettes: build
@test -n "$(BASECAMP_RECORD_TOKEN)" || { echo "Set BASECAMP_RECORD_TOKEN"; exit 1; }
@test -n "$(BASECAMP_RECORD_TARGET)" || { echo "Set BASECAMP_RECORD_TARGET (e.g. http://3.basecampapi.localhost:4001)"; exit 1; }
@test -n "$(BASECAMP_RECORD_ACCOUNT)" || { echo "Set BASECAMP_RECORD_ACCOUNT"; exit 1; }
@test -n "$(BASECAMP_RECORD_PROJECT)" || { echo "Set BASECAMP_RECORD_PROJECT"; exit 1; }
rm -f e2e/cassettes/happypath/*.json
BASECAMP_RECORD_TOKEN=$(BASECAMP_RECORD_TOKEN) \
BASECAMP_RECORD_TARGET=$(BASECAMP_RECORD_TARGET) \
BASECAMP_RECORD_ACCOUNT=$(BASECAMP_RECORD_ACCOUNT) \
BASECAMP_RECORD_PROJECT=$(BASECAMP_RECORD_PROJECT) \
bats e2e/qa_happypath.bats
@echo "Cassettes recorded to e2e/cassettes/happypath/"

# Run pre-release smoke suite against a live test account (requires BASECAMP_TOKEN)
.PHONY: smoke
smoke: build
./e2e/smoke/run_smoke.sh

# Show coverage gaps from smoke traces (unverifiable + out-of-scope).
# Pass/fail comes from bats exit codes via make smoke, not traces.
.PHONY: qa-report
qa-report:
@QA_TRACE_DIR=$${QA_TRACE_DIR:-tmp/qa-traces}; \
if [ ! -f "$$QA_TRACE_DIR/traces.jsonl" ]; then \
echo "No traces found. Run 'make smoke' first."; \
exit 1; \
fi; \
echo "=== QA Coverage Gaps ==="; \
echo ""; \
unverified=$$(jq -r 'select(.status == "unverifiable") | .test' "$$QA_TRACE_DIR/traces.jsonl" | wc -l | tr -d ' '); \
outofscope=$$(jq -r 'select(.status == "out-of-scope") | .test' "$$QA_TRACE_DIR/traces.jsonl" | wc -l | tr -d ' '); \
echo " Unverifiable: $$unverified"; \
echo " Out-of-scope: $$outofscope"; \
echo ""; \
if [ "$$unverified" -gt 0 ]; then \
echo "UNVERIFIABLE:"; \
jq -r 'select(.status == "unverifiable") | " - \(.test): \(.reason)"' "$$QA_TRACE_DIR/traces.jsonl"; \
echo ""; \
fi; \
if [ "$$outofscope" -gt 0 ]; then \
echo "OUT-OF-SCOPE:"; \
jq -r 'select(.status == "out-of-scope") | " - \(.test): \(.reason)"' "$$QA_TRACE_DIR/traces.jsonl"; \
echo ""; \
fi

# Run tests with race detector
.PHONY: race-test
race-test: check-toolchain
Expand Down Expand Up @@ -431,11 +483,14 @@ help:
@echo " build-bsd Build for FreeBSD + OpenBSD (arm64 + amd64)"
@echo ""
@echo "Test:"
@echo " test Run Go unit tests"
@echo " test-e2e Run end-to-end tests against Go binary"
@echo " race-test Run tests with race detector"
@echo " test-coverage Run tests with coverage report"
@echo " coverage Run tests with coverage and open in browser"
@echo " test Run Go unit tests"
@echo " test-e2e Run end-to-end tests against Go binary"
@echo " race-test Run tests with race detector"
@echo " test-coverage Run tests with coverage report"
@echo " coverage Run tests with coverage and open in browser"
@echo " record-cassettes Record happy-path cassettes (TOKEN+TARGET+ACCOUNT+PROJECT)"
@echo " smoke Run pre-release smoke suite (BASECAMP_TOKEN=...)"
@echo " qa-report Show QA coverage report from smoke traces"
@echo ""
@echo "Performance:"
@echo " bench Run all benchmarks"
Expand Down
Empty file.
418 changes: 418 additions & 0 deletions e2e/cassettes/happypath/cassette.json

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions e2e/qa_happypath.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bats
# qa_happypath.bats - Minimal happy-path tests replaying committed cassettes.
# Catches the "files list was busted" class of bug before merge.
#
# Scope: deterministic read-only commands replayed from a single cassette.
# This verifies the CLI correctly parses API responses and produces valid
# JSON output — not that the recorder handles arbitrary interaction patterns.
#
# Recording: make record-cassettes (requires TOKEN, TARGET, ACCOUNT, PROJECT)
# Replaying: make test-e2e (automatic — cassettes committed to repo)

load test_helper

CASSETTE_DIR="$BATS_TEST_DIRNAME/cassettes/happypath"

setup_file() {
if [[ -n "${BASECAMP_RECORD_TOKEN:-}" ]]; then
# Record mode: forward through proxy to live server, save cassettes
if [[ -z "${BASECAMP_RECORD_TARGET:-}" ]]; then
echo "ERROR: BASECAMP_RECORD_TARGET required in record mode" >&2
return 1
fi
start_proxy record "$CASSETTE_DIR" "$BASECAMP_RECORD_TARGET"
else
# Replay mode: serve from committed cassettes
if [[ ! -d "$CASSETTE_DIR" ]] || ! ls "$CASSETTE_DIR"/*.json &>/dev/null; then
if [[ -n "${QA_HAPPYPATH_OPTIONAL:-}" ]]; then
skip "No cassettes found. Run 'make record-cassettes' first."
fi
echo "ERROR: No cassettes in $CASSETTE_DIR" >&2
echo "Record them with: make record-cassettes" >&2
echo "Or set QA_HAPPYPATH_OPTIONAL=1 to skip during bring-up." >&2
return 1
fi
start_proxy replay "$CASSETTE_DIR"
fi
}

teardown_file() {
stop_proxy
}

# Per-test setup: point CLI at the proxy, configure credentials.
# Both record and replay use BASECAMP_TOKEN (bypasses OAuth/account lookup).
# Account ID and project ID must match the data baked into the cassettes.
setup_extra() {
local acct="${BASECAMP_RECORD_ACCOUNT:-181900405}"
local proj="${BASECAMP_RECORD_PROJECT:-2085958494}"

export BASECAMP_BASE_URL="http://127.0.0.1:${REPLAY_PORT}"
export BASECAMP_ACCOUNT_ID="$acct"
export BASECAMP_PROJECT_ID="$proj"

if [[ -n "${BASECAMP_RECORD_TOKEN:-}" ]]; then
export BASECAMP_TOKEN="$BASECAMP_RECORD_TOKEN"
else
export BASECAMP_TOKEN="test-token"
fi
}


# --- Happy-path tests ---
# Each test asserts .ok, output shape, and that domain-specific text
# (e.g. "projects", "todos") appears in the output — verifying the CLI
# parsed the API response, not just that it exited 0.
#
# Note: accounts list is excluded — it hits /authorization.json which lives
# on Launchpad, not the Basecamp API, so it can't be proxied single-host.

@test "projects list returns projects" {
run basecamp projects list --json
assert_success
assert_json_value '.ok' 'true'
assert_json_not_null '.data[0].id'
assert_json_not_null '.data[0].name'
assert_output_contains 'projects' # summary includes "N projects"
}

@test "todos list returns todos" {
run basecamp todos list --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '(.data | type)' 'array'
assert_output_contains 'todos' # summary includes "N todos"
}

@test "files list returns files" {
run basecamp files list --json
assert_success
assert_json_value '.ok' 'true'
assert_json_not_null '.summary'
assert_output_contains 'files' # summary includes "N folders, N files, N documents"
}

@test "recordings list returns recordings" {
run basecamp recordings list --type Todo --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '(.data | type)' 'array'
assert_output_contains 'Todos' # summary includes "N Todos"
}

@test "messages list returns messages" {
run basecamp messages list --json
assert_success
assert_json_value '.ok' 'true'
assert_json_value '(.data | type)' 'array'
assert_output_contains 'messages' # summary includes "N messages"
}
93 changes: 93 additions & 0 deletions e2e/recorder/cassette.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
)

// Cassette holds a set of recorded HTTP interactions.
type Cassette struct {
RecordedAt time.Time `json:"recorded_at"`
Account string `json:"account"`
Target string `json:"target"`
Interactions []Interaction `json:"interactions"`
}

// Interaction pairs a request with its response.
type Interaction struct {
Request RecordedRequest `json:"request"`
Response RecordedResponse `json:"response"`
}

// RecordedRequest captures the request method, original URL, and canonical key.
type RecordedRequest struct {
Method string `json:"method"`
URL string `json:"url"`
CanonicalKey string `json:"canonical_key"`
}

// RecordedResponse captures the status, headers, and body.
type RecordedResponse struct {
Status int `json:"status"`
Headers map[string][]string `json:"headers"`
Body string `json:"body"`
}

// canonicalKey builds a deterministic matching key from method, path, sorted
// query params, and an optional body hash. Host is deliberately excluded —
// each cassette is scoped to one target.
func canonicalKey(method string, u *url.URL, body []byte) string {
params := u.Query()
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)

var qparts []string
for _, k := range keys {
vs := params[k]
sort.Strings(vs)
for _, v := range vs {
qparts = append(qparts, k+"="+v)
}
}

key := method + " " + u.Path
if len(qparts) > 0 {
key += "?" + strings.Join(qparts, "&")
}
if len(body) > 0 {
normalized := normalizeJSON(body)
h := sha256.Sum256(normalized)
key += " #" + fmt.Sprintf("%x", h[:8])
}
return key
}

func normalizeJSON(data []byte) []byte {
var v any
if json.Unmarshal(data, &v) == nil {
if sorted, err := json.Marshal(v); err == nil {
return sorted
}
}
return data
}

// rewriteLinkHost replaces the host portion of URLs inside Link headers.
//
// <https://3.basecampapi.com/99999/projects.json?page=2>; rel="next"
// becomes
// <http://127.0.0.1:PORT/99999/projects.json?page=2>; rel="next"
var linkURLRe = regexp.MustCompile(`<https?://[^/]+(/[^>]*)>`)

func rewriteLinkHost(link, proxyHost string) string {
return linkURLRe.ReplaceAllString(link, "<"+proxyHost+"$1>")
}
91 changes: 91 additions & 0 deletions e2e/recorder/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

func main() {
mode := flag.String("mode", "replay", "Operating mode: record, replay")
target := flag.String("target", "", "Target URL (required for record mode)")
cassDir := flag.String("cassettes", ".", "Cassette directory")
portFile := flag.String("port-file", "", "Write listen port to this file")
account := flag.String("account", "", "Account label for cassette metadata")
flag.Parse()

lc := net.ListenConfig{}
ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("listen: %v", err)
}
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
if !ok {
log.Fatal("unexpected listener address type")
}
port := tcpAddr.Port
proxyHost := fmt.Sprintf("http://127.0.0.1:%d", port)
log.Printf("listening on %s (mode=%s)", proxyHost, *mode)

if *portFile != "" {
if err := os.WriteFile(*portFile, []byte(fmt.Sprintf("%d", port)), 0o600); err != nil {
log.Fatalf("writing port file: %v", err)
}
}

var handler http.Handler

// Both modes trap signals for clean shutdown. Record mode saves
// cassettes; replay mode just exits 0 (killed by stop_proxy).
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)

switch *mode {
case "replay":
rs, err := newReplayServer(*cassDir)
if err != nil {
log.Fatalf("loading cassettes: %v", err)
}
rs.proxyHost = proxyHost
handler = rs

go func() {
<-sig
os.Exit(0)
}()

case "record":
if *target == "" {
log.Fatal("-target required in record mode")
}
rp := newRecordingProxy(*target, *cassDir, *account, proxyHost)
handler = rp

go func() {
<-sig
if err := rp.save(); err != nil {
log.Printf("error saving cassettes: %v", err)
os.Exit(1)
}
os.Exit(0)
}()

default:
log.Fatalf("unknown mode: %s", *mode)
}

srv := &http.Server{
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Fatal(srv.Serve(ln))
}
Loading
Loading