From 20eace0884f7283141641e9b6f83a4b46ebc9aae Mon Sep 17 00:00:00 2001 From: david amick Date: Thu, 22 Feb 2024 08:47:29 -0800 Subject: [PATCH] Add DeleteTransaction API call --- api/client.go | 1 + api/transaction/service.go | 19 +++++- api/transaction/service_test.go | 33 ++++++++++ client.go | 5 ++ client_test.go | 109 ++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) diff --git a/api/client.go b/api/client.go index f41a314..d1fdcdb 100644 --- a/api/client.go +++ b/api/client.go @@ -16,6 +16,7 @@ type ClientWriter interface { POST(url string, responseModel interface{}, requestBody []byte) error PUT(url string, responseModel interface{}, requestBody []byte) error PATCH(url string, responseModel interface{}, requestBody []byte) error + DELETE(url string, responseModel interface{}) error } // ClientReaderWriter contract for a read-write client diff --git a/api/transaction/service.go b/api/transaction/service.go index c4c31d2..ee73c44 100644 --- a/api/transaction/service.go +++ b/api/transaction/service.go @@ -183,6 +183,23 @@ func (s *Service) UpdateTransactions(budgetID string, return resModel.Data, nil } +// DeleteTransaction deletes a transaction from a budget +// https://api.youneedabudget.com/v1#/Transactions/deleteTransaction +func (s *Service) DeleteTransaction(budgetID, transactionID string) (*Transaction, error) { + resModel := struct { + Data struct { + Transaction *Transaction `json:"transaction"` + } `json:"data"` + }{} + + url := fmt.Sprintf("/budgets/%s/transactions/%s", budgetID, transactionID) + err := s.c.DELETE(url, &resModel) + if err != nil { + return nil, err + } + return resModel.Data.Transaction, nil +} + // GetTransactionsByAccount fetches the list of transactions of a specific account // from a budget with filtering capabilities // https://api.youneedabudget.com/v1#/Transactions/getTransactionsByAccount @@ -257,7 +274,7 @@ func (s *Service) GetTransactionsByPayee(budgetID, payeeID string, // GetScheduledTransactions fetches the list of scheduled transactions from // a budget -//https://api.youneedabudget.com/v1#/Scheduled_Transactions/getScheduledTransactions +// https://api.youneedabudget.com/v1#/Scheduled_Transactions/getScheduledTransactions func (s *Service) GetScheduledTransactions(budgetID string) ([]*Scheduled, error) { resModel := struct { Data struct { diff --git a/api/transaction/service_test.go b/api/transaction/service_test.go index cff723b..dff9a78 100644 --- a/api/transaction/service_test.go +++ b/api/transaction/service_test.go @@ -1190,6 +1190,39 @@ func TestService_UpdateTransaction(t *testing.T) { assert.Equal(t, expectedTransaction, tx) } +func TestService_DeleteTransaction(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + url := "https://api.youneedabudget.com/v1/budgets/aa248caa-eed7-4575-a990-717386438d2c/transactions/e6ad88f5-6f16-4480-9515-5377012750dd" + httpmock.RegisterResponder(http.MethodDelete, url, + func(req *http.Request) (*http.Response, error) { + res := httpmock.NewStringResponse(200, `{ + "data": { + "transaction": { + "id": "e6ad88f5-6f16-4480-9515-5377012750dd" + } + } +} + `) + res.Header.Add("X-Rate-Limit", "36/200") + return res, nil + }, + ) + + client := ynab.NewClient("") + tx, err := client.Transaction().DeleteTransaction( + "aa248caa-eed7-4575-a990-717386438d2c", + "e6ad88f5-6f16-4480-9515-5377012750dd", + ) + assert.NoError(t, err) + + expected := &transaction.Transaction{ + ID: "e6ad88f5-6f16-4480-9515-5377012750dd", + } + assert.Equal(t, expected, tx) +} + func TestFilter_ToQuery(t *testing.T) { sinceDate, err := api.DateFromString("2020-02-02") assert.NoError(t, err) diff --git a/client.go b/client.go index 4bed555..2f5765c 100644 --- a/client.go +++ b/client.go @@ -135,6 +135,11 @@ func (c *client) PATCH(url string, responseModel interface{}, requestBody []byte return c.do(http.MethodPatch, url, responseModel, requestBody) } +// DELETE sends a DELETE request to the YNAB API +func (c *client) DELETE(url string, responseModel interface{}) error { + return c.do(http.MethodDelete, url, responseModel, nil) +} + // do sends a request to the YNAB API func (c *client) do(method, url string, responseModel interface{}, requestBody []byte) error { fullURL := fmt.Sprintf("%s%s", apiEndpoint, url) diff --git a/client_test.go b/client_test.go index a357260..90fd8cf 100644 --- a/client_test.go +++ b/client_test.go @@ -493,3 +493,112 @@ func TestClient_PATCH(t *testing.T) { }{}, response) }) } + +func TestClient_DELETE(t *testing.T) { + t.Run("success", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodDelete, fmt.Sprintf("%s%s", apiEndpoint, "/foo"), + func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "application/json", req.Header.Get("Accept")) + assert.Equal(t, "Bearer 6zL9vh8]B9H3BEecwL%Vzh^VwKR3C2CNZ3Bv%=fFxm$z)duY[U+2=3CydZrkQFnA", req.Header.Get("Authorization")) + + res := httpmock.NewStringResponse(http.StatusOK, `{ + "data": { + "transaction": { + "id": "some_id" + } + } +}`) + res.Header.Add("X-Rate-Limit", "36/200") + return res, nil + }, + ) + + response := struct { + Data struct { + Transaction struct { + ID string `json:"id"` + } `json:"transaction"` + } `json:"data"` + }{} + + c := NewClient("6zL9vh8]B9H3BEecwL%Vzh^VwKR3C2CNZ3Bv%=fFxm$z)duY[U+2=3CydZrkQFnA") + err := c.(*client).DELETE("/foo", &response) + assert.NoError(t, err) + assert.Equal(t, "some_id", response.Data.Transaction.ID) + }) + + t.Run("failure with with expected API error", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodDelete, fmt.Sprintf("%s%s", apiEndpoint, "/foo"), + func(req *http.Request) (*http.Response, error) { + res := httpmock.NewStringResponse(http.StatusBadRequest, `{ + "error": { + "id": "400", + "name": "error_name", + "detail": "Error detail" + } + }`) + res.Header.Add("X-Rate-Limit", "36/200") + return res, nil + }, + ) + + response := struct { + Foo string `json:"foo"` + }{} + + c := NewClient("") + err := c.(*client).DELETE("/foo", &response) + expectedErrStr := "api: error id=400 name=error_name detail=Error detail" + assert.EqualError(t, err, expectedErrStr) + }) + + t.Run("failure with with unexpected API error", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodDelete, fmt.Sprintf("%s%s", apiEndpoint, "/foo"), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusInternalServerError, "Internal Server Error"), nil + }, + ) + + response := struct { + Foo string `json:"foo"` + }{} + + c := NewClient("") + err := c.(*client).DELETE("/foo", &response) + expectedErrStr := "api: error id=500 name=unknown_api_error detail=Unknown API error" + assert.EqualError(t, err, expectedErrStr) + }) + + t.Run("silent failure due to invalid response model", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodDelete, fmt.Sprintf("%s%s", apiEndpoint, "/foo"), + func(req *http.Request) (*http.Response, error) { + res := httpmock.NewStringResponse(http.StatusOK, `{"bar":"foo"}`) + res.Header.Add("X-Rate-Limit", "36/200") + return res, nil + }, + ) + + response := struct { + Foo string `json:"foo"` + }{} + + c := NewClient("") + err := c.(*client).DELETE("/foo", &response) + assert.NoError(t, err) + assert.Equal(t, struct { + Foo string `json:"foo"` + }{}, response) + }) +}