summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormihna123 <mihailonvojinovic@gmail.com>2026-08-29 23:28:28 +0200
committermihna123 <mihailonvojinovic@gmail.com>2026-08-29 23:28:28 +0200
commit41d4965db78414ba53237f791919ae17c0b99e14 (patch)
treebd3e25202e8cb66cff14b7c119c7afdbbc7b1ff6
parent7ba6bc53be1546b44610840c46996dd6e2147405 (diff)
downloadopenstore-41d4965db78414ba53237f791919ae17c0b99e14.tar.gz
openstore-41d4965db78414ba53237f791919ae17c0b99e14.zip
user: add a service stub with validated register method
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--internal/user/input.go65
-rw-r--r--internal/user/mock_test.go150
-rw-r--r--internal/user/service.go56
-rw-r--r--internal/user/service_test.go134
6 files changed, 409 insertions, 0 deletions
diff --git a/go.mod b/go.mod
index e52724b..4c2f56c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module codeberg.org/mihna123/openstore
go 1.25.0
+
+require golang.org/x/crypto v0.55.0
diff --git a/go.sum b/go.sum
index e69de29..a6e2343 100644
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
diff --git a/internal/user/input.go b/internal/user/input.go
new file mode 100644
index 0000000..38db678
--- /dev/null
+++ b/internal/user/input.go
@@ -0,0 +1,65 @@
+package user
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+)
+
+type RegisterInput struct {
+ Username string
+ Email string
+ Phone string
+ Password string
+}
+
+type CreateInput struct {
+ Username string
+ Email string
+ Phone string
+ PwHash string
+}
+
+var ErrInvalidInput = errors.New("invalid input")
+var ErrUserExists = errors.New("user already exists")
+var ErrUserNotFound = errors.New("user not found")
+
+var usrnameRegExp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
+var mailRegExp = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
+var phoneRegExp = regexp.MustCompile(`^\+?[0-9][0-9\s-]*$`)
+
+func (in *RegisterInput) validate() error {
+ if len(in.Password) < 8 {
+ return fmt.Errorf("%w: password too short - min is 8", ErrInvalidInput)
+ }
+
+ if len(in.Password) > 72 {
+ return fmt.Errorf("%w: password too long - max is 72", ErrInvalidInput)
+ }
+
+ if in.Username == "" {
+ return fmt.Errorf("%w: username is mandatory", ErrInvalidInput)
+ }
+
+ if !usrnameRegExp.MatchString(in.Username) {
+ return fmt.Errorf("%w: bad username - must start with a letter", ErrInvalidInput)
+ }
+
+ if len(in.Username) < 3 {
+ return fmt.Errorf("%w: username too short - min is 3 chars", ErrInvalidInput)
+ }
+
+ if in.Email == "" {
+ return fmt.Errorf("%w: email is mandatory", ErrInvalidInput)
+ }
+
+ if !mailRegExp.MatchString(in.Email) {
+ return fmt.Errorf("%w: bad email format", ErrInvalidInput)
+ }
+
+ if in.Phone != "" && !phoneRegExp.MatchString(in.Phone) {
+ return fmt.Errorf("%w: bad phone format", ErrInvalidInput)
+ }
+
+ return nil
+}
diff --git a/internal/user/mock_test.go b/internal/user/mock_test.go
new file mode 100644
index 0000000..0f0f672
--- /dev/null
+++ b/internal/user/mock_test.go
@@ -0,0 +1,150 @@
+package user
+
+type RepositoryMock struct {
+ users []User
+ err error
+}
+
+func getEmptyRepository() *RepositoryMock {
+ return &RepositoryMock{users: []User{}, err: nil}
+}
+
+func (r *RepositoryMock) Create(in *CreateInput) (*User, error) {
+ if r.err != nil {
+ return nil, r.err
+ }
+
+ user := User{
+ Username: in.Username,
+ Phone: in.Phone,
+ Email: in.Email,
+ passwordHash: in.PwHash,
+ }
+
+ r.users = append(r.users, user)
+ return &user, nil
+}
+
+func (r *RepositoryMock) GetByUsernameOrEmail(username string, email string) (*User, error) {
+ if r.err != nil {
+ return nil, r.err
+ }
+
+ for _, user := range r.users {
+ if user.Username == username || user.Email == email {
+ return &user, nil
+ }
+ }
+
+ return nil, ErrUserNotFound
+}
+
+func getInvalidRegisterInputs() map[string]RegisterInput {
+ baseInput := RegisterInput{
+ Username: "testusername",
+ Email: "testemail@mail.com",
+ Phone: "+232442223",
+ Password: "1233213344234",
+ }
+
+ badInputs := map[string]RegisterInput{}
+
+ input := baseInput
+ input.Username = ""
+ badInputs["no username"] = input
+
+ input = baseInput
+ input.Username = " "
+ badInputs["username is only whitespace"] = input
+
+ input = baseInput
+ input.Username = "m ike"
+ badInputs["username contains a space"] = input
+
+ input = baseInput
+ input.Username = "1mike"
+ badInputs["username starts with a number"] = input
+
+ input = baseInput
+ input.Username = "ab"
+ badInputs["username is less than 3 chars"] = input
+
+ input = baseInput
+ input.Email = ""
+ badInputs["no email"] = input
+
+ input = baseInput
+ input.Email = " "
+ badInputs["email is just whitespace"] = input
+
+ input = baseInput
+ input.Email = "some weird@email.com"
+ badInputs["email contains inner whitespace"] = input
+
+ input = baseInput
+ input.Email = "someweird@em ail.com"
+ badInputs["email contains whitespace after @ and before dot"] = input
+
+ input = baseInput
+ input.Email = "someweird@email.c om"
+ badInputs["email contains whitespace after dot"] = input
+
+ input = baseInput
+ input.Email = "emailnoat.com"
+ badInputs["email has no @"] = input
+
+ input = baseInput
+ input.Email = "emailnodot@somcom"
+ badInputs["email has no dot"] = input
+
+ input = baseInput
+ input.Email = "@."
+ badInputs["email is only at dot"] = input
+
+ input = baseInput
+ input.Phone = "070e833"
+ badInputs["phone number has letters"] = input
+
+ input = baseInput
+ input.Phone = " "
+ badInputs["phone number is only empty space"] = input
+
+ input = baseInput
+ input.Password = ""
+ badInputs["no password"] = input
+
+ input = baseInput
+ input.Password = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"
+ badInputs["password is over 72 chars"] = input
+
+ input = baseInput
+ input.Password = "1234"
+ badInputs["password is less than 8 chars"] = input
+
+ return badInputs
+}
+
+func (r *RepositoryMock) fill() {
+ users := []User{
+ {
+ Username: "johnsmith",
+ Email: "jognsmith@email.com",
+ Phone: "+332200334",
+ passwordHash: "213412",
+ },
+ {
+ Username: "janedoe",
+ Email: "janedoe@email.com",
+ Phone: "+332200334",
+ passwordHash: "213412",
+ },
+ {
+ Username: "mrtest",
+ Email: "mrtest@email.com",
+ Phone: "+332200334",
+ passwordHash: "213412",
+ },
+ }
+
+ r.users = users
+}
diff --git a/internal/user/service.go b/internal/user/service.go
new file mode 100644
index 0000000..a8a72a6
--- /dev/null
+++ b/internal/user/service.go
@@ -0,0 +1,56 @@
+package user
+
+import (
+ "errors"
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+type Repository interface {
+ Create(in *CreateInput) (*User, error)
+ GetByUsernameOrEmail(username string, email string) (*User, error)
+}
+
+type Service struct {
+ repository Repository
+ bcryptCost int
+}
+
+func New(r Repository) *Service {
+ return &Service{repository: r, bcryptCost: bcrypt.DefaultCost}
+}
+
+func (s *Service) Register(in *RegisterInput) (*User, error) {
+ if err := in.validate(); err != nil {
+ return nil, fmt.Errorf("register: %w", err)
+ }
+
+ _, err := s.repository.GetByUsernameOrEmail(in.Username, in.Email)
+ if err == nil {
+ return nil, fmt.Errorf("register: %w", ErrUserExists)
+ }
+
+ if !errors.Is(err, ErrUserNotFound) {
+ return nil, fmt.Errorf("register: %w", err)
+ }
+
+ pwHash, err := bcrypt.GenerateFromPassword([]byte(in.Password), s.bcryptCost)
+ if err != nil {
+ return nil, fmt.Errorf("register: %w", err)
+ }
+
+ ci := &CreateInput{
+ Username: in.Username,
+ Email: in.Email,
+ Phone: in.Phone,
+ PwHash: string(pwHash),
+ }
+
+ user, err := s.repository.Create(ci)
+ if err != nil {
+ return nil, fmt.Errorf("register: %w", err)
+ }
+
+ return user, nil
+}
diff --git a/internal/user/service_test.go b/internal/user/service_test.go
new file mode 100644
index 0000000..938d2c3
--- /dev/null
+++ b/internal/user/service_test.go
@@ -0,0 +1,134 @@
+package user
+
+import (
+ "errors"
+ "log/slog"
+ "os"
+ "testing"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+func TestMain(m *testing.M) {
+ slog.SetDefault(slog.New(slog.DiscardHandler))
+ os.Exit(m.Run())
+}
+
+func NewTestUserService(t *testing.T) *Service {
+ t.Helper()
+ return &Service{repository: getEmptyRepository(), bcryptCost: bcrypt.MinCost}
+}
+
+func TestRegisterInputValidateFaultyData(t *testing.T) {
+ badInputs := getInvalidRegisterInputs()
+
+ for name, in := range badInputs {
+ t.Run(name, func(t *testing.T) {
+ err := in.validate()
+ if err == nil {
+ t.Errorf("bad input got through: %+v", in)
+ }
+ if !errors.Is(err, ErrInvalidInput) {
+ t.Errorf("unexpected error: got %v, want ErrInvalidInput", err)
+ }
+ })
+ }
+}
+
+func TestRegisterBadInput(t *testing.T) {
+ in := &RegisterInput{}
+ s := NewTestUserService(t)
+ _, err := s.Register(in)
+ if err == nil {
+ t.Error("no error when registering with faulty input")
+ }
+}
+
+func TestRegisterExistingUser(t *testing.T) {
+ r := getEmptyRepository()
+ r.fill()
+
+ badInputs := map[string]RegisterInput{
+ "existing username": {
+ Username: r.users[0].Username,
+ Email: "thisemaildoesnt@exist.com",
+ Phone: "123321",
+ Password: "22332233",
+ },
+ "existing email": {
+ Username: "thisusernamedoesntexist",
+ Email: r.users[0].Email,
+ Phone: "123321",
+ Password: "22332233",
+ },
+ }
+
+ for name, in := range badInputs {
+ t.Run(name, func(t *testing.T) {
+ s := &Service{repository: r, bcryptCost: bcrypt.MinCost}
+ _, err := s.Register(&in)
+ if err == nil {
+ t.Error("existing user registered")
+ }
+
+ if !errors.Is(err, ErrUserExists) {
+ t.Errorf("unexpected error: got %v, want ErrUserExists", err)
+ }
+ })
+ }
+}
+
+func TestRegisterRepositoryError(t *testing.T) {
+ s := &Service{repository: &RepositoryMock{err: errors.New("db down")}}
+ in := &RegisterInput{Username: "bob", Email: "b@b.com", Password: "thisissecure"}
+ _, err := s.Register(in)
+
+ if err == nil {
+ t.Error("register swallowed repository error")
+ }
+
+ if errors.Is(err, ErrUserExists) {
+ t.Error("repository failure reported as ErrUserExists")
+ }
+}
+
+func TestSuccessfulRegister(t *testing.T) {
+ s := NewTestUserService(t)
+ in := &RegisterInput{
+ Username: "happyuser123",
+ Email: "happyuser@email.com",
+ Phone: "+381233232",
+ Password: "supersecretpassword1234",
+ }
+ usr, err := s.Register(in)
+ if err != nil {
+ t.Errorf("register with good input failed: %+v", in)
+ }
+
+ err = bcrypt.CompareHashAndPassword([]byte(usr.passwordHash), []byte(in.Password))
+ if err != nil {
+ t.Errorf("hash and password compare error: %v", err)
+ }
+}
+
+func TestRegisterSameUserTwice(t *testing.T) {
+ s := NewTestUserService(t)
+ in := &RegisterInput{
+ Username: "happyuser123",
+ Email: "happyuser@email.com",
+ Phone: "+381233232",
+ Password: "supersecretpassword1234",
+ }
+
+ s.Register(in)
+
+ _, err := s.Register(in)
+ if err == nil {
+ t.Errorf("registered same user twice: %+v", in)
+ }
+
+ if !errors.Is(err, ErrUserExists) {
+ t.Errorf("unexpected error: got %v, want ErrUserExists", err)
+ }
+
+}