Skip to content

Commit

Permalink
feat: add /v3 DELETE endpoint for cleanup (#862)
Browse files Browse the repository at this point in the history
  • Loading branch information
morremeyer authored Nov 28, 2023
1 parent 3ef1669 commit ebb9ca5
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 2 deletions.
32 changes: 32 additions & 0 deletions api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
32 changes: 32 additions & 0 deletions api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
21 changes: 21 additions & 0 deletions api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions pkg/controllers/cleanup_v3.go
Original file line number Diff line number Diff line change
@@ -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(&params)
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)
}
79 changes: 79 additions & 0 deletions pkg/controllers/cleanup_v3_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions pkg/httperrors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion pkg/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion pkg/router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit ebb9ca5

Please sign in to comment.