feat: add a new command - db user create (#4)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #4
This commit is contained in:
Dawid Wysokiński 2022-11-19 07:24:05 +00:00
parent d83aaa5beb
commit 5b061dfef3
18 changed files with 835 additions and 166 deletions

View File

@ -1,173 +1,15 @@
package db
import (
"fmt"
"strconv"
"strings"
"gitea.dwysokinski.me/twhelp/sessions/cmd/sessions/internal"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/migrations"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
)
func New() *cli.Command {
return &cli.Command{
Name: "db",
Usage: "Manages database migrations",
Name: "db",
Subcommands: []*cli.Command{
newMigrateCmd(),
newRollbackCmd(),
newCreateCmd(),
newStatusCmd(),
},
}
}
func newMigrateCmd() *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "Migrates database",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
if err = migrator.Init(c.Context); err != nil {
return fmt.Errorf("migrator.Init: %w", err)
}
group, err := migrator.Migrate(c.Context)
if err != nil {
return fmt.Errorf("migrator.Migrate: %w", err)
}
if group.ID == 0 {
logger.Info("there are no new migrations to run")
return nil
}
logger.Info("migrated to "+strconv.FormatInt(group.ID, 10), zap.Int64("id", group.ID))
return nil
},
}
}
func newRollbackCmd() *cli.Command {
return &cli.Command{
Name: "rollback",
Usage: "Rollbacks the last migration group",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
group, err := migrator.Rollback(c.Context)
if err != nil {
return fmt.Errorf("migrator.Rollback: %w", err)
}
if group.ID == 0 {
logger.Info("there are no groups to roll back")
return nil
}
logger.Info("rolled back "+strconv.FormatInt(group.ID, 10), zap.Int64("id", group.ID))
return nil
},
}
}
func newCreateCmd() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Creates migration",
Subcommands: []*cli.Command{
{
Name: "go",
Usage: "Creates Go migration",
Action: func(c *cli.Context) error {
logger := zap.L()
migrator := migrations.NewMigrator(nil)
mf, err := migrator.CreateGoMigration(c.Context, strings.Join(c.Args().Slice(), "_"))
if err != nil {
return fmt.Errorf("migrator.CreateGoMigration: %w", err)
}
logger.Info("created migration", zap.String("name", mf.Name), zap.String("path", mf.Path))
return nil
},
},
{
Name: "sql",
Usage: "Creates SQL migration",
Action: func(c *cli.Context) error {
logger := zap.L()
migrator := migrations.NewMigrator(nil)
files, err := migrator.CreateSQLMigrations(c.Context, strings.Join(c.Args().Slice(), "_"))
if err != nil {
return fmt.Errorf("migrator.CreateSQLMigrations: %w", err)
}
for _, mf := range files {
logger.Info("created migration", zap.String("name", mf.Name), zap.String("path", mf.Path))
}
return nil
},
},
},
}
}
func newStatusCmd() *cli.Command {
return &cli.Command{
Name: "status",
Usage: "Prints migrations status",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
ms, err := migrator.MigrationsWithStatus(c.Context)
if err != nil {
return fmt.Errorf("migrator.MigrationsWithStatus: %w", err)
}
logger.Info("migrations: " + ms.String())
logger.Info("last migration group: " + ms.LastGroup().String())
logger.Info("unapplied: " + ms.Unapplied().String())
return nil
newMigrationCmd(),
newUserCmd(),
},
}
}

View File

