Skip to content

Commit ebb9ca5

Browse files
authored
feat: add /v3 DELETE endpoint for cleanup (#862)
1 parent 3ef1669 commit ebb9ca5

File tree

8 files changed

+230
-2
lines changed

8 files changed

+230
-2
lines changed

api/docs.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3842,6 +3842,38 @@ const docTemplate = `{
38423842
}
38433843
}
38443844
},
3845+
"delete": {
3846+
"description": "Permanently deletes all resources",
3847+
"tags": [
3848+
"v3"
3849+
],
3850+
"summary": "Delete everything",
3851+
"parameters": [
3852+
{
3853+
"type": "string",
3854+
"description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'",
3855+
"name": "confirm",
3856+
"in": "query"
3857+
}
3858+
],
3859+
"responses": {
3860+
"204": {
3861+
"description": "No Content"
3862+
},
3863+
"400": {
3864+
"description": "Bad Request",
3865+
"schema": {
3866+
"$ref": "#/definitions/httperrors.HTTPError"
3867+
}
3868+
},
3869+
"500": {
3870+
"description": "Internal Server Error",
3871+
"schema": {
3872+
"$ref": "#/definitions/httperrors.HTTPError"
3873+
}
3874+
}
3875+
}
3876+
},
38453877
"options": {
38463878
"description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs",
38473879
"tags": [

api/swagger.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3831,6 +3831,38 @@
38313831
}
38323832
}
38333833
},
3834+
"delete": {
3835+
"description": "Permanently deletes all resources",
3836+
"tags": [
3837+
"v3"
3838+
],
3839+
"summary": "Delete everything",
3840+
"parameters": [
3841+
{
3842+
"type": "string",
3843+
"description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'",
3844+
"name": "confirm",
3845+
"in": "query"
3846+
}
3847+
],
3848+
"responses": {
3849+
"204": {
3850+
"description": "No Content"
3851+
},
3852+
"400": {
3853+
"description": "Bad Request",
3854+
"schema": {
3855+
"$ref": "#/definitions/httperrors.HTTPError"
3856+
}
3857+
},
3858+
"500": {
3859+
"description": "Internal Server Error",
3860+
"schema": {
3861+
"$ref": "#/definitions/httperrors.HTTPError"
3862+
}
3863+
}
3864+
}
3865+
},
38343866
"options": {
38353867
"description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs",
38363868
"tags": [

api/swagger.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4375,6 +4375,27 @@ paths:
43754375
tags:
43764376
- Transactions
43774377
/v3:
4378+
delete:
4379+
description: Permanently deletes all resources
4380+
parameters:
4381+
- description: Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'
4382+
in: query
4383+
name: confirm
4384+
type: string
4385+
responses:
4386+
"204":
4387+
description: No Content
4388+
"400":
4389+
description: Bad Request
4390+
schema:
4391+
$ref: '#/definitions/httperrors.HTTPError'
4392+
"500":
4393+
description: Internal Server Error
4394+
schema:
4395+
$ref: '#/definitions/httperrors.HTTPError'
4396+
summary: Delete everything
4397+
tags:
4398+
- v3
43784399
get:
43794400
description: Returns general information about the v3 API
43804401
responses:

pkg/controllers/cleanup_v3.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package controllers
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/envelope-zero/backend/v3/pkg/httperrors"
7+
"github.com/envelope-zero/backend/v3/pkg/models"
8+
"github.com/gin-gonic/gin"
9+
)
10+
11+
// CleanupV3 permanently deletes all resources in the database
12+
//
13+
// @Summary Delete everything
14+
// @Description Permanently deletes all resources
15+
// @Tags v3
16+
// @Success 204
17+
// @Failure 400 {object} httperrors.HTTPError
18+
// @Failure 500 {object} httperrors.HTTPError
19+
// @Param confirm query string false "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'"
20+
// @Router /v3 [delete]
21+
func (co Controller) CleanupV3(c *gin.Context) {
22+
var params struct {
23+
Confirm string `form:"confirm"`
24+
}
25+
26+
err := c.Bind(&params)
27+
if err != nil || params.Confirm != "yes-please-delete-everything" {
28+
c.JSON(http.StatusBadRequest, httperrors.HTTPError{
29+
Error: httperrors.ErrCleanupConfirmation.Error(),
30+
})
31+
return
32+
}
33+
34+
// The order is important here since there are foreign keys to consider!
35+
models := []models.Model{
36+
models.Allocation{},
37+
models.MatchRule{},
38+
models.Transaction{},
39+
models.MonthConfig{},
40+
models.Envelope{},
41+
models.Category{},
42+
models.Account{},
43+
models.Budget{},
44+
}
45+
46+
// Use a transaction so that we can roll back if errors happen
47+
tx := co.DB.Begin()
48+
49+
for _, model := range models {
50+
err := tx.Unscoped().Where("true").Delete(&model).Error
51+
if err != nil {
52+
c.JSON(http.StatusInternalServerError, httperrors.HTTPError{
53+
Error: err.Error(),
54+
})
55+
tx.Rollback()
56+
return
57+
}
58+
}
59+
60+
tx.Commit()
61+
c.JSON(http.StatusNoContent, nil)
62+
}

pkg/controllers/cleanup_v3_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package controllers_test
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"testing"
7+
"time"
8+
9+
"github.com/envelope-zero/backend/v3/internal/types"
10+
"github.com/envelope-zero/backend/v3/pkg/models"
11+
"github.com/envelope-zero/backend/v3/test"
12+
"github.com/shopspring/decimal"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func (suite *TestSuiteStandard) TestCleanupV3() {
17+
_ = suite.createTestBudget(models.BudgetCreate{})
18+
account := suite.createTestAccount(models.AccountCreate{Name: "TestCleanup"})
19+
_ = suite.createTestCategory(models.CategoryCreate{})
20+
envelope := suite.createTestEnvelope(models.EnvelopeCreate{})
21+
_ = suite.createTestAllocation(models.AllocationCreate{})
22+
_ = suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(17.32)})
23+
_ = suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{})
24+
_ = suite.createTestMatchRule(suite.T(), models.MatchRuleCreate{AccountID: account.Data.ID, Match: "Delete me"})
25+
26+
tests := []string{
27+
"http://example.com/v3/budgets",
28+
"http://example.com/v1/accounts",
29+
"http://example.com/v1/categories",
30+
"http://example.com/v3/transactions",
31+
"http://example.com/v1/envelopes",
32+
"http://example.com/v1/allocations",
33+
"http://example.com/v1/month-configs",
34+
"http://example.com/v3/match-rules",
35+
}
36+
37+
// Delete
38+
recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v3?confirm=yes-please-delete-everything", "")
39+
assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent)
40+
41+
// Verify
42+
for _, tt := range tests {
43+
suite.T().Run(tt, func(t *testing.T) {
44+
recorder := test.Request(suite.controller, suite.T(), http.MethodGet, tt, "")
45+
assertHTTPStatus(suite.T(), &recorder, http.StatusOK)
46+
47+
var response struct {
48+
Data []any `json:"data"`
49+
}
50+
51+
suite.decodeResponse(&recorder, &response)
52+
assert.Len(t, response.Data, 0, "There are resources left for type %s", tt)
53+
})
54+
}
55+
}
56+
57+
func (suite *TestSuiteStandard) TestCleanupV3Fails() {
58+
tests := []struct {
59+
name string
60+
path string
61+
}{
62+
{"Invalid path", "confirm=2"},
63+
{"Confirmation wrong", "confirm=invalid-confirmation"},
64+
}
65+
66+
for _, tt := range tests {
67+
suite.T().Run(tt.name, func(t *testing.T) {
68+
recorder := test.Request(suite.controller, t, http.MethodDelete, fmt.Sprintf("http://example.com/v3?%s", tt.path), "")
69+
assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest)
70+
})
71+
}
72+
}
73+
74+
func (suite *TestSuiteStandard) TestCleanupV3DBError() {
75+
suite.CloseDB()
76+
77+
recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v3?confirm=yes-please-delete-everything", "")
78+
assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError)
79+
}

pkg/httperrors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var (
3535
ErrNoFilePost = errors.New("you must send a file to this endpoint")
3636
ErrFileEmpty = errors.New("the file you uploaded is empty or invalid")
3737
ErrAccountIDParameter = errors.New("the accountId parameter must be set")
38+
ErrCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect")
3839
)
3940

4041
// Generate a struct containing the HTTP error on the fly.

pkg/router/router.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ func AttachRoutes(co controllers.Controller, group *gin.RouterGroup) {
155155
v3 := group.Group("/v3")
156156
{
157157
v3.GET("", GetV3)
158+
v3.DELETE("", co.CleanupV3)
158159
v3.OPTIONS("", OptionsV3)
159160
}
160161

@@ -368,5 +369,5 @@ func GetV3(c *gin.Context) {
368369
// @Success 204
369370
// @Router /v3 [options]
370371
func OptionsV3(c *gin.Context) {
371-
httputil.OptionsGet(c)
372+
httputil.OptionsGetDelete(c)
372373
}

pkg/router/router_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ func TestOptions(t *testing.T) {
243243
{"/version", router.OptionsVersion, "OPTIONS, GET"},
244244
{"/v1", router.OptionsV1, "OPTIONS, GET, DELETE"},
245245
{"/v2", router.OptionsV2, "OPTIONS, GET"},
246-
{"/v3", router.OptionsV3, "OPTIONS, GET"},
246+
{"/v3", router.OptionsV3, "OPTIONS, GET, DELETE"},
247247
}
248248

249249
for _, tt := range tests {

0 commit comments

Comments
 (0)