From 366386c8bfc32f63f7e86e3f5115aaa6b95eb835 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Wed, 9 Sep 2026 16:25:48 +0200 Subject: [PATCH] test: re-worked user repo tests --- internal/api/handler/version.go | 6 +- internal/repository/user_test.go | 347 ++++++++++++++++--------------- 2 files changed, 188 insertions(+), 165 deletions(-) diff --git a/internal/api/handler/version.go b/internal/api/handler/version.go index b6104ce..bb30fd6 100644 --- a/internal/api/handler/version.go +++ b/internal/api/handler/version.go @@ -6,7 +6,7 @@ import ( "git.dittmar.dev/robin/dttmr-api/internal/api/response" ) -type VersionResponse struct { +type versionResponse struct { Version string `json:"version"` Commit string `json:"commit"` BuildTime string `json:"buildTime"` @@ -19,11 +19,11 @@ type VersionResponse struct { // @Tags Version // @Accept json // @Produce json -// @Success 200 {object} VersionResponse +// @Success 200 {object} versionResponse // @Router /version [get] func VersionHandler(version string, commit string, buildTime string) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - response.JSON(r.Context(), w, http.StatusOK, VersionResponse{ + response.JSON(r.Context(), w, http.StatusOK, versionResponse{ Version: version, Commit: commit, BuildTime: buildTime, diff --git a/internal/repository/user_test.go b/internal/repository/user_test.go index 3adb480..43a2a87 100644 --- a/internal/repository/user_test.go +++ b/internal/repository/user_test.go @@ -1,185 +1,208 @@ package repository import ( - "fmt" + "context" + "database/sql" + "errors" + "regexp" "testing" "time" - "git.dittmar.dev/robin/dttmr-api/internal/domain" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.dittmar.dev/robin/dttmr-api/internal/domain" +) + +func newUserRepo(t *testing.T) (*UserRepo, sqlmock.Sqlmock) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, mock.ExpectationsWereMet()) + _ = db.Close() + }) + + return &UserRepo{Repo: NewRepo(NewTransactor(db))}, mock +} + +const ( + insertUserQuery = `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at` + deleteUserQuery = `DELETE FROM users WHERE id = $1` + updatePassQuery = `UPDATE users SET password_hash = $1 WHERE id = $2` + selectUserQuery = `SELECT id, email, name FROM users WHERE email = $1` ) func TestUserRepo_CreateUser(t *testing.T) { - db, mock, err := sqlmock.New() - assert.NoError(t, err) - defer db.Close() - - //repo := NewUserRepo(db) - - //ctx := context.Background() - email := "test@example.com" - name := "Test User" - passwordHash := "hashedpassword123" - - now := time.Now() - expectedUser := &domain.User{ - ID: "1", - Email: email, - Name: name, - CreatedAt: now, - } - t.Run("success", func(t *testing.T) { - mock.ExpectBegin() + repo, mock := newUserRepo(t) - mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). - WithArgs(email, name, passwordHash). - WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt)) + createdAt := time.Date(2026, 9, 9, 10, 0, 0, 0, time.UTC) + mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)). + WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "created_at"}). + AddRow("2f1c...", createdAt), + ) - mock.ExpectCommit() + user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash") - //user, err := repo.CreateUser(ctx, email, name, passwordHash) - //assert.NoError(t, err) - //assert.Equal(t, expectedUser, user) - //assert.NoError(t, mock.ExpectationsWereMet()) + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, &domain.User{ + ID: "2f1c...", + Email: "robin@dittmar.dev", + Name: "Robin", + CreatedAt: createdAt, + }, user) }) - t.Run("begin_tx_error", func(t *testing.T) { - mock.ExpectBegin().WillReturnError(fmt.Errorf("tx error")) + t.Run("db error is wrapped", func(t *testing.T) { + repo, mock := newUserRepo(t) - //user, err := repo.CreateUser(ctx, email, name, passwordHash) - //assert.Error(t, err) - //assert.Contains(t, err.Error(), "begin transaction") - //assert.Nil(t, user) - //assert.NoError(t, mock.ExpectationsWereMet()) - }) + dbErr := errors.New("duplicate key value violates unique constraint") + mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)). + WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash"). + WillReturnError(dbErr) - t.Run("insert_error", func(t *testing.T) { - mock.ExpectBegin() - mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). - WithArgs(email, name, passwordHash). - WillReturnError(fmt.Errorf("insert error")) - mock.ExpectRollback() + user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash") - //user, err := repo.CreateUser(ctx, email, name, passwordHash) - //assert.Error(t, err) - //assert.Contains(t, err.Error(), "failed to insert user") - //assert.Nil(t, user) - //assert.NoError(t, mock.ExpectationsWereMet()) - }) - - t.Run("commit_error", func(t *testing.T) { - mock.ExpectBegin() - mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). - WithArgs(email, name, passwordHash). - WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt)) - mock.ExpectCommit().WillReturnError(fmt.Errorf("commit error")) - - //user, err := repo.CreateUser(ctx, email, name, passwordHash) - //assert.Error(t, err) - //assert.Contains(t, err.Error(), "commit transaction") - //assert.Nil(t, user) - //assert.NoError(t, mock.ExpectationsWereMet()) + assert.Nil(t, user) + assert.ErrorIs(t, err, dbErr) + assert.ErrorContains(t, err, "failed to insert user") }) } -//func TestUserRepo_CreateUser2(t *testing.T) { -// email := "test@example.com" -// name := "Test User" -// passwordHash := "hashedpassword123" -// now := time.Now() -// expectedID := "42" -// -// insertQuery := regexp.QuoteMeta( -// "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at", -// ) -// -// testCases := []struct { -// name string -// setupMock func(mock sqlmock.Sqlmock) -// expectedError string -// }{ -// { -// name: "Success: User created perfectly", -// setupMock: func(mock sqlmock.Sqlmock) { -// mock.ExpectBegin() -// -// rows := sqlmock.NewRows([]string{"id", "created_at"}). -// AddRow(expectedID, now) -// -// mock.ExpectQuery(insertQuery). -// WithArgs(email, name, passwordHash). -// WillReturnRows(rows) -// -// mock.ExpectCommit() -// }, -// expectedError: "", -// }, -// { -// name: "Failure: Database connection fails on BeginTx", -// setupMock: func(mock sqlmock.Sqlmock) { -// mock.ExpectBegin().WillReturnError(errors.New("db connection failed")) -// }, -// expectedError: "begin transaction: db connection failed", -// }, -// { -// name: "Failure: Query fails (e.g., duplicate email)", -// setupMock: func(mock sqlmock.Sqlmock) { -// mock.ExpectBegin() -// -// mock.ExpectQuery(insertQuery). -// WithArgs(email, name, passwordHash). -// WillReturnError(errors.New("unique constraint violation")) -// -// mock.ExpectRollback() -// }, -// expectedError: "failed to insert user: unique constraint violation", -// }, -// { -// name: "Failure: Commit fails (e.g., network timeout)", -// setupMock: func(mock sqlmock.Sqlmock) { -// mock.ExpectBegin() -// -// rows := sqlmock.NewRows([]string{"id", "created_at"}). -// AddRow(expectedID, now) -// -// mock.ExpectQuery(insertQuery). -// WithArgs(email, name, passwordHash). -// WillReturnRows(rows) -// -// mock.ExpectCommit().WillReturnError(errors.New("commit timeout")) -// }, -// expectedError: "commit transaction: commit timeout", -// }, -// } -// -// for _, tc := range testCases { -// t.Run(tc.name, func(t *testing.T) { -// db, mock, err := sqlmock.New() -// require.NoError(t, err) -// defer db.Close() -// -// tc.setupMock(mock) -// -// //repo := NewUserRepo(db) -// // -// //user, err := repo.CreateUser(context.Background(), email, name, passwordHash) -// -// if tc.expectedError != "" { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedError) -// //assert.Nil(t, user) -// } else { -// require.NoError(t, err) -// //require.NotNil(t, user) -// //assert.Equal(t, expectedID, user.ID) -// //assert.Equal(t, email, user.Email) -// //assert.Equal(t, name, user.Name) -// //assert.Equal(t, now, user.CreatedAt) -// } -// -// assert.NoError(t, mock.ExpectationsWereMet()) -// }) -// } -//} +func TestUserRepo_DeleteUser(t *testing.T) { + t.Run("success", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + assert.NoError(t, repo.DeleteUser(context.Background(), "user-1")) + }) + + t.Run("unknown id is not reported", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)). + WithArgs("does-not-exist"). + WillReturnResult(sqlmock.NewResult(0, 0)) + + assert.NoError(t, repo.DeleteUser(context.Background(), "does-not-exist")) + }) + + t.Run("db error is wrapped", func(t *testing.T) { + repo, mock := newUserRepo(t) + + dbErr := errors.New("connection reset") + mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)). + WithArgs("user-1"). + WillReturnError(dbErr) + + err := repo.DeleteUser(context.Background(), "user-1") + + assert.ErrorIs(t, err, dbErr) + assert.ErrorContains(t, err, "failed to delete user") + }) +} + +func TestUserRepo_ChangePassword(t *testing.T) { + t.Run("success", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)). + WithArgs("$2a$10$newhash", "user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + assert.NoError(t, repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash")) + }) + + t.Run("db error is wrapped", func(t *testing.T) { + repo, mock := newUserRepo(t) + + dbErr := errors.New("deadlock detected") + mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)). + WithArgs("$2a$10$newhash", "user-1"). + WillReturnError(dbErr) + + err := repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash") + + assert.ErrorIs(t, err, dbErr) + assert.ErrorContains(t, err, "failed to update user") + }) +} + +func TestUserRepo_UsesTransactionFromContext(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + tx, err := repo.db.BeginTx(context.Background(), nil) + require.NoError(t, err) + + ctx := context.WithValue(context.Background(), txKey{}, tx) + require.NoError(t, repo.DeleteUser(ctx, "user-1")) + require.NoError(t, tx.Commit()) +} + +func TestUserRepo_GetUserByEmail(t *testing.T) { + t.Run("success", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)). + WithArgs("robin@dittmar.dev"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "email", "name"}). + AddRow("user-1", "robin@dittmar.dev", "Robin"), + ) + + user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev") + + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, "user-1", user.ID) + assert.Equal(t, "robin@dittmar.dev", user.Email) + assert.Equal(t, "Robin", user.Name) + assert.Zero(t, user.CreatedAt) // not selected by this query + }) + + t.Run("not found stays matchable via errors.Is", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)). + WithArgs("nobody@dittmar.dev"). + WillReturnError(sql.ErrNoRows) + + user, err := repo.GetUserByEmail(context.Background(), "nobody@dittmar.dev") + + assert.Nil(t, user) + assert.ErrorIs(t, err, sql.ErrNoRows) + assert.ErrorContains(t, err, "failed to get user") + }) + + t.Run("scan error on type mismatch", func(t *testing.T) { + repo, mock := newUserRepo(t) + + mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)). + WithArgs("robin@dittmar.dev"). + WillReturnRows( + sqlmock.NewRows([]string{"id", "email", "name"}). + AddRow(nil, "robin@dittmar.dev", "Robin"), + ) + + user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev") + + assert.Nil(t, user) + assert.Error(t, err) + }) +}