Skip to content

Commit 0e8f00e

Browse files
authored
feat!: switch to UUIDs (#170)
Resolves #123. BREAKING CHANGE: Resource IDs are now UUIDs. You need to delete your existing database before this version will start up.
1 parent d7a6fc9 commit 0e8f00e

29 files changed

+618
-607
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ require (
3939
github.com/go-playground/universal-translator v0.18.0 // indirect
4040
github.com/go-playground/validator/v10 v10.10.1 // indirect
4141
github.com/golang/protobuf v1.5.2 // indirect
42-
github.com/google/uuid v1.3.0 // indirect
42+
github.com/google/uuid v1.3.0
4343
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
4444
github.com/jackc/pgconn v1.12.0 // indirect
4545
github.com/jackc/pgio v1.0.0 // indirect

internal/controllers/account.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/envelope-zero/backend/internal/httputil"
88
"github.com/envelope-zero/backend/internal/models"
99
"github.com/gin-gonic/gin"
10+
"github.com/google/uuid"
1011
)
1112

1213
type AccountListResponse struct {
@@ -23,7 +24,7 @@ type Account struct {
2324
}
2425

2526
type AccountLinks struct {
26-
Self string `json:"self" example:"https://example.com/api/v1/accounts/17"`
27+
Self string `json:"self" example:"https://example.com/api/v1/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"`
2728
}
2829

2930
// RegisterAccountRoutes registers the routes for accounts with
@@ -135,12 +136,13 @@ func GetAccounts(c *gin.Context) {
135136
// @Param accountId path uint64 true "ID of the account"
136137
// @Router /v1/accounts/{accountId} [get]
137138
func GetAccount(c *gin.Context) {
138-
id, err := httputil.ParseID(c, "accountId")
139+
p, err := uuid.Parse(c.Param("accountId"))
139140
if err != nil {
141+
httputil.ErrorInvalidUUID(c)
140142
return
141143
}
142144

143-
accountObject, err := getAccountObject(c, id)
145+
accountObject, err := getAccountObject(c, p)
144146
if err != nil {
145147
return
146148
}
@@ -160,12 +162,13 @@ func GetAccount(c *gin.Context) {
160162
// @Param account body models.AccountCreate true "Account"
161163
// @Router /v1/accounts/{accountId} [patch]
162164
func UpdateAccount(c *gin.Context) {
163-
id, err := httputil.ParseID(c, "accountId")
165+
p, err := uuid.Parse(c.Param("accountId"))
164166
if err != nil {
167+
httputil.ErrorInvalidUUID(c)
165168
return
166169
}
167170

168-
account, err := getAccountResource(c, id)
171+
account, err := getAccountResource(c, p)
169172
if err != nil {
170173
return
171174
}
@@ -191,12 +194,13 @@ func UpdateAccount(c *gin.Context) {
191194
// @Param accountId path uint64 true "ID of the account"
192195
// @Router /v1/accounts/{accountId} [delete]
193196
func DeleteAccount(c *gin.Context) {
194-
id, err := httputil.ParseID(c, "accountId")
197+
p, err := uuid.Parse(c.Param("accountId"))
195198
if err != nil {
199+
httputil.ErrorInvalidUUID(c)
196200
return
197201
}
198202

199-
account, err := getAccountResource(c, id)
203+
account, err := getAccountResource(c, p)
200204
if err != nil {
201205
return
202206
}
@@ -207,7 +211,7 @@ func DeleteAccount(c *gin.Context) {
207211
}
208212

209213
// getAccountResource is the internal helper to verify permissions and return an account.
210-
func getAccountResource(c *gin.Context, id uint64) (models.Account, error) {
214+
func getAccountResource(c *gin.Context, id uuid.UUID) (models.Account, error) {
211215
var account models.Account
212216

213217
err := models.DB.Where(&models.Account{
@@ -223,7 +227,7 @@ func getAccountResource(c *gin.Context, id uint64) (models.Account, error) {
223227
return account, nil
224228
}
225229

226-
func getAccountObject(c *gin.Context, id uint64) (Account, error) {
230+
func getAccountObject(c *gin.Context, id uuid.UUID) (Account, error) {
227231
resource, err := getAccountResource(c, id)
228232
if err != nil {
229233
return Account{}, err
@@ -239,8 +243,8 @@ func getAccountObject(c *gin.Context, id uint64) (Account, error) {
239243
//
240244
// This function is only needed for getAccountObject as we cannot create an instance of Account
241245
// with mixed named and unnamed parameters.
242-
func getAccountLinks(c *gin.Context, id uint64) AccountLinks {
243-
url := httputil.RequestPathV1(c) + fmt.Sprintf("/accounts/%d", id)
246+
func getAccountLinks(c *gin.Context, id uuid.UUID) AccountLinks {
247+
url := httputil.RequestPathV1(c) + fmt.Sprintf("/accounts/%s", id)
244248

245249
return AccountLinks{
246250
Self: url,

internal/controllers/account_test.go

Lines changed: 68 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ import (
88
"github.com/envelope-zero/backend/internal/controllers"
99
"github.com/envelope-zero/backend/internal/models"
1010
"github.com/envelope-zero/backend/internal/test"
11+
"github.com/google/uuid"
1112
"github.com/shopspring/decimal"
1213
"github.com/stretchr/testify/assert"
1314
)
1415

16+
func createTestAccount(t *testing.T, c models.AccountCreate) controllers.AccountResponse {
17+
r := test.Request(t, http.MethodPost, "/v1/accounts", c)
18+
test.AssertHTTPStatus(t, http.StatusCreated, &r)
19+
20+
var a controllers.AccountResponse
21+
test.DecodeResponse(t, &r, &a)
22+
23+
return a
24+
}
25+
1526
func TestGetAccounts(t *testing.T) {
16-
recorder := test.Request(t, "GET", "/v1/accounts", "")
27+
recorder := test.Request(t, http.MethodGet, "/v1/accounts", "")
1728

1829
var response controllers.AccountListResponse
1930
test.DecodeResponse(t, &recorder, &response)
@@ -24,19 +35,16 @@ func TestGetAccounts(t *testing.T) {
2435
}
2536

2637
bankAccount := response.Data[0]
27-
assert.Equal(t, uint64(1), bankAccount.BudgetID)
2838
assert.Equal(t, "Bank Account", bankAccount.Name)
2939
assert.Equal(t, true, bankAccount.OnBudget)
3040
assert.Equal(t, false, bankAccount.External)
3141

3242
cashAccount := response.Data[1]
33-
assert.Equal(t, uint64(1), cashAccount.BudgetID)
3443
assert.Equal(t, "Cash Account", cashAccount.Name)
3544
assert.Equal(t, false, cashAccount.OnBudget)
3645
assert.Equal(t, false, cashAccount.External)
3746

3847
externalAccount := response.Data[2]
39-
assert.Equal(t, uint64(1), externalAccount.BudgetID)
4048
assert.Equal(t, "External Account", externalAccount.Name)
4149
assert.Equal(t, false, externalAccount.OnBudget)
4250
assert.Equal(t, true, externalAccount.External)
@@ -64,155 +72,130 @@ func TestGetAccounts(t *testing.T) {
6472
}
6573

6674
func TestNoAccountNotFound(t *testing.T) {
67-
recorder := test.Request(t, "GET", "/v1/accounts/37", "")
75+
recorder := test.Request(t, http.MethodGet, "/v1/accounts/39633f90-3d9f-4b1e-ac24-c341c432a6e3", "")
6876

6977
test.AssertHTTPStatus(t, http.StatusNotFound, &recorder)
7078
}
7179

72-
// TestAccountInvalidIDs verifies that on non-number requests for account IDs,
73-
// the API returs a Bad Request status code.
7480
func TestAccountInvalidIDs(t *testing.T) {
75-
r := test.Request(t, "GET", "/v1/accounts/-56", "")
81+
/*
82+
* GET
83+
*/
84+
r := test.Request(t, http.MethodGet, "/v1/accounts/-56", "")
7685
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
7786

78-
r = test.Request(t, "GET", "/v1/accounts/notANumber", "")
87+
r = test.Request(t, http.MethodGet, "/v1/accounts/notANumber", "")
7988
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
8089

81-
r = test.Request(t, "GET", "/v1/accounts/56", "")
82-
test.AssertHTTPStatus(t, http.StatusNotFound, &r)
83-
84-
r = test.Request(t, "GET", "/v1/accounts/1", "")
85-
test.AssertHTTPStatus(t, http.StatusOK, &r)
90+
r = test.Request(t, http.MethodGet, "/v1/accounts/23", "")
91+
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
8692

87-
r = test.Request(t, "PATCH", "/v1/accounts/-274", "")
93+
/*
94+
* PATCH
95+
*/
96+
r = test.Request(t, http.MethodPatch, "/v1/accounts/-274", "")
8897
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
8998

90-
r = test.Request(t, "PATCH", "/v1/accounts/stringRandom", "")
99+
r = test.Request(t, http.MethodPatch, "/v1/accounts/stringRandom", "")
91100
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
92101

93-
r = test.Request(t, "DELETE", "/v1/accounts/-274", "")
102+
/*
103+
* DELETE
104+
*/
105+
r = test.Request(t, http.MethodDelete, "/v1/accounts/-274", "")
94106
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
95107

96-
r = test.Request(t, "DELETE", "/v1/accounts/stringRandom", "")
108+
r = test.Request(t, http.MethodDelete, "/v1/accounts/stringRandom", "")
97109
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
98110
}
99111

100112
func TestCreateAccount(t *testing.T) {
101-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "name": "New Account", "note": "More tests something something" }`)
102-
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)
103-
104-
var apiAccount controllers.AccountResponse
105-
test.DecodeResponse(t, &recorder, &apiAccount)
113+
_ = createTestAccount(t, models.AccountCreate{Name: "Test account for creation"})
106114
}
107115

108116
func TestCreateBrokenAccount(t *testing.T) {
109-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "createdAt": "New Account", "note": "More tests for accounts to ensure less brokenness something" }`)
117+
recorder := test.Request(t, http.MethodPost, "/v1/accounts", `{ "createdAt": "New Account", "note": "More tests for accounts to ensure less brokenness something" }`)
110118
test.AssertHTTPStatus(t, http.StatusBadRequest, &recorder)
111119
}
112120

113121
func TestCreateAccountNoBody(t *testing.T) {
114-
recorder := test.Request(t, "POST", "/v1/accounts", "")
122+
recorder := test.Request(t, http.MethodPost, "/v1/accounts", "")
115123
test.AssertHTTPStatus(t, http.StatusBadRequest, &recorder)
116124
}
117125

118126
func TestCreateAccountNoBudget(t *testing.T) {
119-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "budgetId": 5476 }`)
127+
recorder := test.Request(t, http.MethodPost, "/v1/accounts", models.AccountCreate{BudgetID: uuid.New()})
120128
test.AssertHTTPStatus(t, http.StatusNotFound, &recorder)
121129
}
122130

123131
func TestGetAccount(t *testing.T) {
124-
recorder := test.Request(t, "GET", "/v1/accounts/1", "")
125-
test.AssertHTTPStatus(t, http.StatusOK, &recorder)
126-
127-
var account controllers.AccountResponse
128-
test.DecodeResponse(t, &recorder, &account)
129-
130-
var dbAccount models.Account
131-
models.DB.First(&dbAccount, account.Data.ID)
132+
a := createTestAccount(t, models.AccountCreate{})
132133

133-
// The test transactions have a sum of -30
134-
if !decimal.NewFromFloat(-30).Equals(account.Data.Balance) {
135-
assert.Fail(t, "Account balance does not equal -30", account.Data.Balance)
136-
}
134+
r := test.Request(t, http.MethodGet, a.Data.Links.Self, "")
135+
assert.Equal(t, http.StatusOK, r.Code)
137136
}
138137

139138
func TestGetAccountTransactionsNonExistingAccount(t *testing.T) {
140-
recorder := test.Request(t, "GET", "/v1/accounts/57372/transactions", "")
141-
assert.Equal(t, 404, recorder.Code)
139+
recorder := test.Request(t, http.MethodGet, "/v1/accounts/57372/transactions", "")
140+
assert.Equal(t, http.StatusNotFound, recorder.Code)
142141
}
143142

144143
func TestUpdateAccount(t *testing.T) {
145-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "name": "New Account", "note": "More tests something something" }`)
146-
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)
147-
148-
var account controllers.AccountResponse
149-
test.DecodeResponse(t, &recorder, &account)
144+
a := createTestAccount(t, models.AccountCreate{Name: "Original name"})
150145

151-
recorder = test.Request(t, "PATCH", account.Data.Links.Self, `{ "name": "Updated new account for testing" }`)
152-
test.AssertHTTPStatus(t, http.StatusOK, &recorder)
146+
r := test.Request(t, http.MethodPatch, a.Data.Links.Self, models.AccountCreate{Name: "Updated new account for testing"})
147+
test.AssertHTTPStatus(t, http.StatusOK, &r)
153148

154-
var updatedAccount controllers.AccountResponse
155-
test.DecodeResponse(t, &recorder, &updatedAccount)
149+
var u controllers.AccountResponse
150+
test.DecodeResponse(t, &r, &u)
156151

157-
assert.Equal(t, "Updated new account for testing", updatedAccount.Data.Name)
152+
assert.Equal(t, "Updated new account for testing", u.Data.Name)
158153
}
159154

160155
func TestUpdateAccountBroken(t *testing.T) {
161-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "name": "New Account", "note": "More tests something something" }`)
162-
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)
156+
a := createTestAccount(t, models.AccountCreate{
157+
Name: "New Account",
158+
Note: "More tests something something",
159+
})
163160

164-
var account controllers.AccountResponse
165-
test.DecodeResponse(t, &recorder, &account)
166-
167-
recorder = test.Request(t, "PATCH", account.Data.Links.Self, `{ "name": 2" }`)
168-
test.AssertHTTPStatus(t, http.StatusBadRequest, &recorder)
161+
r := test.Request(t, http.MethodPatch, a.Data.Links.Self, `{ "name": 2" }`)
162+
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
169163
}
170164

171165
func TestUpdateNonExistingAccount(t *testing.T) {
172-
recorder := test.Request(t, "PATCH", "/v1/accounts/48902805", `{ "name": "2" }`)
166+
recorder := test.Request(t, http.MethodPatch, "/v1/accounts/9b81de41-eead-451d-bc6b-31fceedd236c", models.AccountCreate{Name: "This account does not exist"})
173167
test.AssertHTTPStatus(t, http.StatusNotFound, &recorder)
174168
}
175169

176170
func TestDeleteAccountsAndEmptyList(t *testing.T) {
177-
recorder := test.Request(t, "DELETE", "/v1/accounts/1", "")
178-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
179-
180-
recorder = test.Request(t, "DELETE", "/v1/accounts/2", "")
181-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
182-
183-
recorder = test.Request(t, "DELETE", "/v1/accounts/3", "")
184-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
171+
r := test.Request(t, http.MethodGet, "/v1/accounts", "")
185172

186-
recorder = test.Request(t, "DELETE", "/v1/accounts/4", "")
187-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
173+
var l controllers.AccountListResponse
174+
test.DecodeResponse(t, &r, &l)
188175

189-
recorder = test.Request(t, "DELETE", "/v1/accounts/5", "")
190-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
191-
192-
recorder = test.Request(t, "DELETE", "/v1/accounts/6", "")
193-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
176+
for _, a := range l.Data {
177+
r = test.Request(t, http.MethodDelete, a.Links.Self, "")
178+
test.AssertHTTPStatus(t, http.StatusNoContent, &r)
179+
}
194180

195-
recorder = test.Request(t, "GET", "/v1/accounts", "")
196-
var apiResponse controllers.AccountListResponse
197-
test.DecodeResponse(t, &recorder, &apiResponse)
181+
r = test.Request(t, http.MethodGet, "/v1/accounts", "")
182+
test.DecodeResponse(t, &r, &l)
198183

199184
// Verify that the account list is an empty list, not null
200-
assert.NotNil(t, apiResponse.Data)
201-
assert.Empty(t, apiResponse.Data)
185+
assert.NotNil(t, l.Data)
186+
assert.Empty(t, l.Data)
202187
}
203188

204189
func TestDeleteNonExistingAccount(t *testing.T) {
205-
recorder := test.Request(t, "DELETE", "/v1/accounts/48902805", "")
190+
recorder := test.Request(t, http.MethodDelete, "/v1/accounts/77b70a75-4bb3-4d1d-90cf-5b7a61f452f5", "")
206191
test.AssertHTTPStatus(t, http.StatusNotFound, &recorder)
207192
}
208193

209194
func TestDeleteAccountWithBody(t *testing.T) {
210-
recorder := test.Request(t, "POST", "/v1/accounts", `{ "name": "Delete me now!" }`)
211-
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)
195+
a := createTestAccount(t, models.AccountCreate{Name: "Delete me now!"})
212196

213-
var account controllers.AccountResponse
214-
test.DecodeResponse(t, &recorder, &account)
197+
r := test.Request(t, http.MethodDelete, a.Data.Links.Self, models.AccountCreate{Name: "Some other account"})
198+
test.AssertHTTPStatus(t, http.StatusNoContent, &r)
215199

216-
recorder = test.Request(t, "DELETE", account.Data.Links.Self, `{ "name": "test name 23" }`)
217-
test.AssertHTTPStatus(t, http.StatusNoContent, &recorder)
200+
r = test.Request(t, http.MethodGet, a.Data.Links.Self, "")
218201
}

0 commit comments

Comments
 (0)