From 492bedae6913d50fb54b8e3203d81d7330308f46 Mon Sep 17 00:00:00 2001 From: chris-4chain <152964795+chris-4chain@users.noreply.github.com> Date: Thu, 5 Dec 2024 07:46:19 +0100 Subject: [PATCH] feat(SPV-1249) paymail list & assert with JSON template (#793) --- actions/paymails/routes.go | 12 ++ actions/paymails/search.go | 66 ++++++++ actions/paymails/search_test.go | 156 +++++++++++++++++ actions/register.go | 2 + .../assert_spvwallet_application.go | 7 + actions/users/get_test.go | 88 ++++++++++ actions/users/routes_test.go | 39 ----- actions/users/update_test.go | 77 +++++++++ actions/users/xpubs_test.go | 43 ----- docs/docs.go | 97 +++++++++++ docs/swagger.json | 97 +++++++++++ docs/swagger.yaml | 69 ++++++++ engine/tester/jsonrequire/funcs.go | 35 ++++ engine/tester/jsonrequire/matcher.go | 36 ++++ engine/tester/jsonrequire/matcher_test.go | 110 ++++++++++++ engine/tester/jsonrequire/placeholders.go | 158 ++++++++++++++++++ engine/tester/jsonrequire/xpath.go | 68 ++++++++ models/filter/paymail_filter.go | 27 ++- 18 files changed, 1100 insertions(+), 87 deletions(-) create mode 100644 actions/paymails/routes.go create mode 100644 actions/paymails/search.go create mode 100644 actions/paymails/search_test.go create mode 100644 actions/users/get_test.go delete mode 100644 actions/users/routes_test.go create mode 100644 actions/users/update_test.go delete mode 100644 actions/users/xpubs_test.go create mode 100644 engine/tester/jsonrequire/funcs.go create mode 100644 engine/tester/jsonrequire/matcher.go create mode 100644 engine/tester/jsonrequire/matcher_test.go create mode 100644 engine/tester/jsonrequire/placeholders.go create mode 100644 engine/tester/jsonrequire/xpath.go diff --git a/actions/paymails/routes.go b/actions/paymails/routes.go new file mode 100644 index 000000000..c63aa0af5 --- /dev/null +++ b/actions/paymails/routes.go @@ -0,0 +1,12 @@ +package paymails + +import ( + "github.com/bitcoin-sv/spv-wallet/server/handlers" + routes "github.com/bitcoin-sv/spv-wallet/server/handlers" +) + +// RegisterRoutes creates the specific package routes in RESTful style +func RegisterRoutes(handlersManager *routes.Manager) { + group := handlersManager.Group(routes.GroupAPI, "/paymails") + group.GET("", handlers.AsUser(paymailAddressesSearch)) +} diff --git a/actions/paymails/search.go b/actions/paymails/search.go new file mode 100644 index 000000000..f798847d9 --- /dev/null +++ b/actions/paymails/search.go @@ -0,0 +1,66 @@ +package paymails + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/actions/common" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/internal/query" + "github.com/bitcoin-sv/spv-wallet/mappings" + "github.com/bitcoin-sv/spv-wallet/models/filter" + "github.com/bitcoin-sv/spv-wallet/models/response" + "github.com/bitcoin-sv/spv-wallet/server/reqctx" + "github.com/gin-gonic/gin" +) + +// paymailAddressesSearch will fetch a list of paymail addresses filtered by metadata +// Paymail addresses search by metadata +// @Summary Paymail addresses search +// @Description Paymail addresses search +// @Tags Users +// @Produce json +// @Param SearchPaymails body filter.PaymailFilter false "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis" +// @Success 200 {object} []response.PaymailAddress "List of paymail addresses" +// @Failure 400 "Bad request - Error while parsing SearchPaymails from request body" +// @Failure 500 "Internal server error - Error while searching for paymail addresses" +// @Router /api/v1/paymails [get] +// @Security x-auth-xpub +func paymailAddressesSearch(c *gin.Context, userContext *reqctx.UserContext) { + logger := reqctx.Logger(c) + + searchParams, err := query.ParseSearchParams[filter.PaymailFilter](c) + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCannotParseQueryParams.WithTrace(err), logger) + return + } + + conditions := searchParams.Conditions.ToDbConditions() + metadata := mappings.MapToMetadata(searchParams.Metadata) + pageOptions := mappings.MapToDbQueryParams(&searchParams.Page) + + paymailAddresses, err := reqctx.Engine(c).GetPaymailAddressesByXPubID( + c, + userContext.GetXPubID(), + metadata, + conditions, + pageOptions, + ) + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCouldNotFindPaymail.WithTrace(err), logger) + return + } + + count, err := reqctx.Engine(c).GetPaymailAddressesCount(c.Request.Context(), metadata, conditions) + if err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCouldNotFindPaymail.WithTrace(err), logger) + return + } + + paymailAddressContracts := common.MapToTypeContracts(paymailAddresses, mappings.MapToPaymailContract) + + result := response.PageModel[response.PaymailAddress]{ + Content: paymailAddressContracts, + Page: common.GetPageDescriptionFromSearchParams(pageOptions, count), + } + c.JSON(http.StatusOK, result) +} diff --git a/actions/paymails/search_test.go b/actions/paymails/search_test.go new file mode 100644 index 000000000..53d0689d1 --- /dev/null +++ b/actions/paymails/search_test.go @@ -0,0 +1,156 @@ +package paymails_test + +import ( + "strings" + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" +) + +func TestCurrentUserPaymails(t *testing.T) { + t.Run("return paymails info for user (single paymail)", func(t *testing.T) { + // given: + given, then := testabilities.New(t) + cleanup := given.StartedSPVWallet() + defer cleanup() + client := given.HttpClient().ForGivenUser(fixtures.Sender) + + // when: + res, _ := client.R().Get("/api/v1/paymails") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "content": [ + { + "address": "{{.Address}}", + "alias": "{{.Alias}}", + "avatar": "{{ matchURL | orEmpty }}", + "createdAt": "{{ matchTimestamp }}", + "deletedAt": null, + "domain": "{{.Domain}}", + "id": "{{ matchID64 }}", + "metadata": "*", + "publicName": "{{.PublicName}}", + "updatedAt": "{{ matchTimestamp }}", + "xpubId": "{{.XPubID}}" + } + ], + "page": { + "number": 1, + "size": 50, + "totalElements": 1, + "totalPages": 1 + } + }`, map[string]any{ + "Address": strings.ToLower(fixtures.Sender.Paymails[0]), + "PublicName": fixtures.Sender.Paymails[0], + "Alias": getAliasFromPaymail(t, fixtures.Sender.Paymails[0]), + "XPubID": fixtures.Sender.XPubID(), + "Domain": fixtures.PaymailDomain, + }) + }) + + t.Run("return paymails info for user (multiple paymails)", func(t *testing.T) { + // given: + given, then := testabilities.New(t) + cleanup := given.StartedSPVWallet() + defer cleanup() + client := given.HttpClient().ForGivenUser(fixtures.UserWithMorePaymails) + + // when: + res, _ := client.R().Get("/api/v1/paymails") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "content": [ + { + "address": "{{.SecondPaymail.Address}}", + "alias": "{{.SecondPaymail.Alias}}", + "avatar": "{{ matchURL | orEmpty }}", + "createdAt": "{{ matchTimestamp }}", + "deletedAt": null, + "domain": "{{.Domain}}", + "id": "{{ matchID64 }}", + "metadata": "*", + "publicName": "{{.SecondPaymail.PublicName}}", + "updatedAt": "{{ matchTimestamp }}", + "xpubId": "{{.XPubID}}" + }, + { + "address": "{{.FirstPaymail.Address}}", + "alias": "{{.FirstPaymail.Alias}}", + "avatar": "{{ matchURL | orEmpty }}", + "createdAt": "{{ matchTimestamp }}", + "deletedAt": null, + "domain": "{{.Domain}}", + "id": "{{ matchID64 }}", + "metadata": "*", + "publicName": "{{.FirstPaymail.PublicName}}", + "updatedAt": "{{ matchTimestamp }}", + "xpubId": "{{.XPubID}}" + } + ], + "page": { + "number": 1, + "size": 50, + "totalElements": 2, + "totalPages": 1 + } + }`, map[string]any{ + "FirstPaymail": map[string]any{ + "Address": strings.ToLower(fixtures.UserWithMorePaymails.Paymails[0]), + "PublicName": fixtures.UserWithMorePaymails.Paymails[0], + "Alias": getAliasFromPaymail(t, fixtures.UserWithMorePaymails.Paymails[0]), + }, + "SecondPaymail": map[string]any{ + "Address": strings.ToLower(fixtures.UserWithMorePaymails.Paymails[1]), + "PublicName": fixtures.UserWithMorePaymails.Paymails[1], + "Alias": getAliasFromPaymail(t, fixtures.UserWithMorePaymails.Paymails[1]), + }, + "XPubID": fixtures.UserWithMorePaymails.XPubID(), + "Domain": fixtures.PaymailDomain, + }) + }) + + t.Run("try to return paymails info for admin", func(t *testing.T) { + // given: + given, then := testabilities.New(t) + cleanup := given.StartedSPVWallet() + defer cleanup() + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R().Get("/api/v1/paymails") + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("return xpub info for anonymous", func(t *testing.T) { + // given: + given, then := testabilities.New(t) + cleanup := given.StartedSPVWallet() + defer cleanup() + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R().Get("/api/v1/paymails") + + // then: + then.Response(res).IsUnauthorized() + }) +} + +func getAliasFromPaymail(t testing.TB, paymail string) (alias string) { + parts := strings.SplitN(paymail, "@", 2) + if len(parts) == 0 { + t.Fatalf("Failed to parse paymail: %s", paymail) + } + alias = strings.ToLower(parts[0]) + return +} diff --git a/actions/register.go b/actions/register.go index f60c17183..331035248 100644 --- a/actions/register.go +++ b/actions/register.go @@ -7,6 +7,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/actions/contacts" "github.com/bitcoin-sv/spv-wallet/actions/destinations" "github.com/bitcoin-sv/spv-wallet/actions/merkleroots" + "github.com/bitcoin-sv/spv-wallet/actions/paymails" "github.com/bitcoin-sv/spv-wallet/actions/sharedconfig" "github.com/bitcoin-sv/spv-wallet/actions/transactions" "github.com/bitcoin-sv/spv-wallet/actions/users" @@ -24,6 +25,7 @@ func Register(appConfig *config.AppConfig, handlersManager *handlers.Manager) { transactions.RegisterRoutes(handlersManager) utxos.RegisterRoutes(handlersManager) users.RegisterRoutes(handlersManager) + paymails.RegisterRoutes(handlersManager) sharedconfig.RegisterRoutes(handlersManager) merkleroots.RegisterRoutes(handlersManager) contacts.RegisterRoutes(handlersManager) diff --git a/actions/testabilities/assert_spvwallet_application.go b/actions/testabilities/assert_spvwallet_application.go index 904068257..98f230d3e 100644 --- a/actions/testabilities/assert_spvwallet_application.go +++ b/actions/testabilities/assert_spvwallet_application.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/bitcoin-sv/spv-wallet/actions/testabilities/apierror" + "github.com/bitcoin-sv/spv-wallet/engine/tester/jsonrequire" "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,6 +22,7 @@ type SPVWalletResponseAssertions interface { IsOK() SPVWalletResponseAssertions HasStatus(status int) SPVWalletResponseAssertions WithJSONf(expectedFormat string, args ...any) + WithJSONMatching(expectedTemplateFormat string, params map[string]any) // IsUnauthorized asserts that the response status code is 401 and the error is about lack of authorization. IsUnauthorized() // IsUnauthorizedForAdmin asserts that the response status code is 401 and the error is that admin is not authorized to use the endpoint. @@ -78,6 +80,11 @@ func (a *responseAssertions) WithJSONf(expectedFormat string, args ...any) { a.assertJSONBody(expectedFormat, args...) } +func (a *responseAssertions) WithJSONMatching(expectedTemplateFormat string, params map[string]any) { + a.assertJSONContentType() + jsonrequire.Match(a.t, expectedTemplateFormat, params, a.response.String()) +} + func (a *responseAssertions) assertJSONContentType() { contentType := a.response.Header().Get("Content-Type") mediaType, _, err := mime.ParseMediaType(contentType) diff --git a/actions/users/get_test.go b/actions/users/get_test.go new file mode 100644 index 000000000..11f69d36b --- /dev/null +++ b/actions/users/get_test.go @@ -0,0 +1,88 @@ +package users_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" +) + +func TestCurrentUserGet(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWallet() + defer cleanup() + + t.Run("return xpub info for user", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R().Get("/api/v1/users/current") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "id": "{{.ID}}", + "createdAt": "{{ matchTimestamp }}", + "updatedAt": "{{ matchTimestamp }}", + "currentBalance": 0, + "deletedAt": null, + "metadata": "*", + "nextExternalNum": 1, + "nextInternalNum": 0 + }`, map[string]any{ + "ID": fixtures.Sender.XPubID(), + }) + }) + + t.Run("return xpub info for user (old api)", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R().Get("/v1/xpub") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "id": "{{.ID}}", + "created_at": "/.*/", + "updated_at": "/.*/", + "current_balance": 0, + "deleted_at": null, + "metadata": "*", + "next_external_num": 1, + "next_internal_num": 0 + }`, map[string]any{ + "ID": fixtures.Sender.XPubID(), + }) + }) + + t.Run("return xpub info for admin", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R().Get("/api/v1/users/current") + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("return xpub info for anonymous", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R().Get("/api/v1/users/current") + + // then: + then.Response(res).IsUnauthorized() + }) +} diff --git a/actions/users/routes_test.go b/actions/users/routes_test.go deleted file mode 100644 index 8fe2a88c6..000000000 --- a/actions/users/routes_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package users - -import ( - "testing" - - "github.com/bitcoin-sv/spv-wallet/config" - "github.com/stretchr/testify/assert" -) - -// TestXPubRegisterRoutes will test routes -func (ts *TestSuite) TestXPubRegisterRoutes() { - ts.T().Run("test routes", func(t *testing.T) { - - testCases := []struct { - method string - url string - }{ - {"GET", "/" + config.APIVersion + "/xpub"}, - {"PATCH", "/" + config.APIVersion + "/xpub"}, - - {"GET", "/api/" + config.APIVersion + "/users/current"}, - {"PATCH", "/api/" + config.APIVersion + "/users/current"}, - } - - ts.Router.Routes() - - for _, testCase := range testCases { - found := false - for _, routeInfo := range ts.Router.Routes() { - if testCase.url == routeInfo.Path && testCase.method == routeInfo.Method { - assert.NotNil(t, routeInfo.HandlerFunc) - found = true - break - } - } - assert.True(t, found) - } - }) -} diff --git a/actions/users/update_test.go b/actions/users/update_test.go new file mode 100644 index 000000000..228729d65 --- /dev/null +++ b/actions/users/update_test.go @@ -0,0 +1,77 @@ +package users_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + "github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures" +) + +func TestCurrentUserUpdate(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWallet() + defer cleanup() + + metadataToUpdate := map[string]any{ + "num": 1234, + "str": "abc", + } + + t.Run("update xpub metadata as user", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + // when: + res, _ := client.R(). + SetBody(metadataToUpdate). + Patch("/api/v1/users/current") + + // then: + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "createdAt": "{{ matchTimestamp }}", + "updatedAt": "{{ matchTimestamp }}", + "currentBalance": 0, + "deletedAt": null, + "id": "{{.ID}}", + "metadata": { + "num": 1234, + "str": "abc" + }, + "nextExternalNum": 1, + "nextInternalNum": 0 + }`, map[string]any{ + "ID": fixtures.Sender.XPubID(), + }) + }) + + t.Run("update xpub metadata for admin", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAdmin() + + // when: + res, _ := client.R(). + SetBody(metadataToUpdate). + Patch("/api/v1/users/current") + + // then: + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("update xpub metadata for anonymous", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + // when: + res, _ := client.R(). + SetBody(metadataToUpdate). + Patch("/api/v1/users/current") + + // then + then.Response(res).IsUnauthorized() + }) +} diff --git a/actions/users/xpubs_test.go b/actions/users/xpubs_test.go deleted file mode 100644 index 6b168aedc..000000000 --- a/actions/users/xpubs_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package users - -import ( - "testing" - - "github.com/bitcoin-sv/spv-wallet/config" - "github.com/bitcoin-sv/spv-wallet/server/handlers" - "github.com/bitcoin-sv/spv-wallet/tests" - "github.com/stretchr/testify/suite" -) - -// TestSuite is for testing the entire package using real/mocked services -type TestSuite struct { - tests.TestSuite -} - -// SetupSuite runs at the start of the suite -func (ts *TestSuite) SetupSuite() { - ts.BaseSetupSuite() -} - -// TearDownSuite runs after the suite finishes -func (ts *TestSuite) TearDownSuite() { - ts.BaseTearDownSuite() -} - -// SetupTest runs before each test -func (ts *TestSuite) SetupTest() { - ts.BaseSetupTest() - - handlersManager := handlers.NewManager(ts.Router, config.APIVersion) - RegisterRoutes(handlersManager) -} - -// TearDownTest runs after each test -func (ts *TestSuite) TearDownTest() { - ts.BaseTearDownTest() -} - -// TestTestSuite kick-starts all suite tests -func TestTestSuite(t *testing.T) { - suite.Run(t, new(TestSuite)) -} diff --git a/docs/docs.go b/docs/docs.go index e9d78f6e3..f2ff1445b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1207,6 +1207,50 @@ const docTemplate = `{ } } }, + "/api/v1/paymails": { + "get": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Paymail addresses search", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Paymail addresses search", + "parameters": [ + { + "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", + "name": "SearchPaymails", + "in": "body", + "schema": { + "$ref": "#/definitions/filter.PaymailFilter" + } + } + ], + "responses": { + "200": { + "description": "List of paymail addresses", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/response.PaymailAddress" + } + } + }, + "400": { + "description": "Bad request - Error while parsing SearchPaymails from request body" + }, + "500": { + "description": "Internal server error - Error while searching for paymail addresses" + } + } + } + }, "/api/v1/transactions": { "get": { "security": [ @@ -5199,6 +5243,49 @@ const docTemplate = `{ } } }, + "filter.PaymailFilter": { + "type": "object", + "properties": { + "createdRange": { + "description": "CreatedRange specifies the time range when a record was created.", + "allOf": [ + { + "$ref": "#/definitions/filter.TimeRange" + } + ] + }, + "domain": { + "type": "string", + "example": "example.com" + }, + "id": { + "type": "string", + "example": "ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542" + }, + "includeDeleted": { + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "type": "boolean", + "default": false, + "example": true + }, + "publicName": { + "type": "string", + "example": "Alice" + }, + "updatedRange": { + "description": "UpdatedRange specifies the time range when a record was updated.", + "allOf": [ + { + "$ref": "#/definitions/filter.TimeRange" + } + ] + }, + "xpubId": { + "type": "string", + "example": "79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00" + } + } + }, "filter.SearchAccessKeys": { "type": "object", "properties": { @@ -6006,6 +6093,11 @@ const docTemplate = `{ "models.PaymailAddress": { "type": "object", "properties": { + "address": { + "description": "Address is a paymail address that combines alias and domain with @", + "type": "string", + "example": "test@spvwallet.com" + }, "alias": { "description": "Alias is a paymail address's alias (first part of paymail).", "type": "string", @@ -7175,6 +7267,11 @@ const docTemplate = `{ "response.PaymailAddress": { "type": "object", "properties": { + "address": { + "description": "Address is a paymail address that combines alias and domain with @", + "type": "string", + "example": "test@spvwallet.com" + }, "alias": { "description": "Alias is a paymail address's alias (first part of paymail).", "type": "string", diff --git a/docs/swagger.json b/docs/swagger.json index e49a8d9bb..39d9e13db 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1198,6 +1198,50 @@ } } }, + "/api/v1/paymails": { + "get": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Paymail addresses search", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Paymail addresses search", + "parameters": [ + { + "description": "Supports targeted resource searches with filters and metadata, plus options for pagination and sorting to streamline data exploration and analysis", + "name": "SearchPaymails", + "in": "body", + "schema": { + "$ref": "#/definitions/filter.PaymailFilter" + } + } + ], + "responses": { + "200": { + "description": "List of paymail addresses", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/response.PaymailAddress" + } + } + }, + "400": { + "description": "Bad request - Error while parsing SearchPaymails from request body" + }, + "500": { + "description": "Internal server error - Error while searching for paymail addresses" + } + } + } + }, "/api/v1/transactions": { "get": { "security": [ @@ -5190,6 +5234,49 @@ } } }, + "filter.PaymailFilter": { + "type": "object", + "properties": { + "createdRange": { + "description": "CreatedRange specifies the time range when a record was created.", + "allOf": [ + { + "$ref": "#/definitions/filter.TimeRange" + } + ] + }, + "domain": { + "type": "string", + "example": "example.com" + }, + "id": { + "type": "string", + "example": "ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542" + }, + "includeDeleted": { + "description": "IncludeDeleted is a flag whether or not to include deleted items in the search results", + "type": "boolean", + "default": false, + "example": true + }, + "publicName": { + "type": "string", + "example": "Alice" + }, + "updatedRange": { + "description": "UpdatedRange specifies the time range when a record was updated.", + "allOf": [ + { + "$ref": "#/definitions/filter.TimeRange" + } + ] + }, + "xpubId": { + "type": "string", + "example": "79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00" + } + } + }, "filter.SearchAccessKeys": { "type": "object", "properties": { @@ -5997,6 +6084,11 @@ "models.PaymailAddress": { "type": "object", "properties": { + "address": { + "description": "Address is a paymail address that combines alias and domain with @", + "type": "string", + "example": "test@spvwallet.com" + }, "alias": { "description": "Alias is a paymail address's alias (first part of paymail).", "type": "string", @@ -7166,6 +7258,11 @@ "response.PaymailAddress": { "type": "object", "properties": { + "address": { + "description": "Address is a paymail address that combines alias and domain with @", + "type": "string", + "example": "test@spvwallet.com" + }, "alias": { "description": "Alias is a paymail address's alias (first part of paymail).", "type": "string", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7b4650368..9e7938174 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -558,6 +558,35 @@ definitions: - $ref: '#/definitions/filter.TimeRange' description: UpdatedRange specifies the time range when a record was updated. type: object + filter.PaymailFilter: + properties: + createdRange: + allOf: + - $ref: '#/definitions/filter.TimeRange' + description: CreatedRange specifies the time range when a record was created. + domain: + example: example.com + type: string + id: + example: ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542 + type: string + includeDeleted: + default: false + description: IncludeDeleted is a flag whether or not to include deleted items + in the search results + example: true + type: boolean + publicName: + example: Alice + type: string + updatedRange: + allOf: + - $ref: '#/definitions/filter.TimeRange' + description: UpdatedRange specifies the time range when a record was updated. + xpubId: + example: 79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00 + type: string + type: object filter.SearchAccessKeys: properties: conditions: @@ -1153,6 +1182,11 @@ definitions: type: object models.PaymailAddress: properties: + address: + description: Address is a paymail address that combines alias and domain with + @ + example: test@spvwallet.com + type: string alias: description: Alias is a paymail address's alias (first part of paymail). example: test @@ -2012,6 +2046,11 @@ definitions: type: object response.PaymailAddress: properties: + address: + description: Address is a paymail address that combines alias and domain with + @ + example: test@spvwallet.com + type: string alias: description: Alias is a paymail address's alias (first part of paymail). example: test @@ -3381,6 +3420,36 @@ paths: - Merkleroots - Block Header Service - BHS + /api/v1/paymails: + get: + description: Paymail addresses search + parameters: + - description: Supports targeted resource searches with filters and metadata, + plus options for pagination and sorting to streamline data exploration and + analysis + in: body + name: SearchPaymails + schema: + $ref: '#/definitions/filter.PaymailFilter' + produces: + - application/json + responses: + "200": + description: List of paymail addresses + schema: + items: + $ref: '#/definitions/response.PaymailAddress' + type: array + "400": + description: Bad request - Error while parsing SearchPaymails from request + body + "500": + description: Internal server error - Error while searching for paymail addresses + security: + - x-auth-xpub: [] + summary: Paymail addresses search + tags: + - Users /api/v1/transactions: get: description: Get transactions diff --git a/engine/tester/jsonrequire/funcs.go b/engine/tester/jsonrequire/funcs.go new file mode 100644 index 000000000..5aed60b91 --- /dev/null +++ b/engine/tester/jsonrequire/funcs.go @@ -0,0 +1,35 @@ +package jsonrequire + +import ( + "fmt" + "text/template" +) + +var funcsMap = template.FuncMap{ + "matchTimestamp": matchTimestamp, + "matchURL": matchURL, + "orEmpty": orEmpty, + "matchID64": matchID64, +} + +func matchTimestamp() string { + return `/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|[+-]\\d{2}:\\d{2})$/` +} + +func matchURL() string { + return `/^(https?|ftp):\\/\\/[^\\s/$.?#].[^\\s]*$/` +} + +func matchID64() string { + return `/^[a-zA-Z0-9]{64}$/` +} + +func orEmpty(statement string) string { + if !containsRegex(statement) { + return statement + } + + regex := extractRegex(statement) + + return fmt.Sprintf(`/(%s)|^$/`, regex) +} diff --git a/engine/tester/jsonrequire/matcher.go b/engine/tester/jsonrequire/matcher.go new file mode 100644 index 000000000..57242f863 --- /dev/null +++ b/engine/tester/jsonrequire/matcher.go @@ -0,0 +1,36 @@ +package jsonrequire + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" +) + +// Match helps to make assertions on JSON strings when some values are not known in advance. +// For example, when we do the assertion on JSON serialized models, we can't predict the values of fields like IDs or timestamps. +// In such cases, we can use a "template" with placeholders for these values. +// See matcher_test.go for examples +func Match(t testing.TB, expectedTemplateFormat string, params map[string]any, actual string) { + t.Helper() + expected := compileTemplate(t, expectedTemplateFormat, params) + assertJSONWithPlaceholders(t, expected, actual) +} + +func compileTemplate(t testing.TB, templateFormat string, params map[string]any) string { + t.Helper() + tmpl, err := template. + New(""). + Funcs(funcsMap). + Parse(templateFormat) + + if err != nil { + require.Fail(t, "Failed to parse template", err) + } + var expected bytes.Buffer + if err = tmpl.Execute(&expected, params); err != nil { + require.Fail(t, "Failed to execute template", err) + } + return expected.String() +} diff --git a/engine/tester/jsonrequire/matcher_test.go b/engine/tester/jsonrequire/matcher_test.go new file mode 100644 index 000000000..facbb5de2 --- /dev/null +++ b/engine/tester/jsonrequire/matcher_test.go @@ -0,0 +1,110 @@ +package jsonrequire + +import "testing" + +func TestJSONPlaceholders(t *testing.T) { + tests := map[string]struct { + template string + actual string + }{ + "flat structure": { + template: `{"a": 1, "b": "/^[a-zA-Z]+$/", "c": "exact-match"}`, + actual: `{"a": 1, "b": "asd", "c": "exact-match"}`, + }, + "no placeholders": { + template: `{"a": 1, "b": "b", "c": "c"}`, + actual: `{"a": 1, "b": "b", "c": "c"}`, + }, + "regex for number": { + template: `{"a": 1, "b": "/^\\d{1,3}$/", "c": 3}`, + actual: `{"a": 1, "b": 122, "c": 3}`, + }, + "regex in nested obj": { + template: `{"a": 1, "b": { "c": "/^[a-zA-Z]+$/", "d": "exact-match" }}`, + actual: `{"a": 1, "b": { "c": "asd", "d": "exact-match" }}`, + }, + "any string regex": { + template: `{"a": 1, "b": "/.*/", "c": "exact-match"}`, + actual: `{"a": 1, "b": "asd", "c": "exact-match"}`, + }, + "regex in obj in array": { + template: `{"a": 1, "b": [{"b": "/^[a-zA-Z]+$/", "c": "exact-match"}]}`, + actual: `{"a": 1, "b": [{"b": "asd", "c": "exact-match"}]}`, + }, + "regex in obj in array (two elements)": { + template: `{"a": 1, "b": [{"b": "/^[a-zA-Z]+$/", "c": "exact-match"}, {"d":"/^\\d{1,3}$/"}]}`, + actual: `{"a": 1, "b": [{"b": "asd", "c": "exact-match"}, {"d": 122}]}`, + }, + "regex in value in array": { + template: `{"a": 1, "b": ["/^[a-zA-Z]+$/"]}`, + actual: `{"a": 1, "b": ["asd"]}`, + }, + "accept any (but must exist)": { + template: `{"a": 1, "metadata": "*"}`, + actual: `{"a": 1, "metadata": {"b": "asd", "c": "exact-match"}}`, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + Match(t, test.template, nil, test.actual) + }) + } +} + +func TestJSONTemplate(t *testing.T) { + tests := map[string]struct { + template string + actual string + params map[string]any + }{ + "match timestamp": { + template: `{"timestamp": "{{ matchTimestamp }}", "withTimezone": "{{ matchTimestamp }}"}`, + actual: `{"timestamp": "2024-12-03T09:39:42.5515364Z", "withTimezone": "2024-12-03T15:21:27.1080542+01:00"}`, + }, + "or empty with empty value": { + template: `{"timestamp": "{{ matchTimestamp | orEmpty }}"}`, + actual: `{"timestamp": ""}`, + }, + "or empty with actual value": { + template: `{"timestamp": "{{ matchTimestamp | orEmpty }}"}`, + actual: `{"timestamp": "2024-12-03T09:39:42.5515364Z"}`, + }, + "match URL http": { + template: `{ "url": "{{ matchURL }}" }`, + actual: `{ "url": "http://example.com" }`, + }, + "match URL https": { + template: `{ "url": "{{ matchURL }}" }`, + actual: `{ "url": "https://example.com" }`, + }, + "match URL ftp": { + template: `{ "url": "{{ matchURL }}" }`, + actual: `{ "url": "ftp://example.com" }`, + }, + "match URL localhost": { + template: `{ "url": "{{ matchURL }}" }`, + actual: `{ "url": "http://localhost" }`, + }, + "match URL with path and search params": { + template: `{ "url": "{{ matchURL }}" }`, + actual: `{ "url": "https://example.com/path?hello=123" }`, + }, + "match URL with orEmpty on empty value": { + template: `{ "url": "{{ matchURL | orEmpty }}" }`, + actual: `{ "url": "" }`, + }, + "match URL with orEmpty on actual value": { + template: `{ "url": "{{ matchURL | orEmpty }}" }`, + actual: `{ "url": "https://example.com" }`, + }, + "match string ID with 64 characters": { + template: `{ "id": "{{ matchID64 }}" }`, + actual: `{ "id": "d425432e0d10a46af1ec6d00f380e9581ebf7907f3486572b3cd561a4c326e14" }`, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + Match(t, test.template, test.params, test.actual) + }) + } +} diff --git a/engine/tester/jsonrequire/placeholders.go b/engine/tester/jsonrequire/placeholders.go new file mode 100644 index 000000000..95d762cd4 --- /dev/null +++ b/engine/tester/jsonrequire/placeholders.go @@ -0,0 +1,158 @@ +package jsonrequire + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func assertJSONWithPlaceholders(t testing.TB, expected, actual string) { + t.Helper() + + var expectedJSONValue, actualJSONValue map[string]any + if err := json.Unmarshal([]byte(expected), &expectedJSONValue); err != nil { + require.Fail(t, fmt.Sprintf("Expected value ('%s') is not valid json.\nJSON parsing error: '%s'", expected, err.Error())) + } + + if err := json.Unmarshal([]byte(actual), &actualJSONValue); err != nil { + require.Fail(t, fmt.Sprintf("Input value ('%s') is not valid json.\nJSON parsing error: '%s'", actual, err.Error())) + } + + processJSONPlaceholders(t, expectedJSONValue, actualJSONValue, "") + + if assert.ObjectsAreEqual(expectedJSONValue, actualJSONValue) { + return + } + + // We want a message that shows the diff between the two JSON strings not the decoded objects. + expectedJsonString := expected + actualJSONString := actual + + // Try to unify the JSON strings to make the diff more readable. + marshaled, err := json.MarshalIndent(expectedJSONValue, "", " ") + if err == nil { + expectedJsonString = string(marshaled) + } + + marshaled, err = json.MarshalIndent(actualJSONValue, "", " ") + if err == nil { + actualJSONString = string(marshaled) + } + + assert.Equal(t, expectedJsonString, actualJSONString) +} + +func processJSONPlaceholders(t testing.TB, template any, base map[string]any, xpath string) { + t.Helper() + + rval, kind, _ := typeof(template) + + if kind == reflect.Map { + for _, k := range rval.MapKeys() { + currentXPath := addToXPath(xpath, k.String()) + _, templateKind, templateVal := typeof(rval.MapIndex(k).Interface()) + + if templateKind == reflect.String { + str := templateVal.(string) + toCopy := processTemplateCandidate(t, str, base, currentXPath) + if toCopy == nil { + continue + } + + // copy the value from the base to the template + rval.SetMapIndex(k, *toCopy) + } else { + processJSONPlaceholders(t, templateVal, base, currentXPath) + } + } + } else if kind == reflect.Slice { + for i := 0; i < rval.Len(); i++ { + currentXPath := xpath + fmt.Sprintf("[%d]", i) + value := rval.Index(i) + + _, itemKind, itemVal := typeof(value.Interface()) + if itemKind == reflect.String { + str := itemVal.(string) + toCopy := processTemplateCandidate(t, str, base, currentXPath) + if toCopy != nil { + rval.Index(i).Set(*toCopy) + } + } else { + processJSONPlaceholders(t, value.Interface(), base, currentXPath) + } + } + } +} + +func processTemplateCandidate(t testing.TB, templateVal string, base map[string]any, xpath string) *reflect.Value { + isRegex := containsRegex(templateVal) + + if !isRegex && templateVal != "*" { + return nil + } + + valueFromBase := getByXPath(t, base, xpath) + + if isRegex { + reg := regexp.MustCompile(extractRegex(templateVal)) + valueFromBaseAsStr := stringValue(t, valueFromBase) + if !reg.MatchString(stringValue(t, valueFromBase)) { + require.Fail(t, fmt.Sprintf("value at '%s' (%s) does not match '%s'", xpath, valueFromBaseAsStr, templateVal)) + } + } + + valueToCopy := reflect.ValueOf(valueFromBase) + return &valueToCopy +} + +func isNumberLike(kind reflect.Kind) bool { + return kind >= reflect.Int && kind <= reflect.Uint64 || kind >= reflect.Float32 && kind <= reflect.Float64 +} + +func stringValue(t testing.TB, v any) string { + _, kind, _ := typeof(v) + if isNumberLike(kind) { + return fmt.Sprintf("%v", v) + } + if kind == reflect.String { + return v.(string) + } + require.Fail(t, "value is not a string or number-like") + return "" +} + +func containsRegex(str string) bool { + return len(str) > 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") +} + +func extractRegex(str string) string { + return str[1 : len(str)-1] +} + +func typeof(v any) (reflect.Value, reflect.Kind, any) { + if v == nil { + return reflect.Value{}, reflect.Invalid, nil + } + rval := reflect.ValueOf(v) + kind := reflect.TypeOf(v).Kind() + + if kind == reflect.Ptr { + rval = rval.Elem() + kind = rval.Kind() + } + + return rval, kind, rval.Interface() +} + +func addToXPath(xpath, key string) string { + if xpath == "" { + return key + } + return xpath + "/" + key +} diff --git a/engine/tester/jsonrequire/xpath.go b/engine/tester/jsonrequire/xpath.go new file mode 100644 index 000000000..0b4040d90 --- /dev/null +++ b/engine/tester/jsonrequire/xpath.go @@ -0,0 +1,68 @@ +package jsonrequire + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var arrayKeyRegex = regexp.MustCompile(`^([a-zA-Z0-9_-]+)\[(\d+)]$`) + +func getByXPath(t testing.TB, data map[string]any, path string) any { + keys := strings.Split(path, "/") + current := any(data) + + for _, key := range keys { + if key == "" { + continue + } + // Check if the key is accessing an array element (e.g., "key[0]") + if matches := arrayKeyRegex.FindStringSubmatch(key); matches != nil { + mapKey := matches[1] + index, err := strconv.Atoi(matches[2]) + if err != nil { + require.Fail(t, fmt.Sprintf("invalid array index '%s'", matches[2])) + } + + m, ok := current.(map[string]any) + if !ok { + require.Fail(t, "path does not exist or is not a map") + } + + array, exists := m[mapKey] + if !exists { + require.Fail(t, fmt.Sprintf("key '%s' not found", mapKey)) + } + + slice, ok := array.([]any) + if !ok { + require.Fail(t, fmt.Sprintf("key '%s' is not an array", mapKey)) + } + + if index < 0 || index >= len(slice) { + require.Fail(t, fmt.Sprintf("index '%d' out of bounds for key '%s'", index, mapKey)) + } + + // Move to the array element + current = slice[index] + } else { + // Normal map traversal + m, ok := current.(map[string]any) + if !ok { + require.Fail(t, "path does not exist or is not a map") + } + + val, exists := m[key] + if !exists { + require.Fail(t, fmt.Sprintf("key '%s' not found", key)) + } + current = val + } + } + + return current +} diff --git a/models/filter/paymail_filter.go b/models/filter/paymail_filter.go index 290b96187..83094ff95 100644 --- a/models/filter/paymail_filter.go +++ b/models/filter/paymail_filter.go @@ -1,20 +1,19 @@ package filter -// AdminPaymailFilter is a struct for handling request parameters for paymail_addresses search requests -type AdminPaymailFilter struct { +// PaymailFilter is a struct for handling request parameters for paymail_addresses search requests +type PaymailFilter struct { // ModelFilter is a struct for handling typical request parameters for search requests //lint:ignore SA5008 We want to reuse json tags also to mapstructure. ModelFilter `json:",inline,squash"` ID *string `json:"id,omitempty" example:"ffb86c103d17d87c15aaf080aab6be5415c9fa885309a79b04c9910e39f2b542"` XpubID *string `json:"xpubId,omitempty" example:"79f90a6bab0a44402fc64828af820e9465645658aea2d138c5205b88e6dabd00"` - Alias *string `json:"alias,omitempty" example:"alice"` Domain *string `json:"domain,omitempty" example:"example.com"` PublicName *string `json:"publicName,omitempty" example:"Alice"` } // ToDbConditions converts filter fields to the datastore conditions using gorm naming strategy -func (d *AdminPaymailFilter) ToDbConditions() map[string]interface{} { +func (d *PaymailFilter) ToDbConditions() map[string]interface{} { if d == nil { return nil } @@ -23,9 +22,27 @@ func (d *AdminPaymailFilter) ToDbConditions() map[string]interface{} { // Column names come from the database model, see: /engine/model_paymail_addresses.go applyIfNotNil(conditions, "id", d.ID) applyIfNotNil(conditions, "xpub_id", d.XpubID) - applyIfNotNil(conditions, "alias", d.Alias) applyIfNotNil(conditions, "domain", d.Domain) applyIfNotNil(conditions, "public_name", d.PublicName) return conditions } + +type AdminPaymailFilter struct { + //lint:ignore SA5008 We want to reuse json tags also to mapstructure. + PaymailFilter `json:",inline,squash"` + + Alias *string `json:"alias,omitempty" example:"alice"` +} + +func (d *AdminPaymailFilter) ToDbConditions() map[string]interface{} { + if d == nil { + return nil + } + conditions := d.PaymailFilter.ToDbConditions() + + // Column names come from the database model, see: /engine/model_paymail_addresses.go + applyIfNotNil(conditions, "alias", d.Alias) + + return conditions +}