@ -0,0 +1,173 @@
package db
import (
"fmt"
"strconv"
"strings"
"gitea.dwysokinski.me/twhelp/sessions/cmd/sessions/internal"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/migrations"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
)
func newMigrationCmd() *cli.Command {
return &cli.Command{
Name: "migration",
Usage: "Manages database migrations",
Subcommands: []*cli.Command{
newMigrateCmd(),
newRollbackCmd(),
newCreateMigrationCmd(),
newStatusCmd(),
},
}
}
func newMigrateCmd() *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "Migrates database",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
if err = migrator.Init(c.Context); err != nil {
return fmt.Errorf("migrator.Init: %w", err)
}
group, err := migrator.Migrate(c.Context)
if err != nil {
return fmt.Errorf("migrator.Migrate: %w", err)
}
if group.ID == 0 {
logger.Info("there are no new migrations to run")
return nil
}
logger.Info("migrated to "+strconv.FormatInt(group.ID, 10), zap.Int64("id", group.ID))
return nil
},
}
}
func newRollbackCmd() *cli.Command {
return &cli.Command{
Name: "rollback",
Usage: "Rollbacks the last migration group",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
group, err := migrator.Rollback(c.Context)
if err != nil {
return fmt.Errorf("migrator.Rollback: %w", err)
}
if group.ID == 0 {
logger.Info("there are no groups to roll back")
return nil
}
logger.Info("rolled back "+strconv.FormatInt(group.ID, 10), zap.Int64("id", group.ID))
return nil
},
}
}
func newCreateMigrationCmd() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Creates a migration",
Subcommands: []*cli.Command{
{
Name: "go",
Usage: "Creates a Go migration",
Action: func(c *cli.Context) error {
logger := zap.L()
migrator := migrations.NewMigrator(nil)
mf, err := migrator.CreateGoMigration(c.Context, strings.Join(c.Args().Slice(), "_"))
if err != nil {
return fmt.Errorf("migrator.CreateGoMigration: %w", err)
}
logger.Info("created migration", zap.String("name", mf.Name), zap.String("path", mf.Path))
return nil
},
},
{
Name: "sql",
Usage: "Creates an SQL migration",
Action: func(c *cli.Context) error {
logger := zap.L()
migrator := migrations.NewMigrator(nil)
files, err := migrator.CreateSQLMigrations(c.Context, strings.Join(c.Args().Slice(), "_"))
if err != nil {
return fmt.Errorf("migrator.CreateSQLMigrations: %w", err)
}
for _, mf := range files {
logger.Info("created migration", zap.String("name", mf.Name), zap.String("path", mf.Path))
}
return nil
},
},
},
}
}
func newStatusCmd() *cli.Command {
return &cli.Command{
Name: "status",
Usage: "Prints migrations status",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
migrator := migrations.NewMigrator(db)
ms, err := migrator.MigrationsWithStatus(c.Context)
if err != nil {
return fmt.Errorf("migrator.MigrationsWithStatus: %w", err)
}
logger.Info("migrations: " + ms.String())
logger.Info("last migration group: " + ms.LastGroup().String())
logger.Info("unapplied: " + ms.Unapplied().String())
return nil
},
}
}

View File

@ -0,0 +1,69 @@
package db
import (
"context"
"fmt"
"time"
"gitea.dwysokinski.me/twhelp/sessions/cmd/sessions/internal"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/service"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
)
const (
createUserTimeout = time.Second
)
func newUserCmd() *cli.Command {
return &cli.Command{
Name: "user",
Usage: "Manages users",
Subcommands: []*cli.Command{
newCreateUserCmd(),
},
}
}
func newCreateUserCmd() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Creates a user",
Action: func(c *cli.Context) error {
logger := zap.L()
db, err := internal.NewBunDB()
if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err)
}
defer func() {
_ = db.Close()
}()
params, err := domain.NewCreateUserParams(c.String("name"))
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.Context, createUserTimeout)
defer cancel()
user, err := service.NewUser(bundb.NewUser(db)).Create(ctx, params)
if err != nil {
return fmt.Errorf("UserService.Create: %w", err)
}
logger.Info("user created", zap.Int64("id", user.ID), zap.String("name", user.Name))
return nil
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Required: true,
},
},
}
}

2
go.mod
View File

