Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func main() {
log.Fatal(err)
}

repository := repo.NewPostgresUserRepository(databaseConn)
repository := repo.NewPostgresUserRepositoryFromPool(databaseConn)

userService := service.NewUserService(repository)
userHandler := handler.NewUserHandler(userService)
Expand Down
42 changes: 2 additions & 40 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,63 +12,25 @@ require (
)

require (
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.2.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/infisical/go-sdk v0.5.100 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/api v0.215.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
128 changes: 2 additions & 126 deletions go.sum

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions internal/userservice/handler/verify_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package handler

import "testing"

func TestIsCodeEmpty(t *testing.T) {
if err := isCodeEmpty(""); err == nil {
t.Fatalf("expected error for empty code, got nil")
}
if err := isCodeEmpty("123456"); err != nil {
t.Fatalf("unexpected error for non-empty code: %v", err)
}
}

func TestIsCodeLengthMismatch(t *testing.T) {
if err := isCodeLengthMismatch("12345"); err == nil {
t.Fatalf("expected length mismatch error for 5 digits, got nil")
}
if err := isCodeLengthMismatch("1234567"); err == nil {
t.Fatalf("expected length mismatch error for 7 digits, got nil")
}
if err := isCodeLengthMismatch("123456"); err != nil {
t.Fatalf("unexpected error for correct length: %v", err)
}
}

func TestIsCodeNotDigitable(t *testing.T) {
if err := isCodeNotDigitable("12a456"); err == nil {
t.Fatalf("expected not-digitable error, got nil")
}
if err := isCodeNotDigitable("12345!"); err == nil {
t.Fatalf("expected not-digitable error with symbol, got nil")
}
// Arabic-Indic digits are valid according to unicode.IsDigit
if err := isCodeNotDigitable("١٢٣٤٥٦"); err != nil {
t.Fatalf("unexpected error for unicode digits: %v", err)
}
if err := isCodeNotDigitable("123456"); err != nil {
t.Fatalf("unexpected error for numeric code: %v", err)
}
}

func TestIsUserIDNegative(t *testing.T) {
if err := isUserIDNegative(-1); err == nil {
t.Fatalf("expected error for negative userID, got nil")
}
if err := isUserIDNegative(0); err != nil {
t.Fatalf("unexpected error for non-negative userID: %v", err)
}
}

func TestValidateConfirmationCode(t *testing.T) {
tests := []struct{
name string
code string
wantErr bool
}{
{name: "empty", code: "", wantErr: true},
{name: "short", code: "12345", wantErr: true},
{name: "long", code: "1234567", wantErr: true},
{name: "non-digit", code: "12a456", wantErr: true},
{name: "ok", code: "123456", wantErr: false},
}

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
err := validateConfirmationCode(tc.code)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}


5 changes: 5 additions & 0 deletions internal/userservice/repository/general.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const (

const (
DBTimeout = 5 * time.Second

MaxPoolConns = 10
MinPoolConns = 2
MaxConnLifetime = time.Hour
MaxConnIdleTime = 30 * time.Minute
)

var (
Expand Down
3 changes: 0 additions & 3 deletions internal/userservice/repository/remove_verification_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ const (
)

func (r *PostgresUserRepository) RemoveCodeFromDB(ctx context.Context, userID int64) error {
ctx, cancel := context.WithTimeout(ctx, DBTimeout)
defer cancel()

_, err := r.db.Exec(ctx, RemoveVerificationCodeQuery, userID)

return err
Expand Down
170 changes: 104 additions & 66 deletions internal/userservice/repository/repository.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package repository

import (
"context"
"errors"
"os/user"
"time"
"context"
"errors"
"os/user"
"time"

pgx "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)

const (
Expand All @@ -19,31 +19,79 @@ const (

CheckVerificationCodeExistsQuery = `SELECT EXISTS (SELECT 1 FROM email_verifications WHERE user_id = $1)`

InsertVerificationCodeQuery = `
UpsertVerificationCodeQuery = `
INSERT INTO email_verifications (user_id, code, created_at)
VALUES ($1, $2, $3)
`

UpdateVerificationCodeQuery = `
UPDATE email_verifications SET code = $1 created_at = $2 WHERE user_id = $3
ON CONFLICT (user_id)
DO UPDATE SET code = EXCLUDED.code, created_at = EXCLUDED.created_at;
`
)

// Row abstracts the Scan method used from pgx
type Row interface {
Scan(dest ...any) error
}

// Tx abstracts the subset of pgx.Tx we need
type Tx interface {
QueryRow(ctx context.Context, sql string, args ...any) Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}

// DB abstracts the subset of pgxpool.Pool we need
type DB interface {
Begin(ctx context.Context) (Tx, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
QueryRow(ctx context.Context, sql string, args ...any) Row
Ping(ctx context.Context) error
}

type PostgresUserRepository struct {
db *pgx.Conn
db DB
}

func NewDatabaseConnection(ctx context.Context, databaseURL string) (*pgx.Conn, error) {
db, err := pgx.Connect(ctx, databaseURL)
func NewDatabaseConnection(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}

return db, nil
applyPoolTunables(config)

db, err := newPoolWithConfig(ctx, config)
if err != nil {
return nil, err
}

if err := pingPool(ctx, db); err != nil {
return nil, err
}

return db, nil
}

// applyPoolTunables centralizes connection pool settings for easier unit testing
func applyPoolTunables(config *pgxpool.Config) {
config.MaxConns = MaxPoolConns
config.MinConns = MinPoolConns
config.MaxConnLifetime = MaxConnLifetime
config.MaxConnIdleTime = MaxConnIdleTime
}

// Indirections for testability (overridden in tests)
var newPoolWithConfig = pgxpool.NewWithConfig
var pingPool = func(ctx context.Context, db *pgxpool.Pool) error { return db.Ping(ctx) }

// NewPostgresUserRepository accepts an abstract DB (useful for tests)
func NewPostgresUserRepository(db DB) *PostgresUserRepository {
return &PostgresUserRepository{db: db}
}

func NewPostgresUserRepository(db *pgx.Conn) *PostgresUserRepository {
return &PostgresUserRepository{db: db}
// NewPostgresUserRepositoryFromPool wraps a *pgxpool.Pool for production usage
func NewPostgresUserRepositoryFromPool(pool *pgxpool.Pool) *PostgresUserRepository {
return &PostgresUserRepository{db: &pgxDB{pool: pool}}
}

func (r *PostgresUserRepository) GetByLogin(login string) (*user.User, error) {
Expand All @@ -54,56 +102,46 @@ func (r *PostgresUserRepository) GetByEmail(email string) (*user.User, error) {
return nil, nil
}

func (r *PostgresUserRepository) WriteVerificationCode(ctx context.Context, userID int64, verificationCode string) (error) {
var exists bool

ctx, cancel := context.WithTimeout(ctx, DBTimeout)
defer cancel()

err := r.db.QueryRow(ctx, CheckVerificationCodeExistsQuery, userID).Scan(&exists)
if err != nil {
return err
}

if exists {
_, err := r.db.Exec(ctx, UpdateVerificationCodeQuery, verificationCode, time.Now(), userID)
if err != nil {
return err
}
} else {
_, err := r.db.Exec(ctx, InsertVerificationCodeQuery, userID, verificationCode, time.Now())
if err != nil {
return err
}
}

return nil
func (r *PostgresUserRepository) WriteVerificationCode(ctx context.Context, userID int64, verificationCode string) error {
_, err := r.db.Exec(ctx, UpsertVerificationCodeQuery, userID, verificationCode, time.Now())
return err
}

func (r *PostgresUserRepository) Register(ctx context.Context, login string, email string, hashedPassword string, verificationCode string) (int64, error) {
var userID int64

ctx, cancel := context.WithTimeout(ctx, DBTimeout)
defer cancel()

err := r.db.QueryRow(ctx, RegisterUserQuery, login, email, hashedPassword).Scan(&userID)
if err != nil {
var pgxErr *pgconn.PgError
if errors.As(err, &pgxErr) {
switch pgxErr.ConstraintName {
case "users_login_idx":
return -1, ErrorLoginTaken
case "users_email_idx":
return -1, ErrorEmailTaken
}
}
return -1, ErrorQueryFailed
}

err = r.WriteVerificationCode(ctx, userID, verificationCode)
if err != nil {
return -1, err
}

return userID, nil
tx, err := r.db.Begin(ctx)
if err != nil {
return -1, err
}
defer tx.Rollback(ctx)

var userID int64
err = tx.QueryRow(ctx, RegisterUserQuery, login, email, hashedPassword).Scan(&userID)
if err != nil {
var pgxErr *pgconn.PgError
if errors.As(err, &pgxErr) {
switch pgxErr.ConstraintName {
case "users_login_idx":
return -1, ErrorLoginTaken
case "users_email_idx":
return -1, ErrorEmailTaken
}
}
return -1, ErrorQueryFailed
}

_, err = tx.Exec(ctx, `
INSERT INTO email_verifications (user_id, code, created_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id)
DO UPDATE SET code = EXCLUDED.code, created_at = EXCLUDED.created_at
`, userID, verificationCode, time.Now())
if err != nil {
return -1, err
}

if err := tx.Commit(ctx); err != nil {
return -1, err
}

return userID, nil
}
Loading
Loading