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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Run `bootdev --version` on your command line to make sure the installation worke

**Optional troubleshooting:**

If you're getting a "command not found" error for `bootdev help`, it's most likely because the directory containing the `bootdev` program isn't in your [`PATH`](https://opensource.com/article/17/6/set-path-linux). You need to add the directory to your `PATH` by modifying your shell's configuration file. You probably need to add `$HOME/go/bin` (the default `GOBIN` directory where `go` installs programs) to your `PATH`:
If you're getting a "command not found" error for `bootdev`, it's likely because the directory containing the `bootdev` program isn't in your [`PATH`](https://opensource.com/article/17/6/set-path-linux). You need to add the directory to `PATH` by modifying your shell's configuration file. In most cases, this means adding `$HOME/go/bin` (the default `GOBIN` directory where `go` installs programs):

```sh
# For Linux/WSL
Expand Down Expand Up @@ -127,7 +127,7 @@ For lessons with HTTP tests, you can configure the CLI with a base URL that over
- To set the base URL, run:

```sh
bootdev config base_url <url>
bootdev config base_url YOUR_URL
```

_Make sure you include the protocol scheme (`http://`) in the URL._
Expand All @@ -151,10 +151,10 @@ The CLI text output is rendered with extra colors: green (e.g., success messages
- To customize these colors, run:

```sh
bootdev config colors --red <value> --green <value> --gray <value>
bootdev config colors --red VALUE --green VALUE --gray VALUE
```

_You can use an [ANSI color code](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) or a hex string as the `<value>`._
_You can use an [ANSI color code](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) or a hex string as the `VALUE`._

- To get the current colors, run:

Expand Down
6 changes: 4 additions & 2 deletions checks/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ func runHTTPRequest(
dat, err := json.Marshal(requestStep.Request.BodyJSON)
cobra.CheckErr(err)
interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables)
req, err = http.NewRequest(requestStep.Request.Method, completeURL,
req, err = http.NewRequest(
requestStep.Request.Method, completeURL,
bytes.NewBuffer([]byte(interpolatedBodyJSONStr)),
)
if err != nil {
Expand All @@ -50,7 +51,8 @@ func runHTTPRequest(

encodedFormStr := formValues.Encode()
var err error
req, err = http.NewRequest(requestStep.Request.Method, completeURL,
req, err = http.NewRequest(
requestStep.Request.Method, completeURL,
strings.NewReader(encodedFormStr),
)
if err != nil {
Expand Down
128 changes: 128 additions & 0 deletions checks/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package checks

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

api "github.com/bootdotdev/bootdev/client"
)

func TestInterpolateVariables(t *testing.T) {
got := InterpolateVariables(
"${baseURL}/users/${id}?missing=${missing}",
map[string]string{"baseURL": "http://localhost:8080", "id": "42"},
)
want := "http://localhost:8080/users/42?missing=${missing}"
if got != want {
t.Fatalf("InterpolateVariables() = %q, want %q", got, want)
}
}

func TestInterpolationNames(t *testing.T) {
got := InterpolationNames("${baseURL}/users/${id}/${id}")
want := []string{"baseURL", "id", "id"}
if len(got) != len(want) {
t.Fatalf("InterpolationNames() = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("InterpolationNames() = %#v, want %#v", got, want)
}
}
}

func TestRunHTTPRequestInterpolatesRequestAndCapturesResponseVariables(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want %q", r.Method, http.MethodPost)
return
}
if r.URL.Path != "/users/42" {
t.Errorf("path = %q, want %q", r.URL.Path, "/users/42")
return
}
if r.Header.Get("X-User-ID") != "42" {
t.Errorf("X-User-ID = %q, want %q", r.Header.Get("X-User-ID"), "42")
return
}

username, password, ok := r.BasicAuth()
if !ok || username != "user" || password != "pass" {
t.Errorf("BasicAuth() = %q, %q, %v; want user, pass, true", username, password, ok)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("failed reading request body: %v", err)
return
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("failed unmarshalling request body %q: %v", string(body), err)
return
}
if payload["message"] != "hello Theo" {
t.Errorf("message = %q, want %q", payload["message"], "hello Theo")
return
}

w.Header().Set("X-Request-OK", "yes")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"token":"abc123"}`))
}))
defer server.Close()

variables := map[string]string{
"id": "42",
"name": "Theo",
}
requestStep := api.CLIStepHTTPRequest{
ResponseVariables: []api.HTTPRequestResponseVariable{{Name: "token", Path: ".token"}},
Request: api.HTTPRequest{
Method: http.MethodPost,
FullURL: api.BaseURLPlaceholder + "/users/${id}",
Headers: map[string]string{
"X-User-ID": "${id}",
},
BodyJSON: map[string]any{
"message": "hello ${name}",
},
BasicAuth: &api.HTTPBasicAuth{Username: "user", Password: "pass"},
},
}

result := runHTTPRequest(server.Client(), server.URL, variables, requestStep)
if result.Err != "" {
t.Fatalf("unexpected request error: %s", result.Err)
}
if result.StatusCode != http.StatusCreated {
t.Fatalf("StatusCode = %d, want %d", result.StatusCode, http.StatusCreated)
}
if result.ResponseHeaders["X-Request-Ok"] != "yes" {
t.Fatalf("ResponseHeaders[X-Request-Ok] = %q, want %q", result.ResponseHeaders["X-Request-Ok"], "yes")
}
if result.BodyString != `{"token":"abc123"}` {
t.Fatalf("BodyString = %q, want token response", result.BodyString)
}
if result.Variables["token"] != "abc123" {
t.Fatalf("captured token = %q, want %q", result.Variables["token"], "abc123")
}
if result.Variables["id"] != "42" {
t.Fatalf("original variable id = %q, want %q", result.Variables["id"], "42")
}
}

func TestTruncateAndStringifyBodyCapsBinaryBody(t *testing.T) {
body := []byte(strings.Repeat("a", 20*1024))
body[0] = 0

got := truncateAndStringifyBody(body)
if len(got) != 16*1024 {
t.Fatalf("len(truncateAndStringifyBody(binary)) = %d, want %d", len(got), 16*1024)
}
}
2 changes: 1 addition & 1 deletion checks/jq.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func prettyPrintStdoutJqTest(test api.StdoutJqTest, variables map[string]string) string {
queryText := InterpolateVariables(test.Query, variables)
var str strings.Builder
str.WriteString(fmt.Sprintf("Expect jq query '%s' to yield values satisfying:", queryText))
fmt.Fprintf(&str, "Expect jq query '%s' to yield values satisfying:", queryText)
if len(test.ExpectedResults) == 0 {
str.WriteString("\n - [no expected results provided]")
return str.String()
Expand Down
176 changes: 176 additions & 0 deletions checks/jq_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package checks

import (
"reflect"
"strings"
"testing"

api "github.com/bootdotdev/bootdev/client"
)

func TestRunStdoutJqQuery(t *testing.T) {
tests := []struct {
name string
stdout string
test api.StdoutJqTest
variables map[string]string
want api.CLICommandJqOutput
wantError string
}{
{
name: "queries json with interpolated query",
stdout: `{"users":[{"name":"Lane"},{"name":"Theo"}]}`,
test: api.StdoutJqTest{
InputMode: "json",
Query: `.users[] | select(.name == "${name}") | .name`,
},
variables: map[string]string{"name": "Theo"},
want: api.CLICommandJqOutput{
Query: `.users[] | select(.name == "Theo") | .name`,
Results: []string{`"Theo"`},
},
},
{
name: "queries jsonl as array",
stdout: "{\"id\":1}\n{\"id\":2}\n",
test: api.StdoutJqTest{
InputMode: "jsonl",
Query: `.[].id`,
},
want: api.CLICommandJqOutput{
Query: `.[].id`,
Results: []string{`1`, `2`},
},
},
{
name: "returns parse error",
stdout: `{not json}`,
test: api.StdoutJqTest{
InputMode: "json",
Query: `.name`,
},
want: api.CLICommandJqOutput{
Query: `.name`,
},
wantError: "invalid character",
},
{
name: "returns jq error",
stdout: `{"name":"Theo"}`,
test: api.StdoutJqTest{
InputMode: "json",
Query: `.name[`,
},
want: api.CLICommandJqOutput{
Query: `.name[`,
Error: "unexpected EOF",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runStdoutJqQuery(tt.stdout, tt.test, tt.variables)
if tt.wantError != "" {
if got.Query != tt.want.Query {
t.Fatalf("Query = %q, want %q", got.Query, tt.want.Query)
}
if !strings.Contains(got.Error, tt.wantError) {
t.Fatalf("expected error containing %q, got %q", tt.wantError, got.Error)
}
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("runStdoutJqQuery() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestParseJqInputRejectsMultipleJSONValuesInJSONMode(t *testing.T) {
_, err := parseJqInput("{\"id\":1}\n{\"id\":2}\n", "json")
if err == nil {
t.Fatal("expected error for multiple JSON values in json mode")
}
if err.Error() != "expected a single JSON value" {
t.Fatalf("expected single-value error, got %q", err.Error())
}
}

func TestFormatJqResults(t *testing.T) {
got := formatJqResults([]any{"hello", float64(42), true, nil, map[string]any{"id": float64(1)}})
want := []string{`"hello"`, `42`, `true`, `null`, `{"id":1}`}
if !reflect.DeepEqual(got, want) {
t.Fatalf("formatJqResults() = %#v, want %#v", got, want)
}
}

func TestFormatJqExpectedValueInterpolatesOnlyStrings(t *testing.T) {
variables := map[string]string{"name": "Theo"}

gotString := formatJqExpectedValue(api.JqExpectedResult{
Type: api.JqTypeString,
Value: "hello ${name}",
}, variables)
if gotString != `"hello Theo"` {
t.Fatalf("expected interpolated string value, got %q", gotString)
}

gotInt := formatJqExpectedValue(api.JqExpectedResult{
Type: api.JqTypeInt,
Value: "${name}",
}, variables)
if gotInt != `"${name}"` {
t.Fatalf("expected non-string jq type to avoid interpolation, got %q", gotInt)
}
}

func TestValFromJqPath(t *testing.T) {
tests := []struct {
name string
path string
jsn string
want any
wantErr string
}{
{
name: "returns one value",
path: `.token`,
jsn: `{"token":"abc123"}`,
want: "abc123",
},
{
name: "errors on missing value",
path: `.missing`,
jsn: `{"token":"abc123"}`,
wantErr: "value not found",
},
{
name: "errors on multiple values",
path: `.items[].id`,
jsn: `{"items":[{"id":1},{"id":2}]}`,
wantErr: "invalid number of values found",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := valFromJqPath(tt.path, tt.jsn)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error %q", tt.wantErr)
}
if err.Error() != tt.wantErr {
t.Fatalf("expected error %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("valFromJqPath() = %#v, want %#v", got, tt.want)
}
})
}
}
Loading