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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ http-assert [flags] <URL>

The three header flags can be repeated to make several assertions of that kind. Every other assertion flag takes a single value; giving one twice exits `71` rather than silently keeping the last.

`--assert-ok` and `--assert-body-empty` can be negated with `=false`, which asserts the opposite rather than cancelling the flag:

```bash
# Assert the endpoint IS failing -- useful for testing that a guard rejects
http-assert --assert-ok=false https://api.example.com/forbidden

# Assert something came back, without saying what
http-assert --assert-body-empty=false https://api.example.com/report
```

The three body assertions run against the decoded payload, never the bytes on the wire — see [Compression](#compression).

### Redirects
Expand Down
15 changes: 15 additions & 0 deletions assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ func AssertBodyEmpty() Assertion {
}
}

func AssertBodyNotEmpty() Assertion {
return func(res *httpResponse) error {
body, err := bodyOf(res)
if err != nil {
return err
}

if len(body) == 0 {
return fmt.Errorf("body: expected to be non-empty, got nothing")
}

return nil
}
}

func AssertBodyEqual(expContent string) Assertion {
return func(res *httpResponse) error {
body, err := bodyOf(res)
Expand Down
50 changes: 50 additions & 0 deletions assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,56 @@ func Test_AssertRedirect(t *testing.T) {
}
}

// Test_AssertBodyNotEmpty is the assertion --assert-body-empty=false selects.
// It had no constructor at all, which is why that flag registered nothing (#32).
func Test_AssertBodyNotEmpty(t *testing.T) {
t.Parallel()

tests := []struct {
Name string
Body []byte
Want string
}{
{Name: "a body satisfies it", Body: []byte("x")},
{
Name: "an empty body does not",
Body: []byte{},
Want: "body: expected to be non-empty, got nothing",
},
{
// What a 204 produces. It must fail the same way an empty slice
// does, not differently.
Name: "a nil body does not",
Want: "body: expected to be non-empty, got nothing",
},
{
// Whitespace is content. The assertion is about presence, not
// meaning, and trimming here would make it about both.
Name: "whitespace counts as a body",
Body: []byte(" "),
},
}

a := AssertBodyNotEmpty()
for _, tc := range tests {
t.Run(tc.Name, func(t *testing.T) {
checkErr(t, "not-empty", a(&httpResponse{BodyBytes: tc.Body}), tc.Want)
})
}

// The pair must be exact opposites on every input, or =false means
// something subtly other than "not that".
t.Run("it is the exact inverse of AssertBodyEmpty", func(t *testing.T) {
empty := AssertBodyEmpty()
for _, body := range [][]byte{nil, {}, []byte(" "), []byte("x"), []byte("longer body")} {
res := &httpResponse{BodyBytes: body}
if (empty(res) == nil) == (a(res) == nil) {
t.Errorf("both agree on %q; they must disagree", string(body))
}
}
})
}

// Test_AssertMatchConstructorsRejectBadPatterns covers the failure path the
// pattern-based constructors gained when they stopped panicking (#17).
func Test_AssertMatchConstructorsRejectBadPatterns(t *testing.T) {
Expand Down
45 changes: 45 additions & 0 deletions e2e_assert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,48 @@ func TestE2EAssertEmptyBody(t *testing.T) {
assertContains(t, r, `body: expected "value", missing`)
})
}

// TestE2EAssertBooleanNegation pins what =false means on the two boolean
// assertions. It used to mean two different things: --assert-ok=false selected
// the inverse assertion, while --assert-body-empty=false selected none at all
// and killed the run with "no assertions defined" (#32).
func TestE2EAssertBooleanNegation(t *testing.T) {
for _, tc := range []struct {
Name string
Args []string
Want int
}{
// --assert-ok, both directions, both outcomes.
{"--assert-ok on a 200", []string{"--assert-ok", url("/ok")}, exitOK},
{"--assert-ok on a 500", []string{"--assert-ok", url("/500")}, exitRequestFail},
{"--assert-ok=false on a 500", []string{"--assert-ok=false", url("/500")}, exitOK},
{"--assert-ok=false on a 200", []string{"--assert-ok=false", url("/ok")}, exitRequestFail},

// --assert-body-empty, the same four.
{"--assert-body-empty on a 204", []string{"--assert-body-empty", url("/empty")}, exitOK},
{"--assert-body-empty on a body", []string{"--assert-body-empty", url("/ok")}, exitRequestFail},
{"--assert-body-empty=false on a body", []string{"--assert-body-empty=false", url("/ok")}, exitOK},
{"--assert-body-empty=false on a 204", []string{"--assert-body-empty=false", url("/empty")}, exitRequestFail},
} {
t.Run(tc.Name, func(t *testing.T) {
assertExit(t, run(t, nil, tc.Args...), tc.Want)
})
}

// The failure mode that made this a bug rather than an inconsistency: a
// user names an assertion and is told there are none.
t.Run("a negated flag is never treated as no assertion", func(t *testing.T) {
for _, f := range []string{"--assert-ok=false", "--assert-body-empty=false"} {
r := run(t, nil, f, url("/empty"))
assertNotContains(t, r, "no assertions defined")
}
})

// Negation still counts as naming the flag, so repeating it is refused
// exactly as repeating the positive form is.
t.Run("a repeated negation is still refused", func(t *testing.T) {
r := run(t, nil, "--assert-ok", "--assert-ok=false", url("/ok"))
assertExit(t, r, exitBadFlagVal)
assertContains(t, r, "was given 2 times")
})
}
16 changes: 0 additions & 16 deletions e2e_known_issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,22 +130,6 @@ func TestKnownIssue31MaxTimeAcceptsNonPositive(t *testing.T) {
}
}

