Skip to content

Commit f9af524

Browse files
Add local integration test for stable tests (#20)
- TCLI_INTEGRATION=1 to run integration. - currently turned on in `make test`
1 parent 7f41505 commit f9af524

4 files changed

Lines changed: 448 additions & 1 deletion

File tree

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ lint:
3939
./tools/run_linter.sh
4040

4141
test:
42-
go test ./...
42+
TCLI_INTEGRATION=1 go test ./...
4343

4444
docker:
4545
docker build -t $(IMAGE) -f tools/Dockerfile .

‎internal/petstoretest/server.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2025-2026 HP Development Company, L.P.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Package petstoretest provides an in-memory HTTP server that implements
5+
// the subset of the Swagger Petstore API tcli's example pipelines exercise.
6+
// Intended for CI integration tests replaces the external
7+
// petstore.swagger.io dependency with a fast, deterministic local server.
8+
package petstoretest
9+
10+
import (
11+
"encoding/json"
12+
"net/http"
13+
"net/http/httptest"
14+
"strconv"
15+
"sync"
16+
)
17+
18+
// Pet mirrors the minimal shape addPet accepts and getPetById returns.
19+
// Fields not used by tcli pipelines are not included.
20+
type Pet struct {
21+
ID int64 `json:"id"`
22+
Name string `json:"name"`
23+
PhotoUrls []string `json:"photoUrls"`
24+
Status string `json:"status,omitempty"`
25+
Tags []any `json:"tags"`
26+
Category any `json:"category,omitempty"`
27+
}
28+
29+
// apiResponse mirrors the real petstore's ApiResponse envelope, used for
30+
// delete confirmation and 404 replies.
31+
type apiResponse struct {
32+
Code int `json:"code"`
33+
Type string `json:"type"`
34+
Message string `json:"message"`
35+
}
36+
37+
// NewServer returns a running httptest.Server serving the pet endpoints
38+
// under `/v2/`. Callers must call Close(). Each server has its own
39+
// in-memory store, so tests are isolated from one another.
40+
func NewServer() *httptest.Server {
41+
st := &store{pets: make(map[int64]*Pet)}
42+
mux := http.NewServeMux()
43+
mux.HandleFunc("POST /v2/pet", st.addPet)
44+
mux.HandleFunc("GET /v2/pet/findByStatus", st.findByStatus)
45+
mux.HandleFunc("GET /v2/pet/{petId}", st.getPetByID)
46+
mux.HandleFunc("DELETE /v2/pet/{petId}", st.deletePet)
47+
return httptest.NewServer(mux)
48+
}
49+
50+
type store struct {
51+
mu sync.Mutex
52+
pets map[int64]*Pet
53+
}
54+
55+
func (s *store) addPet(w http.ResponseWriter, r *http.Request) {
56+
var pet Pet
57+
if err := json.NewDecoder(r.Body).Decode(&pet); err != nil {
58+
http.Error(w, err.Error(), http.StatusBadRequest)
59+
return
60+
}
61+
// Default an empty status so findByStatus behaves predictably when the
62+
// caller omits it (the real petstore does the same coercion in practice).
63+
if pet.Status == "" {
64+
pet.Status = "available"
65+
}
66+
if pet.Tags == nil {
67+
pet.Tags = []any{}
68+
}
69+
s.mu.Lock()
70+
s.pets[pet.ID] = &pet
71+
s.mu.Unlock()
72+
writeJSON(w, http.StatusOK, pet)
73+
}
74+
75+
func (s *store) getPetByID(w http.ResponseWriter, r *http.Request) {
76+
id, err := strconv.ParseInt(r.PathValue("petId"), 10, 64)
77+
if err != nil {
78+
http.Error(w, "invalid petId", http.StatusBadRequest)
79+
return
80+
}
81+
s.mu.Lock()
82+
pet, ok := s.pets[id]
83+
s.mu.Unlock()
84+
if !ok {
85+
writeJSON(w, http.StatusNotFound, apiResponse{
86+
Code: 1, Type: "error", Message: "Pet not found",
87+
})
88+
return
89+
}
90+
writeJSON(w, http.StatusOK, pet)
91+
}
92+
93+
// deletePet mirrors the real petstore's response: the `message` field
94+
// carries the deleted id back, which the CRUD pipeline relies on for its
95+
// `{petId:.message}` format expression to chain into verify_gone.
96+
func (s *store) deletePet(w http.ResponseWriter, r *http.Request) {
97+
idStr := r.PathValue("petId")
98+
id, err := strconv.ParseInt(idStr, 10, 64)
99+
if err != nil {
100+
http.Error(w, "invalid petId", http.StatusBadRequest)
101+
return
102+
}
103+
s.mu.Lock()
104+
delete(s.pets, id)
105+
s.mu.Unlock()
106+
writeJSON(w, http.StatusOK, apiResponse{
107+
Code: 200, Type: "unknown", Message: idStr,
108+
})
109+
}
110+
111+
func (s *store) findByStatus(w http.ResponseWriter, r *http.Request) {
112+
want := r.URL.Query().Get("status")
113+
s.mu.Lock()
114+
// Always return [] (never null) so the pipeline runner's array-flatten
115+
// logic sees a well-formed JSON array even when no pets match.
116+
out := make([]*Pet, 0, len(s.pets))
117+
for _, p := range s.pets {
118+
if want == "" || p.Status == want {
119+
out = append(out, p)
120+
}
121+
}
122+
s.mu.Unlock()
123+
writeJSON(w, http.StatusOK, out)
124+
}
125+
126+
func writeJSON(w http.ResponseWriter, status int, v any) {
127+
w.Header().Set("Content-Type", "application/json")
128+
w.WriteHeader(status)
129+
_ = json.NewEncoder(w).Encode(v)
130+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2025-2026 HP Development Company, L.P.
2+
// SPDX-License-Identifier: MIT
3+
4+
package petstoretest
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"io"
10+
"net/http"
11+
"strings"
12+
"testing"
13+
)
14+
15+
// Sanity tests for the fake server. These run every time (no gate) because
16+
// they are self-contained and fast no external tcli invocation involved.
17+
18+
func TestServer_AddThenGet(t *testing.T) {
19+
srv := NewServer()
20+
defer srv.Close()
21+
22+
got := doJSON(t, srv.URL, "POST", "/v2/pet", `{"id":7,"name":"a","photoUrls":["x"]}`)
23+
if got["id"] != float64(7) {
24+
t.Errorf("addPet returned id=%v, want 7", got["id"])
25+
}
26+
got = doJSON(t, srv.URL, "GET", "/v2/pet/7", "")
27+
if got["name"] != "a" {
28+
t.Errorf("getPetById returned name=%v, want a", got["name"])
29+
}
30+
}
31+
32+
func TestServer_GetMissingReturns404(t *testing.T) {
33+
srv := NewServer()
34+
defer srv.Close()
35+
resp := do(t, srv.URL, "GET", "/v2/pet/999", "")
36+
defer resp.Body.Close()
37+
if resp.StatusCode != http.StatusNotFound {
38+
t.Errorf("status = %d, want 404", resp.StatusCode)
39+
}
40+
}
41+
42+
func TestServer_DeleteThenGet404(t *testing.T) {
43+
srv := NewServer()
44+
defer srv.Close()
45+
doJSON(t, srv.URL, "POST", "/v2/pet", `{"id":1,"name":"x","photoUrls":[]}`)
46+
got := doJSON(t, srv.URL, "DELETE", "/v2/pet/1", "")
47+
if got["message"] != "1" {
48+
t.Errorf("deletePet.message = %v, want \"1\" (id echoed back)", got["message"])
49+
}
50+
resp := do(t, srv.URL, "GET", "/v2/pet/1", "")
51+
defer resp.Body.Close()
52+
if resp.StatusCode != http.StatusNotFound {
53+
t.Errorf("after delete, GET status = %d, want 404", resp.StatusCode)
54+
}
55+
}
56+
57+
func TestServer_FindByStatusReturnsArray(t *testing.T) {
58+
srv := NewServer()
59+
defer srv.Close()
60+
doJSON(t, srv.URL, "POST", "/v2/pet", `{"id":1,"name":"a","photoUrls":[],"status":"available"}`)
61+
doJSON(t, srv.URL, "POST", "/v2/pet", `{"id":2,"name":"b","photoUrls":[],"status":"sold"}`)
62+
63+
resp := do(t, srv.URL, "GET", "/v2/pet/findByStatus?status=available", "")
64+
defer resp.Body.Close()
65+
var arr []map[string]any
66+
if err := json.NewDecoder(resp.Body).Decode(&arr); err != nil {
67+
t.Fatalf("decode array: %v", err)
68+
}
69+
if len(arr) != 1 || arr[0]["id"] != float64(1) {
70+
t.Errorf("findByStatus(available) = %+v, want single pet id=1", arr)
71+
}
72+
}
73+
74+
func do(t *testing.T, base, method, path, body string) *http.Response {
75+
t.Helper()
76+
var b io.Reader
77+
if body != "" {
78+
b = strings.NewReader(body)
79+
}
80+
req, err := http.NewRequest(method, base+path, b)
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
if body != "" {
85+
req.Header.Set("Content-Type", "application/json")
86+
}
87+
resp, err := http.DefaultClient.Do(req)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
return resp
92+
}
93+
94+
func doJSON(t *testing.T, base, method, path, body string) map[string]any {
95+
t.Helper()
96+
resp := do(t, base, method, path, body)
97+
defer resp.Body.Close()
98+
var buf bytes.Buffer
99+
_, _ = io.Copy(&buf, resp.Body)
100+
if resp.StatusCode >= 300 {
101+
t.Fatalf("%s %s -> %d: %s", method, path, resp.StatusCode, buf.String())
102+
}
103+
var out map[string]any
104+
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
105+
t.Fatalf("decode %s %s: %v (body=%q)", method, path, err, buf.String())
106+
}
107+
return out
108+
}

0 commit comments

Comments
 (0)