From ebb9ca59c1046b949c370978ca219690553593f6 Mon Sep 17 00:00:00 2001 From: morre Date: Tue, 28 Nov 2023 10:47:23 +0100 Subject: [PATCH] feat: add /v3 DELETE endpoint for cleanup (#862) --- api/docs.go | 32 ++++++++++++ api/swagger.json | 32 ++++++++++++ api/swagger.yaml | 21 ++++++++ pkg/controllers/cleanup_v3.go | 62 +++++++++++++++++++++++ pkg/controllers/cleanup_v3_test.go | 79 ++++++++++++++++++++++++++++++ pkg/httperrors/errors.go | 1 + pkg/router/router.go | 3 +- pkg/router/router_test.go | 2 +- 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 pkg/controllers/cleanup_v3.go create mode 100644 pkg/controllers/cleanup_v3_test.go diff --git a/api/docs.go b/api/docs.go index 19a3189d..a4566687 100644 --- a/api/docs.go +++ b/api/docs.go @@ -3842,6 +3842,38 @@ const docTemplate = `{ } } }, + "delete": { + "description": "Permanently deletes all resources", + "tags": [ + "v3" + ], + "summary": "Delete everything", + "parameters": [ + { + "type": "string", + "description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'", + "name": "confirm", + "in": "query" + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ diff --git a/api/swagger.json b/api/swagger.json index 2e0263f1..6418e6fe 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -3831,6 +3831,38 @@ } } }, + "delete": { + "description": "Permanently deletes all resources", + "tags": [ + "v3" + ], + "summary": "Delete everything", + "parameters": [ + { + "type": "string", + "description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'", + "name": "confirm", + "in": "query" + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ diff --git a/api/swagger.yaml b/api/swagger.yaml index 60903351..56de614d 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -4375,6 +4375,27 @@ paths: tags: - Transactions /v3: + delete: + description: Permanently deletes all resources + parameters: + - description: Confirmation to delete all resources. Must have the value 'yes-please-delete-everything' + in: query + name: confirm + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete everything + tags: + - v3 get: description: Returns general information about the v3 API responses: diff --git a/pkg/controllers/cleanup_v3.go b/pkg/controllers/cleanup_v3.go new file mode 100644 index 00000000..208a24c3 --- /dev/null +++ b/pkg/controllers/cleanup_v3.go @@ -0,0 +1,62 @@ +package controllers + +import ( + "net/http" + + "github.com/envelope-zero/backend/v3/pkg/httperrors" + "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/gin-gonic/gin" +) + +// CleanupV3 permanently deletes all resources in the database +// +// @Summary Delete everything +// @Description Permanently deletes all resources +// @Tags v3 +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param confirm query string false "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'" +// @Router /v3 [delete] +func (co Controller) CleanupV3(c *gin.Context) { + var params struct { + Confirm string `form:"confirm"` + } + + err := c.Bind(¶ms) + if err != nil || params.Confirm != "yes-please-delete-everything" { + c.JSON(http.StatusBadRequest, httperrors.HTTPError{ + Error: httperrors.ErrCleanupConfirmation.Error(), + }) + return + } + + // The order is important here since there are foreign keys to consider! + models := []models.Model{ + models.Allocation{}, + models.MatchRule{}, + models.Transaction{}, + models.MonthConfig{}, + models.Envelope{}, + models.Category{}, + models.Account{}, + models.Budget{}, + } + + // Use a transaction so that we can roll back if errors happen + tx := co.DB.Begin() + + for _, model := range models { + err := tx.Unscoped().Where("true").Delete(&model).Error + if err != nil { + c.JSON(http.StatusInternalServerError, httperrors.HTTPError{ + Error: err.Error(), + }) + tx.Rollback() + return + } + } + + tx.Commit() + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/cleanup_v3_test.go b/pkg/controllers/cleanup_v3_test.go new file mode 100644 index 00000000..cef0eba2 --- /dev/null +++ b/pkg/controllers/cleanup_v3_test.go @@ -0,0 +1,79 @@ +package controllers_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v3/test" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) TestCleanupV3() { + _ = suite.createTestBudget(models.BudgetCreate{}) + account := suite.createTestAccount(models.AccountCreate{Name: "TestCleanup"}) + _ = suite.createTestCategory(models.CategoryCreate{}) + envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) + _ = suite.createTestAllocation(models.AllocationCreate{}) + _ = suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(17.32)}) + _ = suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) + _ = suite.createTestMatchRule(suite.T(), models.MatchRuleCreate{AccountID: account.Data.ID, Match: "Delete me"}) + + tests := []string{ + "http://example.com/v3/budgets", + "http://example.com/v1/accounts", + "http://example.com/v1/categories", + "http://example.com/v3/transactions", + "http://example.com/v1/envelopes", + "http://example.com/v1/allocations", + "http://example.com/v1/month-configs", + "http://example.com/v3/match-rules", + } + + // Delete + recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v3?confirm=yes-please-delete-everything", "") + assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) + + // Verify + for _, tt := range tests { + suite.T().Run(tt, func(t *testing.T) { + recorder := test.Request(suite.controller, suite.T(), http.MethodGet, tt, "") + assertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + var response struct { + Data []any `json:"data"` + } + + suite.decodeResponse(&recorder, &response) + assert.Len(t, response.Data, 0, "There are resources left for type %s", tt) + }) + } +} + +func (suite *TestSuiteStandard) TestCleanupV3Fails() { + tests := []struct { + name string + path string + }{ + {"Invalid path", "confirm=2"}, + {"Confirmation wrong", "confirm=invalid-confirmation"}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + recorder := test.Request(suite.controller, t, http.MethodDelete, fmt.Sprintf("http://example.com/v3?%s", tt.path), "") + assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) + }) + } +} + +func (suite *TestSuiteStandard) TestCleanupV3DBError() { + suite.CloseDB() + + recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v3?confirm=yes-please-delete-everything", "") + assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) +} diff --git a/pkg/httperrors/errors.go b/pkg/httperrors/errors.go index 9c0a418d..6c565a16 100644 --- a/pkg/httperrors/errors.go +++ b/pkg/httperrors/errors.go @@ -35,6 +35,7 @@ var ( ErrNoFilePost = errors.New("you must send a file to this endpoint") ErrFileEmpty = errors.New("the file you uploaded is empty or invalid") ErrAccountIDParameter = errors.New("the accountId parameter must be set") + ErrCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect") ) // Generate a struct containing the HTTP error on the fly. diff --git a/pkg/router/router.go b/pkg/router/router.go index 986a2055..dcd9d373 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -155,6 +155,7 @@ func AttachRoutes(co controllers.Controller, group *gin.RouterGroup) { v3 := group.Group("/v3") { v3.GET("", GetV3) + v3.DELETE("", co.CleanupV3) v3.OPTIONS("", OptionsV3) } @@ -368,5 +369,5 @@ func GetV3(c *gin.Context) { // @Success 204 // @Router /v3 [options] func OptionsV3(c *gin.Context) { - httputil.OptionsGet(c) + httputil.OptionsGetDelete(c) } diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 4b1ffd39..addd52b5 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -243,7 +243,7 @@ func TestOptions(t *testing.T) { {"/version", router.OptionsVersion, "OPTIONS, GET"}, {"/v1", router.OptionsV1, "OPTIONS, GET, DELETE"}, {"/v2", router.OptionsV2, "OPTIONS, GET"}, - {"/v3", router.OptionsV3, "OPTIONS, GET"}, + {"/v3", router.OptionsV3, "OPTIONS, GET, DELETE"}, } for _, tt := range tests {