// TestKnownIssue32NegatedBooleanFlagsDiffer: --assert-ok=false registers the
// inverse assertion; --assert-body-empty=false registers nothing.
func TestKnownIssue32NegatedBooleanFlagsDiffer(t *testing.T) {
t.Run("assert-ok=false asserts NOT ok", func(t *testing.T) {
characterizes(t, 32, "an undocumented negation that happens to be useful")
assertExit(t, run(t, nil, "--assert-ok=false", url("/500")), exitOK)
})

t.Run("assert-body-empty=false registers nothing", func(t *testing.T) {
characterizes(t, 32, "the same syntax on a sibling flag is a no-op")
r := run(t, nil, "--assert-body-empty=false", url("/ok"))
assertExit(t, r, exitRequestFail)
assertContains(t, r, "no assertions defined")
})
}

// TestKnownIssue33BareHeaderSendsEmptyValue: a -H value with no colon parses to
// an empty value and is sent as an empty-valued header.
//
Expand Down
51 changes: 38 additions & 13 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// once. Every other assertion flag takes a single value and is rejected if
// given twice, rather than quietly keeping the last one.
//
// The two boolean assertions negate with =false, which selects the opposite
// assertion rather than cancelling the flag.
//
// http-assert --assert-ok https://example.com
// http-assert --assert-status 201 -X POST -d '{"n":1}' https://api.example.com/things
// http-assert --assert-header 'Content-Type: application/json' \
Expand Down Expand Up @@ -124,6 +127,11 @@ Repeat --assert-header, --assert-header-eq or --assert-header-missing to make
several assertions of that kind. Every other assertion flag takes a single
value and is rejected if given twice, rather than quietly keeping the last.

The two boolean assertions can be negated with =false, which selects the
opposite assertion rather than cancelling the flag: --assert-ok=false asserts
the status IS an error, and --assert-body-empty=false asserts the body is not
empty.

Exit codes:
0 every assertion passed
71 a flag or environment value was rejected
Expand Down Expand Up @@ -491,11 +499,12 @@ func registerAssertionFlags(cmd *cobra.Command) {
cmd.Flags().StringArray("assert-header-missing", nil, "Assert header is missing")
cmd.Flags().String("assert-body", "", "Assert body matches the provided regexp")
cmd.Flags().String("assert-body-eq", "", "Assert body equals the provided value")
cmd.Flags().Bool("assert-body-empty", false, "Assert body is empty")
cmd.Flags().Bool("assert-body-empty", false,
"Assert body is empty; =false asserts it is not")

// Common shorthands
cmd.Flags().Bool("assert-ok", false,
"Assert response status is not an error (2xx or 3xx)")
"Assert response status is not an error (2xx or 3xx); =false asserts it is")
cmd.Flags().String("assert-redirect", "",
"Assert redirect location matches the provided regexp; redirects are not followed")
cmd.Flags().String("assert-redirect-eq", "",
Expand Down Expand Up @@ -566,16 +575,36 @@ func mustCompileAssertion(flag, pattern string, build func(string) (Assertion, e
return a
}

// boolAssertion turns a boolean assertion flag into the assertion it asks for.
//
// A boolean assertion has two of them, and =false selects the second rather
// than cancelling the flag: --assert-ok asserts the status is not an error,
// --assert-ok=false asserts that it is. Naming an assertion and getting no
// assertion would be the one outcome worth refusing, and it is what
// --assert-body-empty=false used to do -- the run died with "no assertions
// defined" after the user had named one (#32).
//
// The pairing lives here, once, rather than being written out per flag. The two
// flags drifted apart because nothing connected them; a helper is what connects
// them, in the same way rejectRepeats derives from the flag's type rather than
// from a list.
func boolAssertion(cmd *cobra.Command, name string, whenTrue, whenFalse func() Assertion) []Assertion {
if !cmd.Flags().Changed(name) {
return nil
}

if v, _ := cmd.Flags().GetBool(name); v {
return []Assertion{whenTrue()}
}

return []Assertion{whenFalse()}
}

func parseAssertionFlags(cmd *cobra.Command) []Assertion {
var res []Assertion

if cmd.Flags().Changed("assert-ok") {
if v, _ := cmd.Flags().GetBool("assert-ok"); v {
res = append(res, AssertStatusOK())
} else {
res = append(res, AssertStatusNOK())
}
}
res = append(res, boolAssertion(cmd, "assert-ok", AssertStatusOK, AssertStatusNOK)...)
res = append(res, boolAssertion(cmd, "assert-body-empty", AssertBodyEmpty, AssertBodyNotEmpty)...)

if cmd.Flags().Changed("assert-redirect") {
v, _ := cmd.Flags().GetString("assert-redirect")
Expand Down Expand Up @@ -614,10 +643,6 @@ func parseAssertionFlags(cmd *cobra.Command) []Assertion {
v, _ := cmd.Flags().GetString("assert-body-eq")
res = append(res, AssertBodyEqual(v))
}
if v, _ := cmd.Flags().GetBool("assert-body-empty"); v {
res = append(res, AssertBodyEmpty())
}

return res
}

Expand Down
Loading