diff --git a/README.md b/README.md index 0f75ab7..1d9b2d6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,16 @@ http-assert [flags] 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 diff --git a/assertions.go b/assertions.go index 7108da3..8eeed96 100644 --- a/assertions.go +++ b/assertions.go @@ -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) diff --git a/assertions_test.go b/assertions_test.go index b46aec5..84137c8 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -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) { diff --git a/e2e_assert_test.go b/e2e_assert_test.go index ff629fe..45246cf 100644 --- a/e2e_assert_test.go +++ b/e2e_assert_test.go @@ -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") + }) +} diff --git a/e2e_known_issues_test.go b/e2e_known_issues_test.go index 34d7763..5a9773a 100644 --- a/e2e_known_issues_test.go +++ b/e2e_known_issues_test.go @@ -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. // diff --git a/main.go b/main.go index 80c5e02..230ddce 100644 --- a/main.go +++ b/main.go @@ -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' \ @@ -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 @@ -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", "", @@ -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") @@ -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 }