add users logic

This commit is contained in:
Efremenko Arhip
2026-07-26 14:38:24 +03:00
parent 16db1e41f7
commit a3db1a1e67
65 changed files with 3626 additions and 19 deletions
+11
View File
@@ -0,0 +1,11 @@
package domain
import "errors"
// Domain validation errors. They describe an entity that cannot be constructed,
// independent of storage or transport, and the HTTP layer maps them to 400.
var (
ErrEmptyEmail = errors.New("email is required")
ErrInvalidEmail = errors.New("email is not a valid address")
ErrEmptyPasswordHash = errors.New("password hash is required")
)
+80
View File
@@ -0,0 +1,80 @@
package domain
import (
"net/mail"
"strings"
"time"
"github.com/google/uuid"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
func (r Role) Valid() bool {
switch r {
case RoleAdmin, RoleUser:
return true
default:
return false
}
}
type User struct {
ID uuid.UUID
Email string
PasswordHash string
Role Role
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUser builds a freshly registered account. It normalises the email and
// enforces every invariant a row must satisfy, so an invalid User cannot be
// constructed. The caller supplies the already-hashed password — hashing is an
// adapter's job, not the domain's.
//
// Registration always produces a plain user; promotion to admin is a manual,
// out-of-band operation, never something a request can trigger.
func NewUser(email, passwordHash string, now time.Time) (*User, error) {
email = NormalizeEmail(email)
if err := validateEmail(email); err != nil {
return nil, err
}
if passwordHash == "" {
return nil, ErrEmptyPasswordHash
}
return &User{
ID: uuid.New(),
Email: email,
PasswordHash: passwordHash,
Role: RoleUser,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and
// "bob@x.com" resolve to the same account. Every lookup and every insert must
// pass through here, matching the ck_users_email_lower database constraint.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func validateEmail(email string) error {
if email == "" {
return ErrEmptyEmail
}
// A minimal shape check, not full RFC 5322: real deliverability is proven by
// sending mail, not by a regex. mail.ParseAddress also accepts the
// "Name <addr>" form, so we insist the parsed address equals the input.
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return ErrInvalidEmail
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package domain
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
func TestNewUserNormalisesAndValidates(t *testing.T) {
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
u, err := NewUser(" Bob@Example.COM ", "hashed", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Email != "bob@example.com" {
t.Errorf("email not normalised: got %q", u.Email)
}
if u.Role != RoleUser {
t.Errorf("new user must default to RoleUser, got %q", u.Role)
}
if u.ID == uuid.Nil {
t.Error("new user must get an id")
}
if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) {
t.Error("timestamps not set from clock")
}
}
func TestNewUserRejectsBadInput(t *testing.T) {
now := time.Now()
cases := []struct {
name string
email string
hash string
wantErr error
}{
{"empty email", "", "h", ErrEmptyEmail},
{"no domain", "bob", "h", ErrInvalidEmail},
{"name form", "Bob <bob@x.com>", "h", ErrInvalidEmail},
{"empty hash", "bob@x.com", "", ErrEmptyPasswordHash},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewUser(tc.email, tc.hash, now)
if !errors.Is(err, tc.wantErr) {
t.Errorf("got %v, want %v", err, tc.wantErr)
}
})
}
}
func TestRoleValid(t *testing.T) {
if !RoleUser.Valid() || !RoleAdmin.Valid() {
t.Error("user and admin must be valid")
}
if Role("root").Valid() {
t.Error("unknown role must be invalid")
}
}