From a0222deba00f5bfa69bedb63417763138e634bc9 Mon Sep 17 00:00:00 2001 From: Sina Chaichi Maleki Date: Sat, 9 May 2026 22:02:47 +0200 Subject: [PATCH] Add more test coverage --- api/account_test.go | 219 +++++++++++++++++++++++++++++++++ api/transfer_test.go | 255 +++++++++++++++++++++++++++++++++++++++ db/sqlc/account_test.go | 23 ++++ db/sqlc/entry_test.go | 71 +++++++++++ db/sqlc/store_test.go | 12 ++ db/sqlc/transfer_test.go | 78 ++++++++++++ db/sqlc/user_test.go | 20 +++ 7 files changed, 678 insertions(+) create mode 100644 api/transfer_test.go create mode 100644 db/sqlc/entry_test.go create mode 100644 db/sqlc/transfer_test.go diff --git a/api/account_test.go b/api/account_test.go index 6f385d6..06ebc14 100644 --- a/api/account_test.go +++ b/api/account_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "testing" + "github.com/gin-gonic/gin" + "github.com/lib/pq" mockdb "github.com/sinachaichi/gault/db/mock" db "github.com/sinachaichi/gault/db/sqlc" "github.com/sinachaichi/gault/util" @@ -38,6 +40,223 @@ func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Accoun require.Equal(t, account, gotAccount) } +func TestCreateAccountAPI(t *testing.T) { + account := randomAccount() + + testCases := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockStore) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { + name: "OK", + body: gin.H{ + "owner": account.Owner, + "currency": account.Currency, + }, + buildStubs: func(store *mockdb.MockStore) { + arg := db.CreateAccountParams{ + Owner: account.Owner, + Currency: account.Currency, + Balance: 0, + } + store.EXPECT(). + CreateAccount(gomock.Any(), gomock.Eq(arg)). + Times(1). + Return(account, nil) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + requireBodyMatchAccount(t, recorder.Body, account) + }, + }, + { + name: "InternalError", + body: gin.H{ + "owner": account.Owner, + "currency": account.Currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + CreateAccount(gomock.Any(), gomock.Any()). + Times(1). + Return(db.Account{}, sql.ErrConnDone) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + { + name: "DuplicateOwnerCurrency", + body: gin.H{ + "owner": account.Owner, + "currency": account.Currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + CreateAccount(gomock.Any(), gomock.Any()). + Times(1). + Return(db.Account{}, &pq.Error{Code: "23505"}) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusForbidden, recorder.Code) + }, + }, + { + name: "InvalidCurrency", + body: gin.H{ + "owner": account.Owner, + "currency": "XYZ", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + CreateAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "MissingOwner", + body: gin.H{ + "currency": account.Currency, + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + CreateAccount(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockStore(ctrl) + tc.buildStubs(store) + + server := NewServer(store) + recorder := httptest.NewRecorder() + + data, err := json.Marshal(tc.body) + require.NoError(t, err) + + request, err := http.NewRequest(http.MethodPost, "/accounts", bytes.NewReader(data)) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + + server.router.ServeHTTP(recorder, request) + tc.checkResponse(t, recorder) + }) + } +} + +func TestListAccountAPI(t *testing.T) { + n := 5 + accounts := make([]db.Account, n) + for i := 0; i < n; i++ { + accounts[i] = randomAccount() + } + + testCases := []struct { + name string + pageID int + pageSize int + buildStubs func(store *mockdb.MockStore) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { + name: "OK", + pageID: 1, + pageSize: 5, + buildStubs: func(store *mockdb.MockStore) { + arg := db.ListAccountsParams{ + Limit: 5, + Offset: 0, + } + store.EXPECT(). + ListAccounts(gomock.Any(), gomock.Eq(arg)). + Times(1). + Return(accounts, nil) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + }, + }, + { + name: "InternalError", + pageID: 1, + pageSize: 5, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + ListAccounts(gomock.Any(), gomock.Any()). + Times(1). + Return(nil, sql.ErrConnDone) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + { + name: "InvalidPageID", + pageID: 0, + pageSize: 5, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + ListAccounts(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "InvalidPageSize", + pageID: 1, + pageSize: 2, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + ListAccounts(gomock.Any(), gomock.Any()). + Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockStore(ctrl) + tc.buildStubs(store) + + server := NewServer(store) + recorder := httptest.NewRecorder() + + url := fmt.Sprintf("/accounts?page_id=%d&page_size=%d", tc.pageID, tc.pageSize) + request, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + + server.router.ServeHTTP(recorder, request) + tc.checkResponse(t, recorder) + }) + } +} + func TestGetAccountAPI(t *testing.T) { account := randomAccount() diff --git a/api/transfer_test.go b/api/transfer_test.go new file mode 100644 index 0000000..f1019e5 --- /dev/null +++ b/api/transfer_test.go @@ -0,0 +1,255 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + mockdb "github.com/sinachaichi/gault/db/mock" + db "github.com/sinachaichi/gault/db/sqlc" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCreateTransferAPI(t *testing.T) { + amount := int64(10) + + account1 := randomAccount() + account2 := randomAccount() + account3 := randomAccount() + + account1.Currency = "USD" + account2.Currency = "USD" + account3.Currency = "EUR" + + testCases := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockStore) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { + name: "OK", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(account1, nil) + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account2.ID)). + Times(1). + Return(account2, nil) + store.EXPECT(). + TransferTx(gomock.Any(), gomock.Eq(db.TransferTxParams{ + FromAccountID: account1.ID, + ToAccountID: account2.ID, + Amount: amount, + })). + Times(1). + Return(db.TransferTxResult{}, nil) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + }, + }, + { + name: "FromAccountNotFound", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(db.Account{}, sql.ErrNoRows) + store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account2.ID)).Times(0) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusNotFound, recorder.Code) + }, + }, + { + name: "ToAccountNotFound", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(account1, nil) + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account2.ID)). + Times(1). + Return(db.Account{}, sql.ErrNoRows) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusNotFound, recorder.Code) + }, + }, + { + name: "FromAccountCurrencyMismatch", + body: gin.H{ + "from_account_id": account3.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account3.ID)). + Times(1). + Return(account3, nil) + store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account2.ID)).Times(0) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "ToAccountCurrencyMismatch", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account3.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(account1, nil) + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account3.ID)). + Times(1). + Return(account3, nil) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "InvalidCurrency", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "XYZ", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Times(0) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "NegativeAmount", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": -amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Times(0) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + name: "GetFromAccountInternalError", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(db.Account{}, sql.ErrConnDone) + store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account2.ID)).Times(0) + store.EXPECT().TransferTx(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + { + name: "TransferTxInternalError", + body: gin.H{ + "from_account_id": account1.ID, + "to_account_id": account2.ID, + "amount": amount, + "currency": "USD", + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account1.ID)). + Times(1). + Return(account1, nil) + store.EXPECT(). + GetAccount(gomock.Any(), gomock.Eq(account2.ID)). + Times(1). + Return(account2, nil) + store.EXPECT(). + TransferTx(gomock.Any(), gomock.Any()). + Times(1). + Return(db.TransferTxResult{}, sql.ErrConnDone) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusInternalServerError, recorder.Code) + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockStore(ctrl) + tc.buildStubs(store) + + server := NewServer(store) + recorder := httptest.NewRecorder() + + data, err := json.Marshal(tc.body) + require.NoError(t, err) + + request, err := http.NewRequest(http.MethodPost, "/transfers", bytes.NewReader(data)) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + + server.router.ServeHTTP(recorder, request) + tc.checkResponse(t, recorder) + }) + } +} diff --git a/db/sqlc/account_test.go b/db/sqlc/account_test.go index d15adf9..d5dc903 100644 --- a/db/sqlc/account_test.go +++ b/db/sqlc/account_test.go @@ -78,6 +78,29 @@ func TestDeleteAccount(t *testing.T) { require.Empty(t, account2) } +func TestGetAccountForUpdate(t *testing.T) { + account1 := createRandomAccount(t) + account2, err := testQueries.GetAccountForUpdate(context.Background(), account1.ID) + + require.NoError(t, err) + require.NotEmpty(t, account2) + + require.Equal(t, account1.ID, account2.ID) + require.Equal(t, account1.Owner, account2.Owner) + require.Equal(t, account1.Balance, account2.Balance) + require.Equal(t, account1.Currency, account2.Currency) + require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second) +} + +func TestWithTx(t *testing.T) { + tx, err := testDB.BeginTx(context.Background(), nil) + require.NoError(t, err) + defer tx.Rollback() + + q := testQueries.WithTx(tx) + require.NotNil(t, q) +} + func TestListAccounts(t *testing.T) { for i := 0; i < 10; i++ { createRandomAccount(t) diff --git a/db/sqlc/entry_test.go b/db/sqlc/entry_test.go new file mode 100644 index 0000000..6bd00d3 --- /dev/null +++ b/db/sqlc/entry_test.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "testing" + "time" + + "github.com/sinachaichi/gault/util" + "github.com/stretchr/testify/require" +) + +func createRandomEntry(t *testing.T, account Account) Entry { + arg := CreateEntryParams{ + AccountID: account.ID, + Amount: util.RandomMoney(), + } + + entry, err := testQueries.CreateEntry(context.Background(), arg) + require.NoError(t, err) + require.NotEmpty(t, entry) + + require.Equal(t, arg.AccountID, entry.AccountID) + require.Equal(t, arg.Amount, entry.Amount) + + require.NotZero(t, entry.ID) + require.NotZero(t, entry.CreatedAt) + + return entry +} + +func TestCreateEntry(t *testing.T) { + account := createRandomAccount(t) + createRandomEntry(t, account) +} + +func TestGetEntry(t *testing.T) { + account := createRandomAccount(t) + entry1 := createRandomEntry(t, account) + + entry2, err := testQueries.GetEntry(context.Background(), entry1.ID) + require.NoError(t, err) + require.NotEmpty(t, entry2) + + require.Equal(t, entry1.ID, entry2.ID) + require.Equal(t, entry1.AccountID, entry2.AccountID) + require.Equal(t, entry1.Amount, entry2.Amount) + require.WithinDuration(t, entry1.CreatedAt, entry2.CreatedAt, time.Second) +} + +func TestListEntries(t *testing.T) { + account := createRandomAccount(t) + + for i := 0; i < 10; i++ { + createRandomEntry(t, account) + } + + arg := ListEntriesParams{ + AccountID: account.ID, + Limit: 5, + Offset: 5, + } + + entries, err := testQueries.ListEntries(context.Background(), arg) + require.NoError(t, err) + require.Len(t, entries, 5) + + for _, entry := range entries { + require.NotEmpty(t, entry) + require.Equal(t, account.ID, entry.AccountID) + } +} diff --git a/db/sqlc/store_test.go b/db/sqlc/store_test.go index cd081fc..41310ee 100644 --- a/db/sqlc/store_test.go +++ b/db/sqlc/store_test.go @@ -115,6 +115,18 @@ func TestTransferTx(t *testing.T) { } +func TestTransferTxError(t *testing.T) { + store := NewStore(testDB) + + _, err := store.TransferTx(context.Background(), TransferTxParams{ + FromAccountID: -1, + ToAccountID: -2, + Amount: 10, + }) + + require.Error(t, err) +} + func TestTransferTxDeadlock(t *testing.T) { store := NewStore(testDB) diff --git a/db/sqlc/transfer_test.go b/db/sqlc/transfer_test.go new file mode 100644 index 0000000..f64580c --- /dev/null +++ b/db/sqlc/transfer_test.go @@ -0,0 +1,78 @@ +package db + +import ( + "context" + "testing" + "time" + + "github.com/sinachaichi/gault/util" + "github.com/stretchr/testify/require" +) + +func createRandomTransfer(t *testing.T, fromAccount, toAccount Account) Transfer { + arg := CreateTransferParams{ + FromAccountID: fromAccount.ID, + ToAccountID: toAccount.ID, + Amount: util.RandomMoney(), + } + + transfer, err := testQueries.CreateTransfer(context.Background(), arg) + require.NoError(t, err) + require.NotEmpty(t, transfer) + + require.Equal(t, arg.FromAccountID, transfer.FromAccountID) + require.Equal(t, arg.ToAccountID, transfer.ToAccountID) + require.Equal(t, arg.Amount, transfer.Amount) + + require.NotZero(t, transfer.ID) + require.NotZero(t, transfer.CreatedAt) + + return transfer +} + +func TestCreateTransfer(t *testing.T) { + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + createRandomTransfer(t, account1, account2) +} + +func TestGetTransfer(t *testing.T) { + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + transfer1 := createRandomTransfer(t, account1, account2) + + transfer2, err := testQueries.GetTransfer(context.Background(), transfer1.ID) + require.NoError(t, err) + require.NotEmpty(t, transfer2) + + require.Equal(t, transfer1.ID, transfer2.ID) + require.Equal(t, transfer1.FromAccountID, transfer2.FromAccountID) + require.Equal(t, transfer1.ToAccountID, transfer2.ToAccountID) + require.Equal(t, transfer1.Amount, transfer2.Amount) + require.WithinDuration(t, transfer1.CreatedAt, transfer2.CreatedAt, time.Second) +} + +func TestListTransfers(t *testing.T) { + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + + for i := 0; i < 10; i++ { + createRandomTransfer(t, account1, account2) + } + + arg := ListTransfersParams{ + FromAccountID: account1.ID, + ToAccountID: account2.ID, + Limit: 5, + Offset: 5, + } + + transfers, err := testQueries.ListTransfers(context.Background(), arg) + require.NoError(t, err) + require.Len(t, transfers, 5) + + for _, transfer := range transfers { + require.NotEmpty(t, transfer) + require.True(t, transfer.FromAccountID == account1.ID || transfer.ToAccountID == account2.ID) + } +} diff --git a/db/sqlc/user_test.go b/db/sqlc/user_test.go index f128cdb..a81e88b 100644 --- a/db/sqlc/user_test.go +++ b/db/sqlc/user_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "testing" "time" @@ -47,4 +48,23 @@ func TestGetUser(t *testing.T) { require.Equal(t, user1.Email, user2.Email) require.WithinDuration(t, user1.PasswordChangedAt, user2.PasswordChangedAt, time.Second) require.WithinDuration(t, user1.CreatedAt, user2.CreatedAt, time.Second) +} + +func TestGetUserNotFound(t *testing.T) { + _, err := testQueries.GetUser(context.Background(), util.RandomOwner()) + require.ErrorIs(t, err, sql.ErrNoRows) +} + +func TestCreateUserDuplicate(t *testing.T) { + user := createRandomUser(t) + + arg := CreateUserParams{ + Username: user.Username, + HashedPassword: "secret", + FullName: util.RandomOwner(), + Email: util.RandomEmail(), + } + + _, err := testQueries.CreateUser(context.Background(), arg) + require.Error(t, err) } \ No newline at end of file