diff --git a/internal/migrate/migrations/1-init_db.sql b/internal/migrate/migrations/1-init_db.sql index 039ecc3..bc2f837 100644 --- a/internal/migrate/migrations/1-init_db.sql +++ b/internal/migrate/migrations/1-init_db.sql @@ -5,7 +5,7 @@ CREATE TABLE todo( ); CREATE TABLE users( - id uuid PRIMARY KEY, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR NOT NULL, hashed_password VARCHAR NOT NULL ); diff --git a/internal/user/repository/repository.go b/internal/user/repository/repository.go new file mode 100644 index 0000000..0dc657d --- /dev/null +++ b/internal/user/repository/repository.go @@ -0,0 +1,115 @@ +package repository + +import ( + "context" + "errors" + "log/slog" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNotFound error = errors.New("user cannot be found") +) + +type UserRow struct { + Id uuid.UUID + Email string + HashedPassword string +} + +func NewUserRow(id uuid.UUID, email string, hashedPassword string) UserRow { + return UserRow{Id: id, Email: email, HashedPassword: hashedPassword} +} + +func (u UserRow) Equal(user UserRow) bool { + return u.Id == user.Id && u.Email == user.Email && u.HashedPassword == user.HashedPassword +} + +type UserRepository struct { + logger *slog.Logger + db *pgxpool.Pool +} + +func NewUserRepository(logger *slog.Logger, db *pgxpool.Pool) *UserRepository { + return &UserRepository{ + logger: logger, + db: db, + } +} + +func (r *UserRepository) GetById(ctx context.Context, id uuid.UUID) (UserRow, error) { + user := UserRow{} + + err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1;", id).Scan(&user.Id, &user.Email, &user.HashedPassword) + + if err != nil { + if err == pgx.ErrNoRows { + return user, ErrNotFound + } + + r.logger.ErrorContext(ctx, err.Error()) + return user, err + } + + return user, nil +} + +func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, error) { + var result pgx.Row + if user.Id.IsNil() { + result = r.db.QueryRow(ctx, "INSERT INTO users (email, hashed_password) VALUES ($1, $2) RETURNING id;", user.Email, user.HashedPassword) + + err := result.Scan(&user.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return UserRow{}, err + } + } else { + _, err := r.db.Exec(ctx, "INSERT INTO users (id, email, hashed_password) VALUES ($1, $2, $3);", user.Id, user.Email, user.HashedPassword) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return UserRow{}, err + } + } + + return user, nil +} + +func (r *UserRepository) Update(ctx context.Context, user UserRow) error { + result, err := r.db.Exec(ctx, "UPDATE users SET email = $1, hashed_password = $2 WHERE id = $3;", user.Email, user.HashedPassword, user.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error { + result, err := r.db.Exec(ctx, "DELETE FROM users WHERE id = $1;", id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/user/repository/repository_test.go b/internal/user/repository/repository_test.go new file mode 100644 index 0000000..0e8ccdc --- /dev/null +++ b/internal/user/repository/repository_test.go @@ -0,0 +1,84 @@ +package repository_test + +import ( + "context" + "errors" + "log/slog" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/test" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "github.com/gofrs/uuid/v5" +) + +func TestCRUD(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + uuid := NewUUID(t) + + t.Run("creates new user", func(t *testing.T) { + newUser := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + _, err := r.Create(ctx, newUser) + AssertNoError(t, err) + }) + + t.Run("gets user", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + got, err := r.GetById(ctx, uuid) + AssertNoError(t, err) + AssertUserRows(t, got, want) + }) + + t.Run("updates user", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "new@test.com", HashedPassword: "supersecurehash"} + err := r.Update(ctx, want) + AssertNoError(t, err) + + got, err := r.GetById(ctx, uuid) + AssertNoError(t, err) + AssertUserRows(t, got, want) + }) + + t.Run("deletes user", func(t *testing.T) { + err := r.Delete(ctx, uuid) + AssertNoError(t, err) + + want := repository.ErrNotFound + _, got := r.GetById(ctx, uuid) + + AssertErrors(t, got, want) + }) +} + +func AssertErrors(t testing.TB, got, want error) { + t.Helper() + if !errors.Is(got, want) { + t.Errorf("got error: %v, want error: %v", want, got) + } +} + +func AssertNoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func AssertUserRows(t testing.TB, got, want repository.UserRow) { + t.Helper() + if !got.Equal(want) { + t.Errorf("got %+v want %+v", got, want) + } +} + +func NewUUID(t testing.TB) uuid.UUID { + t.Helper() + uuid, err := uuid.NewV4() + if err != nil { + t.Errorf("error generation uuid: %v", err) + } + return uuid +}