diff --git a/actions/contacts/accept.go b/actions/contacts/accept.go new file mode 100644 index 000000000..c7e860401 --- /dev/null +++ b/actions/contacts/accept.go @@ -0,0 +1,44 @@ +package contacts + +import ( + "errors" + "net/http" + + "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/server/auth" + "github.com/gin-gonic/gin" +) + +// accept will accept contact request +// Accept contact godoc +// @Summary Accept contact +// @Description Accept contact. For contact with status "awaiting" change status to "unconfirmed" +// @Tags Contact +// @Produce json +// @Param paymail path string true "Paymail address of the contact the user wants to accept" +// @Success 200 +// @Failure 404 "Contact not found" +// @Failure 422 "Contact status not awaiting" +// @Failure 500 "Internal server error" +// @Router /v1/contact/accepted/{paymail} [PATCH] +// @Security x-auth-xpub +func (a *Action) accept(c *gin.Context) { + reqXPubID := c.GetString(auth.ParamXPubHashKey) + paymail := c.Param("paymail") + + err := a.Services.SpvWalletEngine.AcceptContact(c, reqXPubID, paymail) + + if err != nil { + switch { + case errors.Is(err, engine.ErrContactNotFound): + c.JSON(http.StatusNotFound, err.Error()) + case errors.Is(err, engine.ErrContactIncorrectStatus): + c.JSON(http.StatusUnprocessableEntity, err.Error()) + default: + c.JSON(http.StatusInternalServerError, err.Error()) + } + return + } + + c.Status(http.StatusOK) +} diff --git a/actions/contacts/confirm.go b/actions/contacts/confirm.go new file mode 100644 index 000000000..203e1e6b9 --- /dev/null +++ b/actions/contacts/confirm.go @@ -0,0 +1,43 @@ +package contacts + +import ( + "errors" + "net/http" + + "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/server/auth" + "github.com/gin-gonic/gin" +) + +// confirm will confirm contact request +// Confirm contact godoc +// @Summary Confirm contact +// @Description Confirm contact. For contact with status "unconfirmed" change status to "confirmed" +// @Tags Contact +// @Produce json +// @Param paymail path string true "Paymail address of the contact the user wants to confirm" +// @Success 200 +// @Failure 404 "Contact not found" +// @Failure 422 "Contact status not unconfirmed" +// @Failure 500 "Internal server error" +// @Router /v1/contact/confirmed/{paymail} [PATCH] +// @Security x-auth-xpub +func (a *Action) confirm(c *gin.Context) { + reqXPubID := c.GetString(auth.ParamXPubHashKey) + paymail := c.Param("paymail") + + err := a.Services.SpvWalletEngine.ConfirmContact(c, reqXPubID, paymail) + + if err != nil { + switch { + case errors.Is(err, engine.ErrContactNotFound): + c.JSON(http.StatusNotFound, err.Error()) + case errors.Is(err, engine.ErrContactIncorrectStatus): + c.JSON(http.StatusUnprocessableEntity, err.Error()) + default: + c.JSON(http.StatusInternalServerError, err.Error()) + } + return + } + c.Status(http.StatusOK) +} diff --git a/actions/contacts/models.go b/actions/contacts/models.go index 6c15437a3..60c5b4145 100644 --- a/actions/contacts/models.go +++ b/actions/contacts/models.go @@ -23,13 +23,3 @@ func (p *UpsertContact) validate() error { return nil } - -// UpdateContact is the model for updating a contact -type UpdateContact struct { - XPubID string `json:"xpub_id"` - FullName string `json:"full_name"` - Paymail string `json:"paymail"` - PubKey string `json:"pubKey"` - Status string `json:"status"` - Metadata engine.Metadata `json:"metadata"` -} diff --git a/actions/contacts/reject.go b/actions/contacts/reject.go new file mode 100644 index 000000000..25399e407 --- /dev/null +++ b/actions/contacts/reject.go @@ -0,0 +1,43 @@ +package contacts + +import ( + "errors" + "net/http" + + "github.com/bitcoin-sv/spv-wallet/engine" + "github.com/bitcoin-sv/spv-wallet/server/auth" + "github.com/gin-gonic/gin" +) + +// reject will reject contact request +// Reject contact godoc +// @Summary Reject contact +// @Description Reject contact. For contact with status "awaiting" delete contact +// @Tags Contact +// @Produce json +// @Param paymail path string true "Paymail address of the contact the user wants to reject" +// @Success 200 +// @Failure 404 "Contact not found" +// @Failure 422 "Contact status not awaiting" +// @Failure 500 "Internal server error" +// @Router /v1/contact/rejected/{paymail} [PATCH] +// @Security x-auth-xpub +func (a *Action) reject(c *gin.Context) { + reqXPubID := c.GetString(auth.ParamXPubHashKey) + paymail := c.Param("paymail") + + err := a.Services.SpvWalletEngine.RejectContact(c, reqXPubID, paymail) + + if err != nil { + switch { + case errors.Is(err, engine.ErrContactNotFound): + c.JSON(http.StatusNotFound, err.Error()) + case errors.Is(err, engine.ErrContactIncorrectStatus): + c.JSON(http.StatusUnprocessableEntity, err.Error()) + default: + c.JSON(http.StatusInternalServerError, err.Error()) + } + return + } + c.Status(http.StatusOK) +} diff --git a/actions/contacts/routes.go b/actions/contacts/routes.go index 2e89868f9..18592a438 100644 --- a/actions/contacts/routes.go +++ b/actions/contacts/routes.go @@ -17,12 +17,14 @@ func NewHandler(appConfig *config.AppConfig, services *config.AppServices) route action := &Action{actions.Action{AppConfig: appConfig, Services: services}} apiEndpoints := routes.APIEndpointsFunc(func(router *gin.RouterGroup) { - contactGroup := router.Group("/contact") - contactGroup.PUT("/:paymail", action.upsert) + group := router.Group("/contact") + group.PUT("/:paymail", action.upsert) - contactGroup.PATCH("", action.update) - contactsGroup := router.Group("/contacts") - contactsGroup.GET("", action.search) + group.PATCH("/accepted/:paymail", action.accept) + group.PATCH("/rejected/:paymail", action.reject) + group.PATCH("/confirmed/:paymail", action.confirm) + + group.POST("search", action.search) }) return apiEndpoints diff --git a/actions/contacts/routes_test.go b/actions/contacts/routes_test.go index dcbe4965d..83272431e 100644 --- a/actions/contacts/routes_test.go +++ b/actions/contacts/routes_test.go @@ -15,7 +15,10 @@ func (ts *TestSuite) TestContactsRegisterRoutes() { url string }{ {"PUT", "/" + config.APIVersion + "/contact/:paymail"}, - {"GET", "/" + config.APIVersion + "/contacts"}, + {"PATCH", "/" + config.APIVersion + "/contact/accepted/:paymail"}, + {"PATCH", "/" + config.APIVersion + "/contact/rejected/:paymail"}, + {"PATCH", "/" + config.APIVersion + "/contact/confirmed/:paymail"}, + {"POST", "/" + config.APIVersion + "/contact/search"}, } ts.Router.Routes() diff --git a/actions/contacts/search.go b/actions/contacts/search.go index 6a3af6d15..17c9e961d 100644 --- a/actions/contacts/search.go +++ b/actions/contacts/search.go @@ -4,9 +4,7 @@ import ( "net/http" "github.com/bitcoin-sv/spv-wallet/actions" - "github.com/bitcoin-sv/spv-wallet/engine" "github.com/bitcoin-sv/spv-wallet/mappings" - "github.com/bitcoin-sv/spv-wallet/models" "github.com/bitcoin-sv/spv-wallet/server/auth" "github.com/gin-gonic/gin" ) @@ -25,42 +23,29 @@ import ( // @Success 200 {object} []models.Contact "List of contacts" // @Failure 400 "Bad request - Error while parsing SearchRequestParameters from request body" // @Failure 500 "Internal server error - Error while searching for contacts" -// @Router /v1/contacts [get] +// @Router /v1/contact/search [POST] // @Security x-auth-xpub func (a *Action) search(c *gin.Context) { reqXPubID := c.GetString(auth.ParamXPubHashKey) - params := c.Request.URL.Query() - - queryParams, metadata, _, err := actions.GetSearchQueryParameters(c) + queryParams, metadata, conditions, err := actions.GetSearchQueryParameters(c) if err != nil { c.JSON(http.StatusExpectationFailed, err.Error()) return } - dbConditions := make(map[string]interface{}) - - for key, value := range params { - dbConditions[key] = value - } - - dbConditions["xpub_id"] = reqXPubID - - var contacts []*engine.Contact - if contacts, err = a.Services.SpvWalletEngine.GetContacts( + contacts, err := a.Services.SpvWalletEngine.GetContacts( c.Request.Context(), + reqXPubID, metadata, - &dbConditions, + *conditions, queryParams, - ); err != nil { - c.JSON(http.StatusExpectationFailed, err.Error()) - return - } + ) - contactContracts := make([]*models.Contact, 0) - for _, contact := range contacts { - contactContracts = append(contactContracts, mappings.MapToContactContract(contact)) + if err != nil { + c.JSON(http.StatusInternalServerError, err.Error()) + return } - c.JSON(http.StatusOK, contactContracts) + c.JSON(http.StatusOK, mappings.MapToContactContracts(contacts)) } diff --git a/actions/contacts/update.go b/actions/contacts/update.go deleted file mode 100644 index 2aa1f0e00..000000000 --- a/actions/contacts/update.go +++ /dev/null @@ -1,49 +0,0 @@ -package contacts - -import ( - "net/http" - - "github.com/bitcoin-sv/spv-wallet/engine" - "github.com/bitcoin-sv/spv-wallet/mappings" - "github.com/bitcoin-sv/spv-wallet/server/auth" - "github.com/gin-gonic/gin" -) - -// update will update an existing model -// Update Contact godoc -// @Summary Update contact -// @Description Update contact -// @Tags Contacts -// @Produce json -// @Param metadata body string true "Contacts Metadata" -// @Success 200 {object} models.Contact "Updated contact" -// @Failure 400 "Bad request - Error while parsing UpdateContact from request body" -// @Failure 500 "Internal server error - Error while updating contact" -// @Router /v1/contact [patch] -// @Security x-auth-xpub -func (a *Action) update(c *gin.Context) { - reqXPubID := c.GetString(auth.ParamXPubHashKey) - - var requestBody UpdateContact - - if err := c.ShouldBindJSON(&requestBody); err != nil { - c.JSON(http.StatusBadRequest, err.Error()) - return - } - - if requestBody.XPubID == "" { - c.JSON(http.StatusBadRequest, "Id is missing") - } - - contact, err := a.Services.SpvWalletEngine.UpdateContact(c.Request.Context(), requestBody.FullName, requestBody.PubKey, reqXPubID, requestBody.Paymail, engine.ContactStatus(requestBody.Status), engine.WithMetadatas(requestBody.Metadata)) - - if err != nil { - c.JSON(http.StatusExpectationFailed, err.Error()) - return - } - - contract := mappings.MapToContactContract(contact) - - c.JSON(http.StatusOK, contract) - -} diff --git a/docs/docs.go b/docs/docs.go index 94fb3984b..3efa4ddc6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1008,90 +1008,128 @@ const docTemplate = `{ } } }, - "/v1/contact": { + "/v1/contact/accepted/{paymail}": { "patch": { "security": [ { "x-auth-xpub": [] } ], - "description": "Update contact", + "description": "Accept contact. For contact with status \"awaiting\" change status to \"unconfirmed\"", "produces": [ "application/json" ], "tags": [ - "Contacts" + "Contact" ], - "summary": "Update contact", + "summary": "Accept contact", "parameters": [ { - "description": "Contacts Metadata", - "name": "metadata", - "in": "body", - "required": true, - "schema": { - "type": "string" - } + "type": "string", + "description": "Paymail address of the contact the user wants to accept", + "name": "paymail", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "Updated contact", - "schema": { - "$ref": "#/definitions/models.Contact" - } + "description": "OK" }, - "400": { - "description": "Bad request - Error while parsing UpdateContact from request body" + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not awaiting" }, "500": { - "description": "Internal server error - Error while updating contact" + "description": "Internal server error" } } } }, - "/v1/contact/{paymail}": { - "put": { + "/v1/contact/confirmed/{paymail}": { + "patch": { "security": [ { "x-auth-xpub": [] } ], - "description": "Add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.", + "description": "Confirm contact. For contact with status \"unconfirmed\" change status to \"confirmed\"", "produces": [ "application/json" ], "tags": [ "Contact" ], - "summary": "Upsert contact", + "summary": "Confirm contact", "parameters": [ { "type": "string", - "description": "Paymail address of the contact the user wants to add/modify", + "description": "Paymail address of the contact the user wants to confirm", "name": "paymail", "in": "path", "required": true + } + ], + "responses": { + "200": { + "description": "OK" }, + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not unconfirmed" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/contact/rejected/{paymail}": { + "patch": { + "security": [ { - "description": "Full name and metadata needed to add/modify contact", - "name": "UpsertContact", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/contacts.UpsertContact" - } + "x-auth-xpub": [] + } + ], + "description": "Reject contact. For contact with status \"awaiting\" delete contact", + "produces": [ + "application/json" + ], + "tags": [ + "Contact" + ], + "summary": "Reject contact", + "parameters": [ + { + "type": "string", + "description": "Paymail address of the contact the user wants to reject", + "name": "paymail", + "in": "path", + "required": true } ], "responses": { - "201": { - "description": "Created" + "200": { + "description": "OK" + }, + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not awaiting" + }, + "500": { + "description": "Internal server error" } } } }, - "/v1/contacts": { - "get": { + "/v1/contact/search": { + "post": { "security": [ { "x-auth-xpub": [] @@ -1156,6 +1194,46 @@ const docTemplate = `{ } } }, + "/v1/contact/{paymail}": { + "put": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.", + "produces": [ + "application/json" + ], + "tags": [ + "Contact" + ], + "summary": "Upsert contact", + "parameters": [ + { + "type": "string", + "description": "Paymail address of the contact the user wants to add/modify", + "name": "paymail", + "in": "path", + "required": true + }, + { + "description": "Full name and metadata needed to add/modify contact", + "name": "UpsertContact", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/contacts.UpsertContact" + } + } + ], + "responses": { + "201": { + "description": "Created" + } + } + } + }, "/v1/destination": { "get": { "security": [ @@ -2256,6 +2334,7 @@ const docTemplate = `{ "example": "Test User" }, "id": { + "description": "ID is a unique identifier of contact.", "type": "string", "example": "68af358bde7d8641621c7dd3de1a276c9a62cfa9e2d0740494519f1ba61e2f4a" }, @@ -2289,11 +2368,6 @@ const docTemplate = `{ "description": "UpdatedAt is a time when outer model was updated.", "type": "string", "example": "2024-02-26T11:01:28.069911Z" - }, - "xpubID": { - "description": "XpubID is the contact's xpub related id used to register contact.", - "type": "string", - "example": "bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 1c2d0e849..99721c98c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -998,90 +998,128 @@ } } }, - "/v1/contact": { + "/v1/contact/accepted/{paymail}": { "patch": { "security": [ { "x-auth-xpub": [] } ], - "description": "Update contact", + "description": "Accept contact. For contact with status \"awaiting\" change status to \"unconfirmed\"", "produces": [ "application/json" ], "tags": [ - "Contacts" + "Contact" ], - "summary": "Update contact", + "summary": "Accept contact", "parameters": [ { - "description": "Contacts Metadata", - "name": "metadata", - "in": "body", - "required": true, - "schema": { - "type": "string" - } + "type": "string", + "description": "Paymail address of the contact the user wants to accept", + "name": "paymail", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "Updated contact", - "schema": { - "$ref": "#/definitions/models.Contact" - } + "description": "OK" }, - "400": { - "description": "Bad request - Error while parsing UpdateContact from request body" + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not awaiting" }, "500": { - "description": "Internal server error - Error while updating contact" + "description": "Internal server error" } } } }, - "/v1/contact/{paymail}": { - "put": { + "/v1/contact/confirmed/{paymail}": { + "patch": { "security": [ { "x-auth-xpub": [] } ], - "description": "Add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.", + "description": "Confirm contact. For contact with status \"unconfirmed\" change status to \"confirmed\"", "produces": [ "application/json" ], "tags": [ "Contact" ], - "summary": "Upsert contact", + "summary": "Confirm contact", "parameters": [ { "type": "string", - "description": "Paymail address of the contact the user wants to add/modify", + "description": "Paymail address of the contact the user wants to confirm", "name": "paymail", "in": "path", "required": true + } + ], + "responses": { + "200": { + "description": "OK" }, + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not unconfirmed" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/contact/rejected/{paymail}": { + "patch": { + "security": [ { - "description": "Full name and metadata needed to add/modify contact", - "name": "UpsertContact", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/contacts.UpsertContact" - } + "x-auth-xpub": [] + } + ], + "description": "Reject contact. For contact with status \"awaiting\" delete contact", + "produces": [ + "application/json" + ], + "tags": [ + "Contact" + ], + "summary": "Reject contact", + "parameters": [ + { + "type": "string", + "description": "Paymail address of the contact the user wants to reject", + "name": "paymail", + "in": "path", + "required": true } ], "responses": { - "201": { - "description": "Created" + "200": { + "description": "OK" + }, + "404": { + "description": "Contact not found" + }, + "422": { + "description": "Contact status not awaiting" + }, + "500": { + "description": "Internal server error" } } } }, - "/v1/contacts": { - "get": { + "/v1/contact/search": { + "post": { "security": [ { "x-auth-xpub": [] @@ -1146,6 +1184,46 @@ } } }, + "/v1/contact/{paymail}": { + "put": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Add or update contact. When adding a new contact, the system utilizes Paymail's PIKE capability to dispatch an invitation request, asking the counterparty to include the current user in their contacts.", + "produces": [ + "application/json" + ], + "tags": [ + "Contact" + ], + "summary": "Upsert contact", + "parameters": [ + { + "type": "string", + "description": "Paymail address of the contact the user wants to add/modify", + "name": "paymail", + "in": "path", + "required": true + }, + { + "description": "Full name and metadata needed to add/modify contact", + "name": "UpsertContact", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/contacts.UpsertContact" + } + } + ], + "responses": { + "201": { + "description": "Created" + } + } + } + }, "/v1/destination": { "get": { "security": [ @@ -2246,6 +2324,7 @@ "example": "Test User" }, "id": { + "description": "ID is a unique identifier of contact.", "type": "string", "example": "68af358bde7d8641621c7dd3de1a276c9a62cfa9e2d0740494519f1ba61e2f4a" }, @@ -2279,11 +2358,6 @@ "description": "UpdatedAt is a time when outer model was updated.", "type": "string", "example": "2024-02-26T11:01:28.069911Z" - }, - "xpubID": { - "description": "XpubID is the contact's xpub related id used to register contact.", - "type": "string", - "example": "bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0bd493b45..154799fd9 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -341,6 +341,7 @@ definitions: example: Test User type: string id: + description: ID is a unique identifier of contact. example: 68af358bde7d8641621c7dd3de1a276c9a62cfa9e2d0740494519f1ba61e2f4a type: string metadata: @@ -367,10 +368,6 @@ definitions: description: UpdatedAt is a time when outer model was updated. example: "2024-02-26T11:01:28.069911Z" type: string - xpubID: - description: XpubID is the contact's xpub related id used to register contact. - example: bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50 - type: string type: object models.Destination: properties: @@ -1769,33 +1766,6 @@ paths: summary: Search for xpubs tags: - Admin - /v1/contact: - patch: - description: Update contact - parameters: - - description: Contacts Metadata - in: body - name: metadata - required: true - schema: - type: string - produces: - - application/json - responses: - "200": - description: Updated contact - schema: - $ref: '#/definitions/models.Contact' - "400": - description: Bad request - Error while parsing UpdateContact from request - body - "500": - description: Internal server error - Error while updating contact - security: - - x-auth-xpub: [] - summary: Update contact - tags: - - Contacts /v1/contact/{paymail}: put: description: Add or update contact. When adding a new contact, the system utilizes @@ -1823,8 +1793,85 @@ paths: summary: Upsert contact tags: - Contact - /v1/contacts: - get: + /v1/contact/accepted/{paymail}: + patch: + description: Accept contact. For contact with status "awaiting" change status + to "unconfirmed" + parameters: + - description: Paymail address of the contact the user wants to accept + in: path + name: paymail + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "404": + description: Contact not found + "422": + description: Contact status not awaiting + "500": + description: Internal server error + security: + - x-auth-xpub: [] + summary: Accept contact + tags: + - Contact + /v1/contact/confirmed/{paymail}: + patch: + description: Confirm contact. For contact with status "unconfirmed" change status + to "confirmed" + parameters: + - description: Paymail address of the contact the user wants to confirm + in: path + name: paymail + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "404": + description: Contact not found + "422": + description: Contact status not unconfirmed + "500": + description: Internal server error + security: + - x-auth-xpub: [] + summary: Confirm contact + tags: + - Contact + /v1/contact/rejected/{paymail}: + patch: + description: Reject contact. For contact with status "awaiting" delete contact + parameters: + - description: Paymail address of the contact the user wants to reject + in: path + name: paymail + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "404": + description: Contact not found + "422": + description: Contact status not awaiting + "500": + description: Internal server error + security: + - x-auth-xpub: [] + summary: Reject contact + tags: + - Contact + /v1/contact/search: + post: description: Search contacts parameters: - description: page diff --git a/engine/action_contact.go b/engine/action_contact.go index 701728dc8..aac18e292 100644 --- a/engine/action_contact.go +++ b/engine/action_contact.go @@ -14,6 +14,8 @@ var ( ErrInvalidRequesterXpub = errors.New("invalid requester xpub") ErrAddingContactRequest = errors.New("adding contact request failed") ErrMoreThanOnePaymailRegistered = errors.New("there are more than one paymail assigned to the xpub") + ErrContactNotFound = errors.New("contact not found") + ErrContactIncorrectStatus = errors.New("contact is in incorrect status to proceed") ) func (c *Client) UpsertContact(ctx context.Context, ctcFName, ctcPaymail, requesterXpub, requesterPaymail string, opts ...ModelOps) (*Contact, error) { @@ -27,7 +29,10 @@ func (c *Client) UpsertContact(ctx context.Context, ctcFName, ctcPaymail, reques cs: c.Cachestore(), pc: c.PaymailClient(), } - contactPm := pmSrvnt.GetSanitizedPaymail(ctcPaymail) + contactPm, err := pmSrvnt.GetSanitizedPaymail(ctcPaymail) + if err != nil { + return nil, fmt.Errorf("requested contact paymail is invalid. Reason: %w", err) + } contact, err := c.upsertContact(ctx, pmSrvnt, reqXPubID, ctcFName, contactPm, opts...) if err != nil { @@ -57,31 +62,29 @@ func (c *Client) AddContactRequest(ctx context.Context, fullName, paymailAdress, pc: c.PaymailClient(), } - contactPm := pmSrvnt.GetSanitizedPaymail(paymailAdress) + contactPm, err := pmSrvnt.GetSanitizedPaymail(paymailAdress) + if err != nil { + return nil, fmt.Errorf("requested contact paymail is invalid. Reason: %w", err) + } + contactPki, err := pmSrvnt.GetPkiForPaymail(ctx, contactPm) if err != nil { return nil, fmt.Errorf("geting PKI for %s failed. Reason: %w", paymailAdress, err) } // check if exists already - contact, err := getContact(ctx, contactPm.adress, requesterXPubID, c.DefaultModelOptions()...) + contact, err := getContact(ctx, contactPm.Address, requesterXPubID, c.DefaultModelOptions()...) if err != nil { return nil, err } save := false if contact != nil { - // update and back to awaiting if PKI changed - if contact.PubKey != contactPki.PubKey { - contact.Status = ContactAwaitAccept // ? Or error - contact.PubKey = contactPki.PubKey - - save = true - } + save = contact.UpdatePubKey(contactPki.PubKey) } else { contact = newContact( fullName, - contactPm.adress, + contactPm.Address, contactPki.PubKey, requesterXPubID, ContactAwaitAccept, @@ -100,6 +103,85 @@ func (c *Client) AddContactRequest(ctx context.Context, fullName, paymailAdress, return contact, nil } +func (c *Client) GetContacts(ctx context.Context, xPubID string, metadata *Metadata, conditions map[string]interface{}, queryParams *datastore.QueryParams) ([]*Contact, error) { + contacts, err := getContacts(ctx, xPubID, metadata, conditions, queryParams, c.DefaultModelOptions()...) + if err != nil { + return nil, err + } + + return contacts, nil +} + +func (c *Client) AcceptContact(ctx context.Context, xPubID, paymail string) error { + + contact, err := getContact(ctx, paymail, xPubID, c.DefaultModelOptions()...) + if err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while geting contact: %s", err.Error())) + return err + } + if contact == nil { + return ErrContactNotFound + } + + if err = contact.Accept(); err != nil { + c.logContactWarining(xPubID, paymail, err.Error()) + return ErrContactIncorrectStatus + } + + if err = contact.Save(ctx); err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while saving contact: %s", err.Error())) + return err + } + + return nil +} + +func (c *Client) RejectContact(ctx context.Context, xPubID, paymail string) error { + contact, err := getContact(ctx, paymail, xPubID, c.DefaultModelOptions()...) + if err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while geting contact: %s", err.Error())) + return err + } + if contact == nil { + return ErrContactNotFound + } + + if err = contact.Reject(); err != nil { + c.logContactWarining(xPubID, paymail, err.Error()) + return ErrContactIncorrectStatus + } + + if err = contact.Save(ctx); err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while saving contact: %s", err.Error())) + return err + } + + return nil +} + +func (c *Client) ConfirmContact(ctx context.Context, xPubID, paymail string) error { + contact, err := getContact(ctx, paymail, xPubID, c.DefaultModelOptions()...) + if err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while geting contact: %s", err.Error())) + return err + } + if contact == nil { + return ErrContactNotFound + } + + if err = contact.Confirm(); err != nil { + c.logContactWarining(xPubID, paymail, err.Error()) + return ErrContactIncorrectStatus + } + + if err = contact.Save(ctx); err != nil { + c.logContactError(xPubID, paymail, fmt.Sprintf("unexpected error while saving contact: %s", err.Error())) + return err + } + + return nil +} + func (c *Client) getPaymail(ctx context.Context, xpubID, paymailAddr string) (*PaymailAddress, error) { if paymailAddr != "" { res, err := c.GetPaymailAddress(ctx, paymailAddr, c.DefaultModelOptions()...) @@ -129,15 +211,15 @@ func (c *Client) getPaymail(ctx context.Context, xpubID, paymailAddr string) (*P return paymails[0], nil } -func (c *Client) upsertContact(ctx context.Context, pmSrvnt *PaymailServant, reqXPubID, ctcFName string, ctcPaymail *SanitizedPaymail, opts ...ModelOps) (*Contact, error) { +func (c *Client) upsertContact(ctx context.Context, pmSrvnt *PaymailServant, reqXPubID, ctcFName string, ctcPaymail *paymail.SanitisedPaymail, opts ...ModelOps) (*Contact, error) { contactPki, err := pmSrvnt.GetPkiForPaymail(ctx, ctcPaymail) if err != nil { - return nil, fmt.Errorf("geting PKI for %s failed. Reason: %w", ctcPaymail.adress, err) + return nil, fmt.Errorf("geting PKI for %s failed. Reason: %w", ctcPaymail.Address, err) } // check if exists already - contact, err := getContact(ctx, ctcPaymail.adress, reqXPubID, c.DefaultModelOptions()...) + contact, err := getContact(ctx, ctcPaymail.Address, reqXPubID, c.DefaultModelOptions()...) if err != nil { return nil, err } @@ -145,7 +227,7 @@ func (c *Client) upsertContact(ctx context.Context, pmSrvnt *PaymailServant, req if contact == nil { // insert contact = newContact( ctcFName, - ctcPaymail.adress, + ctcPaymail.Address, contactPki.PubKey, reqXPubID, ContactNotConfirmed, @@ -155,11 +237,7 @@ func (c *Client) upsertContact(ctx context.Context, pmSrvnt *PaymailServant, req contact.FullName = ctcFName contact.SetOptions(opts...) - // go back to unconfirmed status - if contact.PubKey != contactPki.PubKey { - contact.Status = ContactNotConfirmed - contact.PubKey = contactPki.PubKey - } + contact.UpdatePubKey(contactPki.PubKey) } if err = contact.Save(ctx); err != nil { @@ -169,47 +247,16 @@ func (c *Client) upsertContact(ctx context.Context, pmSrvnt *PaymailServant, req return contact, nil } -func (c *Client) UpdateContact(ctx context.Context, fullName, pubKey, xPubID, paymailAddr string, status ContactStatus, opts ...ModelOps) (*Contact, error) { - contact, err := getContact(ctx, paymailAddr, xPubID, opts...) - - if err != nil { - return nil, fmt.Errorf("failed to get contact: %w", err) - } - - if contact == nil { - return nil, fmt.Errorf("contact not found") - } - - if fullName != "" { - contact.FullName = fullName - } - - if pubKey != "" { - contact.PubKey = pubKey - } - - if status != "" { - contact.Status = status - } - - if paymailAddr != "" { - contact.Paymail = paymailAddr - } - - if err = contact.Save(ctx); err != nil { - return nil, err - } - - return contact, nil +func (c *Client) logContactWarining(xPubID, cPaymail, warning string) { + c.Logger().Warn(). + Str("xPubID", xPubID). + Str("contact", cPaymail). + Msg(warning) } -func (c *Client) GetContacts(ctx context.Context, metadata *Metadata, conditions *map[string]interface{}, queryParams *datastore.QueryParams, opts ...ModelOps) ([]*Contact, error) { - ctx = c.GetOrStartTxn(ctx, "get_contacts") - - contacts, err := getContacts(ctx, metadata, conditions, queryParams, c.DefaultModelOptions(opts...)...) - if err != nil { - return nil, err - } - - return contacts, nil +func (c *Client) logContactError(xPubID, cPaymail, errorMsg string) { + c.Logger().Error(). + Str("xPubID", xPubID). + Str("contact", cPaymail). + Msg(errorMsg) } diff --git a/engine/action_contact_test.go b/engine/action_contact_test.go new file mode 100644 index 000000000..1ff200105 --- /dev/null +++ b/engine/action_contact_test.go @@ -0,0 +1,336 @@ +package engine + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + xPubGeneric = "62910a1ecbc7123213231563ab3f8aa70568ed934d1e0383cb1bbbfb1bc8f2afe5" + xPubForNotFoundContact = "7fa312762ef940d9f744906913422d750e76b980e5824cc7995a2d803af765ee2c" + paymailGeneric = "test@test.test" + fullName = "John Doe" + pubKey = "pubKey" +) + +type testCase struct { + testID int + name string + data testCaseData + expectedErrorMessage error +} + +type testCaseData struct { + xPub string + paymail string + contactStatus string + deleted bool +} + +func initContactTestCase(t *testing.T) (context.Context, ClientInterface, func()) { + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, withTaskManagerMockup()) + + xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) + err := xPub.Save(ctx) + require.NoError(t, err) + + return ctx, client, deferMe +} + +func TestAcceptContactHappyPath(t *testing.T) { + ctx, client, deferMe := initContactTestCase(t) + defer deferMe() + + t.Run("accept contact, should return nil", func(t *testing.T) { + // given + contact := newContact( + fullName, + paymailGeneric, + pubKey, + xPubGeneric, + ContactAwaitAccept, + ) + contact.enrich(ModelContact, append(client.DefaultModelOptions(), New())...) + err := contact.Save(ctx) + require.NoError(t, err) + + // when + err = client.AcceptContact(ctx, xPubGeneric, paymailGeneric) + + // then + require.NoError(t, err) + + contact1, err := getContact(ctx, paymailGeneric, xPubGeneric, client.DefaultModelOptions()...) + require.NoError(t, err) + require.Equal(t, ContactNotConfirmed, contact1.Status) + }) +} + +func TestAcceptContactErrorPath(t *testing.T) { + + testCases := []testCase{ + { + testID: 1, + name: "non existance contact, should return \"contact not found\" error", + data: testCaseData{ + xPub: xPubForNotFoundContact, + paymail: paymailGeneric, + contactStatus: ContactAwaitAccept.String(), + }, + expectedErrorMessage: ErrContactNotFound, + }, + { + testID: 2, + name: "contact has status \"confirmed\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactAwaitAccept.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 3, + name: "contact has status \"not confirmed\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactNotConfirmed.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 4, + name: "contact has status \"rejected\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactRejected.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 5, + name: "contact has status \"rejected\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactRejected.String(), + deleted: true, + }, + expectedErrorMessage: ErrContactNotFound, + }, + } + + for _, tc := range testCases { + ctx, client, deferMe := initContactTestCase(t) + defer deferMe() + t.Run(tc.name, func(t *testing.T) { + // given + contact := newContact( + fullName, + paymailGeneric, + pubKey, + xPubGeneric, + ContactNotConfirmed, + ) + contact.enrich(ModelContact, append(client.DefaultModelOptions(), New())...) + if tc.data.deleted { + contact.DeletedAt.Valid = true + contact.DeletedAt.Time = time.Now() + } + err := contact.Save(ctx) + require.NoError(t, err) + + // when + err = client.AcceptContact(ctx, tc.data.xPub, tc.data.paymail) + + // then + require.Error(t, err) + require.EqualError(t, err, tc.expectedErrorMessage.Error()) + }) + } +} + +func TestRejectContactHappyPath(t *testing.T) { + ctx, client, deferMe := initContactTestCase(t) + defer deferMe() + + t.Run("reject contact", func(t *testing.T) { + // given + contact := newContact( + fullName, + paymailGeneric, + pubKey, + xPubGeneric, + ContactAwaitAccept, + ) + contact.enrich(ModelContact, append(client.DefaultModelOptions(), New())...) + err := contact.Save(ctx) + require.NoError(t, err) + + // when + err = client.RejectContact(ctx, contact.OwnerXpubID, contact.Paymail) + + // then + require.NoError(t, err) + + contact1, err := getContact(ctx, contact.Paymail, contact.OwnerXpubID, client.DefaultModelOptions()...) + require.NoError(t, err) + require.Empty(t, contact1) + }) +} + +func TestRejectContactErrorPath(t *testing.T) { + + testCases := []testCase{ + { + testID: 1, + name: "non existance contact, should return \"contact not found\" error", + data: testCaseData{ + xPub: xPubForNotFoundContact, + paymail: paymailGeneric, + contactStatus: ContactAwaitAccept.String(), + }, + expectedErrorMessage: ErrContactNotFound, + }, + { + testID: 2, + name: "contact has status \"confirmed\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactConfirmed.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 3, + name: "contact has status \"not confirmed\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactNotConfirmed.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 4, + name: "contact has status \"rejected\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactRejected.String(), + }, + expectedErrorMessage: ErrContactIncorrectStatus, + }, + { + testID: 5, + name: "contact has status \"rejected\", should return \"contact does not have status awaiting\" error", + data: testCaseData{ + xPub: xPubGeneric, + paymail: paymailGeneric, + contactStatus: ContactRejected.String(), + deleted: true, + }, + expectedErrorMessage: ErrContactNotFound, + }} + + for _, tc := range testCases { + ctx, client, deferMe := initContactTestCase(t) + defer deferMe() + t.Run(tc.name, func(t *testing.T) { + // given + contact := newContact( + fullName, + paymailGeneric, + pubKey, + xPubGeneric, + ContactNotConfirmed, + ) + contact.enrich(ModelContact, append(client.DefaultModelOptions(), New())...) + if tc.data.deleted { + contact.DeletedAt.Valid = true + contact.DeletedAt.Time = time.Now() + } + err := contact.Save(ctx) + require.NoError(t, err) + + // when + err = client.RejectContact(ctx, tc.data.xPub, tc.data.paymail) + + // then + require.Error(t, err) + require.EqualError(t, err, tc.expectedErrorMessage.Error()) + }) + } +} + +func TestConfirmContactErrorPath(t *testing.T) { + tcs := []struct { + name string + expectedError error + getContact func() (contact *Contact, paymail string, onwerXpubId string) + }{ + { + name: "contact doesn't exist - return not found error", + expectedError: ErrContactNotFound, + getContact: func() (*Contact, string, string) { + return nil, "idontexist", "xpubID" + }, + }, + { + name: "already confirmed contact - return incorrect status error", + expectedError: ErrContactIncorrectStatus, + getContact: func() (*Contact, string, string) { + cc := newContact("Paul Altreides", "paul@altreides.diune", "pki", "xpub", ContactNotConfirmed) + cc.Confirm() + + return cc, cc.Paymail, cc.OwnerXpubID + }, + }, + { + name: "awaiting contact - return incorrect status error", + expectedError: ErrContactIncorrectStatus, + getContact: func() (*Contact, string, string) { + cc := newContact("Alia Altreides", "alia@altreides.diune", "pki", "xpub", ContactAwaitAccept) + + return cc, cc.Paymail, cc.OwnerXpubID + }, + }, + { + name: "rejected contact - return not found error", + expectedError: ErrContactNotFound, + getContact: func() (*Contact, string, string) { + cc := newContact("Alia Altreides", "alia@altreides.diune", "pki", "xpub", ContactAwaitAccept) + cc.Reject() + + return cc, cc.Paymail, cc.OwnerXpubID + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + // given + ctx, client, cleanup := CreateTestSQLiteClient(t, false, true, withTaskManagerMockup()) + defer cleanup() + + contact, paymail, ownerXpubID := tc.getContact() + if contact != nil { + contact.enrich(ModelContact, client.DefaultModelOptions()...) + err := contact.Save(ctx) + require.NoError(t, err) + } + + // when + err := client.ConfirmContact(ctx, ownerXpubID, paymail) + + // then + require.ErrorIs(t, err, tc.expectedError) + }) + } +} diff --git a/engine/action_paymails.go b/engine/action_paymails.go index 3ec957578..f8527810b 100644 --- a/engine/action_paymails.go +++ b/engine/action_paymails.go @@ -71,7 +71,8 @@ func (c *Client) GetPaymailAddressesByXPubID(ctx context.Context, xPubID string, ctx = c.GetOrStartTxn(ctx, "get_paymail_by_xpub") if conditions == nil { - *conditions = make(map[string]interface{}) + x := make(map[string]interface{}) + conditions = &x } // add the xpub_id to the conditions (*conditions)["xpub_id"] = xPubID diff --git a/engine/contact_service_test.go b/engine/contact_service_test.go index 0aa0f79de..8be447e9b 100644 --- a/engine/contact_service_test.go +++ b/engine/contact_service_test.go @@ -276,8 +276,8 @@ func TestClientService_AddContactRequest(t *testing.T) { require.NoError(t, err) require.NotNil(t, contact) - // mark request as accepted - contact.Status = ContactNotConfirmed + // mark request as confirmed + contact.Status = ContactConfirmed err = contact.Save(ctx) require.NoError(t, err) @@ -295,7 +295,7 @@ func TestClientService_AddContactRequest(t *testing.T) { require.Equal(t, updatedPki, updatedContact.PubKey) // status should back to awaiting - require.Equal(t, ContactAwaitAccept, updatedContact.Status) + require.Equal(t, ContactNotConfirmed, updatedContact.Status) }) } diff --git a/engine/definitions.go b/engine/definitions.go index b8cf05de0..56fe6b92c 100644 --- a/engine/definitions.go +++ b/engine/definitions.go @@ -73,6 +73,7 @@ const ( aliasField = "alias" broadcastStatusField = "broadcast_status" createdAtField = "created_at" + deletedAtField = "deleted_at" currentBalanceField = "current_balance" domainField = "domain" draftIDField = "draft_id" @@ -94,11 +95,7 @@ const ( bumpField = "bump" fullNameField = "full_name" paymailField = "paymail" - - // TODO: check - xPubKeyField = "pub_key" - senderXPubField = "pub_key" - contactStatus = "status" + contactStatusField = "status" // Universal statuses statusCanceled = "canceled" diff --git a/engine/interface.go b/engine/interface.go index 627244dbf..b452296d5 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -61,8 +61,11 @@ type ContactService interface { UpsertContact(ctx context.Context, fullName, paymailAdress, requesterPubKey, requesterPaymail string, opts ...ModelOps) (*Contact, error) AddContactRequest(ctx context.Context, fullName, paymailAdress, requesterXPubID string, opts ...ModelOps) (*Contact, error) - UpdateContact(ctx context.Context, fullName, pubKey, xPubID, paymail string, status ContactStatus, opts ...ModelOps) (*Contact, error) - GetContacts(ctx context.Context, metadata *Metadata, conditions *map[string]interface{}, queryParams *datastore.QueryParams, opts ...ModelOps) ([]*Contact, error) + AcceptContact(ctx context.Context, xPubID, paymail string) error + RejectContact(ctx context.Context, xPubID, paymail string) error + ConfirmContact(ctx context.Context, xPubID, paymail string) error + + GetContacts(ctx context.Context, xPubID string, metadata *Metadata, conditions map[string]interface{}, queryParams *datastore.QueryParams) ([]*Contact, error) } // DestinationService is the destination actions diff --git a/engine/metrics/metrics.go b/engine/metrics/metrics.go index 5dc63a968..89ae22a5a 100644 --- a/engine/metrics/metrics.go +++ b/engine/metrics/metrics.go @@ -36,7 +36,7 @@ func NewMetrics(collector Collector) *Metrics { verifyMerkleRoots: collector.RegisterHistogramVec(verifyMerkleRootsHistogramName, "classification"), recordTransaction: collector.RegisterHistogramVec(recordTransactionHistogramName, "classification", "strategy"), queryTransaction: collector.RegisterHistogramVec(queryTransactionHistogramName, "classification"), - addContact: collector.RegisterHistogramVec(addContactHistogramName, "classification", "pike"), + addContact: collector.RegisterHistogramVec(addContactHistogramName, "classification"), cronHistogram: collector.RegisterHistogramVec(cronHistogramName, "name"), cronLastExecution: collector.RegisterGaugeVec(cronLastExecutionGaugeName, "name"), } diff --git a/engine/model_contact.go b/engine/model_contact.go index d883c8ecc..da841cc5b 100644 --- a/engine/model_contact.go +++ b/engine/model_contact.go @@ -3,10 +3,13 @@ package engine import ( "context" "errors" + "fmt" + "strings" + "time" "github.com/bitcoin-sv/go-paymail" - "github.com/google/uuid" "github.com/bitcoin-sv/spv-wallet/engine/datastore" + "github.com/google/uuid" ) type Contact struct { @@ -39,9 +42,11 @@ func newContact(fullName, paymailAddress, pubKey, ownerXpubID string, status Con } func getContact(ctx context.Context, paymail, ownerXpubID string, opts ...ModelOps) (*Contact, error) { + paymail = strings.ToLower(paymail) conditions := map[string]interface{}{ - xPubIDField: ownerXpubID, - paymailField: paymail, + xPubIDField: ownerXpubID, + paymailField: paymail, + deletedAtField: nil, } contact := &Contact{} @@ -85,16 +90,65 @@ func (c *Contact) validate() error { return nil } -func getContacts(ctx context.Context, metadata *Metadata, conditions *map[string]interface{}, queryParams *datastore.QueryParams, opts ...ModelOps) ([]*Contact, error) { - contacts := make([]*Contact, 0) +func getContacts(ctx context.Context, xPubID string, metadata *Metadata, conditions map[string]interface{}, queryParams *datastore.QueryParams, opts ...ModelOps) ([]*Contact, error) { + if conditions == nil { + conditions = make(map[string]interface{}) + } + conditions[xPubIDField] = xPubID + conditions[deletedAtField] = nil - if err := getModelsByConditions(ctx, ModelContact, &contacts, metadata, conditions, queryParams, opts...); err != nil { + contacts := make([]*Contact, 0) + if err := getModelsByConditions(ctx, ModelContact, &contacts, metadata, &conditions, queryParams, opts...); err != nil { return nil, err } return contacts, nil } +func (c *Contact) Accept() error { + if c.Status != ContactAwaitAccept { + return fmt.Errorf("cannot accept contact. Reason: status: %s, expected: %s", c.Status, ContactAwaitAccept) + } + + c.Status = ContactNotConfirmed + return nil +} + +func (c *Contact) Reject() error { + if c.Status != ContactAwaitAccept { + return fmt.Errorf("cannot reject contact. Reason: status: %s, expected: %s", c.Status, ContactAwaitAccept) + } + + c.DeletedAt.Valid = true + c.DeletedAt.Time = time.Now() + c.Status = ContactRejected + return nil +} + +func (c *Contact) Confirm() error { + if c.Status != ContactNotConfirmed { + return fmt.Errorf("cannot confirm contact. Reason: status: %s, expected: %s", c.Status, ContactNotConfirmed) + } + + c.Status = ContactConfirmed + return nil +} + +func (c *Contact) UpdatePubKey(pk string) (updated bool) { + if c.PubKey != pk { + c.PubKey = pk + + if c.Status == ContactConfirmed { + c.Status = ContactNotConfirmed + } + + updated = true + } + + updated = false + return +} + func (c *Contact) GetModelName() string { return ModelContact.String() } diff --git a/engine/model_contact_status.go b/engine/model_contact_status.go index 1a6f6741c..54b8e2763 100644 --- a/engine/model_contact_status.go +++ b/engine/model_contact_status.go @@ -13,6 +13,7 @@ const ( ContactNotConfirmed ContactStatus = "unconfirmed" ContactAwaitAccept ContactStatus = "awaiting" ContactConfirmed ContactStatus = "confirmed" + ContactRejected ContactStatus = "rejected" ) var contactStatusMapper = NewEnumStringMapper(ContactNotConfirmed, ContactAwaitAccept, ContactConfirmed) diff --git a/engine/model_contact_test.go b/engine/model_contact_test.go index 002ac92e7..9e207e199 100644 --- a/engine/model_contact_test.go +++ b/engine/model_contact_test.go @@ -1,18 +1,16 @@ package engine import ( + "context" "errors" "fmt" + "strings" "testing" + "time" - "github.com/bitcoin-sv/spv-wallet/engine/datastore" - - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const xPubID = "62910a1ecbc7728afad563ab3f8aa70568ed934d1e0383cb1bbbfb1bc8f2afe5" - func Test_contact_validate_success(t *testing.T) { t.Run("valid contact", func(t *testing.T) { // given @@ -80,6 +78,89 @@ func Test_contact_validate_returns_error(t *testing.T) { } } +func Test_Accept(t *testing.T) { + t.Run("accept awaiting contact - status changed to <>", func(t *testing.T) { + // given + sut := newContact("Leto Atreides", "leto@atreides.diune", "pubkey", "xpubid", ContactAwaitAccept) + + // when + err := sut.Accept() + + // then + require.NoError(t, err) + require.Equal(t, ContactNotConfirmed, sut.Status) + }) + + t.Run("accept non-awaiting contact - return error, status has not been changed", func(t *testing.T) { + // given + const notAwaitingStatus = ContactNotConfirmed + sut := newContact("Jessica Atreides", "jess@atreides.diune", "pubkey", "xpubid", notAwaitingStatus) + + // when + err := sut.Accept() + + // then + require.Error(t, err) + require.Equal(t, notAwaitingStatus, sut.Status) + }) +} + +func Test_Reject(t *testing.T) { + t.Run("reject awaiting contact - status changed to <>, contact has been marked as deleted", func(t *testing.T) { + // given + sut := newContact("Vladimir Harkonnen", "vlad@harkonnen.diune", "pubkey", "xpubid", ContactAwaitAccept) + + // when + err := sut.Reject() + + // then + require.NoError(t, err) + require.Equal(t, ContactRejected, sut.Status) + require.True(t, sut.DeletedAt.Valid) + }) + + t.Run("reject non-awaiting contact - return error, status has not been changed", func(t *testing.T) { + // given + const notAwaitingStatus = ContactNotConfirmed + sut := newContact("Feyd-Rautha Harkonnen", "frautha@harkonnen.diune", "pubkey", "xpubid", notAwaitingStatus) + + // when + err := sut.Reject() + + // then + require.Error(t, err) + require.Equal(t, notAwaitingStatus, sut.Status) + require.False(t, sut.DeletedAt.Valid) + }) +} + +func Test_Confirm(t *testing.T) { + t.Run("confirm unconfirmed contact - status changed to <>", func(t *testing.T) { + // given + sut := newContact("Thufir Hawat", "hawat@atreides.diune", "pubkey", "xpubid", ContactNotConfirmed) + + // when + err := sut.Confirm() + + // then + require.NoError(t, err) + require.Equal(t, ContactConfirmed, sut.Status) + }) + + t.Run("confirm non-unconfirmed contact - return error, status has not been changed", func(t *testing.T) { + // given + const notUncormirmedStatus = ContactAwaitAccept + sut := newContact("Gurney Halleck", "halleck@atreides.diune", "pubkey", "xpubid", notUncormirmedStatus) + + // when + err := sut.Confirm() + + // then + require.Error(t, err) + require.Equal(t, notUncormirmedStatus, sut.Status) + }) +} + func Test_getContact(t *testing.T) { t.Run("get by paymail for owner xpubid", func(t *testing.T) { ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) @@ -106,6 +187,33 @@ func Test_getContact(t *testing.T) { require.Equal(t, contact.Status, result.Status) }) + t.Run("get by paymail for owner xpubid - case insensitive", func(t *testing.T) { + ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) + defer deferMe() + + contact := newContact("Homer Simpson", "hOmEr@springfield.com", "xpubblablahomer", + "fafagasfaufrusfrusfrbsur", ContactNotConfirmed, WithClient(client)) + + uppercaseContactPaymail := strings.ToUpper(contact.Paymail) + + err := contact.Save(ctx) + require.NoError(t, err) + + // when + result, err := getContact(ctx, uppercaseContactPaymail, contact.OwnerXpubID, WithClient(client)) + + // then + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, contact.ID, result.ID) + require.Equal(t, contact.OwnerXpubID, result.OwnerXpubID) + require.Equal(t, contact.FullName, result.FullName) + require.Equal(t, contact.Paymail, result.Paymail) + require.Equal(t, contact.PubKey, result.PubKey) + require.Equal(t, contact.Status, result.Status) + }) + t.Run("get by paymail for not matching owner xpubid - returns nil", func(t *testing.T) { ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) defer deferMe() @@ -123,63 +231,143 @@ func Test_getContact(t *testing.T) { require.NoError(t, err) require.Nil(t, result) }) -} -func Test_getContacts(t *testing.T) { - t.Run("status 'not confirmed'", func(t *testing.T) { + t.Run("get deleted contact - returns nil", func(t *testing.T) { ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) defer deferMe() - var metadata *Metadata + contact := newContact("Marge Simpson", "Marge@springfield.com", "xpubblablamarge", + "fafagasfaufrusfrusfrbsur", ContactNotConfirmed, WithClient(client)) - dbConditions := map[string]interface{}{ - xPubIDField: xPubID, - contactStatus: ContactNotConfirmed, - } + err := contact.Save(ctx) + require.NoError(t, err) - var queryParams *datastore.QueryParams + // delete + contact.DeletedAt.Valid = true + contact.DeletedAt.Time = time.Now() + err = contact.Save(ctx) + require.NoError(t, err) - contacts, err := getContacts(ctx, metadata, &dbConditions, queryParams, client.DefaultModelOptions()...) + // when + result, err := getContact(ctx, contact.Paymail, contact.OwnerXpubID, WithClient(client)) + // then require.NoError(t, err) - assert.NotNil(t, contacts) + require.Nil(t, result) }) +} - t.Run("status 'confirmed'", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) - defer deferMe() +func Test_getContacts(t *testing.T) { - var metadata *Metadata + t.Run("get by status 'not confirmed'", func(t *testing.T) { + // given + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) + defer cleanup() - dbConditions := make(map[string]interface{}) + xpubID := "xpubid" - var queryParams *datastore.QueryParams + // fullfill db + saveContactsN(xpubID, ContactAwaitAccept, 10, client) + saveContactsN(xpubID, ContactNotConfirmed, 13, client) - (dbConditions)[xPubIDField] = xPubID - (dbConditions)[contactStatus] = ContactConfirmed + conditions := map[string]interface{}{ + contactStatusField: ContactNotConfirmed, + } - contacts, err := getContacts(ctx, metadata, &dbConditions, queryParams, client.DefaultModelOptions()...) + // when + contacts, err := getContacts(ctx, xpubID, nil, conditions, nil, client.DefaultModelOptions()...) + // then require.NoError(t, err) - assert.Equal(t, 0, len(contacts)) + require.NotNil(t, contacts) + require.Equal(t, 13, len(contacts)) + + for _, c := range contacts { + require.Equal(t, ContactNotConfirmed, c.Status) + } + }) - t.Run("status 'awaiting acceptance'", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) - defer deferMe() + t.Run("get without conditions - return all", func(t *testing.T) { + // given + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) + defer cleanup() + + xpubID := "xpubid" + + // fullfill db + saveContactsN(xpubID, ContactAwaitAccept, 10, client) + saveContactsN(xpubID, ContactNotConfirmed, 13, client) + + // when + contacts, err := getContacts(ctx, xpubID, nil, nil, nil, client.DefaultModelOptions()...) + + // then + require.NoError(t, err) + require.NotNil(t, contacts) + require.Equal(t, 23, len(contacts)) + + }) + + t.Run("get without conditions - ensure returned only with correct xpubid", func(t *testing.T) { + // given + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) + defer cleanup() + + xpubID := "xpubid" + + // fullfill db + saveContactsN(xpubID, ContactAwaitAccept, 10, client) + saveContactsN("other-xpub", ContactNotConfirmed, 13, client) + + // when + contacts, err := getContacts(ctx, xpubID, nil, nil, nil, client.DefaultModelOptions()...) - var metadata *Metadata + // then + require.NoError(t, err) + require.NotNil(t, contacts) + require.Equal(t, 10, len(contacts)) + + }) - dbConditions := make(map[string]interface{}) + t.Run("get without conditions - ensure returned without deleted", func(t *testing.T) { + // given + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup()) + defer cleanup() - var queryParams *datastore.QueryParams + xpubID := "xpubid" - (dbConditions)[xPubIDField] = xPubID - (dbConditions)[contactStatus] = ContactAwaitAccept + // fullfill db + saveContactsN(xpubID, ContactAwaitAccept, 10, client) + saveContactsDeletedN(xpubID, ContactNotConfirmed, 13, client) - contacts, err := getContacts(ctx, metadata, &dbConditions, queryParams, client.DefaultModelOptions()...) + // when + contacts, err := getContacts(ctx, xpubID, nil, nil, nil, client.DefaultModelOptions()...) + // then require.NoError(t, err) - assert.Equal(t, 0, len(contacts)) + require.NotNil(t, contacts) + require.Equal(t, 10, len(contacts)) + }) } + +func saveContactsN(xpubID string, status ContactStatus, n int, c ClientInterface) { + for i := 0; i < n; i++ { + e := newContact(fmt.Sprintf("%s%d", status, i), fmt.Sprintf("%s%d@t.com", status, i), "pubkey", xpubID, status, c.DefaultModelOptions()...) + if err := e.Save(context.Background()); err != nil { + panic(err) + } + } +} + +func saveContactsDeletedN(xpubID string, status ContactStatus, n int, c ClientInterface) { + for i := 0; i < n; i++ { + e := newContact(fmt.Sprintf("%s%d", status, i), fmt.Sprintf("%s%d@t.com", status, i), "pubkey", xpubID, status, c.DefaultModelOptions()...) + e.DeletedAt.Valid = true + e.DeletedAt.Time = time.Now() + if err := e.Save(context.Background()); err != nil { + panic(err) + } + } +} diff --git a/engine/paymail_servant.go b/engine/paymail_servant.go index 1febb8e67..fa6def915 100644 --- a/engine/paymail_servant.go +++ b/engine/paymail_servant.go @@ -19,19 +19,19 @@ type PaymailServant struct { pc paymail.ClientInterface } -type SanitizedPaymail struct { - alias, domain, adress string -} +func (s *PaymailServant) GetSanitizedPaymail(addr string) (*paymail.SanitisedPaymail, error) { + if err := paymail.ValidatePaymail(addr); err != nil { + return nil, err + } -func (s *PaymailServant) GetSanitizedPaymail(paymailAdress string) *SanitizedPaymail { - sanitized := &SanitizedPaymail{} - sanitized.alias, sanitized.domain, sanitized.adress = paymail.SanitizePaymail(paymailAdress) + sanitised := &paymail.SanitisedPaymail{} + sanitised.Alias, sanitised.Domain, sanitised.Address = paymail.SanitizePaymail(addr) - return sanitized + return sanitised, nil } -func (s *PaymailServant) GetPkiForPaymail(ctx context.Context, sPaymail *SanitizedPaymail) (*paymail.PKIResponse, error) { - capabilities, err := getCapabilities(ctx, s.cs, s.pc, sPaymail.domain) +func (s *PaymailServant) GetPkiForPaymail(ctx context.Context, sPaymail *paymail.SanitisedPaymail) (*paymail.PKIResponse, error) { + capabilities, err := getCapabilities(ctx, s.cs, s.pc, sPaymail.Domain) if err != nil { return nil, fmt.Errorf("failed to get paymail capability: %w", err) } @@ -41,7 +41,7 @@ func (s *PaymailServant) GetPkiForPaymail(ctx context.Context, sPaymail *Sanitiz } url := capabilities.GetString(paymail.BRFCPki, paymail.BRFCPkiAlternate) - pki, err := s.pc.GetPKI(url, sPaymail.alias, sPaymail.domain) + pki, err := s.pc.GetPKI(url, sPaymail.Alias, sPaymail.Domain) if err != nil { return nil, fmt.Errorf("error getting PKI: %w", err) } @@ -49,8 +49,8 @@ func (s *PaymailServant) GetPkiForPaymail(ctx context.Context, sPaymail *Sanitiz return pki, nil } -func (s *PaymailServant) AddContactRequest(ctx context.Context, receiverPaymail *SanitizedPaymail, contactData *paymail.PikeContactRequestPayload) (*paymail.PikeContactRequestResponse, error) { - capabilities, err := getCapabilities(ctx, s.cs, s.pc, receiverPaymail.domain) +func (s *PaymailServant) AddContactRequest(ctx context.Context, receiverPaymail *paymail.SanitisedPaymail, contactData *paymail.PikeContactRequestPayload) (*paymail.PikeContactRequestResponse, error) { + capabilities, err := getCapabilities(ctx, s.cs, s.pc, receiverPaymail.Domain) if err != nil { return nil, fmt.Errorf("failed to get paymail capability: %w", err) } @@ -60,7 +60,7 @@ func (s *PaymailServant) AddContactRequest(ctx context.Context, receiverPaymail } url := capabilities.GetString(paymail.BRFCPike, "") - response, err := s.pc.AddContactRequest(url, receiverPaymail.alias, receiverPaymail.domain, contactData) + response, err := s.pc.AddContactRequest(url, receiverPaymail.Alias, receiverPaymail.Domain, contactData) if err != nil { return nil, fmt.Errorf("error during requesting new contact: %w", err) } diff --git a/mappings/contact.go b/mappings/contact.go index c6517884c..c2aa74c10 100644 --- a/mappings/contact.go +++ b/mappings/contact.go @@ -7,29 +7,42 @@ import ( ) // MapToContactContract will map the contact to the spv-wallet-models contract -func MapToContactContract(c *engine.Contact) *models.Contact { - if c == nil { +func MapToContactContract(src *engine.Contact) *models.Contact { + if src == nil { return nil } return &models.Contact{ - ID: c.ID, - Model: *common.MapToContract(&c.Model), - FullName: c.FullName, - Paymail: c.Paymail, - PubKey: c.PubKey, - Status: mapContactStatus(c.Status), + ID: src.ID, + Model: *common.MapToContract(&src.Model), + FullName: src.FullName, + Paymail: src.Paymail, + PubKey: src.PubKey, + Status: mapContactStatus(src.Status), } } -func mapContactStatus(s engine.ContactStatus) string { +// MapToContactContracts will map the contacts collection to the spv-wallet-models contracts collection +func MapToContactContracts(src []*engine.Contact) []*models.Contact { + res := make([]*models.Contact, 0, len(src)) + + for _, c := range src { + res = append(res, MapToContactContract(c)) + } + + return res +} + +func mapContactStatus(s engine.ContactStatus) models.ContactStatus { switch s { case engine.ContactNotConfirmed: - return "unconfirmed" + return models.ContactNotConfirmed case engine.ContactAwaitAccept: - return "awaiting" + return models.ContactAwaitAccept case engine.ContactConfirmed: - return "confirmed" + return models.ContactConfirmed + case engine.ContactRejected: + return models.ContactRejected default: return "unknown" } diff --git a/models/contact.go b/models/contact.go index c5fb76a0a..76bf1366d 100644 --- a/models/contact.go +++ b/models/contact.go @@ -19,9 +19,18 @@ type Contact struct { // PubKey is a public key related to contact (receiver). PubKey string `json:"pubKey" example:"xpub661MyMwAqRbcGpZVrSHU..."` // Status is a contact's current status. - Status string `json:"status" example:"unconfirmed"` + Status ContactStatus `json:"status" example:"unconfirmed"` } +type ContactStatus string + +const ( + ContactNotConfirmed ContactStatus = "unconfirmed" + ContactAwaitAccept ContactStatus = "awaiting" + ContactConfirmed ContactStatus = "confirmed" + ContactRejected ContactStatus = "rejected" +) + func (m *CreateContactResponse) AddAdditionalInfo(k, v string) { if m.AdditionalInfo == nil { m.AdditionalInfo = make(map[string]string) diff --git a/models/draft_transaction.go b/models/draft_transaction.go index 382692b85..6b5148a9b 100644 --- a/models/draft_transaction.go +++ b/models/draft_transaction.go @@ -30,7 +30,7 @@ type DraftTransaction struct { // Hex is a draft transaction hex. Hex string `json:"hex" example:"0100000002..."` // XpubID is a draft transaction's xpub used to sign transaction. - XpubID string `json:"xpub_id" example:"bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50""` + XpubID string `json:"xpub_id" example:"bb8593f85ef8056a77026ad415f02128f3768906de53e9e8bf8749fe2d66cf50"` // ExpiresAt is a time when draft transaction expired. ExpiresAt time.Time `json:"expires_at" example:"2024-02-26T11:00:28.069911Z"` // Configuration contains draft transaction configuration.