@ -8,10 +8,12 @@ require (
github.com/go-chi/chi/v5 v5.0.7
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/kelseyhightower/envconfig v1.4.0
github.com/ory/dockertest/v3 v3.9.1
github.com/stretchr/testify v1.8.1
github.com/uptrace/bun v1.1.8
github.com/uptrace/bun/dbfixture v1.1.8
github.com/uptrace/bun/dialect/pgdialect v1.1.8
github.com/uptrace/bun/driver/pgdriver v1.1.8
github.com/urfave/cli/v2 v2.23.5

4
go.sum
View File

@ -54,6 +54,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
@ -110,6 +112,8 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYm
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.8 h1:slxuaP4LYWFbPRUmTtQhfJN+6eX/6ar2HDKYTcI50SA=
github.com/uptrace/bun v1.1.8/go.mod h1:iT89ESdV3uMupD9ixt6Khidht+BK0STabK/LeZE+B84=
github.com/uptrace/bun/dbfixture v1.1.8 h1:cDRqII+emIgmmhWSebdAZWu5vKmuIw+PZ/arf0oYJdo=
github.com/uptrace/bun/dbfixture v1.1.8/go.mod h1:GrRDt9lIfDpMyAGIJjWNsYeRHKjr8Gr2wDl2Rsf1sR4=
github.com/uptrace/bun/dialect/pgdialect v1.1.8 h1:wayJhjYDPGv8tgOBLolbBtSFQ0TihFoo8E1T129UdA8=
github.com/uptrace/bun/dialect/pgdialect v1.1.8/go.mod h1:nNbU8PHTjTUM+CRtGmqyBb9zcuRAB8I680/qoFSmBUk=
github.com/uptrace/bun/driver/pgdriver v1.1.8 h1:gyL22axRQfjJS2Umq0erzJnp0bLOdUE8/USKZHPQB8o=

View File

@ -13,7 +13,9 @@ import (
"time"
"unicode"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/internal/model"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/migrations"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/cenkalti/backoff/v4"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
@ -21,6 +23,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dbfixture"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)
@ -126,6 +129,29 @@ func runMigrations(tb testing.TB, db *bun.DB) {
require.NoError(tb, err, "couldn't migrate (2)")
}
type bunfixture struct {
*dbfixture.Fixture
}
func loadFixtures(tb testing.TB, bunDB *bun.DB) *bunfixture {
tb.Helper()
fixture := dbfixture.New(bunDB)
err := fixture.Load(context.Background(), os.DirFS("testdata"), "fixture.yml")
require.NoError(tb, err, "couldn't load fixtures")
return &bunfixture{fixture}
}
func (f *bunfixture) user(tb testing.TB, id string) domain.User {
tb.Helper()
row, err := f.Row("User." + id)
require.NoError(tb, err)
g, ok := row.(*model.User)
require.True(tb, ok)
return g.ToDomain()
}
func generateSchema() string {
return strings.TrimFunc(strings.ReplaceAll(uuid.NewString(), "-", "_"), unicode.IsNumber)
}

View File

@ -0,0 +1,16 @@
package model
import (
"time"
"github.com/uptrace/bun"
)
type ApiKey struct {
bun.BaseModel `bun:"base_model,table:api_keys,alias:ak"`
ID int64 `bun:"id,pk,autoincrement,identity"`
Key string `bun:"key,nullzero,type:varchar(255),notnull,unique"`
UserID int64 `bun:"user_id,nullzero,notnull"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
}

View File

@ -1,9 +1,10 @@
package model
import (
"strings"
"time"
"github.com/google/uuid"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/uptrace/bun"
)
@ -11,8 +12,23 @@ type User struct {
bun.BaseModel `bun:"base_model,table:users,alias:user"`
ID int64 `bun:"id,pk,autoincrement,identity"`
Name string `bun:"name,type:varchar(255),notnull"`
NameLower string `bun:"name_lower,type:varchar(255),notnull,unique"`
ApiKey uuid.UUID `bun:"api_key,type:uuid,unique"`
Name string `bun:"name,nullzero,type:varchar(255),notnull"`
NameLower string `bun:"name_lower,nullzero,type:varchar(255),notnull,unique"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
}
func NewUser(p domain.CreateUserParams) User {
return User{
Name: p.Name(),
NameLower: strings.ToLower(p.Name()),
CreatedAt: time.Now(),
}
}
func (u User) ToDomain() domain.User {
return domain.User{
ID: u.ID,
Name: u.Name,
CreatedAt: u.CreatedAt,
}
}

View File

@ -0,0 +1,28 @@
package model_test
import (
"strings"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/internal/model"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestUser(t *testing.T) {
t.Parallel()
var id int64 = 123
params, err := domain.NewCreateUserParams("NAMEname")
assert.NoError(t, err)
result := model.NewUser(params)
result.ID = id
assert.Equal(t, strings.ToLower(params.Name()), result.NameLower)
user := result.ToDomain()
assert.Equal(t, id, user.ID)
assert.Equal(t, params.Name(), user.Name)
assert.WithinDuration(t, time.Now(), user.CreatedAt, 10*time.Millisecond)
}

7
internal/bundb/testdata/fixture.yml vendored Normal file
View File

@ -0,0 +1,7 @@
- model: User
rows:
- _id: user-1
id: 11111
name: User-1
name_lower: user-1
created_at: 2022-03-15T15:00:10.000Z

55
internal/bundb/user.go Normal file
View File

@ -0,0 +1,55 @@
package bundb
import (
"context"
"errors"
"fmt"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb/internal/model"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/jackc/pgerrcode"
"github.com/uptrace/bun"
"github.com/uptrace/bun/driver/pgdriver"
)
type User struct {
db *bun.DB
}
func NewUser(db *bun.DB) *User {
return &User{db: db}
}
func (u *User) Create(ctx context.Context, params domain.CreateUserParams) (domain.User, error) {
user := model.NewUser(params)
if _, err := u.db.NewInsert().
Model(&user).
Returning("*").
Exec(ctx); err != nil {
return domain.User{}, fmt.Errorf(
"something went wrong while inserting user into the db: %w",
mapCreateUserError(err, params),
)
}
return user.ToDomain(), nil
}
func mapCreateUserError(err error, params domain.CreateUserParams) error {
var pgError pgdriver.Error
if !errors.As(err, &pgError) {
return err
}
code := pgError.Field('C')
constraint := pgError.Field('n')
switch {
case code == pgerrcode.UniqueViolation && constraint == "users_name_lower_key":
return domain.UsernameAlreadyTakenError{
Name: params.Name(),
}
default:
return err
}
}

View File

@ -0,0 +1,62 @@
package bundb_test
import (
"context"
"strings"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/sessions/internal/bundb"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUser_Create(t *testing.T) {
t.Parallel()
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewUser(db)
t.Run("OK", func(t *testing.T) {
t.Parallel()
params, err := domain.NewCreateUserParams("nameName")
require.Nil(t, err)
user, err := repo.Create(context.Background(), params)
assert.NoError(t, err)
assert.Greater(t, user.ID, int64(0))
assert.Equal(t, params.Name(), user.Name)
assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second)
})
t.Run("ERR: username must be unique", func(t *testing.T) {
t.Parallel()
name := fixture.user(t, "user-1").Name
names := []string{
name,
strings.ToUpper(name),
strings.ToLower(name),
}
for _, n := range names {
n := n
t.Run(n, func(t *testing.T) {
t.Parallel()
params, err := domain.NewCreateUserParams(n)
require.NoError(t, err)
user, err := repo.Create(context.Background(), params)
assert.ErrorIs(t, err, domain.UsernameAlreadyTakenError{
Name: n,
})
assert.Zero(t, user)
})
}
})
}

93
internal/domain/error.go Normal file
View File

@ -0,0 +1,93 @@
package domain
import (
"errors"
"fmt"
)
var (
ErrRequired = errors.New("cannot be blank")
)
type ErrorCode uint8
const (
ErrorCodeUnknown ErrorCode = iota
ErrorCodeEntityNotFound
ErrorCodeValidationError
ErrorCodeAlreadyExists
)
func (e ErrorCode) String() string {
switch e {
case ErrorCodeEntityNotFound:
return "entity-not-found"
case ErrorCodeValidationError:
return "validation-error"
case ErrorCodeAlreadyExists:
return "already-exists"
case ErrorCodeUnknown:
fallthrough
default:
return "internal-server-error"
}
}
type Error interface {
error
UserError() string
Code() ErrorCode
}
type ValidationError struct {
Field string
Err error
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Err)
}
func (e ValidationError) UserError() string {
return e.Error()
}
func (e ValidationError) Code() ErrorCode {
return ErrorCodeValidationError
}
func (e ValidationError) Unwrap() error {
return e.Err
}
type MinLengthError struct {
Min int
}
func (e MinLengthError) Error() string {
return fmt.Sprintf("the length must be no less than %d", e.Min)
}
func (e MinLengthError) UserError() string {
return e.Error()
}
func (e MinLengthError) Code() ErrorCode {
return ErrorCodeValidationError
}
type MaxLengthError struct {
Max int
}
func (e MaxLengthError) Error() string {
return fmt.Sprintf("the length must be no more than %d", e.Max)
}
func (e MaxLengthError) UserError() string {
return e.Error()
}
func (e MaxLengthError) Code() ErrorCode {
return ErrorCodeValidationError
}

