Skip to content

Commit 3148251

Browse files
committed
test(init): add non-interactive init integration tests
Add POST /v0/projects to mock server and fix GET /v0/projects/{project} to return 404 for unregistered slugs. Add table-driven tests covering the cartesian product of flag combinations for both new and existing project flows.
1 parent b0fd210 commit 3148251

3 files changed

Lines changed: 374 additions & 4 deletions

File tree

internal/mockstainless/server.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,50 @@ func newServeMux(m *Mock) http.Handler {
7676
writeJSON(w, http.StatusOK, Page(m.Projects))
7777
})
7878

79+
mux.HandleFunc("POST /v0/projects", func(w http.ResponseWriter, r *http.Request) {
80+
body := mustReadBody(r)
81+
slug := gjson.GetBytes(body, "slug").String()
82+
displayName := gjson.GetBytes(body, "display_name").String()
83+
org := gjson.GetBytes(body, "org").String()
84+
85+
if slug == "" {
86+
writeJSON(w, http.StatusBadRequest, M{"error": "slug is required"})
87+
return
88+
}
89+
if displayName == "" {
90+
displayName = slug
91+
}
92+
93+
var targets []any
94+
gjson.GetBytes(body, "targets").ForEach(func(_, v gjson.Result) bool {
95+
targets = append(targets, v.String())
96+
return true
97+
})
98+
if len(targets) == 0 {
99+
targets = []any{"typescript", "python", "go"}
100+
}
101+
102+
project := M{
103+
"slug": slug,
104+
"display_name": displayName,
105+
"object": "project",
106+
"org": org,
107+
"config_repo": fmt.Sprintf("https://github.com/%s/%s", org, slug),
108+
"targets": targets,
109+
}
110+
111+
m.mu.Lock()
112+
m.Projects = append(m.Projects, project)
113+
m.mu.Unlock()
114+
115+
// Create a build so the post-creation build-wait step succeeds.
116+
if len(m.Builds) > 0 {
117+
m.CreateBuildFromTemplate(m.Builds[0])
118+
}
119+
120+
writeJSON(w, http.StatusOK, project)
121+
})
122+
79123
mux.HandleFunc("GET /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) {
80124
slug := r.PathValue("project")
81125
for _, p := range m.Projects {
@@ -84,9 +128,7 @@ func newServeMux(m *Mock) http.Handler {
84128
return
85129
}
86130
}
87-
if len(m.Projects) > 0 {
88-
writeJSON(w, http.StatusOK, m.Projects[0])
89-
}
131+
writeJSON(w, http.StatusNotFound, M{"error": "project not found"})
90132
})
91133

