From f7c7238ea5fa002ece0f006e4b5983bee5cb7aa3 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 19 May 2024 15:21:24 +0300 Subject: [PATCH] Oops, needed a cleaner history --- .gitignore | 11 + CONTRIBUTING.md | 4 + LICENSE | 21 + README.md | 229 +++++++++ be_ctx/README.md | 41 ++ be_ctx/matchers_ctx.go | 36 ++ be_http/README.md | 117 +++++ be_http/matchers_http.go | 178 +++++++ be_json/README.md | 50 ++ be_json/matchers_json.go | 159 +++++++ be_jwt/README.md | 108 +++++ be_jwt/matchers_jwt.go | 132 ++++++ be_math/README.md | 145 ++++++ be_math/be_math_suite_test.go | 13 + be_math/matchers_math.go | 142 ++++++ be_math/matchers_math_test.go | 166 +++++++ be_reflected/README.md | 212 +++++++++ be_reflected/be_reflected_suite_test.go | 13 + be_reflected/matchers_reflected.go | 193 ++++++++ be_reflected/matchers_reflected_test.go | 105 +++++ be_string/README.md | 145 ++++++ be_string/be_string_suite_test.go | 13 + be_string/matchers_string.go | 251 ++++++++++ be_string/matchers_string_test.go | 257 ++++++++++ be_suite_test.go | 13 + be_time/README.md | 214 +++++++++ be_time/be_time_suite_test.go | 13 + be_time/matchers_time.go | 341 ++++++++++++++ be_time/matchers_time_test.go | 133 ++++++ be_url/README.md | 162 +++++++ be_url/matchers_url.go | 180 +++++++ core-be-matchers.md | 105 +++++ examples/examples_be_ctx_test.go | 31 ++ examples/examples_be_http_test.go | 118 +++++ examples/examples_be_jwt_test.go | 110 +++++ examples/examples_be_strings_test.go | 89 ++++ examples/examples_be_url_test.go | 85 ++++ examples/examples_suite_test.go | 13 + generate-docs.sh | 16 + go.mod | 23 + go.sum | 38 ++ internal/cast/as.go | 443 ++++++++++++++++++ internal/cast/as_test.go | 50 ++ internal/cast/cast_suite_test.go | 13 + internal/cast/is.go | 74 +++ internal/cast/is_string.go | 188 ++++++++ internal/cast/is_string_test.go | 73 +++ internal/cast/is_test.go | 84 ++++ internal/psi/dive.go | 82 ++++ internal/psi/failable_transform.go | 94 ++++ internal/psi/from-gomega.go | 81 ++++ internal/psi/from-gomock.go | 32 ++ internal/psi/helpers.go | 47 ++ internal/psi/psi.go | 122 +++++ internal/psi_matchers/all_matcher.go | 71 +++ internal/psi_matchers/always_matcher.go | 18 + internal/psi_matchers/always_matcher_test.go | 46 ++ internal/psi_matchers/any_matcher.go | 69 +++ .../psi_matchers/assignable_to_matcher.go | 42 ++ internal/psi_matchers/ctx_matcher.go | 155 ++++++ internal/psi_matchers/eq_matcher.go | 61 +++ internal/psi_matchers/eq_matcher_test.go | 44 ++ internal/psi_matchers/have_length_matcher.go | 85 ++++ .../psi_matchers/have_length_matcher_test.go | 68 +++ internal/psi_matchers/implements_matcher.go | 46 ++ internal/psi_matchers/jwt_token_matcher.go | 81 ++++ internal/psi_matchers/kind_matcher.go | 80 ++++ internal/psi_matchers/never_matcher.go | 20 + internal/psi_matchers/not_matcher.go | 47 ++ .../psi_matchers/psi_matchers_suite_test.go | 13 + .../psi_matchers/request_property_matcher.go | 78 +++ .../psi_matchers/string_template_matcher.go | 143 ++++++ internal/psi_matchers/url_field_matcher.go | 86 ++++ internal/reflect/reflect.go | 34 ++ internal/testing/mocks/urler_mock.go | 51 ++ internal/testing/urler.go | 7 + matchers.go | 24 + matchers_be.go | 57 +++ options/be_string_options.go | 59 +++ options/options.go | 10 + types/be_matcher.go | 29 ++ 81 files changed, 7332 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 be_ctx/README.md create mode 100644 be_ctx/matchers_ctx.go create mode 100644 be_http/README.md create mode 100644 be_http/matchers_http.go create mode 100644 be_json/README.md create mode 100644 be_json/matchers_json.go create mode 100644 be_jwt/README.md create mode 100644 be_jwt/matchers_jwt.go create mode 100644 be_math/README.md create mode 100644 be_math/be_math_suite_test.go create mode 100644 be_math/matchers_math.go create mode 100644 be_math/matchers_math_test.go create mode 100644 be_reflected/README.md create mode 100644 be_reflected/be_reflected_suite_test.go create mode 100644 be_reflected/matchers_reflected.go create mode 100644 be_reflected/matchers_reflected_test.go create mode 100644 be_string/README.md create mode 100644 be_string/be_string_suite_test.go create mode 100644 be_string/matchers_string.go create mode 100644 be_string/matchers_string_test.go create mode 100644 be_suite_test.go create mode 100644 be_time/README.md create mode 100644 be_time/be_time_suite_test.go create mode 100644 be_time/matchers_time.go create mode 100644 be_time/matchers_time_test.go create mode 100644 be_url/README.md create mode 100644 be_url/matchers_url.go create mode 100644 core-be-matchers.md create mode 100644 examples/examples_be_ctx_test.go create mode 100644 examples/examples_be_http_test.go create mode 100644 examples/examples_be_jwt_test.go create mode 100644 examples/examples_be_strings_test.go create mode 100644 examples/examples_be_url_test.go create mode 100644 examples/examples_suite_test.go create mode 100755 generate-docs.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cast/as.go create mode 100644 internal/cast/as_test.go create mode 100644 internal/cast/cast_suite_test.go create mode 100644 internal/cast/is.go create mode 100644 internal/cast/is_string.go create mode 100644 internal/cast/is_string_test.go create mode 100644 internal/cast/is_test.go create mode 100644 internal/psi/dive.go create mode 100644 internal/psi/failable_transform.go create mode 100644 internal/psi/from-gomega.go create mode 100644 internal/psi/from-gomock.go create mode 100644 internal/psi/helpers.go create mode 100644 internal/psi/psi.go create mode 100644 internal/psi_matchers/all_matcher.go create mode 100644 internal/psi_matchers/always_matcher.go create mode 100644 internal/psi_matchers/always_matcher_test.go create mode 100644 internal/psi_matchers/any_matcher.go create mode 100644 internal/psi_matchers/assignable_to_matcher.go create mode 100644 internal/psi_matchers/ctx_matcher.go create mode 100644 internal/psi_matchers/eq_matcher.go create mode 100644 internal/psi_matchers/eq_matcher_test.go create mode 100644 internal/psi_matchers/have_length_matcher.go create mode 100644 internal/psi_matchers/have_length_matcher_test.go create mode 100644 internal/psi_matchers/implements_matcher.go create mode 100644 internal/psi_matchers/jwt_token_matcher.go create mode 100644 internal/psi_matchers/kind_matcher.go create mode 100644 internal/psi_matchers/never_matcher.go create mode 100644 internal/psi_matchers/not_matcher.go create mode 100644 internal/psi_matchers/psi_matchers_suite_test.go create mode 100644 internal/psi_matchers/request_property_matcher.go create mode 100644 internal/psi_matchers/string_template_matcher.go create mode 100644 internal/psi_matchers/url_field_matcher.go create mode 100644 internal/reflect/reflect.go create mode 100644 internal/testing/mocks/urler_mock.go create mode 100644 internal/testing/urler.go create mode 100644 matchers.go create mode 100644 matchers_be.go create mode 100644 options/be_string_options.go create mode 100644 options/options.go create mode 100644 types/be_matcher.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb4e2b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# idea +.idea \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d5ea47c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +### Contributing +Feel free to open issues. + +### TODO: stabilize with contributing guidelines \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf16ff4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 expectto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e43ada --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +## Expect(👨🏼‍💻).To(Be(🚀)) + +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/expectto/be/blob/main/LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/expectto/be.svg)](https://expectto.github.io/be/) + + +`expectto/be` is a Golang package that offers a substantial collection of `Be` matchers. Every `Be` matcher is +compatible with both [Ginkgo](https://github.com/onsi/ginkgo)/[Gomega](https://github.com/onsi/gomega) +and [Gomock](https://github.com/uber-go/mock). Where possible, arguments of matchers can be either finite values or +matchers (Be/Gomega/Gomock).
+Employing `expectto/be` matchers enables you to create straightforward, readable, and maintainable unit or +integration tests in Golang. Tasks such as testing HTTP requests, validating JSON responses, and more become remarkably +comprehensive and straightforward. + +## Table of Contents + +- [Installation](#installation) +- [Example](#example) +- [Matchers](#matchers) + - [Be (core)](#core-be) + - [Be Reflected](#be_reflected) + - [Be Math](#be_math) + - [Be String](#be_string) + - [Be Time](#be_time) + - [Be JWT](#be_jwt) + - [Be URL](#be_url) + - [Be JSON](#be_json) + - [Be HTTP](#be_http) + +- [Contributing](#contributing) +- [License](#license) + +## Installation + +To use `Be` in your Golang project, simply import it: + +```go +import "github.com/expectto/be" +``` + +## Example + +Consider the following example demonstrating the usage of `expectto/be`'s HTTP request matchers: + +```go +req, err := buildRequestForServiceFoo() +Expect(err).To(Succeed()) + +// Matching an HTTP request +Expect(req).To(be_http.Request( + // Matching the URL + be_http.HavingURL(be_url.URL( + be_url.WithHttps(), + be_url.HavingHost("example.com"), + be_url.HavingPath("/path"), + be_url.HavingSearchParam("status", "active"), + be_url.HavingSearchParam("v", be_reflected.AsNumericString()), + be_url.HavingSearchParam("q", "Hello World"), + )), + + // Matching the HTTP method + be_http.POST() + + // Matching request's context + be_http.HavingCtx(be_ctx.Ctx( + be_ctx.WithDeadline(be_time.LaterThan(time.Now().Add(30*time.Minute))), + be_ctx.WithValue("foobar", 100), + )), + + // Matching the request body using JSON matchers + be_http.HavingBody( + be_json.Matcher( + be_json.JsonAsReader, + be_json.HaveKeyValue("hello", "world"), + be_json.HaveKeyValue("n", be_reflected.AsInteger()), + be_json.HaveKeyValue("ids", be_reflected.AsSliceOf[string]), + be_json.HaveKeyValue("details", And( + be_reflected.AsObjects(), + be.HaveLength(2), + ContainElements( + be_json.HaveKeyValue("key", "foo"), + be_json.HaveKeyValue("key", "bar"), + ), + )), + ), + + // Matching HTTP headers + be_http.HavingHeader("X-Custom", "Hey-There"), + be_http.HavingHeader("Authorization", + be_string.MatchTemplate("Bearer {{jwt}}", + be_string.Var("jwt", + be_jwt.Token( + be_jwt.Valid(), + be_jwt.HavingClaim("name", "John Doe"), + ), + ), + ), + ), + ), +)) +``` + +## Matchers + +### Core Be + +📦 `be` provides a set of core matchers for common testing scenarios.
[See detailed docs](core-be-matchers.md) + +#### Core matchers: + +`Always`, `Never`, `All`, `Any`, `Eq`, `Not`, `HaveLength`, `Dive`, `DiveAny`, `DiveFirst` + +### be_reflected + +📦 `be_reflected` provides Be matchers that use reflection, enabling expressive assertions on values' reflect kinds and +types.
[See detailed docs](be_reflected/README.md) + +#### General Matchers based on reflect.Kind: + +`AsKind`, `AsFunc`, `AsChan`, `AsPointer`, `AsFinalPointer`, `AsStruct`, `AsPointerToStruct`, `AsSlice`, `AsPointerToSlice`, `AsSliceOf`, `AsMap`, `AsPointerToMap`, `AsObject`, `AsObjects`, `AsPointerToObject` + +#### Data Type Matchers based on reflect.Kind + +`AsString`, `AsBytes`, `AsNumeric`, `AsNumericString`, `AsInteger`, `AsIntegerString`, `AsFloat`, `AsFloatishString`, + +#### Interface Matchers based on reflect.Kind + +`AsReader`,`AsStringer` + +#### Matchers based on types compatibility: + +`AssignableTo`, `Implementing` + +### be_math + +📦 `be_math` provides Be matchers for mathematical operations.
[See detailed docs](be_math/README.md) + +#### Matchers on math: + +`GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `Approx`, `InRange`, `Odd`, `Even`, `Negative`, `Positive`, `Zero`, `Integral`, `DivisibleBy` + +#### Shortcut aliases for math matchers: + +`Gt`, `Gte`, `Lt`, `Lte` + +### be_string + +📦 `be_string` provides Be matchers for string-related assertions.
[See detailed docs](be_string/README.md) + +#### Matchers on strings + +`NonEmptyString`, `EmptyString`, `Alpha`, `Numeric`, `AlphaNumeric`, `AlphaNumericWithDots`, `Float`, `Titled`, `LowerCaseOnly`, `MatchWildcard`, `ValidEmail` + +#### Template matchers + +`MatchTemplate` + +### be_time + +📦 `be_time` provides Be matchers on time.Time.
[See detailed docs](be_time/README.md) + +#### Time Matchers + +`LaterThan`, `LaterThanEqual`, `EarlierThan`, `EarlierThanEqual`, `Eq`, `Approx`,
+`SameExactMilli`, `SameExactSecond`, `SameExactMinute`, `SameExactHour`,
+`SameExactDay`, `SameExactWeekday`, `SameExactWeek`, `SameExactMonth`,
+`SameSecond`, `SameMinute`, `SameHour`, `SameDay`, `SameYearDay`,
+`SameWeek`, `SameMonth`, `SameYear`, `SameTimzone`, `SameOffset`, `IsDST` + +### be_jwt + +📦 `be_jwt` provides Be matchers for handling JSON Web Tokens (JWT). It includes matchers for transforming and validating +JWT tokens. Matchers corresponds to specific +golang [jwt implementation](https://github.com/golang-jwt/jwt/v5).
[See detailed docs](be_jwt/README.md) + +#### Transformers for JWT matching: + +`TransformSignedJwtFromString`, `TransformJwtFromString` + +#### Matchers on JWT: + +`Token`, `Valid`, `HavingClaims`, `HavingClaim`, `HavingMethodAlg`, `SignedVia` + +### be_url + +📦 `be_url` provides Be matchers on url.URL.
[See detailed docs](be_jwt/README.md) + +#### Transformers for URL Matchers: + +`TransformUrlFromString`, `TransformSchemelessUrlFromString` + +#### URL Matchers: + +`URL`, `HavingHost`, `HavingHostname`, `HavingScheme`, `NotHavingScheme`, `WithHttps`, `WithHttp`, `HavingPort`, `NotHavingPort`, `HavingPath`, `HavingRawQuery`, `HavingSearchParam`, `HavingMultipleSearchParam`, `HavingUsername`, `HavingUserinfo`, `HavingPassword` + +### be_ctx + +📦 `be_ctx` provides Be matchers on context.Context.
[See detailed docs](be_ctx/README.md) + +#### Context Matchers: + +`Ctx`, `CtxWithValue`, `CtxWithDeadline`, `CtxWithError` + +### be_json + +📦 `be_json` provides Be matchers for expressive assertions on JSON.
[See detailed docs](be_json/README.md) + +#### JSON Matchers: + +`Matcher`, `HaveKeyValue` + +### be_http + +📦 `be_http` provides Be matchers for expressive assertions on http.Request.
[See detailed docs](be_http/README.md) + +#### Matchers on HTTP: + +`Request`, `HavingMethod`,
+`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT`, `TRACE`,
+`HavingURL`, `HavingBody`, `HavingHost`, `HavingProto`, `HavingHeader`, `HavingHeaders` + +# Contributing + +`Be` welcomes contributions! Feel free to open issues, suggest improvements, or submit pull +requests. [Contribution guidelines for this project](CONTRIBUTING.md) + +# License + +This project is [licensed under the MIT License](LICENSE). diff --git a/be_ctx/README.md b/be_ctx/README.md new file mode 100644 index 0000000..59f47d2 --- /dev/null +++ b/be_ctx/README.md @@ -0,0 +1,41 @@ +# be_ctx +-- + import "github.com/expectto/be/be_ctx" + +Package be_ctx provides Be matchers on context.Context + +## Usage + +#### func Ctx + +```go +func Ctx(args ...any) types.BeMatcher +``` +Ctx succeeds if the actual value is a context.Context. If no arguments are +provided, it matches any context.Context. Otherwise, it uses the Psi matcher to +match the provided arguments against the actual context's values. + +#### func CtxWithDeadline + +```go +func CtxWithDeadline(deadline any) types.BeMatcher +``` +CtxWithDeadline succeeds if the actual value is a context.Context and its +deadline matches the provided deadline. + +#### func CtxWithError + +```go +func CtxWithError(err any) types.BeMatcher +``` +CtxWithError succeeds if the actual value is a context.Context and its error +matches the provided error value. + +#### func CtxWithValue + +```go +func CtxWithValue(key any, vs ...any) types.BeMatcher +``` +CtxWithValue succeeds if the actual value is a context.Context and contains a +key-value pair where the key matches the provided key and the value matches the +provided arguments using any other matchers. diff --git a/be_ctx/matchers_ctx.go b/be_ctx/matchers_ctx.go new file mode 100644 index 0000000..73264cd --- /dev/null +++ b/be_ctx/matchers_ctx.go @@ -0,0 +1,36 @@ +// Package be_ctx provides Be matchers on context.Context +package be_ctx + +import ( + "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" +) + +// Ctx succeeds if the actual value is a context.Context. +// If no arguments are provided, it matches any context.Context. +// Otherwise, it uses the Psi matcher to match the provided arguments against the actual context's values. +func Ctx(args ...any) types.BeMatcher { + if len(args) == 0 { + return psi_matchers.NewCtxMatcher() + } + + // todo: weak solution, fixme + return psi.Psi(args...) +} + +// CtxWithValue succeeds if the actual value is a context.Context and contains a key-value pair +// where the key matches the provided key and the value matches the provided arguments using any other matchers. +func CtxWithValue(key any, vs ...any) types.BeMatcher { + return psi_matchers.NewCtxValueMatcher(key, vs...) +} + +// CtxWithDeadline succeeds if the actual value is a context.Context and its deadline matches the provided deadline. +func CtxWithDeadline(deadline any) types.BeMatcher { + return psi_matchers.NewCtxDeadlineMatcher(deadline) +} + +// CtxWithError succeeds if the actual value is a context.Context and its error matches the provided error value. +func CtxWithError(err any) types.BeMatcher { + return psi_matchers.NewCtxErrMatcher(err) +} diff --git a/be_http/README.md b/be_http/README.md new file mode 100644 index 0000000..ea00113 --- /dev/null +++ b/be_http/README.md @@ -0,0 +1,117 @@ +# be_http +-- + import "github.com/expectto/be/be_http" + +Package be_http provides matchers for url.Request TODO: more detailed +documentation here is required + +## Usage + +```go +var ( + GET = func() types.BeMatcher { return HavingMethod(http.MethodGet) } + HEAD = func() types.BeMatcher { return HavingMethod(http.MethodHead) } + POST = func() types.BeMatcher { return HavingMethod(http.MethodPost) } + PUT = func() types.BeMatcher { return HavingMethod(http.MethodPut) } + PATCH = func() types.BeMatcher { return HavingMethod(http.MethodPatch) } + DELETE = func() types.BeMatcher { return HavingMethod(http.MethodDelete) } + OPTIONS = func() types.BeMatcher { return HavingMethod(http.MethodOptions) } + CONNECT = func() types.BeMatcher { return HavingMethod(http.MethodConnect) } + TRACE = func() types.BeMatcher { return HavingMethod(http.MethodTrace) } +) +``` +HavingMethod: Syntactic sugar + +#### func HavingBody + +```go +func HavingBody(args ...any) types.BeMatcher +``` +HavingBody succeeds if the actual value is a *http.Request and its body matches +the provided arguments. Note: The body is not re-streamed, so it's not available +after matching. + +#### func HavingHeader + +```go +func HavingHeader(key string, args ...any) types.BeMatcher +``` +HavingHeader matches requests that have header with a given key. Key is a string +key for a header, args can be nil or len(args)==1. Note: Golang's http.Header is +`map[string][]string`, and matching is done on the FIRST value of the header in +case if you have multiple-valued header that needs to be matched, use +HavingHeaders() instead + +These are scenarios that can be handled here: (1) If no args are given, it +simply matches a request with existed header by key. (2) If len(args) == 1 && +args[0] is a stringish, it matches a request with header `Key: Args[0]` (3) if +len(args) == 1 && args[0] is not stringish, it is considered to be matcher for +header's value Examples: - HavingHeader("X-Header") matches request with +non-empty X-Header header - HavingHeader("X-Header", "X-Value") matches request +with X-Header: X-Value - HavingHeader("X-Header", HavePrefix("Bearer ")) +matchers request with header(X-Header)'s value matching given HavePrefix matcher + +#### func HavingHeaders + +```go +func HavingHeaders(key string, args ...any) types.BeMatcher +``` +HavingHeaders matches requests that have header with a given key. Key is a +string key for a header, args can be nil or len(args)==1. Note: Matching is done +on the list of header values. In case if you have single-valued header that +needs to be matched, use HavingHeader() instead + +These are scenarios that can be handled here: (1) If no args are given, it +simply matches a request with existed header by key. (2) If len(args) == 1 && +args[0] is a stringish, it matches a request with header `Key: Args[0]` (3) if +len(args) == 1 && args[0] is not stringish, it is considered to be matcher for +header's value Examples: - HavingHeader("X-Header") matches request with +non-empty X-Header header - HavingHeader("X-Header", "X-Value") matches request +with X-Header: X-Value - HavingHeader("X-Header", Dive(HavePrefix("Foo "))) +matchers request with multiple X-Header values, each of them having Foo prefix + +#### func HavingHost + +```go +func HavingHost(args ...any) types.BeMatcher +``` +HavingHost succeeds if the actual value is a *http.Request and its Host matches +the provided arguments. + +#### func HavingMethod + +```go +func HavingMethod(args ...any) types.BeMatcher +``` +HavingMethod succeeds if the actual value is a *http.Request and its HTTP method +matches the provided arguments. + +#### func HavingProto + +```go +func HavingProto(args ...any) types.BeMatcher +``` +HavingProto succeeds if the actual value is a *http.Request and its Proto +matches the provided arguments. + +#### func HavingURL + +```go +func HavingURL(args ...any) types.BeMatcher +``` +HavingURL succeeds if the actual value is a *http.Request and its URL matches +the provided arguments. + +#### func Request + +```go +func Request(args ...any) types.BeMatcher +``` +Request matches an actual value to be a valid *http.Request corresponding to +given inputs. Possible inputs: 1. Nil args -> so actual value MUST be any valid +*http.Request. 2. Single arg . Actual value MUST be a *http.Request, +whose .URL.String() is compared against args[0]. 3. List of Omega/Gomock/Psi +matchers, that are applied to *http.Request object. + + - Supports matching http.Request properties like method, URL, body, host, proto, and headers. + - Additional arguments can be used for matching specific headers, e.g., WithHeader("Content-Type", "application/json"). diff --git a/be_http/matchers_http.go b/be_http/matchers_http.go new file mode 100644 index 0000000..4a6a707 --- /dev/null +++ b/be_http/matchers_http.go @@ -0,0 +1,178 @@ +// Package be_http provides matchers for url.Request +// TODO: more detailed documentation here is required +package be_http + +import ( + "bytes" + "github.com/expectto/be/be_json" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "io" + "net/http" +) + +// Request matches an actual value to be a valid *http.Request corresponding to given inputs. +// Possible inputs: +// 1. Nil args -> so actual value MUST be any valid *http.Request. +// 2. Single arg . Actual value MUST be a *http.Request, whose .URL.String() is compared against args[0]. +// 3. List of Omega/Gomock/Psi matchers, that are applied to *http.Request object. +// - Supports matching http.Request properties like method, URL, body, host, proto, and headers. +// - Additional arguments can be used for matching specific headers, e.g., WithHeader("Content-Type", "application/json"). +func Request(args ...any) types.BeMatcher { + if len(args) == 0 { + // ReqPropertyMatcher with empty args will simply check if `actual` is *http.Request + return psi_matchers.NewReqPropertyMatcher("", "", nil) + } + + if cast.IsString(args[0], cast.AllowCustomTypes(), cast.AllowPointers()) { + if len(args) != 1 { + panic("string arg must be a single arg") + } + + // TODO: plan a feature, to improve the output of the failed part of the url + // This will be possible if instead of matching whole `req.URL.String()` + // we parse `req` into parts and construct combined matcher on them + // So then, e.g. failure message will strictly say: search-argument ?foo= is why we failed + + // match given string to whole url + return psi_matchers.NewReqPropertyMatcher("Url", "", func(req *http.Request) any { + return req.URL.String() + }, gomega.Equal(cast.AsString(args[0]))) + } + + return Psi(args...) +} + +// HavingMethod succeeds if the actual value is a *http.Request and its HTTP method matches the provided arguments. +func HavingMethod(args ...any) types.BeMatcher { + return psi_matchers.NewReqPropertyMatcher( + "HavingMethod", "method", + func(req *http.Request) any { return req.Method }, + args..., + ) +} + +// HavingMethod: Syntactic sugar +var ( + GET = func() types.BeMatcher { return HavingMethod(http.MethodGet) } + HEAD = func() types.BeMatcher { return HavingMethod(http.MethodHead) } + POST = func() types.BeMatcher { return HavingMethod(http.MethodPost) } + PUT = func() types.BeMatcher { return HavingMethod(http.MethodPut) } + PATCH = func() types.BeMatcher { return HavingMethod(http.MethodPatch) } + DELETE = func() types.BeMatcher { return HavingMethod(http.MethodDelete) } + OPTIONS = func() types.BeMatcher { return HavingMethod(http.MethodOptions) } + CONNECT = func() types.BeMatcher { return HavingMethod(http.MethodConnect) } + TRACE = func() types.BeMatcher { return HavingMethod(http.MethodTrace) } +) + +// HavingURL succeeds if the actual value is a *http.Request and its URL matches the provided arguments. +func HavingURL(args ...any) types.BeMatcher { + return psi_matchers.NewReqPropertyMatcher( + "HavingURL", "url", + func(req *http.Request) any { return req.URL }, + args..., + ) +} + +// HavingBody succeeds if the actual value is a *http.Request and its body matches the provided arguments. +// Note: The body is not re-streamed, so it's not available after matching. +func HavingBody(args ...any) types.BeMatcher { + return psi_matchers.NewReqPropertyMatcher( + "HavingBody", "body", + func(req *http.Request) any { + // TODO: do it in nicer form (Idea is to return a body but so it's still readable later) + body, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(body)) + + return io.NopCloser(bytes.NewBuffer(body)) + }, + args..., + ) +} + +// HavingHost succeeds if the actual value is a *http.Request and its Host matches the provided arguments. +func HavingHost(args ...any) types.BeMatcher { + return psi_matchers.NewReqPropertyMatcher( + "HavingHost", "host", + func(req *http.Request) any { return req.Host }, + args..., + ) +} + +// HavingProto succeeds if the actual value is a *http.Request and its Proto matches the provided arguments. +func HavingProto(args ...any) types.BeMatcher { + return psi_matchers.NewReqPropertyMatcher( + "HavingProto", "proto", + func(req *http.Request) any { return req.Proto }, + args..., + ) +} + +// HavingHeader matches requests that have header with a given key. +// Key is a string key for a header, args can be nil or len(args)==1. +// Note: Golang's http.Header is `map[string][]string`, and matching is done on the FIRST value of the header +// in case if you have multiple-valued header that needs to be matched, use HavingHeaders() instead +// +// These are scenarios that can be handled here: +// (1) If no args are given, it simply matches a request with existed header by key. +// (2) If len(args) == 1 && args[0] is a stringish, it matches a request with header `Key: Args[0]` +// (3) if len(args) == 1 && args[0] is not stringish, it is considered to be matcher for header's value +// Examples: +// - HavingHeader("X-Header") matches request with non-empty X-Header header +// - HavingHeader("X-Header", "X-Value") matches request with X-Header: X-Value +// - HavingHeader("X-Header", HavePrefix("Bearer ")) matchers request with header(X-Header)'s value matching given HavePrefix matcher +func HavingHeader(key string, args ...any) types.BeMatcher { + if len(args) == 0 { + return psi_matchers.NewReqPropertyMatcher( + "HavingHeader", "header", + func(req *http.Request) any { return req.Header }, + be_json.HaveKeyValue(key), + ) + } + if len(args) != 1 { + panic("len(args) must be 0 or 1") + } + + return psi_matchers.NewReqPropertyMatcher( + "HavingHeader", "header", + func(req *http.Request) any { return req.Header }, + be_json.HaveKeyValue(key, NewDiveMatcher(args[0], DiveModeFirst)), + ) +} + +// HavingHeaders matches requests that have header with a given key. +// Key is a string key for a header, args can be nil or len(args)==1. +// Note: Matching is done on the list of header values. +// In case if you have single-valued header that needs to be matched, use HavingHeader() instead +// +// These are scenarios that can be handled here: +// (1) If no args are given, it simply matches a request with existed header by key. +// (2) If len(args) == 1 && args[0] is a stringish, it matches a request with header `Key: Args[0]` +// (3) if len(args) == 1 && args[0] is not stringish, it is considered to be matcher for header's value +// Examples: +// - HavingHeader("X-Header") matches request with non-empty X-Header header +// - HavingHeader("X-Header", "X-Value") matches request with X-Header: X-Value +// - HavingHeader("X-Header", Dive(HavePrefix("Foo "))) matchers request with multiple X-Header values, each of them having Foo prefix +func HavingHeaders(key string, args ...any) types.BeMatcher { + if len(args) == 0 { + // Behaves same way as HavingHeader(key) + + return psi_matchers.NewReqPropertyMatcher( + "HavingHeaders", "header", + func(req *http.Request) any { return req.Header }, + be_json.HaveKeyValue(key), + ) + } + if len(args) != 1 { + panic("len(args) must be 0 or 1") + } + + return psi_matchers.NewReqPropertyMatcher( + "HavingHeader", "header", + func(req *http.Request) any { return req.Header }, + be_json.HaveKeyValue(key, args[0]), + ) +} diff --git a/be_json/README.md b/be_json/README.md new file mode 100644 index 0000000..d7b7de4 --- /dev/null +++ b/be_json/README.md @@ -0,0 +1,50 @@ +# be_json +-- + import "github.com/expectto/be/be_json" + +Package be_json provides Be matchers for expressive assertions on JSON TODO: +more detailed explanation what is considered to be JSON here + +## Usage + +#### func HaveKeyValue + +```go +func HaveKeyValue(key any, args ...any) types.BeMatcher +``` +HaveKeyValue is a facade to gomega.HaveKey & gomega.HaveKeyWithValue + +#### func Matcher + +```go +func Matcher(args ...any) types.BeMatcher +``` +Matcher is a JSON matcher. "JSON" here means a []byte with JSON data in it By +default several input types are available: string(*) / []byte(*), fmt.Stringer, +io.Reader + + - custom string-based or []byte-based types are available as well + +To make it stricter and to specify which format JSON we should expect, you must +pass one of transforms as first argument: + + - JsonAsBytes/ JsonAsString / JsonAsStringer / JsonAsReader (for string-like representation) + - JsonAsObject / JsonAsObjects (for map[string]any representation) + +#### type JsonInputType + +```go +type JsonInputType uint32 +``` + + +```go +const ( + JsonAsBytes JsonInputType = 1 << iota + JsonAsString + JsonAsStringer + JsonAsReader + JsonAsObject + JsonAsObjects +) +``` diff --git a/be_json/matchers_json.go b/be_json/matchers_json.go new file mode 100644 index 0000000..183c60e --- /dev/null +++ b/be_json/matchers_json.go @@ -0,0 +1,159 @@ +// Package be_json provides Be matchers for expressive assertions on JSON +// TODO: more detailed explanation what is considered to be JSON here +package be_json + +import ( + "encoding/json" + "fmt" + "github.com/expectto/be/be_reflected" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "io" +) + +type JsonInputType uint32 + +const ( + JsonAsBytes JsonInputType = 1 << iota + JsonAsString + JsonAsStringer + JsonAsReader + JsonAsObject + JsonAsObjects +) + +// Matcher is a JSON matcher. "JSON" here means a []byte with JSON data in it +// By default several input types are available: string(*) / []byte(*), fmt.Stringer, io.Reader +// - custom string-based or []byte-based types are available as well +// +// To make it stricter and to specify which format JSON we should expect, you +// must pass one of transforms as first argument: +// - JsonAsBytes/ JsonAsString / JsonAsStringer / JsonAsReader (for string-like representation) +// - JsonAsObject / JsonAsObjects (for map[string]any representation) +func Matcher(args ...any) types.BeMatcher { + // Default input is ok to be any of these + var inputMatcher types.BeMatcher = psi_matchers.NewAnyMatcher( + // String-like inputs: + be_reflected.AsBytes(), be_reflected.AsString(), be_reflected.AsStringer(), be_reflected.AsReader(), + + // Object-like inputs: + // Here we accept map[string]any or []map[string]any + be_reflected.AsObject(), be_reflected.AsObjects(), + ) + + // Check if first argument was given as a JsonAs* constant + // that needs to be handled + if len(args) > 0 { + if t, ok := args[0].(JsonInputType); ok { + inputMatchers := make([]types.BeMatcher, 0) + if t&JsonAsBytes != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsBytes()) + } + if t&JsonAsString != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsString()) + } + if t&JsonAsStringer != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsStringer()) + } + if t&JsonAsReader != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsReader()) + } + if t&JsonAsObject != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsObject()) + } + if t&JsonAsObjects != 0 { + inputMatchers = append(inputMatchers, be_reflected.AsObjects()) + } + + // To avoid extra "Any" matching logic, let's simplify case when we have single input matcher + if len(inputMatchers) == 1 { + inputMatcher = inputMatchers[0] + } else { + inputMatcher = psi_matchers.NewAnyMatcher(cast.AsSliceOfAny(inputMatchers)...) + } + args = args[1:] + } + } + + // If no args (after handling JsonAs* constants) + // then we just match if it's valid json + if len(args) == 0 { + // TODO: it must validate if it's actualy a valid JSON + return inputMatcher + } + + return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + inputMatcher, + + // JSON expects arguments to be matchers upon map[string]any + // So let's perform a transform: raw => any + WithFallibleTransform(func(actual any) any { + // `actual` may be an io.Reader that is decoded directly + if reader, ok := actual.(io.Reader); ok { + var data any + if err := json.NewDecoder(reader).Decode(&data); err != nil { + return NewTransformError(fmt.Errorf("to read json: %w", err), actual) + } + if closer, ok := actual.(io.Closer); ok { + _ = closer.Close() + } + + return data + } + + // convert `actual` into `any` (if `actual` is bytes/string): + // it will end up `[]any` or `map[string]any` underneath it + if cast.IsStringish(actual) { + var data any + if err := json.Unmarshal(cast.AsBytes(actual), &data); err != nil { + return NewTransformError(fmt.Errorf("be a valid json: %w", err), actual) + } + + return data + } + + // no conversion is needed, `actual` will be checked via matchers directly + return actual + }, + + // Applying given matchers to the raw JSON + func() types.BeMatcher { + // If we have just one arg then we match against it + // If it's a string, we're remarshalling it into object + if len(args) == 1 && !IsMatcher(args[0]) { + var argData any + if cast.IsStringish(args[0]) { + if err := json.Unmarshal(cast.AsBytes(args[0]), &argData); err != nil { + return psi_matchers.NewNeverMatcher(err) + } + } else { + // todo: check if it's actually object|objects + // so we can nicer failure messages + argData = args[0] + } + + return psi_matchers.NewEqMatcher(argData) + } + + return Psi(args...) + }(), + ), + }} +} + +// HaveKeyValue is a facade to gomega.HaveKey & gomega.HaveKeyWithValue +func HaveKeyValue(key any, args ...any) types.BeMatcher { + if len(args) == 0 { + return Psi(gomega.HaveKey(key)) + } + + // todo: optimize for gomock messages ? + // todo: should we optimize (check if len(args)==1, + // to reduce level of wrapping) ? + return Psi( + gomega.HaveKeyWithValue(key, Psi(args...)), + ) +} diff --git a/be_jwt/README.md b/be_jwt/README.md new file mode 100644 index 0000000..7be526d --- /dev/null +++ b/be_jwt/README.md @@ -0,0 +1,108 @@ +# be_jwt +-- + import "github.com/expectto/be/be_jwt" + +Package be_jwt provides Be matchers for handling JSON Web Tokens (JWT). It +includes matchers for transforming and validating JWT tokens. Matchers +corresponds to specific golang jwt implementation: +https://github.com/golang-jwt/jwt/v5 + +## Usage + +```go +var TransformJwtFromString = func(input string) any { + p := jwt.NewParser() + + t, parts, err := p.ParseUnverified(input, jwt.MapClaims{}) + if err != nil { + return NewTransformError(err, input) + } + + t.Signature, err = p.DecodeSegment(parts[2]) + if err != nil { + return NewTransformError(fmt.Errorf("corrupted signature part: %w", err), input) + } + + return t +} +``` +TransformJwtFromString is a transform function (string->*jwt.Token) without a +secret. It parses the input string as a JWT and returns the resulting +*jwt.Token. + +```go +var TransformSignedJwtFromString = func(secret string) func(string) any { + return func(input string) any { + parsed, err := jwt.Parse(input, func(token *jwt.Token) (any, error) { + return []byte(secret), nil + }) + if err != nil { + return NewTransformError(fmt.Errorf("to parse jwt token (with secret=%s): %w", secret, err), input) + } + + return parsed + } +} +``` +TransformSignedJwtFromString returns a transform function (string->*jwt.Token) +for a given secret. + +#### func HavingClaim + +```go +func HavingClaim(key string, args ...any) types.BeMatcher +``` +HavingClaim succeeds if the actual value is a JWT token and its claim matches +the provided value or matchers. + +#### func HavingClaims + +```go +func HavingClaims(args ...any) types.BeMatcher +``` +HavingClaims succeeds if the actual value is a JWT token and its claims match +the provided value or matchers. + +#### func HavingMethodAlg + +```go +func HavingMethodAlg(args ...any) types.BeMatcher +``` +HavingMethodAlg succeeds if the actual value is a JWT token and its method +algorithm match the provided value or matchers. + +#### func SignedVia + +```go +func SignedVia(secret string) types.BeMatcher +``` +SignedVia succeeds if the actual value is a valid and signed JWT token, verified +using the specified secret key. It's intended for matching against a secret-less +token and applying the secret only for this specific matching. + +Example: + +Token(TransformJwtFromString, SignedVia(secret)) // works similar to: +Token(TransformSignedJwtFromString(secret), Valid()) + +#### func Token + +```go +func Token(args ...any) types.BeMatcher +``` +Token matches the actual value to be a valid *jwt.Token corresponding to given +inputs. Possible inputs: 1. No args -> the actual value MUST be any valid +*jwt.Token. 2. Single arg . The actual value MUST be a *jwt.Token, whose +.String() is compared against args[0]. 3. Single arg <*jwt.Token>. The actual +value MUST be a *jwt.Token. 4. List of Omega/Gomock/Psi matchers that are +applied to *jwt.Token object. + + - TransformJwtFromString/TransformSignedJwtFromString(secret) transforms can be given as the first argument, + so the string->*jwt.Token transform is applied. + +#### func Valid + +```go +func Valid() types.BeMatcher +``` +Valid succeeds if the actual value is a JWT token and it's valid diff --git a/be_jwt/matchers_jwt.go b/be_jwt/matchers_jwt.go new file mode 100644 index 0000000..9b556b0 --- /dev/null +++ b/be_jwt/matchers_jwt.go @@ -0,0 +1,132 @@ +// Package be_jwt provides Be matchers for handling JSON Web Tokens (JWT). +// It includes matchers for transforming and validating JWT tokens. +// Matchers corresponds to specific golang jwt implementation: https://github.com/golang-jwt/jwt/v5 +package be_jwt + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" + "github.com/golang-jwt/jwt/v5" + "github.com/onsi/gomega" + "strings" +) + +// TransformSignedJwtFromString returns a transform function (string->*jwt.Token) for a given secret. +var TransformSignedJwtFromString = func(secret string) func(string) any { + return func(input string) any { + parsed, err := jwt.Parse(input, func(token *jwt.Token) (any, error) { + return []byte(secret), nil + }) + if err != nil { + return NewTransformError(fmt.Errorf("to parse jwt token (with secret=%s): %w", secret, err), input) + } + + return parsed + } +} + +// TransformJwtFromString is a transform function (string->*jwt.Token) without a secret. +// It parses the input string as a JWT and returns the resulting *jwt.Token. +var TransformJwtFromString = func(input string) any { + p := jwt.NewParser() + + t, parts, err := p.ParseUnverified(input, jwt.MapClaims{}) + if err != nil { + return NewTransformError(err, input) + } + + t.Signature, err = p.DecodeSegment(parts[2]) + if err != nil { + return NewTransformError(fmt.Errorf("corrupted signature part: %w", err), input) + } + + return t +} + +// Token matches the actual value to be a valid *jwt.Token corresponding to given inputs. +// Possible inputs: +// 1. No args -> the actual value MUST be any valid *jwt.Token. +// 2. Single arg . The actual value MUST be a *jwt.Token, whose .String() is compared against args[0]. +// 3. Single arg <*jwt.Token>. The actual value MUST be a *jwt.Token. +// 4. List of Omega/Gomock/Psi matchers that are applied to *jwt.Token object. +// - TransformJwtFromString/TransformSignedJwtFromString(secret) transforms can be given as the first argument, +// so the string->*jwt.Token transform is applied. +func Token(args ...any) types.BeMatcher { + if len(args) == 0 { + return psi_matchers.NewJwtTokenMatcher("", nil) + } + + // Given single string arg + if cast.IsString(args[0], cast.AllowCustomTypes(), cast.AllowPointers()) { + if len(args) != 1 { + panic("string arg must be a single arg") + } + + // match given string to raw token + return psi_matchers.NewJwtTokenMatcher("Raw", func(t *jwt.Token) any { + return t.Raw + }, gomega.Equal(args[0])) + } + + return Psi(args...) +} + +// Valid succeeds if the actual value is a JWT token and it's valid +func Valid() types.BeMatcher { + return psi_matchers.NewJwtTokenMatcher( + "Valid", + func(u *jwt.Token) any { return u.Valid }, + gomega.BeTrue(), + ) +} + +// HavingClaims succeeds if the actual value is a JWT token and its claims match the provided value or matchers. +func HavingClaims(args ...any) types.BeMatcher { + return psi_matchers.NewJwtTokenMatcher( + "Claims", + func(u *jwt.Token) any { return u.Claims }, + Psi(args...), + ) +} + +// HavingClaim succeeds if the actual value is a JWT token and its claim matches the provided value or matchers. +func HavingClaim(key string, args ...any) types.BeMatcher { + return psi_matchers.NewJwtTokenMatcher( + fmt.Sprintf("Claim[%s]", key), + func(u *jwt.Token) any { return u.Claims.(jwt.MapClaims)[key] }, + Psi(args...), + ) +} + +// HavingMethodAlg succeeds if the actual value is a JWT token and its method algorithm match the provided value or matchers. +func HavingMethodAlg(args ...any) types.BeMatcher { + return psi_matchers.NewJwtTokenMatcher( + "Method.Alg()", + func(u *jwt.Token) any { return u.Method.Alg() }, + Psi(args...), + ) +} + +// SignedVia succeeds if the actual value is a valid and signed JWT token, +// verified using the specified secret key. +// It's intended for matching against a secret-less token +// and applying the secret only for this specific matching. +// +// Example: +// +// Token(TransformJwtFromString, SignedVia(secret)) // works similar to: +// Token(TransformSignedJwtFromString(secret), Valid()) +func SignedVia(secret string) types.BeMatcher { + return psi_matchers.NewJwtTokenMatcher( + "Method.Verify()", + func(u *jwt.Token) any { + // text is parts[0]+parts[1] + text := strings.Join(strings.Split(u.Raw, ".")[0:2], ".") + return u.Method.Verify(text, u.Signature, []byte(secret)) + }, + gomega.BeNil(), + ) +} diff --git a/be_math/README.md b/be_math/README.md new file mode 100644 index 0000000..8331a4a --- /dev/null +++ b/be_math/README.md @@ -0,0 +1,145 @@ +# be_math +-- + import "github.com/expectto/be/be_math" + +Package be_math provides Be matchers for mathematical operations + +## Usage + +#### func Approx + +```go +func Approx(compareTo, threshold any) types.BeMatcher +``` +Approx succeeds if actual is numerically approximately equal to the passed-in +value within the specified threshold. + +#### func ApproxZero + +```go +func ApproxZero() types.BeMatcher +``` +ApproxZero succeeds if actual is numerically approximately equal to zero Any +type of int/float will work for comparison. + +#### func DivisibleBy + +```go +func DivisibleBy(divisor any) types.BeMatcher +``` +DivisibleBy succeeds if actual is numerically divisible by the passed-in value. + +#### func Even + +```go +func Even() types.BeMatcher +``` +Even succeeds if actual is an even numeric value. + +#### func GreaterThan + +```go +func GreaterThan(arg any) types.BeMatcher +``` +GreaterThan succeeds if actual is numerically greater than the passed-in value. + +#### func GreaterThanEqual + +```go +func GreaterThanEqual(arg any) types.BeMatcher +``` +GreaterThanEqual succeeds if actual is numerically greater than or equal to the +passed-in value. + +#### func Gt + +```go +func Gt(arg any) types.BeMatcher +``` +Gt is an alias for GreaterThan, succeeding if actual is numerically greater than +the passed-in value. + +#### func Gte + +```go +func Gte(arg any) types.BeMatcher +``` +Gte is an alias for GreaterThanEqual, succeeding if actual is numerically +greater than or equal to the passed-in value. + +#### func InRange + +```go +func InRange(from any, fromInclusive bool, until any, untilInclusive bool) types.BeMatcher +``` +InRange succeeds if actual is numerically within the specified range. The range +is defined by the 'from' and 'until' values, and inclusivity is determined by +the 'fromInclusive' and 'untilInclusive' flags. + +#### func Integral + +```go +func Integral() types.BeMatcher +``` +Integral succeeds if actual is an integral float, meaning it has zero decimal +places. This matcher checks if the numeric value has no fractional component. + +#### func LessThan + +```go +func LessThan(arg any) types.BeMatcher +``` +LessThan succeeds if actual is numerically less than the passed-in value. + +#### func LessThanEqual + +```go +func LessThanEqual(arg any) types.BeMatcher +``` +LessThanEqual succeeds if actual is numerically less than or equal to the +passed-in value. + +#### func Lt + +```go +func Lt(arg any) types.BeMatcher +``` +Lt is an alias for LessThan, succeeding if actual is numerically less than the +passed-in value. + +#### func Lte + +```go +func Lte(arg any) types.BeMatcher +``` +Lte is an alias for LessThanEqual, succeeding if actual is numerically less than +or equal to the passed-in value. + +#### func Negative + +```go +func Negative() types.BeMatcher +``` +Negative succeeds if actual is a negative numeric value. + +#### func Odd + +```go +func Odd() types.BeMatcher +``` +Odd succeeds if actual is an odd numeric value. + +#### func Positive + +```go +func Positive() types.BeMatcher +``` +Positive succeeds if actual is a positive numeric value. + +#### func Zero + +```go +func Zero() types.BeMatcher +``` +Zero succeeds if actual is numerically equal to zero. Any type of int/float will +work for comparison. diff --git a/be_math/be_math_suite_test.go b/be_math/be_math_suite_test.go new file mode 100644 index 0000000..850b019 --- /dev/null +++ b/be_math/be_math_suite_test.go @@ -0,0 +1,13 @@ +package be_math_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBeMath(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BeMath Suite") +} diff --git a/be_math/matchers_math.go b/be_math/matchers_math.go new file mode 100644 index 0000000..55b25aa --- /dev/null +++ b/be_math/matchers_math.go @@ -0,0 +1,142 @@ +// Package be_math provides Be matchers for mathematical operations +package be_math + +import ( + "fmt" + "github.com/expectto/be/be_reflected" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gcustom" + "math" +) + +// GreaterThan succeeds if actual is numerically greater than the passed-in value. +func GreaterThan(arg any) types.BeMatcher { + return Psi(gomega.BeNumerically(">", arg)) +} + +// GreaterThanEqual succeeds if actual is numerically greater than or equal to the passed-in value. +func GreaterThanEqual(arg any) types.BeMatcher { + return Psi(gomega.BeNumerically(">=", arg)) +} + +// LessThan succeeds if actual is numerically less than the passed-in value. +func LessThan(arg any) types.BeMatcher { + return Psi(gomega.BeNumerically("<", arg)) +} + +// LessThanEqual succeeds if actual is numerically less than or equal to the passed-in value. +func LessThanEqual(arg any) types.BeMatcher { + return Psi(gomega.BeNumerically("<=", arg)) +} + +// Approx succeeds if actual is numerically approximately equal to the passed-in value within the specified threshold. +func Approx(compareTo, threshold any) types.BeMatcher { + return Psi(gomega.BeNumerically("~", compareTo, threshold)) +} + +// InRange succeeds if actual is numerically within the specified range. +// The range is defined by the 'from' and 'until' values, and inclusivity is determined +// by the 'fromInclusive' and 'untilInclusive' flags. +func InRange(from any, fromInclusive bool, until any, untilInclusive bool) types.BeMatcher { + group := make([]types.BeMatcher, 2) + if fromInclusive { + group[0] = Gte(from) + } else { + group[0] = Gt(from) + } + if untilInclusive { + group[1] = Lte(until) + } else { + group[1] = Lt(until) + } + + // For compiling a nice failure message we better use `[from, until)` format + leftBracket, rightBracket := "(", ")" + if fromInclusive { + leftBracket = "[" + } + if untilInclusive { + rightBracket = "]" + } + + return Psi( + psi_matchers.NewAllMatcher(cast.AsSliceOfAny(group)...), + fmt.Sprintf("be in range %s%v, %v%s", leftBracket, from, until, rightBracket), + ) +} + +// Odd succeeds if actual is an odd numeric value. +func Odd() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + be_reflected.AsInteger(), + gcustom.MakeMatcher(func(actual any) (bool, error) { + return cast.AsInt(actual)%2 != 0, nil + }), + ), "be an odd number") +} + +// Even succeeds if actual is an even numeric value. +func Even() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + be_reflected.AsInteger(), + gcustom.MakeMatcher(func(actual any) (bool, error) { + return cast.AsInt(actual)%2 == 0, nil + }), + ), "be an even number") +} + +// Negative succeeds if actual is a negative numeric value. +func Negative() types.BeMatcher { + return Psi(LessThan(0.0), "be negative") +} + +// Positive succeeds if actual is a positive numeric value. +func Positive() types.BeMatcher { + return Psi(GreaterThan(0.0), "be positive") +} + +// Zero succeeds if actual is numerically equal to zero. +// Any type of int/float will work for comparison. +func Zero() types.BeMatcher { + return Psi(Approx(0, 0), "be zero") +} + +// ApproxZero succeeds if actual is numerically approximately equal to zero +// Any type of int/float will work for comparison. +func ApproxZero() types.BeMatcher { + return Psi(Approx(0, 1e-8), "be approximately zero") +} + +// Integral succeeds if actual is an integral float, meaning it has zero decimal places. +// This matcher checks if the numeric value has no fractional component. +func Integral() types.BeMatcher { + return Psi(func(actual interface{}) (bool, error) { + f := cast.AsFloat(actual) + return f-float64(int(f)) == 0, nil + }, "be integral float value") +} + +// DivisibleBy succeeds if actual is numerically divisible by the passed-in value. +func DivisibleBy(divisor any) types.BeMatcher { + return Psi(func(actual any) (bool, error) { + return math.Mod(cast.AsFloat(actual), cast.AsFloat(divisor)) == 0, nil + }, fmt.Sprintf("be divisible by %v", divisor)) +} + +// Shorter Names: + +// Gt is an alias for GreaterThan, succeeding if actual is numerically greater than the passed-in value. +func Gt(arg any) types.BeMatcher { return GreaterThan(arg) } + +// Gte is an alias for GreaterThanEqual, succeeding if actual is numerically greater than or equal to the passed-in value. +func Gte(arg any) types.BeMatcher { return GreaterThanEqual(arg) } + +// Lt is an alias for LessThan, succeeding if actual is numerically less than the passed-in value. +func Lt(arg any) types.BeMatcher { return LessThan(arg) } + +// Lte is an alias for LessThanEqual, succeeding if actual is numerically less than or equal to the passed-in value. +func Lte(arg any) types.BeMatcher { return LessThanEqual(arg) } diff --git a/be_math/matchers_math_test.go b/be_math/matchers_math_test.go new file mode 100644 index 0000000..50af90c --- /dev/null +++ b/be_math/matchers_math_test.go @@ -0,0 +1,166 @@ +package be_math_test + +import ( + "github.com/expectto/be/be_math" + "github.com/expectto/be/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "strings" +) + +var _ = Describe("BeMath", func() { + + DescribeTable("should positively match", func(matcher types.BeMatcher, actual any) { + // check gomega-compatible matching: + success, err := matcher.Match(actual) + Expect(err).Should(Succeed()) + Expect(success).To(BeTrue()) + + // check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeTrue()) + }, + Entry("10 GreaterThan 5", be_math.GreaterThan(5), 10), + Entry("10 GreaterThan 5 (alias)", be_math.Gt(5), 10), + Entry("10 GreaterThanEqual 5", be_math.GreaterThanEqual(5), 10), + Entry("10 GreaterThanEqual 10", be_math.GreaterThanEqual(10), 10), + Entry("10 GreaterThanEqual 5 (alias)", be_math.Gte(5), 10), + Entry("10 GreaterThanEqual 10 (alias)", be_math.Gte(10), 10), + + Entry("10 LessThan 20", be_math.LessThan(20), 10), + Entry("10 LessThan 20 (alias)", be_math.Lt(20), 10), + Entry("10 LessThanEqual 20", be_math.LessThanEqual(20), 10), + Entry("10 LessThanEqual 10", be_math.LessThanEqual(10), 10), + Entry("10 LessThanEqual 20 (alias)", be_math.Lte(20), 10), + Entry("10 LessThanEqual 10 (alias)", be_math.Lte(10), 10), + + Entry("3 is within range [1, 5]", be_math.InRange(1, true, 5, true), 3), + Entry("3 is within range (1, 5]", be_math.InRange(1, false, 5, true), 3), + Entry("3 is within range [1, 5)", be_math.InRange(1, true, 5, false), 3), + Entry("3 is within range (1, 5)", be_math.InRange(1, false, 5, false), 3), + + Entry("1 is within range [1, 5]", be_math.InRange(1, true, 5, true), 1), + Entry("5 is within range [1, 5]", be_math.InRange(1, true, 5, true), 5), + Entry("1 is within range [1, 5)", be_math.InRange(1, true, 5, false), 1), + + Entry("0.999 is ~ 1.0 within threshold 0.01", be_math.Approx(0.999, 0.01), 1.0), + Entry("3.05 is ~ 3.04 within threshold 0.01", be_math.Approx(3.05, 0.01), 3.04), + Entry("3.5 is ~ 3.4 within threshold 0.2", be_math.Approx(3.5, 0.2), 3.4), + Entry("3.5 is ~ 3.0 within threshold 0.5", be_math.Approx(3.5, 0.5), 3.0), + Entry("3.9 is ~ 3.0 within threshold 1.0", be_math.Approx(3.9, 1.0), 3.0), + Entry("4.0 is ~ 3.0 within threshold 1.0", be_math.Approx(4.0, 1.0), 3.0), + Entry("3.5 is ~ 3.4 within threshold 10.0", be_math.Approx(3.5, 10.0), 3.4), + + Entry("3 is an odd number", be_math.Odd(), 3), + Entry("7 is an odd number", be_math.Odd(), 7), + Entry("-3 is an odd number", be_math.Odd(), -3), + Entry("-7 is an odd number", be_math.Odd(), -7), + + Entry("2 is an even number", be_math.Even(), 2), + Entry("4 is an even number", be_math.Even(), 4), + Entry("-2 is an even number", be_math.Even(), -2), + Entry("-4 is an even number", be_math.Even(), -4), + + Entry("-5 is a negative number", be_math.Negative(), -5), + Entry("-8.5 is a negative number", be_math.Negative(), -8.5), + Entry("5 is a positive number", be_math.Positive(), 5), + Entry("8.5 is a positive number", be_math.Positive(), 8.5), + + Entry("0 is zero", be_math.Zero(), 0.0), + Entry("0.00000000009 is approx zero", be_math.ApproxZero(), 0.00000000009), + + Entry("5 is an integral number", be_math.Integral(), 5.0), + Entry("-10 is an integral number", be_math.Integral(), -10.0), + + Entry("10 is divisible by 5", be_math.DivisibleBy(5), 10), + Entry("18 is divisible by -3", be_math.DivisibleBy(-3), 18), + ) + + DescribeTable("should negatively match", func(matcher types.BeMatcher, actual any) { + // check gomega-compatible matching: + success, err := matcher.Match(actual) + Expect(err).Should(Succeed()) + Expect(success).To(BeFalse()) + + // check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeFalse()) + }, + Entry("5 is not GreaterThan 10", be_math.GreaterThan(10), 5), + Entry("5 is not GreaterThan 5 (alias)", be_math.Gt(5), 5), + Entry("5 is not GreaterThanEqual 10", be_math.GreaterThanEqual(10), 5), + Entry("5 is not GreaterThanEqual 20", be_math.GreaterThanEqual(20), 5), + Entry("5 is not GreaterThanEqual 10 (alias)", be_math.Gte(10), 5), + Entry("5 is not GreaterThanEqual 20 (alias)", be_math.Gte(20), 5), + + Entry("20 is not LessThan 10", be_math.LessThan(10), 20), + Entry("20 is not LessThan 10 (alias)", be_math.Lt(10), 20), + Entry("20 is not LessThanEqual 10", be_math.LessThanEqual(10), 20), + Entry("20 is not LessThanEqual 5", be_math.LessThanEqual(5), 20), + Entry("20 is not LessThanEqual 10 (alias)", be_math.Lte(10), 20), + Entry("20 is not LessThanEqual 5 (alias)", be_math.Lte(5), 20), + + Entry("6 is not within range [1, 5]", be_math.InRange(1, true, 5, true), 6), + Entry("6 is not within range (1, 5]", be_math.InRange(1, false, 5, true), 6), + Entry("6 is not within range [1, 5)", be_math.InRange(1, true, 5, false), 6), + Entry("6 is not within range (1, 5)", be_math.InRange(1, false, 5, false), 6), + + Entry("0.999 is not ~ 1.0 within threshold 0.001", be_math.Approx(1.0, 0.001), 0.999), + Entry("3.05 is not ~ 3.04 within threshold 0.001", be_math.Approx(3.04, 0.001), 3.05), + Entry("3.5 is not ~ 3.0 within threshold 0.2", be_math.Approx(3.0, 0.2), 3.5), + Entry("3.9 is not ~ 3.0 within threshold 0.5", be_math.Approx(3.0, 0.5), 3.9), + Entry("4.0 is not ~ 3.0 within threshold 0.5", be_math.Approx(3.0, 0.5), 4.0), + + Entry("2 is not an odd number", be_math.Odd(), 2), + Entry("4 is not an odd number", be_math.Odd(), 4), + Entry("-2 is not an odd number", be_math.Odd(), -2), + Entry("-4 is not an odd number", be_math.Odd(), -4), + Entry("floats can't be matched as odd numbers", be_math.Odd(), 1.5), + + Entry("3 is not an even number", be_math.Even(), 3), + Entry("7 is not an even number", be_math.Even(), 7), + Entry("-3 is not an even number", be_math.Even(), -3), + Entry("-7 is not an even number", be_math.Even(), -7), + Entry("floats can't be matched as even numbers", be_math.Even(), 1.5), + + Entry("5 is not a negative number", be_math.Negative(), 5), + Entry("8.5 is not a negative number", be_math.Negative(), 8.5), + Entry("0 is not a negative number", be_math.Negative(), 0), + Entry("-5 is not a positive number", be_math.Positive(), -5), + Entry("-8.5 is not a positive number", be_math.Positive(), -8.5), + + Entry("0.1 is not zero", be_math.Zero(), 0.1), + Entry("0.1 is not approx zero", be_math.ApproxZero(), 0.1), + + Entry("5.5 is not an integral number", be_math.Integral(), 5.5), + Entry("-10.5 is not an integral number", be_math.Integral(), -10.5), + + Entry("10 is not divisible by 3", be_math.DivisibleBy(3), 10), + Entry("18 is not divisible by -4", be_math.DivisibleBy(-4), 18), + ) + + DescribeTable("should return a valid failure message", func(matcher types.BeMatcher, actual any, message string) { + // FailureMessage is considered to be called after matching: + _, _ = matcher.Match(actual) + + failureMessage := matcher.FailureMessage(actual) + Expect(failureMessage).To(Equal(message)) + + // in all our matchers negated failure messages are simply `to be` => `not to be` + Expect(matcher.NegatedFailureMessage(actual)).To(Equal( + strings.Replace(failureMessage, "\nto be ", "\nnot to be ", 1), + )) + }, + // Example of entry where FailureMessage is simply inherited from gomega's underlying matching + Entry("5 is not GreaterThan 10", be_math.GreaterThan(10), 5, "Expected\n : 5\nto be >\n : 10"), + + // Examples of entry with custom message (gcustom.MakeMatcher matching) + Entry("10 is not divisible by 3", be_math.DivisibleBy(3), 10, "Expected:\n : 10\nto be divisible by 3"), + Entry("0.1 is not zero", be_math.Zero(), 0.1, "Expected:\n : 0.1\nto be zero"), + + // Examples of entry on complex Psi matchers (chaining + transform) + Entry("float is not odd", be_math.Odd(), 12.5, "Expected:\n : 12.5\nto be an odd number"), + Entry("8 is not odd", be_math.Odd(), 8, "Expected:\n : 8\nto be an odd number"), + Entry("8 (uint) is not odd", be_math.Odd(), uint(8), "Expected:\n : 8\nto be an odd number"), + ) +}) diff --git a/be_reflected/README.md b/be_reflected/README.md new file mode 100644 index 0000000..98b2c0e --- /dev/null +++ b/be_reflected/README.md @@ -0,0 +1,212 @@ +# be_reflected +-- + import "github.com/expectto/be/be_reflected" + +Package be_reflected provides Be matchers that use reflection, enabling +expressive assertions on values' reflect kinds and types. It consists of several +"core" matchers e.g. AsKind / AssignableTo / Implementing And many other +matchers that are made on-top on core ones. E.g. AsFunc / AsString / AsNumber / +### etc + +## Usage + +#### func AsBytes + +```go +func AsBytes() types.BeMatcher +``` +AsBytes succeeds if actual is assignable to a slice of bytes ([]byte). + +#### func AsChan + +```go +func AsChan() types.BeMatcher +``` +AsChan succeeds if actual is of kind reflect.Chan. + +#### func AsFinalPointer + +```go +func AsFinalPointer() types.BeMatcher +``` +AsFinalPointer succeeds if the actual value is a final pointer, meaning it's a +pointer to a non-pointer type. + +#### func AsFloat + +```go +func AsFloat() types.BeMatcher +``` +AsFloat succeeds if actual is a numeric value that represents a floating-point +value. + +#### func AsFloatString + +```go +func AsFloatString() types.BeMatcher +``` +AsFloatString succeeds if actual is a string that can be parsed into a valid +floating-point value. + +#### func AsFunc + +```go +func AsFunc() types.BeMatcher +``` +AsFunc succeeds if actual is of kind reflect.Func. + +#### func AsInteger + +```go +func AsInteger() types.BeMatcher +``` +AsInteger succeeds if actual is a numeric value that represents an integer (from +reflect.Int up to reflect.Uint64). + +#### func AsIntegerString + +```go +func AsIntegerString() types.BeMatcher +``` +AsIntegerString succeeds if actual is a string that can be parsed into a valid +integer value. + +#### func AsKind + +```go +func AsKind(args ...any) types.BeMatcher +``` +AsKind succeeds if actual is assignable to any of the specified kinds or matches +the provided matchers. + +#### func AsMap + +```go +func AsMap() types.BeMatcher +``` +AsMap succeeds if actual is of kind reflect.Map. + +#### func AsNumber + +```go +func AsNumber() types.BeMatcher +``` +AsNumber succeeds if actual is a numeric value, supporting various integer +kinds: reflect.Int, ... reflect.Int64, and floating-point kinds: +reflect.Float32, reflect.Float64 + +#### func AsNumericString + +```go +func AsNumericString() types.BeMatcher +``` +AsNumericString succeeds if actual is a string that can be parsed into a valid +numeric value. + +#### func AsObject + +```go +func AsObject() types.BeMatcher +``` +AsObject is more specific than AsMap. It checks if the given `actual` value is a +map with string keys and values of any type. This is particularly useful in the +context of BeJson matcher, where the term 'Object' aligns with JSON notation. + +#### func AsObjects + +```go +func AsObjects() types.BeMatcher +``` + +#### func AsPointer + +```go +func AsPointer() types.BeMatcher +``` +AsPointer succeeds if the actual value is a pointer. + +#### func AsPointerToMap + +```go +func AsPointerToMap() types.BeMatcher +``` +AsPointerToMap succeeds if actual is a pointer to a map. + +#### func AsPointerToObject + +```go +func AsPointerToObject() types.BeMatcher +``` +AsPointerToObject succeeds if actual is a pointer to a value that matches +AsObject after applying dereference. + +#### func AsPointerToSlice + +```go +func AsPointerToSlice() types.BeMatcher +``` +AsPointerToSlice succeeds if actual is a pointer to a slice. + +#### func AsPointerToStruct + +```go +func AsPointerToStruct() types.BeMatcher +``` +AsPointerToStruct succeeds if actual is a pointer to a struct. + +#### func AsReader + +```go +func AsReader() types.BeMatcher +``` +AsReader succeeds if actual implements the io.Reader interface. + +#### func AsSlice + +```go +func AsSlice() types.BeMatcher +``` +AsSlice succeeds if actual is of kind reflect.Slice. + +#### func AsSliceOf + +```go +func AsSliceOf[T any]() types.BeMatcher +``` +AsSliceOf succeeds if actual is of kind reflect.Slice and each element of the +slice is assignable to the specified type T. + +#### func AsString + +```go +func AsString() types.BeMatcher +``` +AsString succeeds if actual is of kind reflect.String. + +#### func AsStringer + +```go +func AsStringer() types.BeMatcher +``` +AsStringer succeeds if actual implements the fmt.Stringer interface. + +#### func AsStruct + +```go +func AsStruct() types.BeMatcher +``` +AsStruct succeeds if actual is of kind reflect.Struct. + +#### func AssignableTo + +```go +func AssignableTo[T any]() types.BeMatcher +``` +AssignableTo succeeds if actual is assignable to the specified type T. + +#### func Implementing + +```go +func Implementing[T any]() types.BeMatcher +``` +Implementing succeeds if actual implements the specified interface type T. diff --git a/be_reflected/be_reflected_suite_test.go b/be_reflected/be_reflected_suite_test.go new file mode 100644 index 0000000..7addbbf --- /dev/null +++ b/be_reflected/be_reflected_suite_test.go @@ -0,0 +1,13 @@ +package be_reflected_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBeReflected(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BeReflected Suite") +} diff --git a/be_reflected/matchers_reflected.go b/be_reflected/matchers_reflected.go new file mode 100644 index 0000000..3f38f6d --- /dev/null +++ b/be_reflected/matchers_reflected.go @@ -0,0 +1,193 @@ +// Package be_reflected provides Be matchers that use reflection, +// enabling expressive assertions on values' reflect kinds and types. +// It consists of several "core" matchers e.g. AsKind / AssignableTo / Implementing +// And many other matchers that are made on-top on core ones. E.g. AsFunc / AsString / AsNumber / etc +package be_reflected + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + reflect2 "github.com/expectto/be/internal/reflect" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "io" + "reflect" + "strconv" +) + +// AsKind succeeds if actual is assignable to any of the specified kinds or matches the provided matchers. +func AsKind(args ...any) types.BeMatcher { return psi_matchers.NewKindMatcher(args...) } + +// AssignableTo succeeds if actual is assignable to the specified type T. +func AssignableTo[T any]() types.BeMatcher { return psi_matchers.NewAssignableToMatcher[T]() } + +// Implementing succeeds if actual implements the specified interface type T. +func Implementing[T any]() types.BeMatcher { return psi_matchers.NewImplementsMatcher[T]() } + +// Following matchers below are nice syntax-sugar, pretty usages of core matchers above: + +// AsFunc succeeds if actual is of kind reflect.Func. +func AsFunc() types.BeMatcher { return Psi(AsKind(reflect.Func), "be a func") } + +// AsChan succeeds if actual is of kind reflect.Chan. +func AsChan() types.BeMatcher { return Psi(AsKind(reflect.Chan), "be a channel") } + +// AsPointer succeeds if the actual value is a pointer. +func AsPointer() types.BeMatcher { return Psi(AsKind(reflect.Pointer), "be a pointer") } + +// AsFinalPointer succeeds if the actual value is a final pointer, meaning it's a pointer to a non-pointer type. +func AsFinalPointer() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsPointer(), + WithFallibleTransform(func(actual any) any { + return reflect.ValueOf(actual).Elem() + }, psi_matchers.NewNotMatcher(AsPointer())), + ), "be a final pointer") +} + +// AsStruct succeeds if actual is of kind reflect.Struct. +func AsStruct() types.BeMatcher { return Psi(AsKind(reflect.Struct), "be a struct") } + +// AsPointerToStruct succeeds if actual is a pointer to a struct. +func AsPointerToStruct() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsPointer(), + WithFallibleTransform(func(actual any) any { + return reflect.ValueOf(actual).Elem() + }, AsStruct()), + ), "be a pointer to a struct") +} + +// AsSlice succeeds if actual is of kind reflect.Slice. +func AsSlice() types.BeMatcher { return Psi(AsKind(reflect.Slice), "be a slice") } + +// AsPointerToSlice succeeds if actual is a pointer to a slice. +func AsPointerToSlice() types.BeMatcher { + return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + AsPointer(), + WithFallibleTransform(func(actual any) any { + return reflect.ValueOf(actual).Elem() + }, AsSlice()), + }} +} + +// AsSliceOf succeeds if actual is of kind reflect.Slice and each element of the slice +// is assignable to the specified type T. +func AsSliceOf[T any]() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsKind(reflect.Slice), + gomega.HaveEach(AssignableTo[T]()), + ), "be a slice of "+reflect2.TypeFor[T]().String()) +} + +// AsMap succeeds if actual is of kind reflect.Map. +func AsMap() types.BeMatcher { return Psi(AsKind(reflect.Map), "be a map") } + +// AsPointerToMap succeeds if actual is a pointer to a map. +func AsPointerToMap() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsPointer(), + WithFallibleTransform(func(actual any) any { + return reflect.ValueOf(actual).Elem() + }, AsMap()), + ), "be a pointer to a map") +} + +// AsObject is more specific than AsMap. It checks if the given `actual` value is a map with string keys +// and values of any type. This is particularly useful in the context of BeJson matcher, +// where the term 'Object' aligns with JSON notation. +func AsObject() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsKind(reflect.Map), AssignableTo[map[string]any](), + ), "be an object") +} +func AsObjects() types.BeMatcher { + return Psi(AsSliceOf[map[string]any](), "be objects") +} + +// AsPointerToObject succeeds if actual is a pointer to a value that matches AsObject after applying dereference. +func AsPointerToObject() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsPointer(), + WithFallibleTransform(func(actual any) any { + return reflect.ValueOf(actual).Elem() + }, AsObject()), + ), "be a pointer to an object") +} + +// AsReader succeeds if actual implements the io.Reader interface. +func AsReader() types.BeMatcher { + return Psi(Implementing[io.Reader](), "implement io.Reader interface") +} + +// AsStringer succeeds if actual implements the fmt.Stringer interface. +func AsStringer() types.BeMatcher { + return Psi(Implementing[fmt.Stringer](), "implement fmt.Stringer interface") +} + +// AsString succeeds if actual is of kind reflect.String. +func AsString() types.BeMatcher { return Psi(AsKind(reflect.String), "be a string") } + +// AsBytes succeeds if actual is assignable to a slice of bytes ([]byte). +func AsBytes() types.BeMatcher { return Psi(AssignableTo[[]byte](), "be bytes") } + +// AsNumber succeeds if actual is a numeric value, supporting various +// integer kinds: reflect.Int, ... reflect.Int64, +// and floating-point kinds: reflect.Float32, reflect.Float64 +func AsNumber() types.BeMatcher { + return Psi(AsKind( + gomega.BeNumerically(">=", reflect.Int), + gomega.BeNumerically("<=", reflect.Float64), + ), "be a number") +} + +// AsNumericString succeeds if actual is a string that can be parsed into a valid numeric value. +func AsNumericString() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsString(), + WithFallibleTransform(func(actual any) any { + _, err := strconv.ParseFloat(cast.AsString(actual), 64) + return err == nil + }, gomega.BeTrue()), + ), "be a numeric string") +} + +// AsInteger succeeds if actual is a numeric value that represents an integer (from reflect.Int up to reflect.Uint64). +func AsInteger() types.BeMatcher { + return Psi(AsKind( + gomega.BeNumerically(">=", reflect.Int), + gomega.BeNumerically("<=", reflect.Uint64), + ), "be an integer value") +} + +// AsIntegerString succeeds if actual is a string that can be parsed into a valid integer value. +func AsIntegerString() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsString(), + WithFallibleTransform(func(actual any) any { + _, err := strconv.ParseInt(cast.AsString(actual), 10, 64) + return err == nil + }, gomega.BeTrue()), + ), "be an integer-ish string") +} + +// AsFloat succeeds if actual is a numeric value that represents a floating-point value. +func AsFloat() types.BeMatcher { + return Psi(AsKind( + gomega.BeNumerically(">=", reflect.Float32), + gomega.BeNumerically("<=", reflect.Float64), + ), "be a float value") +} + +// AsFloatString succeeds if actual is a string that can be parsed into a valid floating-point value. +func AsFloatString() types.BeMatcher { + return Psi(psi_matchers.NewAllMatcher( + AsString(), + WithFallibleTransform(func(actual any) any { + _, err := strconv.ParseFloat(cast.AsString(actual), 64) + return err == nil + }, gomega.BeTrue()), + ), "be a float-ish string") +} diff --git a/be_reflected/matchers_reflected_test.go b/be_reflected/matchers_reflected_test.go new file mode 100644 index 0000000..8949396 --- /dev/null +++ b/be_reflected/matchers_reflected_test.go @@ -0,0 +1,105 @@ +package be_reflected_test + +import ( + "fmt" + "github.com/expectto/be/be_reflected" + "github.com/expectto/be/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + "reflect" +) + +// TODO: unify tests. Let's make all tests like in `be_math` + +var _ = Describe("MatchersReflected", func() { + Context("AsKind", func() { + DescribeTable("should match kind", func(actual interface{}, expected reflect.Kind) { + Expect(actual).To(be_reflected.AsKind(expected), reflect.TypeOf(actual).Kind().String()) + }, + Entry("int", 1, reflect.Int), + Entry("int8", int8(1), reflect.Int8), + Entry("int16", int16(1), reflect.Int16), + Entry("int32", int32(1), reflect.Int32), + Entry("int64", int64(1), reflect.Int64), + Entry("uint", uint(1), reflect.Uint), + Entry("uint8", uint8(1), reflect.Uint8), + Entry("uint16", uint16(1), reflect.Uint16), + Entry("uint32", uint32(1), reflect.Uint32), + Entry("uint64", uint64(1), reflect.Uint64), + Entry("float32", float32(1), reflect.Float32), + Entry("float64", float64(1), reflect.Float64), + Entry("complex64", complex64(1), reflect.Complex64), + Entry("complex128", complex128(1), reflect.Complex128), + Entry("string", "1", reflect.String), + Entry("bool", true, reflect.Bool), + Entry("chan", make(chan int), reflect.Chan), + Entry("func", func() {}, reflect.Func), + Entry("map", map[string]int{}, reflect.Map), + Entry("ptr", new(int), reflect.Ptr), + Entry("slice", []int{}, reflect.Slice), + Entry("struct", struct{}{}, reflect.Struct), + + // todo add test for reflect.Interface + ) + + DescribeTable("should properly fail on matching", func(actual interface{}, expected reflect.Kind) { + matcher := be_reflected.AsKind(expected) + + success, err := matcher.Match(actual) + Expect(err).To(Succeed()) + Expect(success).To(BeFalse()) + + message := matcher.FailureMessage(actual) + Expect(message).To(Equal(fmt.Sprintf("Expected\n%s\nto be kind of %s", format.Object(actual, 1), expected.String()))) + }, + Entry("int", 1, reflect.Uint), + Entry("int8", int8(1), reflect.Uint8), + Entry("int16", int16(1), reflect.Uint16), + + Entry("int32", int32(1), reflect.Uint32), + Entry("int64", int64(1), reflect.Uint64), + Entry("uint", uint(1), reflect.Int), + Entry("uint8", uint8(1), reflect.Int8), + Entry("uint16", uint16(1), reflect.Int16), + Entry("uint32", uint32(1), reflect.Int32), + Entry("uint64", uint64(1), reflect.Int64), + Entry("float32", float32(1), reflect.Float64), + + Entry("float64", float64(1), reflect.Float32), + Entry("complex64", complex64(1), reflect.Complex128), + Entry("complex128", complex128(1), reflect.Complex64), + Entry("string as not bool", "1", reflect.Bool), + Entry("bool as not string", true, reflect.String), + Entry("func", func() {}, reflect.Chan), + Entry("chan as not func", make(chan int), reflect.Func), + Entry("map", map[string]int{}, reflect.Ptr), + Entry("ptr", new(int), reflect.Map), // <*int | 0xc...>: de-referencad value + ) + }) + + Context("AssignableTo", func() { + + // Named booleans so table is more readable + const WillPass = true + const WontPass = false + + type CustomString string + + DescribeTable("should be assignable to a simple type", func(m types.BeMatcher, actual any, result bool) { + matched, err := m.Match(actual) + Expect(err).To(Succeed()) + Expect(matched).To(Equal(result)) + }, + Entry("int", be_reflected.AssignableTo[int](), 5, WillPass), + Entry("uint", be_reflected.AssignableTo[uint8](), uint8(2), WillPass), + Entry("int vs uint", be_reflected.AssignableTo[uint8](), 2, WontPass), + Entry("float64", be_reflected.AssignableTo[float64](), 100.0, WillPass), + Entry("string", be_reflected.AssignableTo[string](), "hello world", WillPass), + + Entry("CustomString", be_reflected.AssignableTo[CustomString](), CustomString("hello world"), WillPass), + Entry("CustomString as string: no", be_reflected.AssignableTo[string](), CustomString("hello world"), WontPass), + Entry("string as CustomString: no", be_reflected.AssignableTo[CustomString](), "hello world", WontPass), + ) + }) +}) diff --git a/be_string/README.md b/be_string/README.md new file mode 100644 index 0000000..c222773 --- /dev/null +++ b/be_string/README.md @@ -0,0 +1,145 @@ +# be_string +-- + import "github.com/expectto/be/be_string" + +Package be_string provides Be matchers for string-related assertions. + +## Usage + +#### func ContainingCharacters + +```go +func ContainingCharacters(characters string) types.BeMatcher +``` +ContainingCharacters succeeds if actual is a string containing all characters +from a given set + +#### func ContainingOnlyCharacters + +```go +func ContainingOnlyCharacters(characters string) types.BeMatcher +``` +ContainingOnlyCharacters succeeds if actual is a string containing only +characters from a given set + +#### func ContainingSubstring + +```go +func ContainingSubstring(substr string) types.BeMatcher +``` +ContainingSubstring succeeds if actual is a string containing only characters +from a given set + +#### func EmptyString + +```go +func EmptyString() types.BeMatcher +``` +EmptyString succeeds if actual is an empty string. Actual must be a string-like +value (can be adjusted via SetStringFormat method). + +#### func Float + +```go +func Float() types.BeMatcher +``` +Float succeeds if actual is a string representing a valid floating-point number. +Actual must be a string-like value (can be adjusted via SetStringFormat method). + +#### func LowerCaseOnly + +```go +func LowerCaseOnly() types.BeMatcher +``` +LowerCaseOnly succeeds if actual is a string containing only lowercase +characters. Actual must be a string-like value (can be adjusted via +SetStringFormat method). + +#### func MatchTemplate + +```go +func MatchTemplate(template string, vars ...*V) types.BeMatcher +``` +MatchTemplate succeeds if actual matches given template pattern. Provided +template must have `{{Field}}` placeholders. Each distinct placeholder from +template requires a var to be passed in list of `vars`. Var can be a raw value +or a matcher + +E.g. + + Expect(someString).To(be_string.MatchTemplate("Hello {{Name}}. Your number is {{Number}}", be_string.Var("Name", "John"), be_string.Var("Number", 3))) + Expect(someString).To(be_string.MatchTemplate("Hello {{Name}}. Good bye, {{Name}}.", be_string.Var("Name", be_string.Titled())) + +#### func MatchWildcard + +```go +func MatchWildcard(pattern string) types.BeMatcher +``` +MatchWildcard succeeds if actual matches given wildcard pattern. Actual must be +a string-like value (can be adjusted via SetStringFormat method). + +#### func NonEmptyString + +```go +func NonEmptyString() types.BeMatcher +``` +NonEmptyString succeeds if actual is not an empty string. Actual must be a +string-like value (can be adjusted via SetStringFormat method). + +#### func Only + +```go +func Only(option StringOption) types.BeMatcher +``` +Only succeeds if actual is a string containing only characters described by +given options Only() defaults to empty string matching + +#### func Titled + +```go +func Titled(languageArg ...language.Tag) types.BeMatcher +``` +Titled succeeds if actual is a string with the first letter of each word +capitalized. Actual must be a string-like value (can be adjusted via +SetStringFormat method). + +#### func UpperCaseOnly + +```go +func UpperCaseOnly() types.BeMatcher +``` +UpperCaseOnly succeeds if actual is a string containing only uppercase +characters. Actual must be a string-like value (can be adjusted via +SetStringFormat method). + +#### func ValidEmail + +```go +func ValidEmail() types.BeMatcher +``` +ValidEmail succeeds if actual is a valid email. Actual must be a string-like +value (can be adjusted via SetStringFormat method). + +#### func ValidateStringOption + +```go +func ValidateStringOption(opt StringOption, r rune) bool +``` + +#### type V + +```go +type V struct { + Name string + Matcher types.BeMatcher +} +``` + + +#### func Var + +```go +func Var(name string, matching any) *V +``` +Var creates a var used for replacing placeholders for templates in +`MatchTemplate` diff --git a/be_string/be_string_suite_test.go b/be_string/be_string_suite_test.go new file mode 100644 index 0000000..17dbfe6 --- /dev/null +++ b/be_string/be_string_suite_test.go @@ -0,0 +1,13 @@ +package be_string_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBeStrings(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BeString Suite") +} diff --git a/be_string/matchers_string.go b/be_string/matchers_string.go new file mode 100644 index 0000000..451b021 --- /dev/null +++ b/be_string/matchers_string.go @@ -0,0 +1,251 @@ +// Package be_string provides Be matchers for string-related assertions. +package be_string + +import ( + "fmt" + "github.com/IGLOU-EU/go-wildcard" // used specifically for MatchWildcard matcher + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + . "github.com/expectto/be/options" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "net/mail" + "strconv" + "strings" + "unicode" +) + +// psiString is a convenient wrapper for creating every be_string matcher +// It simply wraps `Psi()` call to have a pre-matching for `checking type string` +// and returning a separate clear message if `actual` is not of string type. +var psiString = func(args ...any) types.BeMatcher { + return Psi( + Psi(func(actual any) (bool, error) { + // todo: IsString options should be configurable + return cast.IsString(actual, cast.AllowCustomTypes()), nil + }, "be type of string"), + Psi(args...), + ) +} + +// validateStringOption checks if string options satisfies given rune +func validateStringOption(opt StringOption, r rune) bool { + switch opt { + case Alpha: + return unicode.IsLetter(r) + case Numeric: + return unicode.IsNumber(r) + case Whitespace: + return unicode.IsSpace(r) + case Punctuation: + return unicode.IsPunct(r) + case Dots: + return r == '.' + case SpecialCharacters: + // todo: implement + return false + default: + return false + } +} + +// Only succeeds if actual is a string containing only characters described by given options +// Only() defaults to empty string matching +// Only(Alpha|Numeric) succeeds if string contains only from alphabetic and numeric characters +// Available options are: Alpha, Numeric, Whitespace, Dots, Punctuation, SpecialCharacters +// TODO: special-characters are not supported yet +func Only(option StringOption) types.BeMatcher { + if option == 0 { + return EmptyString() + } + + options := ExtractStringOptions(option) + + // We need stringified version of all options for the failure message + optionsStr := make([]string, len(options), len(options)) + for i := range options { + optionsStr[i] = options[i].String() + } + return psiString(func(actual any) (bool, error) { + str := cast.AsString(actual) + + // empty string is not consider as any of string options + if str == "" { + return false, nil + } + + // Check if it contains only letters + for _, char := range str { + // we're OK until there is a char that doesn't satisfy ANY option: + var valid = false + for _, opt := range options { + valid = valid || validateStringOption(opt, char) + } + if !valid { + return false, nil + } + } + + return true, nil + }, fmt.Sprintf("contain only %s characters", strings.Join(optionsStr, "|"))) +} + +// EmptyString succeeds if actual is an empty string. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func EmptyString() types.BeMatcher { + return psiString(gomega.BeEmpty(), "be an empty string") +} + +// NonEmptyString succeeds if actual is not an empty string. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func NonEmptyString() types.BeMatcher { + return psiString(gomega.Not(gomega.BeEmpty()), "be a non-empty string") +} + +// Float succeeds if actual is a string representing a valid floating-point number. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func Float() types.BeMatcher { + return psiString(func(actual any) (bool, error) { + _, err := strconv.ParseFloat(cast.AsString(actual), 64) + return err == nil, nil + }, "be a string representation of a float value") +} + +// Titled succeeds if actual is a string with the first letter of each word capitalized. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func Titled(languageArg ...language.Tag) types.BeMatcher { + lang := language.English + if len(languageArg) > 0 { + lang = languageArg[0] + } + + return psiString(func(actual any) (bool, error) { + str := cast.AsString(actual) + return cases.Title(lang).String(str) == str, nil + }, "be a titled string") +} + +// LowerCaseOnly succeeds if actual is a string containing only lowercase characters. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func LowerCaseOnly() types.BeMatcher { + return psiString(func(actual any) (bool, error) { + str := cast.AsString(actual) + return strings.ToLower(str) == str, nil + }, "be lower-case") +} + +// UpperCaseOnly succeeds if actual is a string containing only uppercase characters. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func UpperCaseOnly() types.BeMatcher { + return psiString(func(actual any) (bool, error) { + str := cast.AsString(actual) + return strings.ToUpper(str) == str, nil + }, "be upper-case") +} + +// ContainingSubstring succeeds if actual is a string containing only characters from a given set +func ContainingSubstring(substr string) types.BeMatcher { + return psiString(func(actual any) (bool, error) { + return strings.Contains(cast.AsString(actual), substr), nil + }, fmt.Sprintf(`contain "%s" substring`, substr)) +} + +// ContainingOnlyCharacters succeeds if actual is a string containing only characters from a given set +func ContainingOnlyCharacters(characters string) types.BeMatcher { + return psiString(func(actual any) (bool, error) { + // empty string is not considered ContainsOf + str := cast.AsString(actual) + if str == "" || characters == "" { + return false, nil + } + + // string -> map[rune]struct{}{} as a lookup-table + allowedSet := make(map[rune]struct{}) + for _, char := range characters { + allowedSet[char] = struct{}{} + } + + // Check if it's an alphanumeric string + for _, char := range str { + if _, ok := allowedSet[char]; !ok { + return false, nil + } + } + + return true, nil + }, fmt.Sprintf("contain only `%s` characters", characters)) +} + +// ContainingCharacters succeeds if actual is a string containing all characters from a given set +func ContainingCharacters(characters string) types.BeMatcher { + return psiString(func(actual any) (bool, error) { + if len(characters) == 0 { + return true, nil + } + + // empty string is not considered ContainingCharacters + str := cast.AsString(actual) + if str == "" { + return false, nil + } + + // string -> map[rune]struct{}{} as a lookup-table + requiredSet := make(map[rune]struct{}) + for _, char := range characters { + requiredSet[char] = struct{}{} + } + + actualSet := make(map[rune]struct{}) + for _, char := range str { + actualSet[char] = struct{}{} + } + + // Check if it's an alphanumeric string + for _, requiredChar := range characters { + if _, ok := actualSet[requiredChar]; !ok { + return false, nil + } + } + + return true, nil + }, fmt.Sprintf("contain all of `%s` characters", characters)) +} + +// MatchWildcard succeeds if actual matches given wildcard pattern. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func MatchWildcard(pattern string) types.BeMatcher { + return psiString(func(actual any) (bool, error) { + return wildcard.Match(pattern, cast.AsString(actual)), nil + }, fmt.Sprintf(`match wildcard "%s"`, pattern)) +} + +// ValidEmail succeeds if actual is a valid email. +// Actual must be a string-like value (can be adjusted via SetStringFormat method). +func ValidEmail() types.BeMatcher { + return psiString(func(actual any) (bool, error) { + _, err := mail.ParseAddress(cast.AsString(actual)) + return err == nil, nil + }, fmt.Sprintf("be a valid email")) +} + +// +// String Templates +// + +var V = psi_matchers.V + +// MatchTemplate succeeds if actual matches given template pattern. +// Provided template must have `{{Field}}` placeholders. +// Each distinct placeholder from template requires a var to be passed in list of `vars`. +// Value (V) can be a raw value or a matcher +// +// E.g. +// +// Expect(someString).To(be_string.MatchTemplate("Hello {{Name}}. Your number is {{Number}}", be_string.V("Name", "John"), be_string.V("Number", 3))) +// Expect(someString).To(be_string.MatchTemplate("Hello {{Name}}. Good bye, {{Name}}.", be_string.V("Name", be_string.Titled())) +func MatchTemplate(template string, values ...*psi_matchers.Value) types.BeMatcher { + return psi_matchers.NewStringTemplateMatcher(template, values...) +} diff --git a/be_string/matchers_string_test.go b/be_string/matchers_string_test.go new file mode 100644 index 0000000..004e731 --- /dev/null +++ b/be_string/matchers_string_test.go @@ -0,0 +1,257 @@ +package be_string_test + +import ( + "github.com/expectto/be/be_string" + . "github.com/expectto/be/options" + "github.com/expectto/be/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "math/rand" +) + +var _ = Describe("BeStrings (simple matchers)", func() { + DescribeTable("should positively match", func(matcher types.BeMatcher, actual any) { + // check gomega-compatible matching: + success, err := matcher.Match(actual) + Expect(err).Should(Succeed()) + Expect(success).To(BeTrue()) + + // check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeTrue()) + }, + Entry("NonEmptyString", be_string.NonEmptyString(), "Hello"), + Entry("NonEmptyString: one character", be_string.NonEmptyString(), "a"), + Entry("NonEmptyString: just space", be_string.NonEmptyString(), " "), + Entry("EmptyString", be_string.EmptyString(), ""), + + Entry("Only(Alpha) lowercase only", be_string.Only(Alpha), "abcdefg"), + Entry("Only(Alpha) uppercase only", be_string.Only(Alpha), "ABCDEFG"), + Entry("Only(Alpha) mixed case", be_string.Only(Alpha), "AbCdEfG"), + + Entry("Only(Alpha|Whitespace) only letters", be_string.Only(Alpha|Whitespace), "HelloWorld"), + Entry("Only(Alpha|Whitespace) only spaces", be_string.Only(Alpha|Whitespace), " "), + Entry("Only(Alpha|Whitespace) mixed letters and spaces", be_string.Only(Alpha|Whitespace), "Hello World"), + Entry("Only(Alpha|Whitespace) letters with leading/trailing spaces", be_string.Only(Alpha|Whitespace), " Hello World "), + + Entry("Only(Alpha|Punctuation): only letters", be_string.Only(Alpha|Punctuation), "HelloWorld"), + Entry("Only(Alpha|Punctuation): only punctuation", be_string.Only(Alpha|Punctuation), "!().,"), + Entry("Only(Alpha|Punctuation): mixed letters and punctuation", be_string.Only(Alpha|Punctuation), "HelloWorld!"), + + Entry("Only(Alpha|Whitespace|Punctuation) only letters", be_string.Only(Alpha|Whitespace|Punctuation), "HelloWorld"), + Entry("Only(Alpha|Whitespace|Punctuation) only whitespace", be_string.Only(Alpha|Whitespace|Punctuation), " "), + Entry("Only(Alpha|Whitespace|Punctuation) only punctuation", be_string.Only(Alpha|Whitespace|Punctuation), "!().,"), + Entry("Only(Alpha|Whitespace|Punctuation) mixed letters, whitespace, and punctuation", be_string.Only(Alpha|Whitespace|Punctuation), "Hello, World! How are you?"), + + Entry("Only(Whitespace): only whitespace", be_string.Only(Whitespace), " "), + Entry("Only(Whitespace): space with tab with newline", be_string.Only(Whitespace), "\n\t "), + + Entry("Only(Numeric)", be_string.Only(Numeric), "12345"), + Entry("Only(Numeric): one digit", be_string.Only(Numeric), "1"), + Entry("Only(Numeric): big digits", be_string.Only(Numeric), "9999999999999"), + + Entry("Only(Numeric|Whitespace): only numbers", be_string.Only(Numeric|Whitespace), "12345"), + Entry("Only(Numeric|Whitespace): numbers with whitespace", be_string.Only(Numeric|Whitespace), "123 45"), + Entry("Only(Numeric|Whitespace): numbers with leading and trailing whitespace", be_string.Only(Numeric|Whitespace), " 12345 "), + + Entry("Only(Alpha|Numeric): upper case", be_string.Only(Alpha|Numeric), "ABC123"), + Entry("Only(Alpha|Numeric): lower case", be_string.Only(Alpha|Numeric), "abc123"), + Entry("Only(Alpha|Numeric): mixed case", be_string.Only(Alpha|Numeric), "ABCxyz987"), + Entry("Only(Alpha|Numeric): only nums", be_string.Only(Alpha|Numeric), "123456789"), + Entry("Only(Alpha|Numeric): only alpha", be_string.Only(Alpha|Numeric), "abcdef"), + + Entry("Only(Alpha|Numeric|Whitespace): alphanumeric", be_string.Only(Alpha|Numeric|Whitespace), "abc123"), + Entry("Only(Alpha|Numeric|Whitespace): alphanumeric with whitespace", be_string.Only(Alpha|Numeric|Whitespace), "abc123 xyz"), + Entry("Only(Alpha|Numeric|Whitespace): alphanumeric with leading and trailing whitespace", be_string.Only(Alpha|Numeric|Whitespace), " abc123 xyz "), + + Entry("Only(Alpha|Numeric|Punctuation): alphanumeric with punctuation", be_string.Only(Alpha|Numeric|Punctuation), "abc123,xyz"), + Entry("Only(Alpha|Numeric|Punctuation): alphanumeric with leading and trailing punctuation", be_string.Only(Alpha|Numeric|Punctuation), "(abc123.xyz)"), + + Entry("Only(Alpha|Numeric|Whitespace|Punctuation): alphanumeric with whitespace and punctuation", be_string.Only(Alpha|Numeric|Whitespace|Punctuation), "abc 123,xyz"), + Entry("Only(Alpha|Numeric|Whitespace|Punctuation): alphanumeric with leading and trailing punctuation and whitespace", be_string.Only(Alpha|Numeric|Whitespace|Punctuation), "(abc 123.xyz)"), + Entry("Only(Alpha|Numeric|Whitespace|Punctuation): alphanumeric with punctuation and whitespace", be_string.Only(Alpha|Numeric|Whitespace|Punctuation), "abc 123, xyz"), + + Entry("Only(Alpha|Numeric|Dots)", be_string.Only(Alpha|Numeric|Dots), "Abc123.5"), + Entry("Only(Alpha|Numeric|Dots): only nums+dots", be_string.Only(Alpha|Numeric|Dots), "3.141592653589793"), + Entry("Only(Alpha|Numeric|Dots): multiple dots", be_string.Only(Alpha|Numeric|Dots), "a.b.c.1.2.3"), + Entry("Only(Alpha|Numeric|Dots): only dot", be_string.Only(Alpha|Numeric|Dots), "."), + Entry("Only(Alpha|Numeric|Dots): only dots", be_string.Only(Alpha|Numeric|Dots), "..."), + + Entry("Float", be_string.Float(), "3.14"), + Entry("Float: negative", be_string.Float(), "-3.14"), + Entry("Float: integral", be_string.Float(), "5.00"), + Entry("Float: integral (without dot)", be_string.Float(), "5"), + + Entry("Titled", be_string.Titled(), "This Is Titled"), + Entry("Titled:one word", be_string.Titled(), "Yo"), + + Entry("LowerCaseOnly", be_string.LowerCaseOnly(), "this is lowercase"), + Entry("LowerCaseOnly: one character", be_string.LowerCaseOnly(), "x"), + Entry("LowerCaseOnly: not trimmed", be_string.LowerCaseOnly(), " hello "), + + Entry("UpperCaseOnly", be_string.UpperCaseOnly(), "THIS IS CAPS"), + Entry("UpperCaseOnly: one character", be_string.UpperCaseOnly(), "X"), + Entry("UpperCaseOnly: not trimmed", be_string.UpperCaseOnly(), " HELLO "), + + Entry("ContainingSubstring: contains 'abc'", be_string.ContainingSubstring("lazy"), "The quick brown fox jumps over the lazy"), + Entry("ContainingSubstring: contains '123'", be_string.ContainingSubstring("123"), "The password is 123456"), + Entry("ContainingSubstring: contains 'xyz'", be_string.ContainingSubstring("xyz"), "xyz is the last three characters"), + + Entry("ContainingOnlyCharacters: contains only 'abc'", be_string.ContainingOnlyCharacters("abc"), "aaaaab"), + Entry("ContainingOnlyCharacters: contains only '123'", be_string.ContainingOnlyCharacters("123"), "123"), + Entry("ContainingOnlyCharacters: contains only 'xyz'", be_string.ContainingOnlyCharacters("xyz"), "xyzxyzxyzxyz"), + + Entry("ContainingCharacters: contains 'abc'", be_string.ContainingCharacters("abc"), "abc"), + Entry("ContainingCharacters: contains 'abc123'", be_string.ContainingCharacters("abc123"), "1a2b3c"), + Entry("ContainingCharacters: contains '123'", be_string.ContainingCharacters("123"), "foo111112222233331112223bar"), + Entry("ContainingCharacters: empty chars list", be_string.ContainingCharacters(""), "anything"), + Entry("ContainingCharacters: empty given & empty actual", be_string.ContainingCharacters(""), ""), + + Entry("MatchWildcard: prefix one char", be_string.MatchWildcard("*ello"), "Hello"), + Entry("MatchWildcard: prefix longer", be_string.MatchWildcard("*orld"), "Hello World"), + Entry("MatchWildcard: suffix one char", be_string.MatchWildcard("Hell*"), "Hello"), + Entry("MatchWildcard: suffix longer", be_string.MatchWildcard("Hello W*"), "Hello World"), + Entry("MatchWildcard: in the middle", be_string.MatchWildcard("H*d"), "Hello World"), + Entry("MatchWildcard: all-star", be_string.MatchWildcard("*"), "Hello World"), + Entry("MatchWildcard: all-star for nothing", be_string.MatchWildcard("*"), ""), + + Entry("ValidEmail", be_string.ValidEmail(), "test@example.com"), + ) + + DescribeTable("should negatively match", func(matcher types.BeMatcher, actual any) { + // Check gomega-compatible matching: + success, err := matcher.Match(actual) + Expect(err).Should(Succeed()) + Expect(success).To(BeFalse()) + + // Check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeFalse()) + }, + Entry("NonEmptyString: empty string", be_string.NonEmptyString(), ""), + Entry("EmptyString: non-empty string", be_string.EmptyString(), "Hello"), + + Entry("Only(Alpha): alphanumeric string", be_string.Only(Alpha), "abc123"), + Entry("Only(Alpha): string with space", be_string.Only(Alpha), "Hello World"), + Entry("Only(Alpha): string with special characters", be_string.Only(Alpha), "Hello@World"), + Entry("Only(Alpha): empty string", be_string.Only(Alpha), ""), + + Entry("Only(Alpha|Whitespace): contains numbers", be_string.Only(Alpha|Whitespace), "abc123"), + Entry("Only(Alpha|Whitespace): contains punctuation", be_string.Only(Alpha|Whitespace), "abc!"), + Entry("Only(Alpha|Whitespace): contains both numbers and punctuation", be_string.Only(Alpha|Whitespace), "abc 123!"), + Entry("Only(Alpha|Whitespace): empty string", be_string.Only(Alpha|Whitespace), ""), + + Entry("Only(Alpha|Punctuation): contains whitespace", be_string.Only(Alpha|Punctuation), "abc def"), + Entry("Only(Alpha|Punctuation): contains numbers", be_string.Only(Alpha|Punctuation), "abc123"), + Entry("Only(Alpha|Punctuation): empty string", be_string.Only(Alpha|Punctuation), ""), + + Entry("Only(Alpha|Whitespace|Punctuation) contains numbers", be_string.Only(Alpha|Whitespace|Punctuation), "abc123"), + Entry("Only(Alpha|Whitespace|Punctuation) empty string", be_string.Only(Alpha|Whitespace|Punctuation), ""), + + Entry("Only(Whitespace): contains letters", be_string.Only(Whitespace), "abc"), + Entry("Only(Whitespace): contains numbers", be_string.Only(Whitespace), "123"), + Entry("Only(Whitespace): empty string", be_string.Only(Whitespace), ""), + + Entry("Only(Numeric|Whitespace): contains letters", be_string.Only(Numeric|Whitespace), "abc"), + Entry("Only(Numeric|Whitespace): contains punctuation", be_string.Only(Numeric|Whitespace), "1,2,3"), + Entry("Only(Numeric|Whitespace): contains letters and punctuation", be_string.Only(Numeric|Whitespace), "abc 123!"), + Entry("Only(Numeric|Whitespace): empty string", be_string.Only(Numeric|Whitespace), ""), + + Entry("Only(Alpha|Numeric|Whitespace): contains punctuation", be_string.Only(Alpha|Numeric|Whitespace), "abc123!"), + Entry("Only(Alpha|Numeric|Whitespace): contains special characters", be_string.Only(Alpha|Numeric|Whitespace), "abc 123@"), + Entry("Only(Alpha|Numeric|Whitespace): contains letters, numbers, and punctuation", be_string.Only(Alpha|Numeric|Whitespace), "abc 123!"), + Entry("Only(Alpha|Numeric|Whitespace): empty string", be_string.Only(Alpha|Numeric|Whitespace), ""), + + Entry("Only(Alpha|Numeric|Punctuation): contains whitespace", be_string.Only(Alpha|Numeric|Punctuation), "abc 123"), + Entry("Only(Alpha|Numeric|Punctuation): contains whitespace and special characters", be_string.Only(Alpha|Numeric|Punctuation), "abc 123@"), + + Entry("Only(Alpha|Numeric|Whitespace|Punctuation): contains special characters", be_string.Only(Alpha|Numeric|Whitespace|Punctuation), "abc$% 123@"), + Entry("Only(Alpha|Numeric|Whitespace|Punctuation): empty string", be_string.Only(Alpha|Numeric|Whitespace|Punctuation), ""), + + Entry("Only(Numeric): alphanumeric string", be_string.Only(Numeric), "abc123"), + Entry("Only(Numeric): string with space", be_string.Only(Numeric), "123 456"), + Entry("Only(Numeric): string with special characters", be_string.Only(Numeric), "123@456"), + Entry("Only(Numeric): empty string", be_string.Only(Numeric), ""), + + Entry("Only(Alpha|Numeric): string with space", be_string.Only(Alpha|Numeric), "abc 123"), + Entry("Only(Alpha|Numeric): string with special characters", be_string.Only(Alpha|Numeric), "abc@123"), + Entry("Only(Alpha|Numeric): empty string", be_string.Only(Alpha|Numeric), ""), + + Entry("Only(Alpha|Numeric|Dots): string with space", be_string.Only(Alpha|Numeric|Dots), "abc 123"), + Entry("Only(Alpha|Numeric|Dots): string with special characters", be_string.Only(Alpha|Numeric|Dots), "abc@123"), + Entry("Only(Alpha|Numeric|Dots): empty string", be_string.Only(Alpha|Numeric|Dots), ""), + + Entry("Float: string with non-numeric characters", be_string.Float(), "3.14abc"), + Entry("Float: string with space", be_string.Float(), "3.14 5"), + Entry("Float: string with special characters", be_string.Float(), "3.14@5"), + Entry("Float: empty string", be_string.Float(), ""), + + Entry("Titled: non-titled string", be_string.Titled(), "hello world"), + Entry("LowerCaseOnly: string with upper case", be_string.LowerCaseOnly(), "HelloWorld"), + + Entry("ContainingSubstring: does not contain substring", be_string.ContainingSubstring("xyz"), "abc123"), + Entry("ContainingSubstring: empty string", be_string.ContainingSubstring("xyz"), ""), + + Entry("ContainingOnlyCharacters: contains other characters", be_string.ContainingOnlyCharacters("abc"), "defabc123"), + Entry("ContainingOnlyCharacters: empty string", be_string.ContainingOnlyCharacters("abc"), ""), + Entry("ContainingOnlyCharacters: contains whitespace", be_string.ContainingOnlyCharacters("abc"), "a b c"), + + Entry("ContainingCharacters: missing required characters", be_string.ContainingCharacters("abc"), "def123"), + Entry("ContainingCharacters: empty string", be_string.ContainingCharacters("abc"), ""), + Entry("ContainingCharacters: contains extra characters", be_string.ContainingCharacters("abc"), "def123"), + + Entry("MatchWildcard: no match", be_string.MatchWildcard("abc*"), "xyz123"), + + Entry("ValidEmail: invalid email", be_string.ValidEmail(), "test@example@com"), + Entry("ValidEmail: just letters", be_string.ValidEmail(), "testexample"), + Entry("ValidEmail: numeric string", be_string.ValidEmail(), "1000"), + ) + + // All be_string matchers expects input to be a string. + // They will not succeed and return a short "to be type of string" failure message + DescribeTable("non-string type tests", func(matcher types.BeMatcher) { + notStrings := []any{ + 0, false, map[string]any{}, []string{}, func() {}, nil, + } + actual := notStrings[rand.Intn(len(notStrings))] // not a string + + // Check gomega-compatible matching: + success, err := matcher.Match(actual) + Expect(err).Should(Succeed()) + Expect(success).To(BeFalse()) + + // Check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeFalse()) + + // and the message should be the same + Expect(matcher.FailureMessage(actual)).To(HaveSuffix("to be type of string")) + //Expect(matcher.NegatedFailureMessage(actual)).To(HaveSuffix("not to be type of string")) + }, + // todo: at some point Entries should be auto-generated + Entry("NonEmptyString", be_string.NonEmptyString()), + Entry("EmptyString", be_string.EmptyString()), + Entry("Only(Alpha)", be_string.Only(Alpha)), + Entry("Float", be_string.Float()), + Entry("Titled", be_string.Titled()), + Entry("LowerCaseOnly", be_string.LowerCaseOnly()), + Entry("ContainingSubstring", be_string.ContainingSubstring("xyz")), + Entry("ContainingOnlyCharacters", be_string.ContainingOnlyCharacters("abc")), + Entry("ContainingCharacters", be_string.ContainingCharacters("abc")), + Entry("MatchWildcard", be_string.MatchWildcard("abc*")), + Entry("ValidEmail", be_string.ValidEmail()), + ) + +}) + +var _ = Describe("BeStrings (template matching)", func() { + It("Should match a template with 2 variables", func() { + matcher := be_string.MatchTemplate( + "Hello {{UserName}}! Given email is {{Email}}", + be_string.V("UserName", be_string.Only(Alpha|Numeric)), + be_string.V("Email", be_string.ValidEmail()), + ) + input := "Hello Foo123! Given email is hello@gmail.com" + Expect(input).To(matcher) + }) +}) diff --git a/be_suite_test.go b/be_suite_test.go new file mode 100644 index 0000000..f6dba6f --- /dev/null +++ b/be_suite_test.go @@ -0,0 +1,13 @@ +package be_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBe(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Be Suite") +} diff --git a/be_time/README.md b/be_time/README.md new file mode 100644 index 0000000..51a38b5 --- /dev/null +++ b/be_time/README.md @@ -0,0 +1,214 @@ +# be_time +-- + import "github.com/expectto/be/be_time" + +Package be_time provides Be matchers on time.Time + +## Usage + +#### func Approx + +```go +func Approx(compareTo time.Time, threshold time.Duration) types.BeMatcher +``` +Approx succeeds if actual time is approximately equal to the specified time +`compareTo` within the given time duration threshold. + +#### func EarlierThan + +```go +func EarlierThan(compareTo time.Time) types.BeMatcher +``` +EarlierThan succeeds if actual time is earlier than the specified time +`compareTo`. + +#### func EarlierThanEqual + +```go +func EarlierThanEqual(compareTo time.Time) types.BeMatcher +``` +EarlierThanEqual succeeds if actual time is earlier than or equal to the +specified time `compareTo`. + +#### func Eq + +```go +func Eq(compareTo time.Time) types.BeMatcher +``` +Eq succeeds if actual time is equal to the specified time `compareTo` with the +precision of one nanosecond. + +#### func IsDST + +```go +func IsDST(compareTo time.Time) types.BeMatcher +``` +IsDST checks if actual time is DST + +#### func LaterThan + +```go +func LaterThan(compareTo time.Time) types.BeMatcher +``` +LaterThan succeeds if actual time is later than the specified time `compareTo`. + +#### func LaterThanEqual + +```go +func LaterThanEqual(compareTo time.Time) types.BeMatcher +``` +LaterThanEqual succeeds if actual time is later than or equal to the specified +time `compareTo`. + +#### func SameDay + +```go +func SameDay(compareTo time.Time) types.BeMatcher +``` +SameDay checks if the .Day() component of the actual time matches the .Day() +component of the specified time `compareTo`. It only verifies the day +[1..30(31)], disregarding the month, year, etc. + +#### func SameExactDay + +```go +func SameExactDay(compareTo time.Time) types.BeMatcher +``` +SameExactDay succeeds if the actual time falls within the same day as the +specified time `compareTo`. + +#### func SameExactHour + +```go +func SameExactHour(compareTo time.Time) types.BeMatcher +``` +SameExactHour succeeds if the actual time falls within the same hour as the +specified time `compareTo`. + +#### func SameExactMilli + +```go +func SameExactMilli(compareTo time.Time) types.BeMatcher +``` +SameExactMilli succeeds if the actual time falls within the same millisecond as +the specified time `compareTo`. + +#### func SameExactMinute + +```go +func SameExactMinute(compareTo time.Time) types.BeMatcher +``` +SameExactMinute succeeds if the actual time falls within the same minute as the +specified time `compareTo`. + +#### func SameExactMonth + +```go +func SameExactMonth(compareTo time.Time) types.BeMatcher +``` +SameExactMonth succeeds if the actual time falls within the same month as the +specified time `compareTo`. + +#### func SameExactSecond + +```go +func SameExactSecond(compareTo time.Time) types.BeMatcher +``` +SameExactSecond succeeds if the actual time falls within the same second as the +specified time `compareTo`. + +#### func SameExactWeek + +```go +func SameExactWeek(compareTo time.Time) types.BeMatcher +``` +SameExactWeek succeeds if the actual time falls within the same ISO week as the +specified time `compareTo`. + +#### func SameExactWeekday + +```go +func SameExactWeekday(compareTo time.Time) types.BeMatcher +``` +SameExactWeekday succeeds if the weekday component of the actual time is equal +to the weekday component of the specified time `compareTo`. + +#### func SameHour + +```go +func SameHour(compareTo time.Time) types.BeMatcher +``` +SameHour checks if the .Hour() component of the actual time matches the .Hour() +component of the specified time `compareTo`. It only verifies the hour [0..59], +disregarding other components such as second, minute, day, etc. + +#### func SameMinute + +```go +func SameMinute(compareTo time.Time) types.BeMatcher +``` +SameMinute checks if the .Minute() component of the actual time matches the +.Minute() component of the specified time `compareTo`. It only verifies the +minute [0..59], disregarding other components such as second, hour, day, etc. + +#### func SameMonth + +```go +func SameMonth(compareTo time.Time) types.BeMatcher +``` +SameMonth checks if the .Month() component of the actual time matches the +.Month() component of the specified time `compareTo`. It only verifies the month +[1..12], disregarding the year. + +#### func SameOffset + +```go +func SameOffset(compareTo time.Time) types.BeMatcher +``` +SameOffset checks if actual time is the same timezone offset as specified time +`compareTo` Note: times can have different timezone names, but same offset, e.g. +America/New_York and Canada/Toronto + +#### func SameSecond + +```go +func SameSecond(compareTo time.Time) types.BeMatcher +``` +SameSecond checks if the .Second() component of the actual time matches the +.Second() component of the specified time `compareTo`. It only verifies the +second [0..59], disregarding other components such as minute, hour, day, etc. + +#### func SameTimezone + +```go +func SameTimezone(compareTo time.Time) types.BeMatcher +``` +SameTimezone checks if actual time is the same timezone as specified time +`compareTo` + +#### func SameWeek + +```go +func SameWeek(compareTo time.Time) types.BeMatcher +``` +SameWeek succeeds if the ISO week of the actual time are equal to the ISO week +and year components of the specified time `compareTo`. It only verifies the week +[1..53], disregarding of year. Note: use SameExactWeek to respect exact week of +exact year + +#### func SameYear + +```go +func SameYear(compareTo time.Time) types.BeMatcher +``` +SameYear succeeds if the year component of the actual time is equal to the year +component of the specified time `compareTo`. + +#### func SameYearDay + +```go +func SameYearDay(compareTo time.Time) types.BeMatcher +``` +SameYearDay checks if the .YearDay() component of the actual time matches the +.YearDay() component of the specified time `compareTo`. It only verifies the day +[1..365], disregarding the year. diff --git a/be_time/be_time_suite_test.go b/be_time/be_time_suite_test.go new file mode 100644 index 0000000..8ae1285 --- /dev/null +++ b/be_time/be_time_suite_test.go @@ -0,0 +1,13 @@ +package be_time_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBeTime(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "BeTime Suite") +} diff --git a/be_time/matchers_time.go b/be_time/matchers_time.go new file mode 100644 index 0000000..6efb419 --- /dev/null +++ b/be_time/matchers_time.go @@ -0,0 +1,341 @@ +// Package be_time provides Be matchers on time.Time +package be_time + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gcustom" + "time" +) + +// LaterThan succeeds if actual time is later than the specified time `compareTo`. +func LaterThan(compareTo time.Time) types.BeMatcher { + return Psi(gomega.BeTemporally(">", compareTo)) +} + +// LaterThanEqual succeeds if actual time is later than or equal to the specified time `compareTo`. +func LaterThanEqual(compareTo time.Time) types.BeMatcher { + return Psi(gomega.BeTemporally(">=", compareTo)) +} + +// EarlierThan succeeds if actual time is earlier than the specified time `compareTo`. +func EarlierThan(compareTo time.Time) types.BeMatcher { + return Psi(gomega.BeTemporally("<", compareTo)) +} + +// EarlierThanEqual succeeds if actual time is earlier than or equal to the specified time `compareTo`. +func EarlierThanEqual(compareTo time.Time) types.BeMatcher { + return Psi(gomega.BeTemporally("<=", compareTo)) +} + +// Eq succeeds if actual time is equal to the specified time `compareTo` +// with the precision of one nanosecond. +func Eq(compareTo time.Time) types.BeMatcher { + return Psi(gomega.BeTemporally("==", compareTo)) +} + +// Approx succeeds if actual time is approximately equal to the specified time `compareTo` +// within the given time duration threshold. +func Approx(compareTo time.Time, threshold time.Duration) types.BeMatcher { + return Psi(gomega.BeTemporally("~", compareTo, threshold)) +} + +// +// Atomic matching of time parts +// + +func atomicTimePartMatcher[T comparable](actualGetter func(t time.Time) T, compareTo T, customMessageArg ...string) types.BeMatcher { + message := fmt.Sprintf("%v", compareTo) + if len(customMessageArg) > 0 { + message = customMessageArg[0] + } + + return Psi(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + return actualGetter(cast.AsTime(actual)) == compareTo, nil + + // TODO: would be great if Expect part of the message contains reference value as well + // e.g. Expected : .. (TUESDAY) to be Friday + }, "be "+message) +} + +func Year(v int) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) int { return t.Year() }, v) +} +func Month(monthCompareTo time.Month) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) time.Month { return t.Month() }, monthCompareTo) +} +func Day(v int) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) int { return t.Day() }, v, fmt.Sprintf("%d day of month", v)) +} +func YearDay(v int) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) int { return t.YearDay() }, v, fmt.Sprintf("%d day of the year", v)) +} +func Weekday(v time.Weekday) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) time.Weekday { return t.Weekday() }, v) +} +func Unix(v int64) types.BeMatcher { + return atomicTimePartMatcher(func(t time.Time) int64 { return t.Unix() }, v, fmt.Sprintf("equal to %d Unix timestamp", v)) +} + +// TODO: more atomic matchers + +// +// --- Same Exact * --- +// + +// TODO: SameExact* matchers. Naming should note that it's 2 times comparison +// so probably it should be Same...With()) + +// sameExactDuration is an internal matcher that succeeds if +// actual time falls within the same X duration as the specified time `compareTo` +// It's considered to avoid duplication in implementation of public matchers `SameExact*` +func sameExactDuration(compareTo time.Time, duration time.Duration) types.BeMatcher { + return Psi(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + // Truncate both times to the given duration + truncatedCompareTo := compareTo.Truncate(duration) + truncatedActual := actualTime.Truncate(duration) + + // Compare truncated times + return truncatedCompareTo.Equal(truncatedActual), nil + // TODO: better message + }, fmt.Sprintf("be same as %s", duration)) +} + +// SameExactMilli succeeds if the actual time falls within the same millisecond as the specified time `compareTo`. +func SameExactMilli(compareTo time.Time) types.BeMatcher { + return sameExactDuration(compareTo, time.Millisecond) +} + +// SameExactSecond succeeds if the actual time falls within the same second as the specified time `compareTo`. +func SameExactSecond(compareTo time.Time) types.BeMatcher { + return sameExactDuration(compareTo, time.Second) +} + +// SameExactMinute succeeds if the actual time falls within the same minute as the specified time `compareTo`. +func SameExactMinute(compareTo time.Time) types.BeMatcher { + return sameExactDuration(compareTo, time.Minute) +} + +// SameExactHour succeeds if the actual time falls within the same hour as the specified time `compareTo`. +func SameExactHour(compareTo time.Time) types.BeMatcher { + return sameExactDuration(compareTo, time.Hour) +} + +// SameExactDay succeeds if the actual time falls within the same day as the specified time `compareTo`. +func SameExactDay(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + // Different is less than 24 * Hours and they are within the same day + return compareTo.Sub(actualTime).Abs() < 24*time.Hour && compareTo.Day() == actualTime.Day(), nil + })) +} + +// SameExactWeekday succeeds if the weekday component of the actual time is equal to the weekday component +// of the specified time `compareTo`. +func SameExactWeekday(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Weekday() == actualTime.Weekday(), nil + })) +} + +// SameExactWeek succeeds if the actual time falls within the same ISO week as the specified time `compareTo`. +func SameExactWeek(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + compareToYear, compareToWeek := compareTo.ISOWeek() + actualYear, actualWeek := actualTime.ISOWeek() + return compareToYear == actualYear && compareToWeek == actualWeek, nil + })) +} + +// SameExactMonth succeeds if the actual time falls within the same month as the specified time `compareTo`. +func SameExactMonth(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Year() == actualTime.Year() && compareTo.Month() == actualTime.Month(), nil + })) +} + +// +// --- Same * --- +// + +// SameSecond checks if the .Second() component of the actual time matches +// the .Second() component of the specified time `compareTo`. +// It only verifies the second [0..59], disregarding other components such as minute, hour, day, etc. +func SameSecond(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Second() == actualTime.Second(), nil + })) +} + +// SameMinute checks if the .Minute() component of the actual time matches +// the .Minute() component of the specified time `compareTo`. +// It only verifies the minute [0..59], disregarding other components such as second, hour, day, etc. +func SameMinute(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Minute() == actualTime.Minute(), nil + })) +} + +// SameHour checks if the .Hour() component of the actual time matches +// the .Hour() component of the specified time `compareTo`. +// It only verifies the hour [0..59], disregarding other components such as second, minute, day, etc. +func SameHour(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Hour() == actualTime.Hour(), nil + })) +} + +// SameDay checks if the .Day() component of the actual time matches +// the .Day() component of the specified time `compareTo`. +// It only verifies the day [1..30(31)], disregarding the month, year, etc. +func SameDay(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Day() == actualTime.Day(), nil + })) +} + +// SameYearDay checks if the .YearDay() component of the actual time matches +// the .YearDay() component of the specified time `compareTo`. +// It only verifies the day [1..365], disregarding the year. +func SameYearDay(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.YearDay() == actualTime.YearDay(), nil + })) +} + +// SameWeek succeeds if the ISO week of the actual time +// are equal to the ISO week and year components of the specified time `compareTo`. +// It only verifies the week [1..53], disregarding of year. +// Note: use SameExactWeek to respect exact week of exact year +func SameWeek(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + _, compareToWeek := compareTo.ISOWeek() + _, actualWeek := actualTime.ISOWeek() + return compareToWeek == actualWeek, nil + })) +} + +// SameMonth checks if the .Month() component of the actual time matches +// the .Month() component of the specified time `compareTo`. +// It only verifies the month [1..12], disregarding the year. +func SameMonth(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Month() == actualTime.Month(), nil + })) +} + +// SameYear succeeds if the year component of the actual time is equal to the year component +// of the specified time `compareTo`. +func SameYear(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Year() == actualTime.Year(), nil + })) +} + +// SameTimezone checks if actual time is the same timezone as specified time `compareTo` +func SameTimezone(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + return compareTo.Location().String() == actualTime.Location().String(), nil + })) +} + +// SameOffset checks if actual time is the same timezone offset as specified time `compareTo` +// Note: times can have different timezone names, but same offset, e.g. America/New_York and Canada/Toronto +func SameOffset(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + + _, offsetGiven := compareTo.Zone() + _, offsetActual := actualTime.Zone() + return offsetGiven == offsetActual, nil + })) +} + +// IsDST checks if actual time is DST +func IsDST(compareTo time.Time) types.BeMatcher { + return Psi(gcustom.MakeMatcher(func(actual any) (bool, error) { + if !cast.IsTime(actual) { + return false, fmt.Errorf("invalid time type") + } + actualTime := cast.AsTime(actual) + return actualTime.IsDST(), nil + })) +} diff --git a/be_time/matchers_time_test.go b/be_time/matchers_time_test.go new file mode 100644 index 0000000..7ea4032 --- /dev/null +++ b/be_time/matchers_time_test.go @@ -0,0 +1,133 @@ +package be_time_test + +import ( + "github.com/expectto/be/be_time" + "github.com/expectto/be/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "time" +) + +// TimeFormat used for tests is selected to be 1) allmighty 2) more readable +// It's a modification of time.RFC3339 +const TimeFormat = "2006-01-02T15:04:05.999999999Z" + +type TestCase struct { + GetMatcher func(time.Time) types.BeMatcher + Actual string + ActualTz string // because of Actual will always be a string with `Z` suffix, we'll need to convert it back to proper tz + Success bool +} + +func (tc *TestCase) SetTz(tz string) *TestCase { + tc.ActualTz = tz + return tc +} + +// TC is a short way of creating positive TestCase +func TC_OK(m func(time.Time) types.BeMatcher, actual string) *TestCase { + return &TestCase{Success: true, Actual: actual, GetMatcher: m} +} + +// TC_NOT_OK is a short way of creating negative TestCase +func TC_NOT_OK(m func(time.Time) types.BeMatcher, actual string) *TestCase { + return &TestCase{Success: false, Actual: actual, GetMatcher: m} +} + +// compareTo is the reference "compare-to" date for most of our test cases +var compareTo = time.Date(2024, time.February, 02, 15, 30, 0, 0, time.UTC) +var ( + locationEST, _ = time.LoadLocation("EST") +) + +var _ = DescribeTable("Be Time: Matching", func(tc *TestCase) { + var loc = time.UTC + if tc.ActualTz != "" { + var err error + loc, err = time.LoadLocation(tc.ActualTz) + Expect(err).Should(Succeed(), "Invalid Test TZ given") + } + actual, err := time.ParseInLocation(TimeFormat, tc.Actual, loc) + Expect(err).To(Succeed(), "Invalid Test given") + + success, err := tc.GetMatcher(compareTo).Match(actual) + Expect(err).ToNot(HaveOccurred()) + + Expect(success).To(Equal(tc.Success)) +}, + // EarlierThan + Entry("Earlier than: actual is earlier", TC_OK(be_time.EarlierThan, "2024-01-31T00:00:00.0Z")), + Entry("Earlier than: actual is the same", TC_NOT_OK(be_time.EarlierThan, compareTo.Format(TimeFormat))), + Entry("Earlier than: actual is later", TC_NOT_OK(be_time.EarlierThan, "2024-02-25T00:00:00.0Z")), + + // EarlierThanEqual + Entry("Earlier than equal: actual is earlier", TC_OK(be_time.EarlierThanEqual, "2024-01-31T00:00:00.0Z")), + Entry("Earlier than equal: actual is the same", TC_OK(be_time.EarlierThanEqual, compareTo.Format(TimeFormat))), + Entry("Earlier than equal: actual is later", TC_NOT_OK(be_time.EarlierThanEqual, "2024-02-25T00:00:00.0Z")), + + // LaterThan + Entry("Later than: actual is later", TC_OK(be_time.LaterThan, "2024-02-25T00:00:00.0Z")), + Entry("Later than: actual is the same", TC_NOT_OK(be_time.LaterThan, compareTo.Format(TimeFormat))), + Entry("Later than: actual is earlier", TC_NOT_OK(be_time.LaterThan, "2024-01-31T00:00:00.0Z")), + + // LaterThanEqual + Entry("Later than equal: actual is later", TC_OK(be_time.LaterThanEqual, "2024-02-25T00:00:00.0Z")), + Entry("Later than equal: actual is the same", TC_OK(be_time.LaterThanEqual, compareTo.Format(TimeFormat))), + Entry("Later than equal: actual is earlier", TC_NOT_OK(be_time.LaterThanEqual, "2024-01-31T00:00:00.0Z")), + + // Eq + Entry("Eq: actual is the same", TC_OK(be_time.Eq, compareTo.Format(TimeFormat))), + Entry("Eq: actual differs by +5ns", TC_NOT_OK(be_time.Eq, compareTo.Add(5*time.Nanosecond).Format(TimeFormat))), + Entry("Eq: actual differs by +1ns", TC_NOT_OK(be_time.Eq, compareTo.Add(+time.Nanosecond).Format(TimeFormat))), + Entry("Eq: actual differs by -5ns", TC_NOT_OK(be_time.Eq, compareTo.Add(-5*time.Nanosecond).Format(TimeFormat))), + Entry("Eq: actual differs by -1ns", TC_NOT_OK(be_time.Eq, compareTo.Add(-time.Nanosecond).Format(TimeFormat))), + + // SameExactMilli: here we KNOW that compareTO is 15:30:00.000 + Entry("Same Exact Milli: actual is the same", TC_OK(be_time.SameExactMilli, compareTo.Format(TimeFormat))), + Entry("Same Exact Milli: actual differs by 1ns (within 1ms)", TC_OK(be_time.SameExactMilli, compareTo.Add(1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Milli: actual differs by 1ns (outside 1ms)", TC_NOT_OK(be_time.SameExactMilli, compareTo.Add(-1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Milli: actual differs by 999µs (within 1ms)", TC_OK(be_time.SameExactMilli, compareTo.Add(999*time.Microsecond).Format(TimeFormat))), + Entry("Same Exact Milli: actual differs by 999µs (outside 1ms)", TC_NOT_OK(be_time.SameExactMilli, compareTo.Add(-999*time.Microsecond).Format(TimeFormat))), + Entry("Same Exact Milli: actual differs by 5ms", TC_NOT_OK(be_time.SameExactMilli, compareTo.Add(5*time.Millisecond).Format(TimeFormat))), + + // SameExactSecond: here we KNOW that compareTO is 15:30:00.000 + Entry("Same Exact Second: actual is the same", TC_OK(be_time.SameExactSecond, compareTo.Format(TimeFormat))), + Entry("Same Exact Second: actual differs by 1ns (within 1s)", TC_OK(be_time.SameExactSecond, compareTo.Add(1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Second: actual differs by 1ns (outside 1s)", TC_NOT_OK(be_time.SameExactSecond, compareTo.Add(-1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Second: actual differs by 999ms (within 1s)", TC_OK(be_time.SameExactSecond, compareTo.Add(999*time.Millisecond).Format(TimeFormat))), + Entry("Same Exact Second: actual differs by 999ms (outside 1s)", TC_NOT_OK(be_time.SameExactSecond, compareTo.Add(-999*time.Millisecond).Format(TimeFormat))), + Entry("Same Exact Second: actual differs by 5s", TC_NOT_OK(be_time.SameExactSecond, compareTo.Add(5*time.Second).Format(TimeFormat))), + + // SameExactMinute: here we KNOW that compareTO is 15:30:00.000 + Entry("Same Exact Minute: actual is the same", TC_OK(be_time.SameExactMinute, compareTo.Format(TimeFormat))), + Entry("Same Exact Minute: actual differs by 1ns (within 1m)", TC_OK(be_time.SameExactMinute, compareTo.Add(1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Minute: actual differs by 1ns (outside 1m)", TC_NOT_OK(be_time.SameExactMinute, compareTo.Add(-1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Minute: actual differs by 59s (within 1m)", TC_OK(be_time.SameExactMinute, compareTo.Add(59*time.Second).Format(TimeFormat))), + Entry("Same Exact Minute: actual differs by 59s (outside 1m)", TC_NOT_OK(be_time.SameExactMinute, compareTo.Add(-59*time.Second).Format(TimeFormat))), + Entry("Same Exact Minute: actual differs by 61s", TC_NOT_OK(be_time.SameExactMinute, compareTo.Add(61*time.Second).Format(TimeFormat))), + + // SameExactHour: here we KNOW that compareTO is 15:30:00.000 + Entry("Same Exact Hour: actual is the same", TC_OK(be_time.SameExactHour, compareTo.Format(TimeFormat))), + Entry("Same Exact Hour: actual differs by 1ns (within 1h)", TC_OK(be_time.SameExactHour, compareTo.Add(1*time.Nanosecond).Format(TimeFormat))), + Entry("Same Exact Hour: actual differs by 29m (within 1h)", TC_OK(be_time.SameExactHour, compareTo.Add(29*time.Minute).Format(TimeFormat))), + Entry("Same Exact Hour: actual differs by 30m (outside 1h)", TC_NOT_OK(be_time.SameExactHour, compareTo.Add(30*time.Minute).Format(TimeFormat))), + Entry("Same Exact Hour: actual differs by 61m", TC_NOT_OK(be_time.SameExactHour, compareTo.Add(61*time.Minute).Format(TimeFormat))), + + // SameTimezone: here we KNOW that compareTO is in UTC timezone + Entry("Same Timezone: actual is in UTC", TC_OK(be_time.SameTimezone, compareTo.Format(TimeFormat))), + Entry("Same Timezone: actual is in EST", TC_NOT_OK(be_time.SameTimezone, compareTo.In(locationEST).Format(TimeFormat)).SetTz("EST")), + Entry("Same Timezone: actual time is different but in the same timezone", TC_OK(be_time.SameTimezone, compareTo.Add(5*time.Minute).Format(TimeFormat))), + Entry("Same Timezone: actual time is different and in different timezone", TC_NOT_OK(be_time.SameTimezone, compareTo.In(locationEST).Format(TimeFormat)).SetTz("EST")), + + // TODO: other matchers +) + +var _ = Context("Time atomic matchers", func() { + It("should pass on atomic matchers", func() { + Expect(compareTo).To(be_time.Year(2024)) + Expect(compareTo).To(be_time.Month(time.February)) + Expect(compareTo).To(be_time.YearDay(33)) + Expect(compareTo).To(be_time.Weekday(time.Friday)) + Expect(compareTo).To(be_time.Day(2)) + }) +}) diff --git a/be_url/README.md b/be_url/README.md new file mode 100644 index 0000000..11007d9 --- /dev/null +++ b/be_url/README.md @@ -0,0 +1,162 @@ +# be_url +-- + import "github.com/expectto/be/be_url" + +Package be_url provides Be matchers on url.URL + +## Usage + +```go +var TransformSchemelessUrlFromString = func(rawURL string) (*url.URL, error) { + result, err := url.Parse(rawURL) + if err == nil && result.Scheme == "" { + result, err = url.Parse("http://" + rawURL) + if err == nil { + result.Scheme = "" + } + } + return result, err +} +``` +TransformSchemelessUrlFromString returns string->*url.Url transform It allows +string to be a scheme-less url + +```go +var TransformUrlFromString = url.Parse +``` +TransformUrlFromString returns string->*url.Url transform + +#### func HavingHost + +```go +func HavingHost(args ...any) types.BeMatcher +``` +HavingHost succeeds if the actual value is a *url.URL and its Host matches the +provided one (via direct value or matchers) + +#### func HavingHostname + +```go +func HavingHostname(args ...any) types.BeMatcher +``` +HavingHostname succeeds if the actual value is a *url.URL and its Hostname +matches the provided one (via direct value or matchers) + +#### func HavingMultipleSearchParam + +```go +func HavingMultipleSearchParam(searchParamName string, args ...any) types.BeMatcher +``` +HavingMultipleSearchParam succeeds if the actual value is a *url.URL and its +specified search parameter (all its values via slice) matches the provided +arguments. + +#### func HavingPassword + +```go +func HavingPassword(args ...any) types.BeMatcher +``` +HavingPassword succeeds if the actual value is a *url.URL and its Password +matches the provided one. + +#### func HavingPath + +```go +func HavingPath(args ...any) types.BeMatcher +``` +HavingPath succeeds if the actual value is a *url.URL and its Path matches the +given one. + +#### func HavingPort + +```go +func HavingPort(args ...any) types.BeMatcher +``` +HavingPort succeeds if the actual value is a *url.URL and its Port matches the +provided one. + +#### func HavingRawQuery + +```go +func HavingRawQuery(args ...any) types.BeMatcher +``` +HavingRawQuery succeeds if the actual value is a *url.URL and its RawQuery +matches the given one. + +#### func HavingScheme + +```go +func HavingScheme(args ...any) types.BeMatcher +``` +HavingScheme succeeds if the actual value is a *url.URL and its Scheme matches +the provided one (via direct value or matchers) + +#### func HavingSearchParam + +```go +func HavingSearchParam(searchParamName string, args ...any) types.BeMatcher +``` +HavingSearchParam succeeds if the actual value is a *url.URL and its specified +search parameter matches the provided arguments. + +#### func HavingUserinfo + +```go +func HavingUserinfo(args ...any) types.BeMatcher +``` +HavingUserinfo succeeds if the actual value is a *url.URL and its User.String() +matches the provided one. + +#### func HavingUsername + +```go +func HavingUsername(args ...any) types.BeMatcher +``` +HavingUsername succeeds if the actual value is a *url.URL and its Username +matches the provided one. + +#### func NotHavingPort + +```go +func NotHavingPort(args ...any) types.BeMatcher +``` +NotHavingPort succeeds if the actual value is a *url.URL and its Port does not +match the given one. Example: `Expect(u).To(NotHavingPort())` matches port-less +url + +#### func NotHavingScheme + +```go +func NotHavingScheme(args ...any) types.BeMatcher +``` +NotHavingScheme succeeds if the actual value is a *url.URL and its Scheme +negatively matches given value Example: `Expect(u).To(NotHavingScheme())` +matches url without a scheme + +#### func URL + +```go +func URL(args ...any) types.BeMatcher +``` +URL matches actual value to be a valid URL corresponding to given inputs +Possible inputs: 1. Nil args -> so actual value MUST be any valid *url.URL 2. +Single arg . Actual value MUST be a *url.URL, whose .String() compared +against args[0] 3. Single arg <*url.Url>. Actual value MUST be a *url.URL, whose +.String() compared against args[0].String() 4. List of Omega/Gomock/Psi +matchers, that are applied to *url.URL object + + - TransformUrlFromString() transform can be given as first argument, so string->*url.URL transform is applied + +#### func WithHttp + +```go +func WithHttp() types.BeMatcher +``` +WithHttp succeeds if the actual value is a *url.URL and its scheme is "http". + +#### func WithHttps + +```go +func WithHttps() types.BeMatcher +``` +WithHttps succeeds if the actual value is a *url.URL and its scheme is "https". diff --git a/be_url/matchers_url.go b/be_url/matchers_url.go new file mode 100644 index 0000000..1939e17 --- /dev/null +++ b/be_url/matchers_url.go @@ -0,0 +1,180 @@ +// Package be_url provides Be matchers on url.URL +package be_url + +import ( + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "net/url" +) + +// TransformUrlFromString returns string->*url.Url transform +var TransformUrlFromString = url.Parse + +// TransformSchemelessUrlFromString returns string->*url.Url transform +// It allows string to be a scheme-less url +var TransformSchemelessUrlFromString = func(rawURL string) (*url.URL, error) { + result, err := url.Parse(rawURL) + if err == nil && result.Scheme == "" { + result, err = url.Parse("http://" + rawURL) + if err == nil { + result.Scheme = "" + } + } + return result, err +} + +// URL matches actual value to be a valid URL corresponding to given inputs +// Possible inputs: +// 1. Nil args -> so actual value MUST be any valid *url.URL +// 2. Single arg . Actual value MUST be a *url.URL, whose .String() compared against args[0] +// 3. Single arg <*url.Url>. Actual value MUST be a *url.URL, whose .String() compared against args[0].String() +// 4. List of Omega/Gomock/Psi matchers, that are applied to *url.URL object +// - TransformUrlFromString() transform can be given as first argument, so string->*url.URL transform is applied +func URL(args ...any) types.BeMatcher { + if len(args) == 0 { + return psi_matchers.NewUrlFieldMatcher("", "", nil) + } + + if cast.IsString(args[0], cast.AllowCustomTypes(), cast.AllowPointers()) { + if len(args) != 1 { + panic("string arg must be a single arg") + } + + // match given string to whole url + return psi_matchers.NewUrlFieldMatcher("Url", "", func(u *url.URL) any { + return u.String() + }, gomega.Equal(args[0])) + } + + return Psi(args...) +} + +// HavingHost succeeds if the actual value is a *url.URL and its Host matches the provided one (via direct value or matchers) +func HavingHost(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingHost", "host", + func(u *url.URL) any { return u.Host }, + args..., + ) +} + +// HavingHostname succeeds if the actual value is a *url.URL and its Hostname matches the provided one (via direct value or matchers) +func HavingHostname(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingHostname", "hostname", + func(u *url.URL) any { return u.Hostname() }, + args..., + ) +} + +// HavingScheme succeeds if the actual value is a *url.URL and its Scheme matches the provided one (via direct value or matchers) +func HavingScheme(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingScheme", "scheme", + func(u *url.URL) any { return u.Scheme }, + args..., + ) +} + +// NotHavingScheme succeeds if the actual value is a *url.URL and its Scheme negatively matches given value +// Example: `Expect(u).To(NotHavingScheme())` matches url without a scheme +func NotHavingScheme(args ...any) types.BeMatcher { + return Psi(gomega.Not(HavingScheme(args...))) +} + +// WithHttps succeeds if the actual value is a *url.URL and its scheme is "https". +func WithHttps() types.BeMatcher { + return HavingScheme("https") +} + +// WithHttp succeeds if the actual value is a *url.URL and its scheme is "http". +func WithHttp() types.BeMatcher { + return HavingScheme("http") +} + +// HavingPort succeeds if the actual value is a *url.URL and its Port matches the provided one. +func HavingPort(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingPort", "port", + func(u *url.URL) any { return u.Port() }, + args..., + ) +} + +// NotHavingPort succeeds if the actual value is a *url.URL and its Port does not match the given one. +// Example: `Expect(u).To(NotHavingPort())` matches port-less url +func NotHavingPort(args ...any) types.BeMatcher { + return Psi(gomega.Not(HavingPort(args...))) +} + +// HavingPath succeeds if the actual value is a *url.URL and its Path matches the given one. +func HavingPath(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingPath", "path", + func(u *url.URL) any { return u.Path }, + args..., + ) +} + +// HavingRawQuery succeeds if the actual value is a *url.URL and its RawQuery matches the given one. +func HavingRawQuery(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingRawQuery", "rawQuery", + func(u *url.URL) any { return u.RawQuery }, + args..., + ) +} + +// HavingSearchParam succeeds if the actual value is a *url.URL and +// its specified search parameter matches the provided arguments. +func HavingSearchParam(searchParamName string, args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingSearchParam", "searchParam", + func(u *url.URL) any { return u.Query().Get(searchParamName) }, + args..., + ) +} + +// HavingMultipleSearchParam succeeds if the actual value is a *url.URL and +// its specified search parameter (all its values via slice) matches the provided arguments. +func HavingMultipleSearchParam(searchParamName string, args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingMultipleSearchParam", "multipleSearchParam", + func(u *url.URL) any { return u.Query()[searchParamName] }, + args..., + ) +} + +// HavingUsername succeeds if the actual value is a *url.URL and its Username matches the provided one. +func HavingUsername(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingUsername", "username", + func(u *url.URL) any { return u.User.Username() }, + args..., + ) +} + +// HavingUserinfo succeeds if the actual value is a *url.URL and its User.String() matches the provided one. +func HavingUserinfo(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingUserinfo", "userinfo", + func(u *url.URL) any { return u.User.String() }, + args..., + ) +} + +// HavingPassword succeeds if the actual value is a *url.URL and its Password matches the provided one. +func HavingPassword(args ...any) types.BeMatcher { + return psi_matchers.NewUrlFieldMatcher( + "HavingPassword", "password", + func(u *url.URL) any { p, _ := u.User.Password(); return p }, + args..., + ) +} + +// todo: RawPath/EscapedPath matchers +// todo:"HavingDistinctSearchParam -> ensuring it has only a single search param and match it( fail if not) +// Difference is that HavingSearchParam will not fail if given param is not single (and onl will match the first one) diff --git a/core-be-matchers.md b/core-be-matchers.md new file mode 100644 index 0000000..4c6cc0e --- /dev/null +++ b/core-be-matchers.md @@ -0,0 +1,105 @@ +# be +-- + import "github.com/expectto/be" + + +## Usage + +```go +var Ctx = be_ctx.Ctx +``` +Ctx is an alias for be_ctx.Ctx + +```go +var HttpRequest = be_http.Request +``` +HttpRequest is an alias for be_http.Request matcher + +```go +var JwtToken = be_jwt.Token +``` +JwtToken is an alias for be_jwt.Token matcher + +```go +var StringAsTemplate = be_string.MatchTemplate +``` +StringAsTemplate is an alias for be_string.MatchTemplate matcher + +```go +var URL = be_url.URL +``` +URL is an alias for be_url.URL matcher + +#### func All + +```go +func All(ms ...any) types.BeMatcher +``` +All is like gomega.And() + +#### func Always + +```go +func Always() types.BeMatcher +``` +Always does always match + +#### func Any + +```go +func Any(ms ...any) types.BeMatcher +``` +Any is like gomega.Or() + +#### func Dive + +```go +func Dive(matcher any) types.BeMatcher +``` +Dive applies the given matcher to each (every) element of the slice. Note: Dive +is very close to gomega.HaveEach + +#### func DiveAny + +```go +func DiveAny(matcher any) types.BeMatcher +``` +DiveAny applies the given matcher to each element and succeeds in case if it +succeeds at least at one item + +#### func DiveFirst + +```go +func DiveFirst(matcher any) types.BeMatcher +``` +DiveFirst applies the given matcher to the first element of the given slice + +#### func Eq + +```go +func Eq(expected any) types.BeMatcher +``` +Eq is like gomega.Equal() + +#### func HaveLength + +```go +func HaveLength(args ...any) types.BeMatcher +``` +HaveLength is like gomega.HaveLen() HaveLength succeeds if the actual value has +a length that matches the provided conditions. It accepts either a count value +or one or more Gomega matchers to specify the desired length conditions. + +#### func Never + +```go +func Never(err error) types.BeMatcher +``` +Never does never succeed (does always fail) + +#### func Not + +```go +func Not(expected any) types.BeMatcher +``` +Not is like gomega.Not() diff --git a/examples/examples_be_ctx_test.go b/examples/examples_be_ctx_test.go new file mode 100644 index 0000000..e2a6f54 --- /dev/null +++ b/examples/examples_be_ctx_test.go @@ -0,0 +1,31 @@ +package examples + +import ( + "context" + "github.com/expectto/be/be_ctx" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MatchersCtx", func() { + ctx := context.Background() + + It("should match a ctx", func() { + Expect(ctx).To(be_ctx.Ctx()) + }) + + It("should match a ctx with a value", func() { + type CtxKey string + ctx := context.WithValue(ctx, CtxKey("foo"), "bar") + // just by key + Expect(ctx).To(be_ctx.CtxWithValue(CtxKey("foo"))) + // key + value directly + Expect(ctx).To(be_ctx.CtxWithValue(CtxKey("foo"), "bar")) + // key + value via matcher + Expect(ctx).To(be_ctx.CtxWithValue(CtxKey("foo"), HavePrefix("ba"))) + }) + + It("should not match when a string given instead of ctx", func() { + Expect("not a ctx but a string").NotTo(be_ctx.Ctx()) + }) +}) diff --git a/examples/examples_be_http_test.go b/examples/examples_be_http_test.go new file mode 100644 index 0000000..ea1362f --- /dev/null +++ b/examples/examples_be_http_test.go @@ -0,0 +1,118 @@ +package examples + +import ( + "bytes" + "github.com/expectto/be" + "github.com/expectto/be/be_http" + "github.com/expectto/be/be_json" + "github.com/expectto/be/be_jwt" + "github.com/expectto/be/be_reflected" + "github.com/expectto/be/be_string" + "github.com/expectto/be/be_url" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "net/http" +) + +var _ = Describe("matchers_http", func() { + It("should match an HTTP request", func() { + req, _ := http.NewRequest("POST", "https://example.com/path?foo=bar", bytes.NewReader([]byte("hello world"))) + req.Header.Set("X-Something", "something") + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.EpM5XBzTJZ4J8AfoJEcJrjth8pfH28LWdjLo90sYb9g") + + Expect(req).To(be.HttpRequest( + be_http.HavingURL(be.URL( + be_url.HavingHost("example.com"), + be_url.WithHttps(), + be_url.HavingPath("/path"), + be_url.HavingSearchParam("foo", "bar"), + )), + be_http.POST(), + be_http.HavingHeader( + "X-Something", "something", + ), + be_http.HavingHeader( + "Authorization", + be.StringAsTemplate("Bearer {{jwt}}", + be_string.V("jwt", + be.JwtToken( + be_jwt.TransformSignedJwtFromString("my-secret"), + be_jwt.Valid(), + be_jwt.HavingClaim("name", "John Doe"), + ), + ), + ), + ), + )) + }) + + It("should check a request", func() { + // 1. Let's say we test a function that returns a *http.Request + // req, err := SomeFunc() + req, _ := http.NewRequest(http.MethodPost, + "https://example.com/path?status=active&v=1&q=Hello+World", + bytes.NewReader([]byte(`{ + "hello": "world", + "n": 3.5, + "details": [{"key":"foo"},{"key":"bar"}], + "ids":["id1", "id2", "id3"] + }`)), + ) + req.Header.Set("X-Custom", "Hey-There") + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") + + // 2. Let's match everything about the request + Expect(req).To(be_http.Request( + // 2.1. Match the URL + be_http.HavingURL(be_url.URL( + be_url.WithHttps(), + be_url.HavingHost("example.com"), + be_url.HavingPath("/path"), + be_url.HavingSearchParam("status", "active"), + be_url.HavingSearchParam("v", be_reflected.AsNumericString()), // any number + be_url.HavingSearchParam("q", "Hello World"), + )), + + be_http.HavingMethod("POST"), + + // 2.2. Match the body + be_http.HavingBody( + be_json.Matcher( + be_json.JsonAsReader, + be_json.HaveKeyValue("hello", "world"), + // TODO: AsInteger should work here, but it's not (as from payload via string, it's float) + be_json.HaveKeyValue("n", be_reflected.AsFloat()), // any int number + // TODO: fix me + //be_json.HaveKeyValue("ids", be_reflected.AsSliceOf[string]), + be_json.HaveKeyValue("details", And( + be_reflected.AsObjects(), + be.HaveLength(2), + ContainElements( + be_json.HaveKeyValue("key", "foo"), + be_json.HaveKeyValue("key", "bar"), + ), + )), + ), + ), + + // 2.3. Matching the headers + + be_http.HavingHeader("X-Custom", "Hey-There"), + be_http.HavingHeader( + "Authorization", + be_string.MatchTemplate("Bearer {{jwt}}", + be_string.V("jwt", + be_jwt.Token( + be_jwt.TransformJwtFromString, + be_jwt.HavingClaim("name", "John Doe"), + // TODO fixme + // should work: be_jwt.SignedVia("my-secret") + ), + ), + ), + ), + + // todo: add example with Time in header, so we can test be_time + )) + }) +}) diff --git a/examples/examples_be_jwt_test.go b/examples/examples_be_jwt_test.go new file mode 100644 index 0000000..a7226e9 --- /dev/null +++ b/examples/examples_be_jwt_test.go @@ -0,0 +1,110 @@ +package examples + +import ( + "github.com/expectto/be/be_jwt" + "github.com/golang-jwt/jwt/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Examples on Matching JWT", func() { + // Here's A JWT signed with secret="my-secret" + // with payload: {"sub":"1","name":"John Doe"} + const tokenStr = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6IkpvaG4gRG9lIn0.o6m3ELBBXXiveSSfK-hdxdlbKoB3UsktDhqlt28etWk" + + // Parsed token + var token *jwt.Token + BeforeEach(func() { + var err error + token, err = jwt.Parse(tokenStr, func(_ *jwt.Token) (any, error) { + return []byte("my-secret"), nil + }) + Expect(err).To(Succeed()) + }) + + Context("parsed token as actual value", func() { + + It("should expect parsed token to be a token", func() { + Expect(token).To(be_jwt.Token()) + }) + + It("should expect parsed token to be the token", func() { + Expect(token).To(be_jwt.Token(tokenStr)) + }) + + It("should expect parsed token to be a valid token", func() { + Expect(token).To(be_jwt.Token(be_jwt.Valid())) + // or simply using just Valid() directly: + Expect(token).To(be_jwt.Valid()) + }) + + It("should expect parsed token to be the token with specified details", func() { + Expect(token).To(be_jwt.Token( + be_jwt.Valid(), + be_jwt.HavingClaims( + HaveKeyWithValue("sub", "1"), + HaveKeyWithValue("name", "John Doe"), + ), + be_jwt.HavingMethodAlg("HS256"), + be_jwt.SignedVia("my-secret"), + )) + }) + }) + + Context("String token as actual value", func() { + + It("should not expect a string token to be a jwt token (without transforming)", func() { + Expect(tokenStr).NotTo(be_jwt.Token()) + }) + Context("with unsigned transform", func() { + // Transform is required as first argument + + It("should expect a string token to be a jwt token via transform", func() { + Expect(tokenStr).To(be_jwt.Token(be_jwt.TransformJwtFromString)) + }) + + It("should not expect a string token to be the jwt token via non-signed transform", func() { + Expect(tokenStr).NotTo(be_jwt.Token(be_jwt.TransformJwtFromString, token)) + }) + + It("should expect a string token to be the jwt token via signed transform", func() { + Expect(tokenStr).To(be_jwt.Token(be_jwt.TransformSignedJwtFromString("my-secret"), token)) + }) + + It("should not expect a string token to be a valid token via unsigned transform", func() { + Expect(tokenStr).To(be_jwt.Token( + be_jwt.TransformJwtFromString, + Not(be_jwt.Valid()), + )) + }) + + It("should expect string token to be a jwt token with given details matched", func() { + Expect(tokenStr).To(be_jwt.Token( + be_jwt.TransformJwtFromString, + be_jwt.HavingClaims( + HaveKeyWithValue("sub", "1"), + HaveKeyWithValue("name", "John Doe"), + ), + be_jwt.HavingMethodAlg("HS256"), + be_jwt.SignedVia("my-secret"), + )) + }) + }) + + Context("signed transform", func() { + It("should expect a string token to be valid via signed transform with proper secret", func() { + Expect(tokenStr).To(be_jwt.Token( + be_jwt.TransformSignedJwtFromString("my-secret"), + be_jwt.Valid(), + )) + }) + + It("should not expect a string token to be a token via invalidly signed transform", func() { + Expect(tokenStr).NotTo(be_jwt.Token( + be_jwt.TransformSignedJwtFromString("invalid-secret"), + )) + }) + }) + }) + +}) diff --git a/examples/examples_be_strings_test.go b/examples/examples_be_strings_test.go new file mode 100644 index 0000000..44f4d96 --- /dev/null +++ b/examples/examples_be_strings_test.go @@ -0,0 +1,89 @@ +package examples + +import ( + "github.com/expectto/be" + "github.com/expectto/be/be_string" + . "github.com/expectto/be/options" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Showcase for MatchersString", func() { + It("should correctly match non-empty string", func() { + Expect("Hello").To(be_string.NonEmptyString()) + Expect("").NotTo(be_string.NonEmptyString()) + }) + + It("should correctly match empty string", func() { + Expect("").To(be_string.EmptyString()) + Expect("Hello").NotTo(be_string.EmptyString()) + }) + + It("should correctly match strings with only alphabets", func() { + Expect("abcXYZ").To(be_string.Only(Alpha)) + Expect("123abc").NotTo(be_string.Only(Alpha)) + }) + + It("should correctly match strings with only integer numeric values", func() { + Expect("123").To(be_string.Only(Numeric)) + Expect("abc123").NotTo(be_string.Only(Numeric)) + Expect("125.0").NotTo(be_string.Only(Numeric)) + }) + + It("should correctly match strings with only float numeric values", func() { + Expect("123.0").NotTo(be_string.Only(Numeric)) + Expect("123").To(be_string.Float()) // float is a numeric as well + Expect(".5").NotTo(be_string.Only(Numeric)) // leading zero is ok to be ommited + }) + + It("should correctly match strings with alphanumeric values", func() { + Expect("abc123").To(be_string.Only(Alpha | Numeric)) + Expect("!@#").NotTo(be_string.Only(Alpha | Numeric)) + }) + + It("should correctly match strings with title case", func() { + Expect("Hello World").To(be_string.Titled()) + Expect("hello world").NotTo(be_string.Titled()) + }) + + It("should correctly match strings with lowercase only", func() { + Expect("hello").To(be_string.LowerCaseOnly()) + Expect("Hello").NotTo(be_string.LowerCaseOnly()) + }) + + It("should correctly match strings using wildcard pattern", func() { + Expect("abc123").To(be_string.MatchWildcard("abc*")) + Expect("xyz").NotTo(be_string.MatchWildcard("abc*")) + }) + + It("should correctly match valid email addresses", func() { + Expect("user@example.com").To(be_string.ValidEmail()) + Expect("invalid-email").NotTo(be_string.ValidEmail()) + }) + + Context("MatchTemplate", func() { + It("should correctly match strings using a template", func() { + Expect("Hello John! Your number is 42. Goodbye John.").To( + be_string.MatchTemplate( + "Hello {{Name}}! Your number is {{Number}}. Goodbye {{Name}}.", + be_string.V("Name", "John"), + be_string.V("Number", be_string.Only(Numeric)), + ), + ) + Expect("Invalid template").NotTo(be_string.MatchTemplate("Hello {{Name}}. Goodbye {{Name}}.")) + }) + + It("should perform basic string & template matching", func() { + Expect("Hello Jack! Your email is ask@example.com. Bye Jack").To( + be_string.MatchTemplate( + `Hello {{User}}! Your email is {{Email}}. Bye {{User}}`, + + // Inside input message we should have either Jack or Jill + be_string.V("User", be.Any("Jack", "Jill")), + // any valid email with @example.com suffix + be_string.V("Email", be.All(be_string.ValidEmail(), HaveSuffix("@example.com"))), + ), + ) + }) + }) +}) diff --git a/examples/examples_be_url_test.go b/examples/examples_be_url_test.go new file mode 100644 index 0000000..dbdbb3a --- /dev/null +++ b/examples/examples_be_url_test.go @@ -0,0 +1,85 @@ +package examples_test + +import ( + "github.com/expectto/be/be_json" + "github.com/expectto/be/be_reflected" + "github.com/expectto/be/be_url" + "github.com/expectto/be/internal/testing/mocks" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + "net/url" +) + +var _ = Describe("Examples on matching URL", func() { + Context("*url.URL as a result of function being tested", func() { + + It("should match against all parts of *url.URL", func() { + u, err := url.Parse(`https://example.com/path/to/?foo=bar_123&v=1&payload={"hello":"world"}`) + Expect(err).Should(Succeed()) + + Expect(u).To(be_url.URL( + be_url.WithHttps(), + be_url.NotHavingPort(), + be_url.HavingHostname("example.com"), + be_url.HavingPath("/path/to/"), + be_url.HavingRawQuery(HavePrefix("foo=bar_123&v=1")), + be_url.HavingSearchParam("foo", ContainSubstring("bar_")), + be_url.HavingSearchParam("v", be_reflected.AsNumericString()), + be_url.HavingSearchParam("payload", be_json.Matcher()), + )) + }) + + It("should simply match separate url args from *url.URL", func() { + u, err := url.Parse("https://example.com/?foo=bar&v=1") + Expect(err).Should(Succeed()) + + Expect(u).To(be_url.HavingSearchParam("foo")) + }) + + It("should ensure a given url is a valid *urlURL", func() { + Expect("http://example.com/foo/bar").To(be_url.URL( + be_url.TransformUrlFromString, + be_url.HavingHostname("example.com"), + be_url.NotHavingPort(), + be_url.HavingPath("/foo/bar"), + Not(be_url.HavingRawQuery()), + )) + }) + + It("should match url parts even without a scheme given", func() { + Expect("example.com/foo/bar?v=1").To(be_url.URL( + be_url.TransformSchemelessUrlFromString, + be_url.HavingHostname("example.com"), + be_url.NotHavingPort(), + be_url.NotHavingScheme(), + be_url.HavingPath("/foo/bar"), + be_url.HavingSearchParam("v", be_reflected.AsNumericString()), + )) + }) + }) + + Context("*url.URL being used as an argument: via gomock", func() { + var ctrl *gomock.Controller + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + }) + AfterEach(func() { + ctrl.Finish() + }) + + It("should match given url to the urler", func() { + urler := mocks.NewMockUrler(ctrl) + urler.EXPECT().SetUrl( + be_url.URL( + be_url.HavingHostname("example.com"), + be_url.HavingScheme("http"), + be_url.HavingPath("/foo/bar"), + ), + ) + + theUrl, _ := url.Parse("http://example.com/foo/bar") + urler.SetUrl(theUrl) + }) + }) +}) diff --git a/examples/examples_suite_test.go b/examples/examples_suite_test.go new file mode 100644 index 0000000..4650af0 --- /dev/null +++ b/examples/examples_suite_test.go @@ -0,0 +1,13 @@ +package examples_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestExamples(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Examples Suite") +} diff --git a/generate-docs.sh b/generate-docs.sh new file mode 100755 index 0000000..dd904ab --- /dev/null +++ b/generate-docs.sh @@ -0,0 +1,16 @@ +#!/bin/bash + + +# I'm not satisfied with how godocdown generates READMEs by default +# but i'm ok with it for now. To be improved later + +godocdown be_ctx > be_ctx/README.md +godocdown be_http > be_http/README.md +godocdown be_json > be_json/README.md +godocdown be_jwt > be_jwt/README.md +godocdown be_math > be_math/README.md +godocdown be_reflected > be_reflected/README.md +godocdown be_string > be_string/README.md +godocdown be_time > be_time/README.md +godocdown be_url > be_url/README.md +godocdown . > core-be-matchers.md \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ad2ba7 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/expectto/be + +go 1.22 + +require ( + github.com/IGLOU-EU/go-wildcard v1.0.3 // latest + github.com/golang-jwt/jwt/v5 v5.2.1 // latest + github.com/onsi/ginkgo/v2 v2.17.3 // latest + github.com/onsi/gomega v1.33.1 // latest + go.uber.org/mock v0.4.0 // latest + golang.org/x/text v0.15.0 // latest +) + +require ( + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/tools v0.20.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd3f337 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/IGLOU-EU/go-wildcard v1.0.3 h1:r8T46+8/9V1STciXJomTWRpPEv4nGJATDbJkdU0Nou0= +github.com/IGLOU-EU/go-wildcard v1.0.3/go.mod h1:/qeV4QLmydCbwH0UMQJmXDryrFKJknWi/jjO8IiuQfY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU= +github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cast/as.go b/internal/cast/as.go new file mode 100644 index 0000000..8928825 --- /dev/null +++ b/internal/cast/as.go @@ -0,0 +1,443 @@ +package cast + +import ( + "encoding/json" + "fmt" + reflect2 "github.com/expectto/be/internal/reflect" + "reflect" + "time" +) + +// AsString converts the given input into a string or a string-like representation. +// It supports various input types, including actual strings, byte slices, JSON RawMessage, and custom string types. +// +// Input values may also be pointers. +// +// Note: If the input is []byte and contains a non-UTF-8 valid sequence, the resulting string may be invalid. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// str = AsString("example") // Converts a string, returns "example" +// str = AsString([]byte("byte_data")) // Converts a byte slice, returns "byte_data" +// str = AsString(CustomStringType("example")) // Converts a custom string type +// +// This function is useful for converting diverse input types into a string representation, +// and it is designed to provide a convenient string conversion for various testing scenarios. +func AsString(a any) string { + // First start with a type casting + switch t := a.(type) { + case string: + return t + case []byte: + return string(t) + case json.RawMessage: + return string(t) + case *json.RawMessage: // shortcut without reflect + return string(*t) + + // we intentionally do not support fmt.Stringer here + // it must be handled manually + } + + // Then fallback to reflect, in case we have custom string/[]byte types + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.Kind() == reflect.String { + return v.String() + } + if v.Kind() == reflect.Slice && v.Type().AssignableTo(reflect.TypeOf([]byte{})) { + return string(v.Bytes()) + } + + panic(fmt.Sprintf("Expected a string-ish/[]byte-ish thing! Got <%T>", a)) +} + +// AsBytes converts the given input into a []byte or a []byte-like representation. +// It supports various input types, including byte slices, JSON RawMessage, and strings. +// +// Input values may also be pointers. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// bytes = AsBytes([]byte("byte_data")) // Converts a byte slice, returns []byte with the same content +// bytes = AsBytes(jsonRawMessage) // Converts a JSON RawMessage +// bytes = AsBytes("example") // Converts a string, returns the corresponding []byte +// +// This function is useful for converting diverse input types into a []byte representation, +// and it is designed to provide a convenient []byte conversion for various testing scenarios. +func AsBytes(a any) []byte { + // First start with a type casting + switch t := a.(type) { + case []byte: + return t + case json.RawMessage: + return t + case *json.RawMessage: // shortcut without reflect + return *t + case string: + return []byte(t) + } + + // Then fallback to reflect, in case we have custom string/[]byte types + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.Kind() == reflect.Slice && v.Type().AssignableTo(reflect.TypeOf([]byte{})) { + return v.Bytes() + } + if v.Kind() == reflect.String { + return []byte(v.String()) + } + + panic(fmt.Sprintf("Expected []byte-ish/string-ish thing! Got <%T>", a)) +} + +// AsBool converts the given input into a bool. +// It supports various input types, including actual bool values and pointers to bool. +// Input values may also be pointers. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// value = AsBool(true) // Converts a bool, returns true +// value = AsBool(&boolValue) // Converts a pointer to a bool, returns the bool value +// +// This function is designed for converting different input types into bool values, +// and it is useful for various testing scenarios where boolean values are expected. +func AsBool(a any) bool { + // First start with a type casting + switch t := a.(type) { + case bool: + return t + case *bool: + return *t + } + + // fallback to reflect + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.Kind() == reflect.Bool { + return v.Bool() + } + + panic(fmt.Sprintf("Expected a bool! Got <%T>: %#v", a, a)) +} + +// AsInt converts the given input into an int. +// It supports various input types, including int values, float64 values (for integral floats), +// and pointers to int or float64. +// Input values may also be pointers. +// +// Note (1): Float64 values are converted to int only if they are integral floats (e.g., 42.0). Otherwise, use AsFloat. +// Note (2): Depending on the machine where the code is compiled, the resulting int may be of different sizes (e.g., int32). +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// intValue := AsInt(42) // Converts an int, returns 42 +// intValue := AsInt(&intValuePtr) // Converts a pointer to int, returns the int value +// intValue := AsInt(42.0) // Converts an integral float, returns 42 +// +// This function is designed for converting different input types into int values, +// and it is useful for various testing scenarios where integer values are expected. +func AsInt(a any) int { + // First start with a type casting + switch t := a.(type) { + case int: + return t + case *int: + return *t + case int8: + return int(t) + case *int8: + return int(*t) + case int16: + return int(t) + case *int16: + return int(*t) + case int32: + return int(t) + case *int32: + return int(*t) + case int64: + return int(t) + case *int64: + return int(*t) + case uint: + return int(t) + case *uint: + return int(*t) + case uint8: + return int(t) + case *uint8: + return int(*t) + case uint16: + return int(t) + case *uint16: + return int(*t) + case uint32: + return int(t) + case *uint32: + return int(*t) + case uint64: + return int(t) + case *uint64: + return int(*t) + case float64: + intResult := int(t) + if float64(intResult) != t { + panic("Expected an integral float") + } + return intResult + case *float64: + intResult := int(*t) + if float64(intResult) != *t { + panic("Expected an integral float") + } + return intResult + case float32: + intResult := int(t) + if float32(intResult) != t { + panic("Expected an integral float") + } + return intResult + case *float32: + intResult := int(*t) + if float32(intResult) != *t { + panic("Expected an integral float") + } + return intResult + } + + // fallback to reflect + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.Kind() >= reflect.Int && v.Kind() <= reflect.Int64 { + return int(v.Int()) + } else if v.Kind() >= reflect.Uint && v.Kind() <= reflect.Uint64 { + return int(v.Uint()) + } else if v.Kind() >= reflect.Float32 && v.Kind() <= reflect.Float64 { + intResult := int(v.Float()) + if float64(intResult) != v.Float() { + panic("Expected an integer float") + } + return intResult + } + + panic(fmt.Sprintf("Expected an integer number! Got <%T>: %#v", a, a)) +} + +// AsFloat converts the given input into a float64. +// It supports various input types, including float64 values and int values (converted to float64). +// Input values may also be pointers. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// floatValue = AsFloat(3.14) // Converts a float64, returns 3.14 +// floatValue = AsFloat(&floatValuePtr) // Converts a pointer to float64, returns the float64 value +// floatValue = AsFloat(42) // Converts an int to a float64, returns 42.0 +// +// This function is designed for converting different input types into float64 values, +// and it is useful for various testing scenarios where floating-point values are expected. +func AsFloat(a any) float64 { + // First start with a type casting + switch t := a.(type) { + case float64: + return t + case *float64: + return *t + case float32: + return float64(t) + case *float32: + return float64(*t) + case int: + return float64(t) + case *int: + return float64(*t) + case int8: + return float64(t) + case *int8: + return float64(*t) + case int16: + return float64(t) + case *int16: + return float64(*t) + case int32: + return float64(t) + case *int32: + return float64(*t) + case int64: + return float64(t) + case *int64: + return float64(*t) + case uint: + return float64(t) + case *uint: + return float64(*t) + case uint8: + return float64(t) + case *uint8: + return float64(*t) + case uint16: + return float64(t) + case *uint16: + return float64(*t) + case uint32: + return float64(t) + case *uint32: + return float64(*t) + case uint64: + return float64(t) + case *uint64: + return float64(*t) + } + + // fallback to reflect + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.Kind() >= reflect.Float32 && v.Kind() <= reflect.Float64 { + return v.Float() + } else if v.Kind() >= reflect.Int && v.Kind() <= reflect.Int64 { + return float64(v.Int()) + } else if v.Kind() >= reflect.Uint && v.Kind() <= reflect.Uint64 { + return float64(v.Uint()) + } + + panic(fmt.Sprintf("Expected a float number! Got <%T>: %#v", a, a)) +} + +// AsKind converts the given input into a reflect.Kind. +// It supports various input types, including reflect.Kind values and pointers to reflect.Kind. +// Input values may also be pointers. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// kind = AsKind(reflect.Int) // Converts a reflect.Kind, returns reflect.Int +// kind = AsKind(&kindPtr) // Converts a pointer to reflect.Kind, returns the reflect.Kind value +// +// This function is designed for converting different input types into reflect.Kind values, +// and it is useful for various testing scenarios where reflection is used. +func AsKind(a any) reflect.Kind { + // First start with a type casting + switch t := a.(type) { + case reflect.Kind: + return t + case *reflect.Kind: + return *t + } + + // No reason to fall back to reflect here + // as too small chance that it will be a custom reflect.Kind type + // or a deeper pointer + + panic(fmt.Sprintf("Expected a reflect.Kind! Got <%T>: %#v", a, a)) +} + +// AsSliceOfAny converts the given input into a []any. +// It supports various input types, including slices, arrays, and pointers to slices or arrays of any type. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// anySlice := AsSliceOfAny([]string{"a", "b", "c"}) // Converts a string slice, returns []any{"a", "b", "c"} +// anySlice := AsSliceOfAny(&intArrayPtr) // Converts a pointer to an array, returns []any with the array content +// +// This function is designed for converting different input types into a []any, +// and it is useful for various testing scenarios where a slice of arbitrary types is expected. +func AsSliceOfAny(v any) []any { + // First start with a type casting + switch t := v.(type) { + case []any: + return t + } + + // Then fallback to reflect + rv := reflect.ValueOf(v) + rv = reflect2.IndirectDeep(rv) + + if rv.Kind() == reflect.Slice { + slice := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + slice[i] = rv.Index(i).Interface() + } + return slice + } + // todo: support arrays + + panic(fmt.Sprintf("Expected a slice! Got <%T>: %#v", v, v)) +} + +func AsStrings(v any) []string { + // First start with a type casting + switch t := v.(type) { + case []string: + return t + } + + // Then fallback to reflect + rv := reflect.ValueOf(v) + rv = reflect2.IndirectDeep(rv) + st := reflect.TypeOf("") + + // todo: support arrays? + if rv.Kind() == reflect.Slice { + slice := make([]string, rv.Len()) + for i := 0; i < rv.Len(); i++ { + if !rv.Index(i).Type().ConvertibleTo(st) { + panic(fmt.Sprintf("expected a slice string! Got s[%d] <%T>: %#v", i, v, v)) + } + + slice[i] = rv.Index(i).String() + } + return slice + } + + panic(fmt.Sprintf("Expected a slice! Got <%T>: %#v", v, v)) +} + +// AsTime converts the given input into a time.Time. +// It supports various input types, including time.Time values and pointers to time.Time. +// +// It panics if it's not possible to perform the conversion. +// +// Example Usage: +// +// timestamp = AsTime(time.Now()) // Converts a time.Time, returns the current time +// timestamp = AsTime(&timeValuePtr) // Converts a pointer to time.Time, returns the time.Time value +// +// This function is designed for converting different input types into time.Time values. +func AsTime(a any) time.Time { + + // First start with a type casting + switch t := a.(type) { + case time.Time: + return t + case *time.Time: + return *t + } + + // fallback to reflect + v := reflect.ValueOf(a) + v = reflect2.IndirectDeep(v) + + if v.CanConvert(reflect2.TypeFor[time.Time]()) { + return v.Interface().(time.Time) + } + + panic(fmt.Sprintf("Expected a time.Time value! Got <%T>: %#v", a, a)) +} diff --git a/internal/cast/as_test.go b/internal/cast/as_test.go new file mode 100644 index 0000000..01821a8 --- /dev/null +++ b/internal/cast/as_test.go @@ -0,0 +1,50 @@ +package cast_test + +import ( + "encoding/json" + "github.com/expectto/be/internal/cast" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("As", func() { + Context("AsString", func() { + It("should return string for string", func() { + Expect(cast.AsString("something")).To(Equal("something")) + }) + + It("should return string for empty string", func() { + Expect(cast.AsString("")).To(Equal("")) + }) + + It("should return string for []byte", func() { + Expect(cast.AsString([]byte("foobar"))).To(Equal("foobar")) + }) + + It("should return string for empty []byte", func() { + Expect(cast.AsString([]byte{})).To(Equal("")) + }) + + It("should return string for CustomString", func() { + type CustomString string + Expect(cast.AsString(CustomString("foobar"))).To(Equal("foobar")) + }) + + It("should return string for json.RawMessage", func() { + Expect(cast.AsString(json.RawMessage(`{"foo":"bar"}`))).To(Equal(`{"foo":"bar"}`)) + }) + + It("should return string for *json.RawMessage", func() { + msg := json.RawMessage(`{"foo":"bar"}`) + Expect(cast.AsString(&msg)).To(Equal(`{"foo":"bar"}`)) + }) + + It("should return string for a string under the pointer", func() { + Expect(cast.AsString(new(string))).To(Equal("")) + }) + + It("should panic for non-stringish", func() { + Expect(func() { cast.AsString(123) }).To(Panic()) + }) + }) +}) diff --git a/internal/cast/cast_suite_test.go b/internal/cast/cast_suite_test.go new file mode 100644 index 0000000..c82deb9 --- /dev/null +++ b/internal/cast/cast_suite_test.go @@ -0,0 +1,13 @@ +package cast_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCast(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cast Suite") +} diff --git a/internal/cast/is.go b/internal/cast/is.go new file mode 100644 index 0000000..71f4a06 --- /dev/null +++ b/internal/cast/is.go @@ -0,0 +1,74 @@ +package cast + +import ( + "reflect" +) + +// IsNil checks if the given input is a nil value. +func IsNil(a any) bool { + if a == nil { + return true + } + + v := reflect.ValueOf(a) + k := v.Kind() + // check if v is OK to be used with IsNil + if k == reflect.Ptr || k == reflect.Interface || k == reflect.Chan || + k == reflect.Func || k == reflect.Map || k == reflect.Slice { + return v.IsNil() + } + + return false +} + +// IsStringish checks if the given input is a string or string-like value. +// To prevent code duplication, it employs panic recovery to handle type conversion +// and is designed for use in testing code, where panics are acceptable. +// +// Example Usage: +// +// IsStringish("example") // Returns true +// IsStringish([]byte("example")) // Returns true +// IsStringish(CustomStringType("example")) // Returns true +// +// This function is suitable for scenarios where you want to quickly determine if +// a value can be treated as a string without handling detailed conversion errors. +func IsStringish(a any) (ok bool) { + ok = true + defer func() { + if err := recover(); err != nil { + ok = false + } + }() + + // here actually doesn't matter if we call AsBytes or AsString + _ = AsBytes(a) + return +} + +func IsStrings(a any) (ok bool) { + ok = true + defer func() { + if err := recover(); err != nil { + ok = false + } + }() + + _ = AsStrings(a) + return +} + +// IsTime checks if the given input is a time.Time value (pointers and/or custom types are OK) +// To prevent code duplication, it employs panic recovery to handle type conversion +// and is designed for use in testing code, where panics are acceptable. +func IsTime(a any) (ok bool) { + ok = true + defer func() { + if err := recover(); err != nil { + ok = false + } + }() + + _ = AsTime(a) + return +} diff --git a/internal/cast/is_string.go b/internal/cast/is_string.go new file mode 100644 index 0000000..6f17b60 --- /dev/null +++ b/internal/cast/is_string.go @@ -0,0 +1,188 @@ +package cast + +import ( + "encoding/json" + reflect2 "github.com/expectto/be/internal/reflect" + "reflect" +) + +// IsString checks if the given input is a string or string-like. +// To avoid duplicating type-checking logic, it provides extensive configuration options for +// customizing the type-checking behavior, making it a versatile utility for testing code. +// It supports both strict and non-strict mode checks, allowing you to precisely control +// which types are considered string-like. It also provides options for handling custom types, +// pointer de-referencing. +// +// Example Usage: +// +// // In a non-strict check, allows custom types, pointer de-referencing. +// IsString("example", AllowCustomTypes(), AllowPointers())) // returns true +// +// // In a strict check, only actual strings are accepted +// IsString("example", Strict()) // Returns true +// IsString([]byte("example"), Strict()) // Returns false +func IsString(a any, opts ...optIsString) bool { + if a == nil { + return false + } + + // Even before computing the config, + // if input is simply a string, return immediately + _, ok := a.(string) + if ok { + return ok + } + + // building a default config and override it with users options + cfg := defaultIsStringConfig.clone() + for _, opt := range opts { + opt(cfg) + } + + // if it was a strict check, and simple casting failed, we can't continue + if cfg.IsStrict() && !ok { + return false + } + + // in allow-all mode we can simply call IsStringish + if cfg.AllowsAll() { + return IsStringish(a) + } + + // We can still use type casting for simple cases, like AllowBytesConversion, AllowPointer: + + if cfg.AllowBytesConversion { + // First start with a type casting + switch a.(type) { + case []byte, json.RawMessage: + return true + } + + if cfg.AllowPointers { + switch a.(type) { + case *[]byte, *json.RawMessage: + return true + } + } + } + + // Further we can only try reflect + + v := reflect.ValueOf(a) + if cfg.AllowDeepPointers { + v = reflect2.IndirectDeep(v) + } else if cfg.AllowPointers { + v = reflect.Indirect(v) + } + + if v.Type() == reflect2.TypeFor[string]() { + return true + } + + if cfg.AllowCustomTypes { + if v.Kind() == reflect.String { + return true + } + + if cfg.AllowBytesConversion { + if v.Kind() == reflect.Slice && v.Type().AssignableTo(reflect.TypeOf([]byte{})) { + return true + } + } + } + + return false +} + +// isStringConfig is a configuration for IsString check. +// An empty config (all flags=false) is considered "strict mode" +// `omitempty` tag is needed for marshalling for IsStrict() func +type isStringConfig struct { + AllowCustomTypes bool `json:"allow_custom_types,omitempty"` + AllowBytesConversion bool `json:"allow_bytes_conversion,omitempty"` + AllowPointers bool `json:"allow_pointers,omitempty"` + AllowDeepPointers bool `json:"allow_deep_pointers,omitempty"` +} + +var defaultIsStringConfig *isStringConfig + +func init() { + // no options given will lead to strict mode by default + ConfigureIsStringConfig() +} + +// clone is done via json round-trip marshalling +func (cis *isStringConfig) clone() *isStringConfig { + // errors are omitted intentionally, as here we can't fail + contents, _ := json.Marshal(cis) + var clone isStringConfig + _ = json.Unmarshal(contents, &clone) + return &clone +} + +// IsStrict returns true if all custom options are disabled +// IsString() in strict mode will return true only for actual `string` values +func (cis *isStringConfig) IsStrict() bool { + // Strict mode is when all flags are false + marshalled, _ := json.Marshal(cis) + return string(marshalled) == "{}" +} + +// AllowsAll returns true if all custom options are enabled +func (cis *isStringConfig) AllowsAll() bool { + el := reflect.ValueOf(cis).Elem() + var result = true + for i := 0; i < el.NumField(); i++ { + result = result && el.Field(i).Bool() + + if !result { + break + } + } + return result +} + +// ConfigureIsStringConfig sets the default configuration for IsString checks. +func ConfigureIsStringConfig(opts ...optIsString) { + cfg := &isStringConfig{} + for _, opt := range opts { + opt(cfg) + } + if defaultIsStringConfig == nil { + defaultIsStringConfig = &isStringConfig{} + } + *defaultIsStringConfig = *cfg +} + +type optIsString func(config *isStringConfig) + +// AllowCustomTypes option allows the use of custom string types for IsString checks. +func AllowCustomTypes() optIsString { + return func(cfg *isStringConfig) { cfg.AllowCustomTypes = true } +} + +// AllowBytesConversion option allows conversion from []byte to string for IsString checks. +func AllowBytesConversion() optIsString { + return func(cfg *isStringConfig) { cfg.AllowBytesConversion = true } +} + +// AllowPointers option allows checking of values under pointers for IsString checks. +func AllowPointers() optIsString { + return func(cfg *isStringConfig) { cfg.AllowPointers = true } +} + +// AllowDeepPointers option allows deep checking of values under pointers for IsString checks. +func AllowDeepPointers() optIsString { + return func(cfg *isStringConfig) { cfg.AllowDeepPointers = true } +} + +// AllowAll option allows all options (makes it the most non-strict) +func AllowAll() optIsString { + return func(cfg *isStringConfig) { + v := reflect.ValueOf(cfg).Elem() + + for i := 0; i < v.NumField(); i++ { + v.Field(i).SetBool(true) + } + } +} diff --git a/internal/cast/is_string_test.go b/internal/cast/is_string_test.go new file mode 100644 index 0000000..e3d9f1b --- /dev/null +++ b/internal/cast/is_string_test.go @@ -0,0 +1,73 @@ +package cast_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/expectto/be/internal/cast" +) + +var _ = Describe("IsString", func() { + When("in strict mode", func() { + It("should return true for string", func() { + Expect(cast.IsString("something")).To(BeTrue()) + }) + It("should return true for empty string", func() { + Expect(cast.IsString("")).To(BeTrue()) + }) + It("should return false for []byte", func() { + Expect(cast.IsString([]byte("foobar"))).To(BeFalse()) + }) + It("should return false for empty []byte", func() { + Expect(cast.IsString([]byte{})).To(BeFalse()) + }) + It("should return false for *string", func() { + helloWorld := "hello world" + Expect(cast.IsString(&helloWorld)).To(BeFalse()) + }) + }) + + When("allowing all", func() { + type CustomString string + type CustomBytes []byte + It("should return true for custom strings", func() { + Expect(cast.IsString(CustomString("hello world"), cast.AllowAll())).To(BeTrue()) + }) + It("should return true for custom strings under pointer", func() { + helloWorld := CustomString("hello world") + ptrHelloWorld := &helloWorld + Expect(cast.IsString(&helloWorld, cast.AllowAll())).To(BeTrue()) + Expect(cast.IsString(&ptrHelloWorld, cast.AllowAll())).To(BeTrue()) + }) + It("should return true for bytes", func() { + Expect(cast.IsString([]byte("hello world"), cast.AllowAll())).To(BeTrue()) + }) + It("should return true for custom bytes", func() { + helloWorld := CustomBytes("hello world") + Expect(cast.IsString(helloWorld, cast.AllowAll())).To(BeTrue()) + Expect(cast.IsString(&helloWorld, cast.AllowAll())).To(BeTrue()) + + ptrHelloWorld := &helloWorld + Expect(cast.IsString(&ptrHelloWorld, cast.AllowAll())).To(BeTrue()) + }) + }) + + When("allowing pointers", func() { + It("should return true for string under the pointer", func() { + Expect(cast.IsString(new(string), cast.AllowPointers())).To(BeTrue()) + Expect(cast.IsString(new(string), cast.AllowDeepPointers())).To(BeTrue()) + + s := "hello" + Expect(cast.IsString(&s, cast.AllowPointers())).To(BeTrue()) + Expect(cast.IsString(&s, cast.AllowDeepPointers())).To(BeTrue()) + ss := &s + Expect(cast.IsString(&ss, cast.AllowDeepPointers())).To(BeTrue()) + Expect(cast.IsString(&ss, cast.AllowPointers())).To(BeFalse()) + }) + + It("should return false for not-a-string under the pointer", func() { + Expect(cast.IsString(new(int), cast.AllowPointers())).To(BeFalse()) + }) + }) + +}) diff --git a/internal/cast/is_test.go b/internal/cast/is_test.go new file mode 100644 index 0000000..990214a --- /dev/null +++ b/internal/cast/is_test.go @@ -0,0 +1,84 @@ +package cast_test + +import ( + "github.com/expectto/be/internal/cast" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Is", func() { + Context("IsNil", func() { + It("should return true for nil", func() { + Expect(cast.IsNil(nil)).To(BeTrue()) + }) + It("should return true for typed nil", func() { + var i *int + Expect(cast.IsNil(i)).To(BeTrue()) + }) + It("should return true for interface nil", func() { + var i interface{} + Expect(cast.IsNil(i)).To(BeTrue()) + }) + + It("should return false for non-nil pointer", func() { + Expect(cast.IsNil(&struct{}{})).To(BeFalse()) + }) + It("should return false for non-nil map", func() { + Expect(cast.IsNil(map[string]int{})).To(BeFalse()) + }) + It("should return false for non-nil func", func() { + Expect(cast.IsNil(func() {})).To(BeFalse()) + }) + + It("should return false for non-nil digit", func() { + Expect(cast.IsNil(0)).To(BeFalse()) + }) + It("should return false for non-nil string", func() { + Expect(cast.IsNil("")).To(BeFalse()) + }) + }) + + Context("IsStringish", func() { + When("considered stringish", func() { + It("should return true for string", func() { + Expect(cast.IsStringish("something")).To(BeTrue()) + }) + It("should return true for empty string", func() { + Expect(cast.IsStringish("")).To(BeTrue()) + }) + It("should return true for []byte", func() { + Expect(cast.IsStringish([]byte("foobar"))).To(BeTrue()) + }) + It("should return true for empty []byte", func() { + Expect(cast.IsStringish([]byte{})).To(BeTrue()) + }) + }) + + When("considered not stringish", func() { + It("should return false for nil", func() { + Expect(cast.IsStringish(nil)).To(BeFalse()) + }) + It("should return false for int", func() { + Expect(cast.IsStringish(123)).To(BeFalse()) + }) + It("should return false for float", func() { + Expect(cast.IsStringish(123.456)).To(BeFalse()) + }) + It("should return false for bool", func() { + Expect(cast.IsStringish(true)).To(BeFalse()) + }) + It("should return false for complex", func() { + Expect(cast.IsStringish(1 + 2i)).To(BeFalse()) + }) + It("should return false for struct", func() { + Expect(cast.IsStringish(struct{}{})).To(BeFalse()) + }) + It("should return false for map", func() { + Expect(cast.IsStringish(map[string]int{})).To(BeFalse()) + }) + It("should return false for func", func() { + Expect(cast.IsStringish(func() {})).To(BeFalse()) + }) + }) + }) +}) diff --git a/internal/psi/dive.go b/internal/psi/dive.go new file mode 100644 index 0000000..3bee0d3 --- /dev/null +++ b/internal/psi/dive.go @@ -0,0 +1,82 @@ +package psi + +import ( + "fmt" + "github.com/expectto/be/internal/cast" +) + +type DiveMode string + +const ( + DiveModeEvery DiveMode = "every" + DiveModeAny DiveMode = "any" + DiveModeFirst DiveMode = "first" +) + +type DiveMatcher struct { + matcher any + mode DiveMode + + *MixinMatcherGomock +} + +func NewDiveMatcher(matcher any, mode DiveMode) *DiveMatcher { + return &DiveMatcher{matcher: matcher, mode: mode} +} + +func (dm *DiveMatcher) Match(actual interface{}) (bool, error) { + matcher := Psi(dm.matcher) + + // todo: nice error if actual is not a slice-ish + // as other way it panics + slice := cast.AsSliceOfAny(actual) + + switch dm.mode { + case DiveModeEvery: + if len(slice) == 0 { + return false, nil + } + + for _, item := range slice { + success, err := matcher.Match(item) + if err != nil { + return false, err + } + if !success { + return false, nil + } + } + return true, nil + + case DiveModeAny: + if len(slice) == 0 { + return true, nil + } + + for _, item := range slice { + success, err := matcher.Match(item) + if err != nil { + return false, err + } + if success { + return true, nil + } + } + + return false, nil + case DiveModeFirst: + if len(slice) == 0 { + return false, fmt.Errorf("dive[first] expects non-empty slice") + } + return matcher.Match(slice[0]) + } + + panic("invalid DeepMatcher mode") +} + +func (dm *DiveMatcher) FailureMessage(actual any) string { + return fmt.Sprintf("to %s on %s of given list", Psi(dm.matcher).FailureMessage(actual), dm.mode) +} +func (dm *DiveMatcher) NegatedFailureMessage(actual any) string { + return "not " + dm.FailureMessage(actual) +} diff --git a/internal/psi/failable_transform.go b/internal/psi/failable_transform.go new file mode 100644 index 0000000..54568db --- /dev/null +++ b/internal/psi/failable_transform.go @@ -0,0 +1,94 @@ +package psi + +import ( + "fmt" + reflect2 "github.com/expectto/be/internal/reflect" + "github.com/expectto/be/types" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + "reflect" +) + +// IsTransformFunc checks if given thing is a Gomega-compatible transform +// For v to be a transform it must be a function of one parameter that returns one value and an optional error +func IsTransformFunc(v any) bool { + if v == nil { + return false + } + txType := reflect.TypeOf(v) + if txType.Kind() != reflect.Func { + return false + } + if txType.NumIn() != 1 { + return false + } + + numOut := txType.NumOut() + if numOut == 1 { + return true + } + if numOut == 2 { + return txType.Out(1).AssignableTo(reflect2.TypeFor[error]()) + } + + return false +} + +// WithFallibleTransform creates a gomega transform matcher that can nicely handle failures +// Also it allows to have nil matcher, meaning that we're OK unless transform failed +func WithFallibleTransform(transform any, matcher gomega.OmegaMatcher) types.BeMatcher { + if matcher != nil { + matcher = gomega.And(WithTransformError(), matcher) + } else { + matcher = WithTransformError() + } + + return Psi(gomega.WithTransform(transform, matcher)) +} + +// TransformErrorMatcher is actually a matcher +type TransformErrorMatcher struct { + actual any + err error +} + +func WithTransformError() *TransformErrorMatcher { + return &TransformErrorMatcher{} +} + +func (matcher *TransformErrorMatcher) Match(actual any) (success bool, err error) { + if err, ok := actual.(error); ok { + matcher.err = err + } + + // Fill in actual value for future messages + if h, ok := actual.(interface { + Actual() any + }); ok { + matcher.actual = h.Actual() + } + + return matcher.err == nil, nil +} + +func (matcher *TransformErrorMatcher) FailureMessage(actual any) string { + return fmt.Sprintf("Expected\n%s\nto %s", format.Object(matcher.actual, 1), matcher.err) +} + +func (matcher *TransformErrorMatcher) NegatedFailureMessage(actual any) string { + return fmt.Sprintf("Expected\n%s\nnot to %s", format.Object(matcher.actual, 1), matcher.err) +} + +// TransformError is used to store error + actual value which caused the error +type TransformError struct { + error + actual any +} + +func NewTransformError(err error, actual any) *TransformError { + return &TransformError{error: err, actual: actual} +} + +func (terr *TransformError) Actual() any { + return terr.actual +} diff --git a/internal/psi/from-gomega.go b/internal/psi/from-gomega.go new file mode 100644 index 0000000..6e1c39c --- /dev/null +++ b/internal/psi/from-gomega.go @@ -0,0 +1,81 @@ +package psi + +import ( + "fmt" + "github.com/expectto/be/types" + "regexp" + "strings" +) + +func FromGomega(omega types.GomegaMatcher, messagePrefixArg ...string) types.BeMatcher { + return &upgradedOmegaMatcher{ + GomegaMatcher: omega, + MixinMatcherGomock: NewMixinMatcherGomock(omega, messagePrefixArg...), + } +} + +// upgradedOmegaMatcher wraps GomegaMatcher and GomockMatcher +// Upgrade "Gomega => Psi" is done via attaching MixinMatcherGomock +type upgradedOmegaMatcher struct { + types.GomegaMatcher + *MixinMatcherGomock +} + +var ( + // ExpectedStr2LinesRegex matches first 2 lines of standard gomega failure message + ExpectedStr2LinesRegex = regexp.MustCompile(`Expected\n.*\n`) +) + +// MixinMatcherGomock should be used for embedding to create a matcher that fits Gomock interface +type MixinMatcherGomock struct { + cachedActual any + messagePrefix *string + + omega types.GomegaMatcher +} + +func (igm *MixinMatcherGomock) Matches(v any) bool { + // todo: we might cache the err if needed + success, _ := igm.omega.Match(v) + return success +} + +func (igm *MixinMatcherGomock) String() string { + gomegaFailureMessage := igm.omega.FailureMessage(igm.cachedActual) + + // considering that Failure message is a standard message made by FormatMessage + // If the message is: + // > Expected + // > + // > + // it will remove first 2 lines + if ExpectedStr2LinesRegex.MatchString(gomegaFailureMessage) { + gomegaFailureMessage = ExpectedStr2LinesRegex.ReplaceAllString(gomegaFailureMessage, "") + } + + // build a prefix (either was given, or by type of underlying matcher) + var messagePrefix string + if igm.messagePrefix != nil { + messagePrefix = *igm.messagePrefix + } else { + messagePrefix = fmt.Sprintf("%T", igm.omega) + } + + // Ensure prefix and message is separate by a single space + messagePrefix = strings.TrimSuffix(messagePrefix, " ") + if messagePrefix != "" { + messagePrefix += " " + } + gomegaFailureMessage = strings.TrimPrefix(gomegaFailureMessage, " ") + return messagePrefix + gomegaFailureMessage +} + +func NewMixinMatcherGomock(Ω types.GomegaMatcher, messagePrefixArg ...string) *MixinMatcherGomock { + igm := &MixinMatcherGomock{omega: Ω} + if len(messagePrefixArg) > 0 { + igm.messagePrefix = new(string) + *igm.messagePrefix = messagePrefixArg[0] + } + + return igm +} diff --git a/internal/psi/from-gomock.go b/internal/psi/from-gomock.go new file mode 100644 index 0000000..5e4d0a2 --- /dev/null +++ b/internal/psi/from-gomock.go @@ -0,0 +1,32 @@ +package psi + +import ( + "github.com/expectto/be/types" +) + +func FromGomock(m types.GomockMatcher) types.BeMatcher { + return &upgradedGomockMatcher{GomockMatcher: m} +} + +// upgradedGomockMatcher wraps GomockMatcher and GomegaMatcher +// Upgrade "Gomock => Psi" is done via attaching methods of GomegaMatcher +type upgradedGomockMatcher struct { + types.GomockMatcher + + // todo: fixme + //gomegaMatchFunc func(any) (bool, error) + //gomegaFailureMessageFunc func(any) string + //gomegaNegatedFailureMessageFunc func(any) string +} + +func (cm *upgradedGomockMatcher) Match(x any) (bool, error) { + return cm.Matches(x), nil +} +func (cm *upgradedGomockMatcher) FailureMessage(actual any) string { + // todo Expected <>: {expected} to equal <> {received} + return cm.String() +} +func (cm *upgradedGomockMatcher) NegatedFailureMessage(actual any) string { + // todo Expected <>: {expected} not to equal <> {received} + return "not " + cm.String() +} diff --git a/internal/psi/helpers.go b/internal/psi/helpers.go new file mode 100644 index 0000000..27eea13 --- /dev/null +++ b/internal/psi/helpers.go @@ -0,0 +1,47 @@ +package psi + +import ( + "github.com/expectto/be/types" + "github.com/onsi/gomega" +) + +// IsMatcher returns true if given input is either Omega or Gomock or a Psi matcher +func IsMatcher(a any) bool { + switch a.(type) { + case types.GomegaMatcher, types.GomockMatcher: + return true + default: + return false + } +} + +// AsMatcher returns BeMatcher that is made from given input +func AsMatcher(m any) types.BeMatcher { + switch t := m.(type) { + case types.BeMatcher: + return t + case types.GomegaMatcher: + return FromGomega(t) + case types.GomockMatcher: + return FromGomock(t) + default: + return FromGomega(gomega.Equal(t)) + } +} + +// IsMatchFunc returns true if given `m any` is a match func (from gomega matchers) +// todo: support typed match funcs (see gomega) +func IsMatchFunc(m any) bool { + _, ok := m.(func(any) (bool, error)) + return ok +} + +// AsMatchFunc returns given `m any` as match func (func (any) (bool, error)) +// todo: support typed match funcs (see gomega) +func AsMatchFunc(m any) func(any) (bool, error) { + v, ok := m.(func(any) (bool, error)) + if !ok { + panic("match func must be func(any) (bool, error)") + } + return v +} diff --git a/internal/psi/psi.go b/internal/psi/psi.go new file mode 100644 index 0000000..5b13a6a --- /dev/null +++ b/internal/psi/psi.go @@ -0,0 +1,122 @@ +// Package psi contains helpers that extends gomega library +// Name psi stands for previous letter from Omega +// (as we want to have a name that is close to gomega, but not to be a gomega) +// +// Package psi is considered as internal package to be used only inside `be` +// It's a core functionality that upgrades any matcher to be a `be` matcher +// Also it contains some core matchers and upgraded things from `gomega/gcustom` package +package psi + +import ( + "github.com/expectto/be/types" + "github.com/onsi/gomega/gcustom" + "strings" +) + +// Psi is a main converter function that converts given input into a PsiMatcher +func Psi(args ...any) types.BeMatcher { + if len(args) == 0 { + return &allMatcher{} // will always match + } + if len(args) == 1 { + if IsTransformFunc(args[0]) { + // not sure, add more tests here + return WithFallibleTransform(args[0], nil) + } + // If a Match Func was given: simply wrap it in a matcher + if IsMatchFunc(args[0]) { + return AsMatcher(gcustom.MakeMatcher(AsMatchFunc(args[0]))) + } + + return AsMatcher(args[0]) + } + + // Special edge case: + // args = {MatchFunc, message} + // OR + // args = {Matcher, message} + if len(args) == 2 { + message, ok := args[1].(string) + if ok { + if IsMatchFunc(args[0]) { + return AsMatcher(gcustom.MakeMatcher(AsMatchFunc(args[0]), message)) + } else if IsMatcher(args[0]) { + return AsMatcher(gcustom.MakeMatcher(AsMatcher(args[0]).Match, message)) + } + } + } + + matchers := make([]types.BeMatcher, 0) + + // Cast each arg as: + // 1. transform func: will be wrapped via WithFallibleTransform then + // 2. Matcher (Gomega/Gomock/Psi) + // 3. any raw value will be converted to EqualMatcher // TODO: this case must be eliminated. We should be stricter + for i, arg := range args { + if IsTransformFunc(arg) { // 1 + transformMatcher := WithFallibleTransform(arg, Psi(args[i+1:]...)) + matchers = append(matchers, Psi(transformMatcher)) + return &allMatcher{matchers: matchers} + } + + matchers = append(matchers, Psi(arg)) // 2 or 3 + } + + return &allMatcher{matchers: matchers} +} + +// allMatcher is declared here internally so we're not importing psi_matchers +// allMatcher matches if all given matchers were matched +// or when no matchers were given +type allMatcher struct { + matchers []types.BeMatcher + + // state + firstFailedMatcher types.BeMatcher + allSucceedMatchers []types.BeMatcher +} + +func (m *allMatcher) Match(actual any) (bool, error) { + m.firstFailedMatcher = nil + m.allSucceedMatchers = make([]types.BeMatcher, 0) + for _, matcher := range m.matchers { + success, err := matcher.Match(actual) + if !success || err != nil { + m.firstFailedMatcher = matcher + return false, err + } else { + m.allSucceedMatchers = append(m.allSucceedMatchers, matcher) + } + } + return true, nil +} + +func (m *allMatcher) FailureMessage(actual any) (message string) { + return m.firstFailedMatcher.FailureMessage(actual) +} + +func (m *allMatcher) NegatedFailureMessage(actual any) (message string) { + // todo: make it nicer + messages := make([]string, len(m.allSucceedMatchers)) + for i, m := range m.allSucceedMatchers { + messages[i] = m.NegatedFailureMessage(actual) + } + return strings.Join(messages, "\n and \n") +} + +func (m *allMatcher) Matches(actual any) bool { + m.firstFailedMatcher = nil + for _, matcher := range m.matchers { + if !matcher.Matches(actual) { + m.firstFailedMatcher = matcher + return false + } + } + return true +} + +func (m *allMatcher) String() string { + return m.firstFailedMatcher.String() +} + +// todo: allMatcher.MatchMayChangeInTheFuture diff --git a/internal/psi_matchers/all_matcher.go b/internal/psi_matchers/all_matcher.go new file mode 100644 index 0000000..013a83f --- /dev/null +++ b/internal/psi_matchers/all_matcher.go @@ -0,0 +1,71 @@ +// Package psi_matchers is a package that contains core matchers required +// Psi() to work properly +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" +) + +// AllMatcher is the same as Gomega's AndMatcher +// with a difference that we can track errors if using via gomock +type AllMatcher struct { + Matchers []types.BeMatcher + + // state + firstFailedMatcher types.BeMatcher +} + +var _ types.BeMatcher = &AllMatcher{} + +func NewAllMatcher(ms ...any) *AllMatcher { + matchers := make([]types.BeMatcher, len(ms)) + for i, m := range ms { + matchers[i] = AsMatcher(m) + } + + return &AllMatcher{Matchers: matchers} +} + +func (m *AllMatcher) Match(actual any) (success bool, err error) { + m.firstFailedMatcher = nil + for _, matcher := range m.Matchers { + success, err := matcher.Match(actual) + if !success || err != nil { + m.firstFailedMatcher = matcher + return false, err + } + } + return true, nil +} + +func (m *AllMatcher) FailureMessage(actual any) (message string) { + return m.firstFailedMatcher.FailureMessage(actual) +} + +func (m *AllMatcher) NegatedFailureMessage(actual any) (message string) { + // not the most beautiful list of matchers, but not bad either... + return format.Message(actual, fmt.Sprintf("To not satisfy all of these matchers: %s", m.Matchers)) +} + +func (m *AllMatcher) Matches(actual any) bool { + m.firstFailedMatcher = nil + for _, matcher := range m.Matchers { + if !matcher.Matches(actual) { + m.firstFailedMatcher = matcher + return false + } + } + return true +} + +func (m *AllMatcher) String() string { + return m.firstFailedMatcher.String() +} + +// todo: AllMatcher.MatchMayChangeInTheFuture + +// todo: will be very nice if failure message will be slightly different +// depending on which one matcher inside AndGroup fails diff --git a/internal/psi_matchers/always_matcher.go b/internal/psi_matchers/always_matcher.go new file mode 100644 index 0000000..55c9079 --- /dev/null +++ b/internal/psi_matchers/always_matcher.go @@ -0,0 +1,18 @@ +package psi_matchers + +import "github.com/expectto/be/types" + +// AlwaysMatcher always matches +type AlwaysMatcher struct{} + +var _ types.BeMatcher = &AlwaysMatcher{} + +func NewAlwaysMatcher() *AlwaysMatcher { + return &AlwaysMatcher{} +} + +func (m *AlwaysMatcher) Match(_ any) (bool, error) { return true, nil } +func (m *AlwaysMatcher) FailureMessage(actual any) string { return "" } +func (m *AlwaysMatcher) NegatedFailureMessage(actual any) string { return "" } +func (m *AlwaysMatcher) Matches(actual any) bool { return true } +func (m *AlwaysMatcher) String() string { return "" } diff --git a/internal/psi_matchers/always_matcher_test.go b/internal/psi_matchers/always_matcher_test.go new file mode 100644 index 0000000..779b4e5 --- /dev/null +++ b/internal/psi_matchers/always_matcher_test.go @@ -0,0 +1,46 @@ +package psi_matchers_test + +import ( + . "github.com/expectto/be/internal/psi_matchers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/expectto/be/types" +) + +var _ = Describe("AlwaysMatcher", func() { + var matcher types.BeMatcher + + BeforeEach(func() { + matcher = NewAlwaysMatcher() + }) + + Context("Matching", func() { + DescribeTable("Always match", + func(actual any) { + success, err := matcher.Match(actual) + Expect(success).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + + success = matcher.Matches(actual) + Expect(success).To(BeTrue()) + + Expect(matcher.FailureMessage(actual)).To(BeEmpty()) + Expect(matcher.NegatedFailureMessage(actual)).To(BeEmpty()) + Expect(matcher.String()).To(BeEmpty()) + }, + Entry("nil", nil), + Entry("string", "foobar"), + Entry("zero", 0), + Entry("positive int", 5), + Entry("negative int", -5), + Entry("bool true", true), + Entry("bool false", false), + Entry("empty slice", []any{}), + Entry("string slice", []string{"foo", "bar"}), + Entry("empty map", map[string]any{}), + Entry("filled map", map[string]any{"foo": "bar"}), + ) + }) +}) diff --git a/internal/psi_matchers/any_matcher.go b/internal/psi_matchers/any_matcher.go new file mode 100644 index 0000000..d9db2f6 --- /dev/null +++ b/internal/psi_matchers/any_matcher.go @@ -0,0 +1,69 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + + "github.com/onsi/gomega/format" +) + +// AnyMatcher is the psi upgrade for gomega's OrMatcher +type AnyMatcher struct { + Matchers []types.BeMatcher + + // state + firstSuccessfulMatcher types.BeMatcher +} + +var _ types.BeMatcher = &AnyMatcher{} + +func NewAnyMatcher(ms ...any) *AnyMatcher { + matchers := make([]types.BeMatcher, len(ms)) + for i, m := range ms { + matchers[i] = AsMatcher(m) + } + + return &AnyMatcher{Matchers: matchers} +} + +func (m *AnyMatcher) Match(actual any) (success bool, err error) { + m.firstSuccessfulMatcher = nil + for _, matcher := range m.Matchers { + currentSuccess, err := matcher.Match(actual) + if err != nil { + return false, err + } + if currentSuccess { + m.firstSuccessfulMatcher = matcher + return true, nil + } + } + return false, nil +} + +func (m *AnyMatcher) FailureMessage(actual any) (message string) { + // not the most beautiful list of matchers, but not bad either... + return format.Message(actual, fmt.Sprintf("To satisfy at least one of these matchers: %s", m.Matchers)) +} + +func (m *AnyMatcher) NegatedFailureMessage(actual any) (message string) { + return m.firstSuccessfulMatcher.NegatedFailureMessage(actual) +} + +// todo: MatchMayChangeInTheFuture + +func (m *AnyMatcher) Matches(actual any) bool { + m.firstSuccessfulMatcher = nil + for _, matcher := range m.Matchers { + if !matcher.Matches(actual) { + m.firstSuccessfulMatcher = matcher + return false + } + } + return true +} + +func (m *AnyMatcher) String() string { + return m.firstSuccessfulMatcher.String() +} diff --git a/internal/psi_matchers/assignable_to_matcher.go b/internal/psi_matchers/assignable_to_matcher.go new file mode 100644 index 0000000..3f31fc9 --- /dev/null +++ b/internal/psi_matchers/assignable_to_matcher.go @@ -0,0 +1,42 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "reflect" +) + +type AssignableToMatcher struct { + assignableTo reflect.Type + + *MixinMatcherGomock +} + +var _ types.BeMatcher = &AssignableToMatcher{} + +func NewAssignableToMatcher[T any]() *AssignableToMatcher { + t := reflect.TypeOf((*T)(nil)).Elem() + + im := &AssignableToMatcher{assignableTo: t} + im.MixinMatcherGomock = NewMixinMatcherGomock(im, "AssignableTo") + + return im +} + +func (matcher *AssignableToMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, nil + } + actualT := reflect.TypeOf(actual) + return actualT.AssignableTo(matcher.assignableTo), nil +} + +func (matcher *AssignableToMatcher) FailureMessage(actual any) string { + return format.Message(actual, fmt.Sprintf("to be assignable to: %s", matcher.assignableTo.String())) +} + +func (matcher *AssignableToMatcher) NegatedFailureMessage(actual any) string { + return format.Message(actual, fmt.Sprintf("not to be assignable to: %s", matcher.assignableTo.String())) +} diff --git a/internal/psi_matchers/ctx_matcher.go b/internal/psi_matchers/ctx_matcher.go new file mode 100644 index 0000000..45cbf09 --- /dev/null +++ b/internal/psi_matchers/ctx_matcher.go @@ -0,0 +1,155 @@ +package psi_matchers + +import ( + "context" + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "go.uber.org/mock/gomock" +) + +var ( + FailCtxNotAContext = fmt.Errorf("be a ctx") + FailCtxValueExpected = fmt.Errorf("have the ctx.value") + FailCtxValueNotMatched = fmt.Errorf("have the ctx.value") // same text as won't be used directly (but still can be distinguished via errors.Is() + FailCtxErrNotMatched = fmt.Errorf("todo...") + FailCtxDeadlineExpected = fmt.Errorf("todo...") + FailCtxDeadlineNotMatched = fmt.Errorf("todo...") +) + +// CtxMatcher is a matcher for ctx// Each instance of CtxMatcher can match across only one thing:// (1) ctx value or (2) error or (3) deadline or (4) done signal +// Do not fill multiple things together here. Use separate instances instead +type CtxMatcher struct { + failReason error + + // 1. Matching inner ctx value: + key any + value any + + // 2. Error matching + errFn any + + // 3. Deadline matching + deadline any + + // 4. Done matching + //doneMatcher types.BeMatcher +} + +var _ gomock.Matcher = &CtxMatcher{} +var _ types.BeMatcher = &CtxMatcher{} + +func (cm *CtxMatcher) Match(v any) (success bool, err error) { + return cm.match(v) +} + +func (cm *CtxMatcher) FailureMessage(v any) (message string) { + return format.Message(v, fmt.Sprintf("to %s", cm.failReason)) +} + +func (cm *CtxMatcher) NegatedFailureMessage(v any) (message string) { + return format.Message(v, fmt.Sprintf("not to %s", cm.failReason)) +} + +func (cm *CtxMatcher) Matches(v any) bool { + success, _ := cm.match(v) + return success +} + +func (cm *CtxMatcher) String() string { + return "failed" +} + +func (cm *CtxMatcher) match(v any) (bool, error) { + ctx, ok := v.(context.Context) + if !ok { + cm.failReason = FailCtxNotAContext + return false, nil + } + + // (1) matching context value + if cm.key != nil { + foundValue := ctx.Value(cm.key) + + if cm.value == nil { + // simply match existence of a value + if foundValue == nil { + cm.failReason = fmt.Errorf("%w key=`%s`", FailCtxValueExpected, cm.key) + return false, nil + } + + return true, nil + } + + valueMatcher := Psi(cm.value) + succeed, err := valueMatcher.Match(foundValue) + if err != nil { + return false, err + } + if !succeed { + cm.failReason = fmt.Errorf("%w key=`%s` that failed on match:\n%s", FailCtxValueNotMatched, cm.key, valueMatcher.FailureMessage(foundValue)) + } + return succeed, nil + } + // (2) matching context err + if cm.errFn != nil { + errMatcher := Psi(cm.errFn) + succeed, err := errMatcher.Match(ctx.Err()) + if err != nil { + return false, err + } + if !succeed { + cm.failReason = fmt.Errorf("%w: %s", FailCtxErrNotMatched, errMatcher.FailureMessage(ctx.Err())) + } + return succeed, nil + } + // (3) matching context deadline + if cm.deadline != nil { + // first simple check if deadline exists + deadline, ok := ctx.Deadline() + if !ok { + cm.failReason = FailCtxDeadlineExpected + return false, nil + } + + deadlineMatcher := Psi(cm.deadline) + succeed, err := deadlineMatcher.Match(deadline) + if err != nil { + return false, err + } + if !succeed { + cm.failReason = fmt.Errorf("%w: %s", FailCtxDeadlineNotMatched, deadlineMatcher.FailureMessage(deadline)) + } + return succeed, nil + } + // (4) matching context Done signal TODO + //ctx.Done() + + return true, nil +} + +func NewCtxMatcher() *CtxMatcher { + return &CtxMatcher{} +} + +func NewCtxValueMatcher(key any, valueArg ...any) *CtxMatcher { + matcher := &CtxMatcher{key: key} + switch len(valueArg) { + case 0: + return matcher + case 1: + matcher.value = valueArg[0] + return matcher + default: + panic("NewCtxValueMatcher expects either 0 or 1 value matcher") + } +} + +func NewCtxDeadlineMatcher(deadline any) *CtxMatcher { + return &CtxMatcher{deadline: deadline} +} + +func NewCtxErrMatcher(errFn any) *CtxMatcher { + return &CtxMatcher{errFn: errFn} +} diff --git a/internal/psi_matchers/eq_matcher.go b/internal/psi_matchers/eq_matcher.go new file mode 100644 index 0000000..bcf3937 --- /dev/null +++ b/internal/psi_matchers/eq_matcher.go @@ -0,0 +1,61 @@ +package psi_matchers + +import ( + "bytes" + "fmt" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "reflect" +) + +type EqMatcher struct { + Expected any + lastActualValue any +} + +var _ types.BeMatcher = &EqMatcher{} + +func NewEqMatcher(expected any) *EqMatcher { + return &EqMatcher{Expected: expected} +} + +func (matcher *EqMatcher) Match(actual any) (success bool, err error) { + if actual == nil && matcher.Expected == nil { + return false, fmt.Errorf("Refusing to compare to .\nBe explicit and use BeNil() instead. This is to avoid mistakes where both sides of an assertion are erroneously uninitialized.") + } + // Shortcut for byte slices + // Comparing long byte slices with reflect.DeepEqual is slow, + // so use bytes.Equal if actual and expected are both byte slices. + if actualByteSlice, ok := actual.([]byte); ok { + if expectedByteSlice, ok := matcher.Expected.([]byte); ok { + return bytes.Equal(actualByteSlice, expectedByteSlice), nil + } + } + matcher.lastActualValue = actual + return reflect.DeepEqual(actual, matcher.Expected), nil +} + +func (matcher *EqMatcher) FailureMessage(actual any) (message string) { + actualString, actualOK := actual.(string) + expectedString, expectedOK := matcher.Expected.(string) + if actualOK && expectedOK { + return format.MessageWithDiff(actualString, "to equal", expectedString) + } + + return format.Message(actual, "to equal", matcher.Expected) +} + +func (matcher *EqMatcher) NegatedFailureMessage(actual any) (message string) { + return format.Message(actual, "not to equal", matcher.Expected) +} + +func (matcher *EqMatcher) Matches(actual any) bool { + res, _ := matcher.Match(actual) + matcher.lastActualValue = actual + return res +} + +// String is considered to be called after Matches() was called +func (matcher *EqMatcher) String() string { + return matcher.FailureMessage(matcher.lastActualValue) +} diff --git a/internal/psi_matchers/eq_matcher_test.go b/internal/psi_matchers/eq_matcher_test.go new file mode 100644 index 0000000..044fa4e --- /dev/null +++ b/internal/psi_matchers/eq_matcher_test.go @@ -0,0 +1,44 @@ +package psi_matchers_test + +import ( + . "github.com/expectto/be/internal/psi_matchers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("EqMatcher", func() { + + DescribeTable("Match", + func(expected, actual any, expectedResult, shouldFail bool) { + matcher := NewEqMatcher(expected) + result, err := matcher.Match(actual) + Expect(result).To(Equal(expectedResult)) + Expect(err != nil).To(Equal(shouldFail)) + + // Matches() is a shortcut to Match + Expect(matcher.Matches(actual)).To(Equal(result)) + }, + Entry("Nil values (are note comparable)", nil, nil, false, true), + Entry("Equal strings", "hello", "hello", true, false), + Entry("Equal byte slices", []byte{1, 2, 3}, []byte{1, 2, 3}, true, false), + Entry("Not equal strings", "hello", "world", false, false), + Entry("Not equal byte slices", []byte{1, 2, 3}, []byte{3, 2, 1}, false, false), + ) + + Describe("Failure", func() { + It("should return the failure message for non-equal strings", func() { + matcher := NewEqMatcher("world") + failureMsg := matcher.FailureMessage("hello") + + Expect(failureMsg).To(Equal("Expected\n : hello\nto equal\n : world")) + }) + + It("should return the failure String() for non-equal strings", func() { + matcher := NewEqMatcher("world") + _ = matcher.Matches("hello") // actual value is passed through Matches9) + failureStr := matcher.String() + + Expect(failureStr).To(Equal("Expected\n : hello\nto equal\n : world")) + }) + }) +}) diff --git a/internal/psi_matchers/have_length_matcher.go b/internal/psi_matchers/have_length_matcher.go new file mode 100644 index 0000000..353e34e --- /dev/null +++ b/internal/psi_matchers/have_length_matcher.go @@ -0,0 +1,85 @@ +package psi_matchers + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/reflect" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "strings" +) + +// HaveLengthMatcher is an Omega-format matcher that matches length of the given list +// in comparison to either a given int value, or matching to other given matchers +type HaveLengthMatcher struct { + count *int + matching types.BeMatcher + + *MixinMatcherGomock +} + +var _ types.BeMatcher = &HaveLengthMatcher{} + +func NewHaveLengthMatcher(args ...any) *HaveLengthMatcher { + if len(args) == 0 { + panic("HaveLenMatcher requires an int or list of matcher be given") + } + + matcher := &HaveLengthMatcher{} + matcher.MixinMatcherGomock = NewMixinMatcherGomock(matcher, "HaveLen") + + // if just argument was given, and it's not a matcher, then it must be an integer + if len(args) == 1 && !IsMatcher(args[0]) { + matcher.count = new(int) + *matcher.count = cast.AsInt(args[0]) + return matcher + } + + // compress all given args as group of matchers + matcher.matching = Psi(args...) + return matcher +} + +func (matcher *HaveLengthMatcher) Match(actual any) (success bool, err error) { + length, ok := reflect.LengthOf(actual) + if !ok { + return false, fmt.Errorf("HaveLen matcher expects a string/array/map/channel/slice. Got:\n%s", format.Object(actual, 1)) + } + + if matcher.count != nil { + return length == *matcher.count, nil + } + + return matcher.matching.Match(length) +} + +func (matcher *HaveLengthMatcher) FailureMessage(actual any) (message string) { + if matcher.count != nil { + return fmt.Sprintf("Expected\n%s\nto have length = %d", format.Object(actual, 1), *matcher.count) + } + + // Assuming that underlying message will be the same format + // change: + // Expect [ ] to <> + // into + // Expect [ ] length to <> + failureMessage := matcher.matching.FailureMessage(actual) + failureMessage = strings.Replace(failureMessage, "\nto", "\nlength to", 1) + return failureMessage +} + +func (matcher *HaveLengthMatcher) NegatedFailureMessage(actual any) (message string) { + if matcher.count != nil { + return fmt.Sprintf("Expected\n%s\nnot to have length = %d", format.Object(actual, 1), *matcher.count) + } + + // Assuming that underlying message will be the same format + // change: + // Expect [ ] not to <> + // into + // Expect [ ] length not to <> + failureMessage := matcher.matching.FailureMessage(actual) + failureMessage = strings.Replace(failureMessage, "\nnot to", "\nlength not to", 1) + return failureMessage +} diff --git a/internal/psi_matchers/have_length_matcher_test.go b/internal/psi_matchers/have_length_matcher_test.go new file mode 100644 index 0000000..0ee10d7 --- /dev/null +++ b/internal/psi_matchers/have_length_matcher_test.go @@ -0,0 +1,68 @@ +package psi_matchers_test + +import ( + . "github.com/expectto/be/internal/psi_matchers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("HaveLengthMatcher", func() { + Context("Match", func() { + It("should match the length correctly", func() { + actual := []int{1, 2, 3} + matcher := NewHaveLengthMatcher(3) + + Expect(matcher.Match(actual)).To(BeTrue()) + }) + + It("should fail for incorrect length", func() { + actual := []int{1, 2} + matcher := NewHaveLengthMatcher(3) + + Expect(matcher.Match(actual)).To(BeFalse()) + }) + + It("should handle invalid input type", func() { + actual := 123 // Invalid type + matcher := NewHaveLengthMatcher(3) + _, err := matcher.Match(actual) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("HaveLen matcher expects a string/array/map/channel/slice")) + }) + }) + // + //Context("FailureMessage", func() { + // It("should return the failure message for incorrect length", func() { + // actual := []int{1, 2} + // failureMsg := matcher.FailureMessage(actual) + // expectedMsg := fmt.Sprintf("Expected\n []int{1, 2}\nto have length = 3") + // Expect(failureMsg).To(Equal(expectedMsg)) + // }) + // + // It("should return the failure message for matcher comparison", func() { + // matcher = psi_matchers.NewHaveLengthMatcher(BeNumerically(">", 5)) + // actual := []int{1, 2, 3} + // failureMsg := matcher.FailureMessage(actual) + // expectedMsg := fmt.Sprintf("Expected\n []int{1, 2, 3}\nlength to be > 5") + // Expect(failureMsg).To(Equal(expectedMsg)) + // }) + //}) + // + //Context("NegatedFailureMessage", func() { + // It("should return the negated failure message for incorrect length", func() { + // actual := []int{1, 2} + // negatedFailureMsg := matcher.NegatedFailureMessage(actual) + // expectedMsg := fmt.Sprintf("Expected\n []int{1, 2}\nnot to have length = 3") + // Expect(negatedFailureMsg).To(Equal(expectedMsg)) + // }) + // + // It("should return the negated failure message for matcher comparison", func() { + // matcher = psi_matchers.NewHaveLengthMatcher(BeNumerically(">", 5)) + // actual := []int{1, 2, 3} + // negatedFailureMsg := matcher.NegatedFailureMessage(actual) + // expectedMsg := fmt.Sprintf("Expected\n []int{1, 2, 3}\nlength not to be > 5") + // Expect(negatedFailureMsg).To(Equal(expectedMsg)) + // }) + //}) +}) diff --git a/internal/psi_matchers/implements_matcher.go b/internal/psi_matchers/implements_matcher.go new file mode 100644 index 0000000..dd8be9a --- /dev/null +++ b/internal/psi_matchers/implements_matcher.go @@ -0,0 +1,46 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "reflect" +) + +type ImplementsMatcher struct { + implements reflect.Type + + *MixinMatcherGomock +} + +var _ types.BeMatcher = &ImplementsMatcher{} + +func NewImplementsMatcher[T any]() *ImplementsMatcher { + t := reflect.TypeOf((*T)(nil)).Elem() + + if t.Kind() != reflect.Interface { + panic("ImplementsMatcher accepts interfaces to be given as T") + } + + im := &ImplementsMatcher{implements: t} + im.MixinMatcherGomock = NewMixinMatcherGomock(im, "Implements") + + return im +} + +func (matcher *ImplementsMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, nil + } + actualT := reflect.TypeOf(actual) + return actualT.Implements(matcher.implements), nil +} + +func (matcher *ImplementsMatcher) FailureMessage(actual any) string { + return format.Message(actual, fmt.Sprintf("to implement: %s", matcher.implements.String())) +} + +func (matcher *ImplementsMatcher) NegatedFailureMessage(actual any) string { + return format.Message(actual, fmt.Sprintf("not to implement: %s", matcher.implements.String())) +} diff --git a/internal/psi_matchers/jwt_token_matcher.go b/internal/psi_matchers/jwt_token_matcher.go new file mode 100644 index 0000000..b86041f --- /dev/null +++ b/internal/psi_matchers/jwt_token_matcher.go @@ -0,0 +1,81 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/golang-jwt/jwt/v5" + "github.com/onsi/gomega/format" + "strings" +) + +type JwtTokenMatcher struct { + publicName string // e.g. HaveClaims + cb func(t *jwt.Token) any + matching types.BeMatcher + + // todo: adjust gomock methods work as intended + *MixinMatcherGomock +} + +var _ types.BeMatcher = &JwtTokenMatcher{} + +func NewJwtTokenMatcher(publicName string, cb func(token *jwt.Token) any, args ...any) *JwtTokenMatcher { + matcher := &JwtTokenMatcher{ + publicName: publicName, + cb: cb, + } + + matcher.MixinMatcherGomock = NewMixinMatcherGomock(matcher, "Token field of") + + // No args means that this matcher succeeds when actual url will have any non-empty {field value} + if len(args) > 0 { + // compressing the args as list of matchers + // or falling back to Equal matcher in case if len(args)==1 + // see types.Psi() for more details + matcher.matching = Psi(args...) + } + + // todo: pass fieldName as description to gomega + return matcher +} + +func (matcher *JwtTokenMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, fmt.Errorf("%s() expects actual value not to be nil", "jwt.Match") + } + + actualUrl, ok := actual.(*jwt.Token) + if !ok { + return false, nil + } + + if matcher.cb == nil { + // we're just matching a valid URL + return true, nil + } + + v := matcher.cb(actualUrl) + + // If no inner matchers were given, then we simply validated if {field value} is not empty + if matcher.matching == nil { + return v != "" && v != nil && v != 0, nil + } + + // simply allow underlying matchers to do their job + return matcher.matching.Match(v) +} + +func (matcher *JwtTokenMatcher) FailureMessage(actual any) string { + v := matcher.cb(actual.(*jwt.Token)) + + if matcher.matching == nil { + return format.Message(v, fmt.Sprintf(`to be a non-empty %s`, "foo")) + } + return matcher.matching.FailureMessage(v) +} + +func (matcher *JwtTokenMatcher) NegatedFailureMessage(actual any) string { + // todo: not so accurate + return strings.Replace(matcher.FailureMessage(actual), "\nto ", "\nnot to ", 1) +} diff --git a/internal/psi_matchers/kind_matcher.go b/internal/psi_matchers/kind_matcher.go new file mode 100644 index 0000000..55ca47a --- /dev/null +++ b/internal/psi_matchers/kind_matcher.go @@ -0,0 +1,80 @@ +package psi_matchers + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "reflect" + "strings" +) + +// +// KindMatcher: +// + +type KindMatcher struct { + kind *reflect.Kind + + matching types.BeMatcher + + *MixinMatcherGomock +} + +var _ types.BeMatcher = &KindMatcher{} + +func NewKindMatcher(args ...any) *KindMatcher { + if len(args) == 0 { + panic("KindMatcher requires a reflect.Kind or list of matcher be given") + } + + matcher := &KindMatcher{} + if len(args) == 1 && !IsMatcher(args[0]) { + matcher.kind = new(reflect.Kind) + *(matcher.kind) = cast.AsKind(args[0]) + return matcher + } + + matcher.matching = Psi(args...) + matcher.MixinMatcherGomock = NewMixinMatcherGomock(matcher, "Kind of") + return matcher +} + +func (matcher *KindMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, nil + } + + if matcher.kind != nil { + return *matcher.kind == reflect.TypeOf(actual).Kind(), nil + } + + return matcher.matching.Match(reflect.TypeOf(actual).Kind()) +} + +func (matcher *KindMatcher) FailureMessage(actual any) string { + if matcher.kind != nil { + return format.Message(actual, fmt.Sprintf("to be kind of %s", matcher.kind.String())) + } + + // Assuming that underlying message will be the same format + // change: + // Expect [ ] to <> + // into + // Expect [ ] kind to <> + // Note: probably a weak solution: consider better + failureMessage := matcher.matching.FailureMessage(actual) + failureMessage = strings.Replace(failureMessage, "\nto", "\nkind to", 1) + return failureMessage +} + +func (matcher *KindMatcher) NegatedFailureMessage(actual any) string { + if matcher.kind != nil { + return format.Message(actual, fmt.Sprintf("not to be kind of %s", matcher.kind.String())) + } + + failureMessage := matcher.matching.NegatedFailureMessage(actual) + failureMessage = strings.Replace(failureMessage, "\nnot to", "\nkind not to", 1) + return failureMessage +} diff --git a/internal/psi_matchers/never_matcher.go b/internal/psi_matchers/never_matcher.go new file mode 100644 index 0000000..6704acf --- /dev/null +++ b/internal/psi_matchers/never_matcher.go @@ -0,0 +1,20 @@ +package psi_matchers + +import "github.com/expectto/be/types" + +// NeverMatcher never matches (always fails) +type NeverMatcher struct { + err error +} + +var _ types.BeMatcher = &NeverMatcher{} + +func NewNeverMatcher(err error) *NeverMatcher { + return &NeverMatcher{err: err} +} + +func (m *NeverMatcher) Match(_ any) (bool, error) { return false, nil } +func (m *NeverMatcher) FailureMessage(actual any) string { return m.err.Error() } +func (m *NeverMatcher) NegatedFailureMessage(actual any) string { return m.err.Error() /* todo */ } +func (m *NeverMatcher) Matches(actual any) bool { return false } +func (m *NeverMatcher) String() string { return m.err.Error() } diff --git a/internal/psi_matchers/not_matcher.go b/internal/psi_matchers/not_matcher.go new file mode 100644 index 0000000..175fced --- /dev/null +++ b/internal/psi_matchers/not_matcher.go @@ -0,0 +1,47 @@ +package psi_matchers + +import ( + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" +) + +type NotMatcher struct { + Matcher types.BeMatcher + lastActualValue any +} + +var _ types.BeMatcher = &NotMatcher{} + +func NewNotMatcher(m any) *NotMatcher { + return &NotMatcher{Matcher: AsMatcher(m)} +} + +func (m *NotMatcher) Match(actual interface{}) (bool, error) { + success, err := m.Matcher.Match(actual) + if err != nil { + return false, err + } + m.lastActualValue = actual + return !success, nil +} + +func (m *NotMatcher) FailureMessage(actual interface{}) (message string) { + return m.Matcher.NegatedFailureMessage(actual) // works beautifully +} + +func (m *NotMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return m.Matcher.FailureMessage(actual) // works beautifully +} + +func (m *NotMatcher) Matches(actual any) bool { + res, _ := m.Match(actual) + return res +} + +// Todo: inaccurate behavior should be fixed +func (m *NotMatcher) String() string { + mes := m.FailureMessage(m.lastActualValue) + return mes +} + +// todo: MatchMayChangeInTheFuture diff --git a/internal/psi_matchers/psi_matchers_suite_test.go b/internal/psi_matchers/psi_matchers_suite_test.go new file mode 100644 index 0000000..25b3469 --- /dev/null +++ b/internal/psi_matchers/psi_matchers_suite_test.go @@ -0,0 +1,13 @@ +package psi_matchers_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPsiMatchers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "PsiMatchers Suite") +} diff --git a/internal/psi_matchers/request_property_matcher.go b/internal/psi_matchers/request_property_matcher.go new file mode 100644 index 0000000..752dac2 --- /dev/null +++ b/internal/psi_matchers/request_property_matcher.go @@ -0,0 +1,78 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "net/http" + "strings" +) + +// ReqPropertyMatcher is a matcher for http.Request properties +type ReqPropertyMatcher struct { + publicName string // e.g. RequestHavingMethod + property string // e.g. "method" + + // cb is a callback for extracting property value from http.Request + cb func(r *http.Request) any + + // matching is a matcher for property value + matching types.BeMatcher +} + +// NewReqPropertyMatcher creates a new matcher for http.Request properties +func NewReqPropertyMatcher(publicName, fieldName string, cb func(r *http.Request) any, args ...any) types.BeMatcher { + matcher := &ReqPropertyMatcher{publicName: publicName, property: fieldName, cb: cb} + + // No args means that this matcher succeeds when actual url will have any non-empty {field value} + if len(args) > 0 { + // compressing the args as list of matchers + // or falling back to Equal matcher in case if len(args)==1 + // see types.Psi() for more details + matcher.matching = Psi(args...) + } + + // todo: pass fieldName as description to gomega + return Psi(matcher) +} + +func (matcher *ReqPropertyMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, fmt.Errorf("%s() expects actual value not to be nil", matcher.publicName) + } + + actualReq, ok := actual.(*http.Request) + if !ok { + return false, fmt.Errorf("%s() expects actual value mast be a <*http.Request> received <%T>", matcher.publicName, actual) + } + + if matcher.cb == nil { + // we're just matching a valid URL + return true, nil + } + + v := matcher.cb(actualReq) + + // If no inner matchers were given, then we simply validated if {field value} is not empty + if matcher.matching == nil { + return v != "" && v != nil && v != 0, nil + } + + // simply allow underlying matchers to do their job + return matcher.matching.Match(v) +} + +func (matcher *ReqPropertyMatcher) FailureMessage(actual any) string { + v := matcher.cb(actual.(*http.Request)) + + if matcher.matching == nil { + return format.Message(v, fmt.Sprintf(`to be a non-empty %s`, matcher.property)) + } + return matcher.matching.FailureMessage(v) +} + +func (matcher *ReqPropertyMatcher) NegatedFailureMessage(actual any) string { + // todo: not so accurate + return strings.Replace(matcher.FailureMessage(actual), "\nto ", "\nnot to ", 1) +} diff --git a/internal/psi_matchers/string_template_matcher.go b/internal/psi_matchers/string_template_matcher.go new file mode 100644 index 0000000..fef8ce2 --- /dev/null +++ b/internal/psi_matchers/string_template_matcher.go @@ -0,0 +1,143 @@ +package psi_matchers + +import ( + "fmt" + "github.com/expectto/be/internal/cast" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "regexp" + "strings" +) + +type Value struct { + Name string + Matcher types.BeMatcher +} + +type Values []*Value + +// V creates a value used for replacing placeholders for templates in `MatchTemplate` +func V(name string, matching any) *Value { + return &Value{Name: name, Matcher: Psi(matching)} +} + +type StringTemplateMatcher struct { + *MixinMatcherGomock + + regex *regexp.Regexp + values Values + + lastValue *Value + lastActual any + failedMessage string +} + +var _ types.BeMatcher = &StringTemplateMatcher{} + +func NewStringTemplateMatcher(template string, values ...*Value) *StringTemplateMatcher { + // very quick and dirty check + // We don't allow `{{Greeting}}{{Username}}`. We expect at least any separator between placeholders: + if strings.Contains(template, "}}{{") { + panic("invalid template: Placeholders can't be concatenated without separators") + } + + // Idea here is to switch from templating to regexp (Ugly, but ok for first attempt) + // {{Name}} => (?P.+) + variableRegex := regexp.MustCompile(`{{\s*([^}\s]+)\s*}}`) + regexStr := variableRegex.ReplaceAllString(template, "(?P<$1>.+)") + + regex, err := regexp.Compile(regexStr) + if err != nil { + panic("invalid template: could not compile a regex from it: " + err.Error()) + } + + return &StringTemplateMatcher{ + regex: regex, values: values, + } +} + +func (matcher *StringTemplateMatcher) Match(actual any) (success bool, err error) { + match := matcher.regex.FindStringSubmatch(cast.AsString(actual)) + if len(match) != len(matcher.regex.SubexpNames()) { + matcher.failedMessage = fmt.Sprintf( + "initial mismatch: number of groups expected to be %d but not %d", + len(match), + len(matcher.regex.SubexpNames()), + ) + return false, nil + } + + results := make(map[string]string) + for i, name := range matcher.regex.SubexpNames() { + if i == 0 || name == "" { + continue + } + name = strings.ToLower(name) + + if savedResult, ok := results[name]; ok { + if savedResult != match[i] { + return false, fmt.Errorf("var %s has multiple values: %s != %s", name, savedResult, match[i]) + } + } + + results[name] = match[i] + } + + // if no vars are given: we simply verified that whole string matches template + // without matching specifically templates variables + if len(matcher.values) == 0 { + return true, nil + } + + for _, v := range matcher.values { + name := strings.ToLower(v.Name) + result, ok := results[name] + if !ok { + return false, fmt.Errorf("var %s given but not met in actual value", name) + } + + matched, err := v.Matcher.Match(result) + if err != nil { + return false, fmt.Errorf("var %s failed: %w", name, err) + } + matcher.lastValue = v + matcher.lastActual = result + + if !matched { + return false, nil + } + } + + return true, nil +} + +func (matcher *StringTemplateMatcher) FailureMessage(actual any) string { + if matcher.lastValue == nil { + return format.Message(actual, "to match template:\n %s", matcher.failedMessage) + } + + return format.Message( + actual, + fmt.Sprintf( + "to match template on value %s:\n %s", + matcher.lastValue.Name, + matcher.lastValue.Matcher.FailureMessage(matcher.lastActual), + ), + ) +} + +func (matcher *StringTemplateMatcher) NegatedFailureMessage(actual any) string { + if matcher.lastValue == nil { + return format.Message(actual, "not to match template:\n %s", matcher.failedMessage) + } + + return format.Message( + actual, + fmt.Sprintf( + "not to match template on value: %s:\n %s", + matcher.lastValue.Name, + matcher.lastValue.Matcher.NegatedFailureMessage(actual), + ), + ) +} diff --git a/internal/psi_matchers/url_field_matcher.go b/internal/psi_matchers/url_field_matcher.go new file mode 100644 index 0000000..2586d6b --- /dev/null +++ b/internal/psi_matchers/url_field_matcher.go @@ -0,0 +1,86 @@ +package psi_matchers + +import ( + "fmt" + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/types" + "github.com/onsi/gomega/format" + "net/url" + "strings" +) + +// UrlFieldMatcher is a helper for matching url fields +// It's considered to be used in matchers_url.go +type UrlFieldMatcher struct { + publicName string // e.g. UrlHavingHost + fieldName string // e.g. "host" + cb func(*url.URL) any + + matching types.BeMatcher + + // todo: adjust gomock methods work as intended + *MixinMatcherGomock +} + +var _ types.BeMatcher = &UrlFieldMatcher{} + +func NewUrlFieldMatcher(publicName, fieldName string, cb func(*url.URL) any, args ...any) *UrlFieldMatcher { + matcher := &UrlFieldMatcher{ + publicName: publicName, + fieldName: fieldName, + cb: cb, + } + + matcher.MixinMatcherGomock = NewMixinMatcherGomock(matcher, "Url field of") + + // No args means that this matcher succeeds when actual url will have any non-empty {field value} + if len(args) > 0 { + // compressing the args as list of matchers + // or falling back to Equal matcher in case if len(args)==1 + // see types.Psi() for more details + matcher.matching = Psi(args...) + } + + // todo: pass fieldName as description to gomega + return matcher +} + +func (matcher *UrlFieldMatcher) Match(actual any) (success bool, err error) { + if actual == nil { + return false, fmt.Errorf("%s() expects actual value not to be nil", matcher.publicName) + } + + actualUrl, ok := actual.(*url.URL) + if !ok { + return false, fmt.Errorf("%s() expects actual value mast be a <*url.URL> received <%T>", matcher.publicName, actual) + } + + if matcher.cb == nil { + // we're just matching a valid URL + return true, nil + } + + v := matcher.cb(actualUrl) + + // If no inner matchers were given, then we simply validated if {field value} is not empty + if matcher.matching == nil { + return v != "" && v != nil && v != 0, nil + } + + // simply allow underlying matchers to do their job + return matcher.matching.Match(v) +} + +func (matcher *UrlFieldMatcher) FailureMessage(actual any) string { + v := matcher.cb(actual.(*url.URL)) + + if matcher.matching == nil { + return format.Message(v, fmt.Sprintf(`to be a non-empty %s`, matcher.fieldName)) + } + return matcher.matching.FailureMessage(v) +} + +func (matcher *UrlFieldMatcher) NegatedFailureMessage(actual any) string { + // todo: not so accurate + return strings.Replace(matcher.FailureMessage(actual), "\nto ", "\nnot to ", 1) +} diff --git a/internal/reflect/reflect.go b/internal/reflect/reflect.go new file mode 100644 index 0000000..a0c94f0 --- /dev/null +++ b/internal/reflect/reflect.go @@ -0,0 +1,34 @@ +// Package reflect contains helpers that extends standard reflect library +package reflect + +import "reflect" + +// TypeFor returns a reflect.Type for a given type +// Deprecated: Should go away when _I hope_ it will be implemented in reflect(go-1.22) +func TypeFor[T any]() reflect.Type { + return reflect.TypeOf((*T)(nil)).Elem() +} + +// IndirectDeep does reflect.Indirect deeply +func IndirectDeep(v reflect.Value) reflect.Value { + for { + if v.Kind() != reflect.Pointer { + break + } + v = v.Elem() + } + return v +} + +// LengthOf returns length of a given type +func LengthOf(a any) (int, bool) { + if a == nil { + return 0, false + } + switch reflect.TypeOf(a).Kind() { + case reflect.Map, reflect.Array, reflect.String, reflect.Chan, reflect.Slice: + return reflect.ValueOf(a).Len(), true + default: + return 0, false + } +} diff --git a/internal/testing/mocks/urler_mock.go b/internal/testing/mocks/urler_mock.go new file mode 100644 index 0000000..b059673 --- /dev/null +++ b/internal/testing/mocks/urler_mock.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/testing/urler.go +// +// Generated by this command: +// +// mockgen -source=internal/testing/urler.go -destination=internal/testing/mocks/urler_mock.go -package=mocks +// +// Package mocks is a generated GoMock package. +package mocks + +import ( + url "net/url" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockUrler is a mock of Urler interface. +type MockUrler struct { + ctrl *gomock.Controller + recorder *MockUrlerMockRecorder +} + +// MockUrlerMockRecorder is the mock recorder for MockUrler. +type MockUrlerMockRecorder struct { + mock *MockUrler +} + +// NewMockUrler creates a new mock instance. +func NewMockUrler(ctrl *gomock.Controller) *MockUrler { + mock := &MockUrler{ctrl: ctrl} + mock.recorder = &MockUrlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUrler) EXPECT() *MockUrlerMockRecorder { + return m.recorder +} + +// SetUrl mocks base method. +func (m *MockUrler) SetUrl(url *url.URL) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetUrl", url) +} + +// SetUrl indicates an expected call of SetUrl. +func (mr *MockUrlerMockRecorder) SetUrl(url any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUrl", reflect.TypeOf((*MockUrler)(nil).SetUrl), url) +} diff --git a/internal/testing/urler.go b/internal/testing/urler.go new file mode 100644 index 0000000..d6df212 --- /dev/null +++ b/internal/testing/urler.go @@ -0,0 +1,7 @@ +package testing + +import "net/url" + +type Urler interface { + SetUrl(url *url.URL) +} diff --git a/matchers.go b/matchers.go new file mode 100644 index 0000000..184058e --- /dev/null +++ b/matchers.go @@ -0,0 +1,24 @@ +package be + +import ( + "github.com/expectto/be/be_ctx" + "github.com/expectto/be/be_http" + "github.com/expectto/be/be_jwt" + "github.com/expectto/be/be_string" + "github.com/expectto/be/be_url" +) + +// HttpRequest is an alias for be_http.Request matcher +var HttpRequest = be_http.Request + +// URL is an alias for be_url.URL matcher +var URL = be_url.URL + +// JwtToken is an alias for be_jwt.Token matcher +var JwtToken = be_jwt.Token + +// StringAsTemplate is an alias for be_string.MatchTemplate matcher +var StringAsTemplate = be_string.MatchTemplate + +// Ctx is an alias for be_ctx.Ctx +var Ctx = be_ctx.Ctx diff --git a/matchers_be.go b/matchers_be.go new file mode 100644 index 0000000..c372a5f --- /dev/null +++ b/matchers_be.go @@ -0,0 +1,57 @@ +package be + +// matchers_be.go contains Public callers for core psi matchers +// For advances matchers check out `be_*` packages + +import ( + . "github.com/expectto/be/internal/psi" + "github.com/expectto/be/internal/psi_matchers" + "github.com/expectto/be/types" +) + +// Always does always match +func Always() types.BeMatcher { + return psi_matchers.NewAlwaysMatcher() +} + +// Never does never succeed (does always fail) +func Never(err error) types.BeMatcher { + return psi_matchers.NewNeverMatcher(err) +} + +// All is like gomega.And() +func All(ms ...any) types.BeMatcher { + return psi_matchers.NewAllMatcher(Psi(ms...)) +} + +// Any is like gomega.Or() +func Any(ms ...any) types.BeMatcher { + return psi_matchers.NewAnyMatcher(ms...) +} + +// Eq is like gomega.Equal() +func Eq(expected any) types.BeMatcher { + return psi_matchers.NewEqMatcher(expected) +} + +// Not is like gomega.Not() +func Not(expected any) types.BeMatcher { + return psi_matchers.NewNotMatcher(Psi(expected)) +} + +// HaveLength is like gomega.HaveLen() +// HaveLength succeeds if the actual value has a length that matches the provided conditions. +// It accepts either a count value or one or more Gomega matchers to specify the desired length conditions. +func HaveLength(args ...any) types.BeMatcher { + return psi_matchers.NewHaveLengthMatcher(args...) +} + +// Dive applies the given matcher to each (every) element of the slice. +// Note: Dive is very close to gomega.HaveEach +func Dive(matcher any) types.BeMatcher { return NewDiveMatcher(matcher, DiveModeEvery) } + +// DiveAny applies the given matcher to each element and succeeds in case if it succeeds at least at one item +func DiveAny(matcher any) types.BeMatcher { return NewDiveMatcher(matcher, DiveModeAny) } + +// DiveFirst applies the given matcher to the first element of the given slice +func DiveFirst(matcher any) types.BeMatcher { return NewDiveMatcher(matcher, DiveModeFirst) } diff --git a/options/be_string_options.go b/options/be_string_options.go new file mode 100644 index 0000000..6ec1e6f --- /dev/null +++ b/options/be_string_options.go @@ -0,0 +1,59 @@ +package options + +type StringOption Option + +const ( + // considering here 0 as no option + _ = 0 + + // Alpha option represents the presence of alphabetical characters. + Alpha StringOption = 1 << (iota - 1) + + // Numeric option represents the presence of numeric characters. + Numeric + + // Whitespace option represents the presence of whitespace characters. + Whitespace + + // Dots option represents the presence of dot characters. + Dots + + // Punctuation option represents the presence of punctuation characters. + Punctuation + + // SpecialCharacters option represents the presence of special characters. + SpecialCharacters +) + +func (f StringOption) String() string { + switch f { + case Alpha: + return "alpha" + case Numeric: + return "numeric" + case Whitespace: + return "whitespace" + case Dots: + return "dots" + case Punctuation: + return "punctuation" + case SpecialCharacters: + return "special characters" + default: + return "unknown" + } +} + +// ExtractStringOptions extracts individual options from a combined string option +func ExtractStringOptions(combined StringOption) []StringOption { + var options []StringOption + + // Iterate over each bit position to check if the option is set + for flag := StringOption(1); flag <= combined; flag <<= 1 { + if combined&flag != 0 { + options = append(options, flag) + } + } + + return options +} diff --git a/options/options.go b/options/options.go new file mode 100644 index 0000000..3700826 --- /dev/null +++ b/options/options.go @@ -0,0 +1,10 @@ +// Package options declares options to be used in customizeable matchers +// Note: Options of ALL `be_*` matchers are stored here, in a separate package `options`. +// +// It's done with consideration that `options` package will be imported via `dot import` +// so inside your tests options are clear & short, without package name +// e.g.: `Expect(myString).To(be_string.Only( Alpha | Numeric | Whitespace )` +package options + +// Option represents an option for any customizable matcher +type Option int diff --git a/types/be_matcher.go b/types/be_matcher.go new file mode 100644 index 0000000..aba28fd --- /dev/null +++ b/types/be_matcher.go @@ -0,0 +1,29 @@ +package types + +// GomockMatcher represents a matcher compatible with the Gomock library. +// We intentionally refrain using the Matcher interface from the Gomock library +// to prevent direct dependency on the Gomock library. +// Interface source: https://github.com/uber-go/mock/blob/main/gomock/matchers.go +type GomockMatcher interface { + Matches(x any) bool + String() string +} + +// GomegaMatcher represents a matcher compatible with the Gomega library. +// We intentionally refrain from using the GomegaMatcher interface from the Gomega library. +// Although the Gomega library is still utilized as a dependency in other parts of `be`, +// and is likely to remain as a dependency for the time being. +// Interface source: https://github.com/onsi/gomega/blob/master/types/types.go#L37 +type GomegaMatcher interface { + Match(actual interface{}) (success bool, err error) + FailureMessage(actual interface{}) (message string) + NegatedFailureMessage(actual interface{}) (message string) +} + +// BeMatcher is the main matcher interface for the `be` library. +// It consolidates all types of matchers supported by `be`, +// which currently includes GomegaMatcher and GomockMatcher. +type BeMatcher interface { + GomegaMatcher + GomockMatcher +}