View File

@ -0,0 +1,55 @@
package domain_test
import (
"fmt"
"testing"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestErrorCode_String(t *testing.T) {
t.Parallel()
assert.Equal(t, domain.ErrorCodeUnknown.String(), "internal-server-error")
assert.Equal(t, domain.ErrorCodeEntityNotFound.String(), "entity-not-found")
assert.Equal(t, domain.ErrorCodeValidationError.String(), "validation-error")
}
func TestValidationError(t *testing.T) {
t.Parallel()
err := domain.ValidationError{
Field: "test",
Err: domain.ErrRequired,
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("%s: %s", err.Field, err.Err.Error()), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.ErrorIs(t, err, err.Err)
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
func TestMinLengthError(t *testing.T) {
t.Parallel()
err := domain.MinLengthError{
Min: 25,
}
var _ domain.Error = err
assert.Equal(t, "the length must be no less than 25", err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
func TestMaxLengthError(t *testing.T) {
t.Parallel()
err := domain.MaxLengthError{
Max: 25,
}
var _ domain.Error = err
assert.Equal(t, "the length must be no more than 25", err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}

87
internal/domain/user.go Normal file
View File

@ -0,0 +1,87 @@
package domain
import (
"errors"
"fmt"
"regexp"
"time"
)
const (
UsernameMinLength = 2
UsernameMaxLength = 40
)
var (
ErrUsernameFormat = errors.New("may only contain alphanumeric characters or single hyphens and cannot begin or end with a hyphen")
rxAlphanumericHyphens = regexp.MustCompile("^[a-zA-Z0-9]+([-][a-zA-Z0-9]+)*$")
)
type User struct {
ID int64
Name string
CreatedAt time.Time
}
type CreateUserParams struct {
name string
}
func NewCreateUserParams(name string) (CreateUserParams, error) {
if name == "" {
return CreateUserParams{}, ValidationError{
Field: "Name",
Err: ErrRequired,
}
}
if len(name) < UsernameMinLength {
return CreateUserParams{}, ValidationError{
Field: "Name",
Err: MinLengthError{
Min: UsernameMinLength,
},
}
}
if len(name) > UsernameMaxLength {
return CreateUserParams{}, ValidationError{
Field: "Name",
Err: MaxLengthError{
Max: UsernameMaxLength,
},
}
}
if !rxAlphanumericHyphens.MatchString(name) {
return CreateUserParams{}, ValidationError{
Field: "Name",
Err: ErrUsernameFormat,
}
}
return CreateUserParams{
name: name,
}, nil
}
func (c CreateUserParams) Name() string {
return c.name
}
type UsernameAlreadyTakenError struct {
Name string
}
func (e UsernameAlreadyTakenError) Error() string {
return fmt.Sprintf("username '%s' is already taken", e.Name)
}
func (e UsernameAlreadyTakenError) UserError() string {
return e.Error()
}
func (e UsernameAlreadyTakenError) Code() ErrorCode {
return ErrorCodeAlreadyExists
}

View File

@ -0,0 +1,105 @@
package domain_test
import (
"fmt"
"testing"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestNewCreateUserParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
username string
apiKey string
expectedErr error
}{
{
name: "OK",
username: "testTEST-test-2",
expectedErr: nil,
},
{
name: "ERR: username is required",
username: "",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.ErrRequired,
},
},
{
name: "ERR: len(username) < 2",
username: "u",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.MinLengthError{
Min: 2,
},
},
},
{
name: "ERR: username - hyphen at the end",
username: "u-",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.ErrUsernameFormat,
},
},
{
name: "ERR: username - hyphen at the beginning",
username: "-u",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.ErrUsernameFormat,
},
},
{
name: "ERR: username - only alphanumeric 1",
username: "userńame",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.ErrUsernameFormat,
},
},
{
name: "ERR: username - only alphanumeric 1",
username: "漢字",
expectedErr: domain.ValidationError{
Field: "Name",
Err: domain.ErrUsernameFormat,
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
params, err := domain.NewCreateUserParams(tt.username)
if tt.expectedErr != nil {
assert.Equal(t, tt.expectedErr, err)
assert.Zero(t, params)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.username, params.Name())
})
}
}
func TestUsernameAlreadyTakenError(t *testing.T) {
t.Parallel()
err := domain.UsernameAlreadyTakenError{
Name: "xxxx",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("username '%s' is already taken", err.Name), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeAlreadyExists, err.Code())
}

29
internal/service/user.go Normal file
View File

@ -0,0 +1,29 @@
package service
import (
"context"
"fmt"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
)
type UserRepository interface {
Create(ctx context.Context, params domain.CreateUserParams) (domain.User, error)
}
type User struct {
repo UserRepository
}
func NewUser(repo UserRepository) *User {
return &User{repo: repo}
}
func (u *User) Create(ctx context.Context, params domain.CreateUserParams) (domain.User, error) {
user, err := u.repo.Create(ctx, params)
if err != nil {
return domain.User{}, fmt.Errorf("UserRepository.Create: %w", err)
}
return user, nil
}

View File

@ -9,7 +9,7 @@ spec:
containers:
- name: sessions-migrations
image: sessions
args: ["db", "migrate"]
args: ["db", "migration", "migrate"]
env:
- name: APP_MODE
value: development