92134
mux.HandleFunc("PATCH /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) {

pkg/cmd/init_test.go

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"github.com/tidwall/gjson"
12+
)
13+
14+
func TestInitNonInteractive(t *testing.T) {
15+
t.Parallel()
16+
17+
type testCase struct {
18+
name string
19+
20+
// Flag configuration
21+
project string
22+
targets string // comma-separated, empty means omit flag
23+
oasFlag string // empty means omit flag
24+
configFlag string // empty means omit flag
25+
26+
isNewProject bool
27+
expectError bool
28+
expectErrorMsg string
29+
30+
// Assertions on the created workspace
31+
wantTargets []string
32+
}
33+
34+
cases := []testCase{
35+
// ── New project ──────────────────────────────────────────────
36+
{
37+
name: "new project with targets and config",
38+
project: "brand-new",
39+
targets: "python,typescript",
40+
oasFlag: "openapi.json",
41+
configFlag: "stainless.yml",
42+
isNewProject: true,
43+
wantTargets: []string{"python", "typescript"},
44+
},
45+
{
46+
name: "new project with targets, no config",
47+
project: "brand-new-no-cfg",
48+
targets: "go",
49+
oasFlag: "openapi.json",
50+
isNewProject: true,
51+
wantTargets: []string{"go"},
52+
},
53+
{
54+
name: "new project without targets fails",
55+
project: "brand-new-no-tgt",
56+
oasFlag: "openapi.json",
57+
isNewProject: true,
58+
expectError: true,
59+
expectErrorMsg: "--targets",
60+
},
61+
{
62+
name: "new project without openapi-spec fails",
63+
project: "brand-new-no-oas",
64+
targets: "python",
65+
isNewProject: true,
66+
expectError: true,
67+
expectErrorMsg: "--openapi-spec",
68+
},
69+
// ── Existing project ─────────────────────────────────────────
70+
{
71+
name: "existing project with all flags",
72+
project: "acme-api",
73+
targets: "python,typescript",
74+
oasFlag: "openapi.json",
75+
configFlag: "stainless.yml",
76+
wantTargets: []string{"python", "typescript"},
77+
},
78+
{
79+
name: "existing project without targets uses server targets",
80+
project: "acme-api",
81+
oasFlag: "openapi.json",
82+
configFlag: "stainless.yml",
83+
wantTargets: []string{"typescript", "python", "go"},
84+
},
85+
{
86+
name: "existing project without config",
87+
project: "acme-api",
88+
targets: "typescript",
89+
oasFlag: "openapi.json",
90+
wantTargets: []string{"typescript"},
91+
},
92+
{
93+
name: "existing project with config, no explicit targets",
94+
project: "acme-api",
95+
oasFlag: "openapi.json",
96+
configFlag: "stainless.yml",
97+
wantTargets: []string{"typescript", "python", "go"},
98+
},
99+
// ── Missing required flags ───────────────────────────────────
100+
{
101+
name: "no project flag fails",
102+
oasFlag: "openapi.json",
103+
expectError: true,
104+
expectErrorMsg: "--project",
105+
},
106+
}
107+
108+
for _, tc := range cases {
109+
t.Run(tc.name, func(t *testing.T) {
110+
t.Parallel()
111+
112+
// Each subtest gets its own mock server to avoid shared state.
113+
server := newMockServer(t)
114+
dir := t.TempDir()
115+
116+
// Create dummy spec/config files in the working directory.
117+
oasContent := `{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}`
118+
require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi.json"), []byte(oasContent), 0644))
119+
require.NoError(t, os.WriteFile(filepath.Join(dir, "stainless.yml"), []byte("client:\n name: Test\n"), 0644))
120+
121+
args := []string{"init", "--api-key", "test-key"}
122+
if tc.project != "" {
123+
args = append(args, "--project", tc.project)
124+
}
125+
if tc.targets != "" {
126+
args = append(args, "--targets", tc.targets)
127+
}
128+
if tc.oasFlag != "" {
129+
args = append(args, "--openapi-spec", tc.oasFlag)
130+
}
131+
if tc.configFlag != "" {
132+
args = append(args, "--stainless-config", tc.configFlag)
133+
}
134+
135+
output := runCLIWithExpectation(t, dir, server.URL(), tc.expectError, args...)
136+
137+
if tc.expectError {
138+
if tc.expectErrorMsg != "" {
139+
assert.Contains(t, output, tc.expectErrorMsg)
140+
}
141+
return
142+
}
143+
144+
// Verify .stainless/workspace.json was created.
145+
wsPath := filepath.Join(dir, ".stainless", "workspace.json")
146+
data, err := os.ReadFile(wsPath)
147+
require.NoError(t, err, "workspace.json should exist")
148+
149+
ws := gjson.ParseBytes(data)
150+
assert.Equal(t, tc.project, ws.Get("project").String(), "workspace project")
151+
152+
// Paths in workspace.json are relative to .stainless/, so
153+
// a file at dir/openapi.json becomes ../openapi.json.
154+
if tc.oasFlag != "" {
155+
assert.Equal(t, "../"+tc.oasFlag, ws.Get("openapi_spec").String(), "workspace openapi_spec")
156+
}
157+
if tc.configFlag != "" {
158+
assert.Equal(t, "../"+tc.configFlag, ws.Get("stainless_config").String(), "workspace stainless_config")
159+
}
160+
161+
// Verify targets were configured.
162+
targets := ws.Get("targets")
163+
for _, want := range tc.wantTargets {
164+
assert.True(t, targets.Get(want).Exists(), "target %q should be configured", want)
165+
}
166+
167+
// Verify correct API calls were made.
168+
if tc.isNewProject {
169+
req := findRequest(t, server.Requests(), "POST", "/v0/projects")
170+
assert.Equal(t, tc.project, gjson.Get(req.Body, "slug").String())
171+
} else {
172+
findRequest(t, server.Requests(), "GET", "/v0/projects")
173+
}
174+
})
175+
}
176+
}
177+
178+
func TestInitNonInteractiveRequests(t *testing.T) {
179+
t.Parallel()
180+
181+
t.Run("new project sends openapi spec content in revision", func(t *testing.T) {
182+
t.Parallel()
183+
server := newMockServer(t)
184+
dir := t.TempDir()
185+
186+
oasContent := `{"openapi":"3.1.0","info":{"title":"ReqTest","version":"1.0.0"}}`
187+
require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(oasContent), 0644))
188+
189+
runCLI(t, dir, server.URL(), "init",
190+
"--api-key", "test-key",
191+
"--project", "req-test-project",
192+
"--targets", "python",
193+
"--openapi-spec", "spec.json",
194+
)
195+
196+
req := findRequest(t, server.Requests(), "POST", "/v0/projects")
197+
assert.Equal(t, "req-test-project", gjson.Get(req.Body, "slug").String())
198+
assert.Equal(t, oasContent, gjson.Get(req.Body, "revision.openapi\\.json.content").String())
199+
assert.False(t, gjson.Get(req.Body, "revision.stainless\\.yml").Exists())
200+
assert.False(t, gjson.Get(req.Body, "revision.stainless\\.json").Exists())
201+
})
202+
203+
t.Run("new project sends stainless config when flag provided", func(t *testing.T) {
204+
t.Parallel()
205+
server := newMockServer(t)
206+
dir := t.TempDir()
207+
208+
oasContent := `{"openapi":"3.1.0","info":{"title":"CfgTest","version":"1.0.0"}}`
209+
cfgContent := "client:\n name: CfgTest\n"
210+
require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(oasContent), 0644))
211+
require.NoError(t, os.WriteFile(filepath.Join(dir, "cfg.yml"), []byte(cfgContent), 0644))
212+
213+
runCLI(t, dir, server.URL(), "init",
214+
"--api-key", "test-key",
215+
"--project", "cfg-test-project",
216+
"--targets", "typescript",
217+
"--openapi-spec", "spec.json",
218+
"--stainless-config", "cfg.yml",
219+
)
220+
221+
req := findRequest(t, server.Requests(), "POST", "/v0/projects")
222+
assert.Equal(t, oasContent, gjson.Get(req.Body, "revision.openapi\\.json.content").String())
223+
assert.Equal(t, cfgContent, gjson.Get(req.Body, "revision.stainless\\.yml.content").String())
224+
})
225+
226+
t.Run("new project sends targets in create request", func(t *testing.T) {
227+
t.Parallel()
228+
server := newMockServer(t)
229+
dir := t.TempDir()
230+
231+
require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644))
232+
233+
runCLI(t, dir, server.URL(), "init",
234+
"--api-key", "test-key",
235+
"--project", "tgt-test-project",
236+
"--targets", "python,go",
237+
"--openapi-spec", "spec.json",
238+
)
239+
240+
req := findRequest(t, server.Requests(), "POST", "/v0/projects")
241+
targetsResult := gjson.Get(req.Body, "targets")
242+
require.True(t, targetsResult.IsArray())
243+
var got []string
244+
for _, v := range targetsResult.Array() {
245+
got = append(got, v.String())
246+
}
247+
assert.ElementsMatch(t, []string{"python", "go"}, got)
248+
})
249+
250+
t.Run("existing project does not POST to create", func(t *testing.T) {
251+
t.Parallel()
252+
server := newMockServer(t)
253+
dir := t.TempDir()
254+
255+
require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644))
256+
require.NoError(t, os.WriteFile(filepath.Join(dir, "cfg.yml"), []byte("client:\n name: E\n"), 0644))
257+
258+
runCLI(t, dir, server.URL(), "init",
259+
"--api-key", "test-key",
260+
"--project", "acme-api",
261+
"--openapi-spec", "spec.json",
262+
"--stainless-config", "cfg.yml",
263+
)
264+
265+
assertNoRequest(t, server.Requests(), "POST", "/v0/projects")
266+
})
267+
}
268+
269+
func TestInitNonInteractiveWorkspaceContents(t *testing.T) {
270+
t.Parallel()
271+
272+
t.Run("workspace.json has correct structure", func(t *testing.T) {
273+
t.Parallel()
274+
server := newMockServer(t)
275+
dir := t.TempDir()
276+
277+
require.NoError(t, os.WriteFile(filepath.Join(dir, "my-spec.json"), []byte(`{}`), 0644))
278+
require.NoError(t, os.WriteFile(filepath.Join(dir, "my-config.yml"), []byte("x: 1\n"), 0644))
279+
280+
runCLI(t, dir, server.URL(), "init",
281+
"--api-key", "test-key",
282+
"--project", "acme-api",
283+
"--targets", "python,go",
284+
"--openapi-spec", "my-spec.json",
285+
"--stainless-config", "my-config.yml",
286+
)
287+
288+
data, err := os.ReadFile(filepath.Join(dir, ".stainless", "workspace.json"))
289+
require.NoError(t, err)
290+
291+
var ws map[string]any
292+
require.NoError(t, json.Unmarshal(data, &ws))
293+
assert.Equal(t, "acme-api", ws["project"])
294+
assert.Equal(t, "../my-spec.json", ws["openapi_spec"])
295+
assert.Equal(t, "../my-config.yml", ws["stainless_config"])
296+
297+
targets, ok := ws["targets"].(map[string]any)
298+
require.True(t, ok, "targets should be a map")
299+
assert.Contains(t, targets, "python")
300+
assert.Contains(t, targets, "go")
301+
})
302+
303+
t.Run("default stainless_config path when flag omitted", func(t *testing.T) {
304+
t.Parallel()
305+
server := newMockServer(t)
306+
dir := t.TempDir()
307+
308+
require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644))
309+
310+
runCLI(t, dir, server.URL(), "init",
311+
"--api-key", "test-key",
312+
"--project", "acme-api",
313+
"--targets", "typescript",
314+
"--openapi-spec", "spec.json",
315+
)
316+
317+
data, err := os.ReadFile(filepath.Join(dir, ".stainless", "workspace.json"))
318+
require.NoError(t, err)
319+
320+
ws := gjson.ParseBytes(data)
321+
configPath := ws.Get("stainless_config").String()
322+
assert.NotEmpty(t, configPath, "stainless_config should have a default value")
323+
})
324+
}

pkg/cmd/workspace_integration_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ type workspaceFixture struct {
3535
func TestWorkspaceProjectAutofillIntegration(t *testing.T) {
3636
t.Parallel()
3737

38-
server := newMockServer(t)
38+
server := newMockServer(t, func(m *mockstainless.Mock) {
39+
// Register the fixture project slugs so GET /v0/projects/{project} returns 200.
40+
mockstainless.WithProject(mockstainless.MockProject{Name: "workspace-project", Org: "acme-corp"})(m)
41+
mockstainless.WithProject(mockstainless.MockProject{Name: "flag-project", Org: "acme-corp"})(m)
42+
})
3943

4044
t.Run("workspace", func(t *testing.T) {
4145
fixture := newWorkspaceFixture(t)

0 commit comments

Comments
 (0)