Merge pull request 'Implement phone number validation for customer and user registration and updates' (#35) from Phone-Uniqueness into develop
Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/35
This commit is contained in:
@@ -1,7 +1,42 @@
|
|||||||
package customer
|
package customer
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
var (
|
orm "tm/pkg/mongo"
|
||||||
ErrCustomerNotFound = errors.New("customer not found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrCustomerNotFound = errors.New("customer not found")
|
||||||
|
|
||||||
|
func mapCustomerWriteError(err error) error {
|
||||||
|
if err == nil || !orm.IsDuplicateKeyError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "phone"):
|
||||||
|
return errors.New("customer with this phone number already exists")
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "email"):
|
||||||
|
return errors.New("customer with this email already exists")
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "username"):
|
||||||
|
return errors.New("customer with this username already exists")
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCustomerConflictError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch err.Error() {
|
||||||
|
case "customer with this phone number already exists",
|
||||||
|
"customer with this email already exists",
|
||||||
|
"customer with this username already exists",
|
||||||
|
"company with this name already exists",
|
||||||
|
"company with this registration number already exists",
|
||||||
|
"company with this tax ID already exists":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,11 +53,7 @@ func (h *Handler) CreateCustomer(c echo.Context) error {
|
|||||||
|
|
||||||
customer, err := h.service.Register(c.Request().Context(), form)
|
customer, err := h.service.Register(c.Request().Context(), form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "customer with this email already exists" ||
|
if isCustomerConflictError(err) {
|
||||||
err.Error() == "customer with this username already exists" ||
|
|
||||||
err.Error() == "company with this name already exists" ||
|
|
||||||
err.Error() == "company with this registration number already exists" ||
|
|
||||||
err.Error() == "company with this tax ID already exists" {
|
|
||||||
return response.Conflict(c, err.Error())
|
return response.Conflict(c, err.Error())
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(err.Error(), "invalid company ID") || err.Error() == "invalid username format" {
|
if strings.HasPrefix(err.Error(), "invalid company ID") || err.Error() == "invalid username format" {
|
||||||
@@ -126,11 +122,7 @@ func (h *Handler) UpdateCustomer(c echo.Context) error {
|
|||||||
if err.Error() == "customer not found" {
|
if err.Error() == "customer not found" {
|
||||||
return response.NotFound(c, "Customer not found")
|
return response.NotFound(c, "Customer not found")
|
||||||
}
|
}
|
||||||
if err.Error() == "customer with this email already exists" ||
|
if isCustomerConflictError(err) {
|
||||||
err.Error() == "customer with this username already exists" ||
|
|
||||||
err.Error() == "company with this name already exists" ||
|
|
||||||
err.Error() == "company with this registration number already exists" ||
|
|
||||||
err.Error() == "company with this tax ID already exists" {
|
|
||||||
return response.Conflict(c, err.Error())
|
return response.Conflict(c, err.Error())
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(err.Error(), "invalid company ID") {
|
if strings.HasPrefix(err.Error(), "invalid company ID") {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Repository interface {
|
|||||||
GetByID(ctx context.Context, id string) (*Customer, error)
|
GetByID(ctx context.Context, id string) (*Customer, error)
|
||||||
GetByEmail(ctx context.Context, email string) (*Customer, error)
|
GetByEmail(ctx context.Context, email string) (*Customer, error)
|
||||||
GetByUsername(ctx context.Context, username string) (*Customer, error)
|
GetByUsername(ctx context.Context, username string) (*Customer, error)
|
||||||
|
GetByPhone(ctx context.Context, phone string) (*Customer, error)
|
||||||
GetByIDs(ctx context.Context, ids []string) ([]Customer, error)
|
GetByIDs(ctx context.Context, ids []string) ([]Customer, error)
|
||||||
GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error)
|
GetByRole(ctx context.Context, role CustomerRole) ([]Customer, error)
|
||||||
GetByCompanies(ctx context.Context, companies []string) ([]Customer, error)
|
GetByCompanies(ctx context.Context, companies []string) ([]Customer, error)
|
||||||
@@ -50,6 +51,8 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
|
|||||||
indexes := []orm.Index{
|
indexes := []orm.Index{
|
||||||
*orm.CreateUniqueIndex("username_idx", bson.D{{Key: "username", Value: 1}}),
|
*orm.CreateUniqueIndex("username_idx", bson.D{{Key: "username", Value: 1}}),
|
||||||
*orm.CreateTextIndex("search_idx", "email", "username"),
|
*orm.CreateTextIndex("search_idx", "email", "username"),
|
||||||
|
*orm.CreateUniqueIndex("phone_idx", bson.D{{Key: "phone", Value: 1}}).
|
||||||
|
WithPartialFilterExpression(bson.M{"phone": bson.M{"$type": "string", "$ne": ""}}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create indexes
|
// Create indexes
|
||||||
@@ -166,6 +169,24 @@ func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Cus
|
|||||||
return customer, nil
|
return customer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByPhone retrieves a customer by phone number
|
||||||
|
func (r *customerRepository) GetByPhone(ctx context.Context, phone string) (*Customer, error) {
|
||||||
|
filter := bson.M{"phone": phone}
|
||||||
|
customer, err := r.ormRepo.FindOne(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, orm.ErrDocumentNotFound) {
|
||||||
|
return nil, ErrCustomerNotFound
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get customer by phone", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"phone": phone,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByUsername retrieves a customer by username
|
// GetByUsername retrieves a customer by username
|
||||||
func (r *customerRepository) GetByUsername(ctx context.Context, username string) (*Customer, error) {
|
func (r *customerRepository) GetByUsername(ctx context.Context, username string) (*Customer, error) {
|
||||||
filter := bson.M{"username": username}
|
filter := bson.M{"username": username}
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
|||||||
return nil, errors.New("customer with this username already exists")
|
return nil, errors.New("customer with this username already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.Phone != nil && *form.Phone != "" {
|
||||||
|
existingCustomer, _ = s.repository.GetByPhone(ctx, *form.Phone)
|
||||||
|
if existingCustomer != nil {
|
||||||
|
return nil, errors.New("customer with this phone number already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check validator
|
// Check validator
|
||||||
if !s.validator.IsValidUsername(form.Username) {
|
if !s.validator.IsValidUsername(form.Username) {
|
||||||
return nil, errors.New("invalid username format")
|
return nil, errors.New("invalid username format")
|
||||||
@@ -149,6 +156,9 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
|||||||
// Save to database
|
// Save to database
|
||||||
err = s.repository.Register(ctx, customer)
|
err = s.repository.Register(ctx, customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if mapped := mapCustomerWriteError(err); mapped != err {
|
||||||
|
return nil, mapped
|
||||||
|
}
|
||||||
s.logger.Error("Failed to create customer", map[string]interface{}{
|
s.logger.Error("Failed to create customer", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"email": form.Email,
|
"email": form.Email,
|
||||||
@@ -373,6 +383,12 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
|||||||
}
|
}
|
||||||
|
|
||||||
if form.Phone != nil && (customer.Phone == nil || *form.Phone != *customer.Phone) {
|
if form.Phone != nil && (customer.Phone == nil || *form.Phone != *customer.Phone) {
|
||||||
|
if *form.Phone != "" {
|
||||||
|
existingCustomer, _ := s.repository.GetByPhone(ctx, *form.Phone)
|
||||||
|
if existingCustomer != nil && existingCustomer.ID.Hex() != id {
|
||||||
|
return nil, errors.New("customer with this phone number already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
customer.Phone = form.Phone
|
customer.Phone = form.Phone
|
||||||
infoChanged = true
|
infoChanged = true
|
||||||
}
|
}
|
||||||
@@ -411,6 +427,9 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
|||||||
// Update in database
|
// Update in database
|
||||||
err = s.repository.Update(ctx, customer)
|
err = s.repository.Update(ctx, customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if mapped := mapCustomerWriteError(err); mapped != err {
|
||||||
|
return nil, mapped
|
||||||
|
}
|
||||||
s.logger.Error("Failed to update customer", map[string]interface{}{
|
s.logger.Error("Failed to update customer", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"customer_id": id,
|
"customer_id": id,
|
||||||
|
|||||||
+33
-3
@@ -1,7 +1,37 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
var (
|
orm "tm/pkg/mongo"
|
||||||
ErrUserNotFound = errors.New("user not found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
func mapUserWriteError(err error) error {
|
||||||
|
if err == nil || !orm.IsDuplicateKeyError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "phone"):
|
||||||
|
return errors.New("phone number already exists")
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "email"):
|
||||||
|
return errors.New("email already exists")
|
||||||
|
case orm.DuplicateKeyMatchesField(err, "username"):
|
||||||
|
return errors.New("username already exists")
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUserConflictError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch err.Error() {
|
||||||
|
case "phone number already exists", "email already exists", "username already exists":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ func (h *Handler) UpdateProfile(c echo.Context) error {
|
|||||||
|
|
||||||
user, err := h.service.Update(c.Request().Context(), userID, form)
|
user, err := h.service.Update(c.Request().Context(), userID, form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUserConflictError(err) {
|
||||||
|
return response.Conflict(c, err.Error())
|
||||||
|
}
|
||||||
return response.BadRequest(c, err.Error(), "")
|
return response.BadRequest(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +277,9 @@ func (h *Handler) CreateUser(c echo.Context) error {
|
|||||||
// Call service
|
// Call service
|
||||||
user, err := h.service.Register(c.Request().Context(), form)
|
user, err := h.service.Register(c.Request().Context(), form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUserConflictError(err) {
|
||||||
|
return response.Conflict(c, err.Error())
|
||||||
|
}
|
||||||
return response.BadRequest(c, err.Error(), "")
|
return response.BadRequest(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +392,9 @@ func (h *Handler) UpdateUser(c echo.Context) error {
|
|||||||
// Call service
|
// Call service
|
||||||
user, err := h.service.Update(c.Request().Context(), idStr, form)
|
user, err := h.service.Update(c.Request().Context(), idStr, form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUserConflictError(err) {
|
||||||
|
return response.Conflict(c, err.Error())
|
||||||
|
}
|
||||||
return response.BadRequest(c, err.Error(), "")
|
return response.BadRequest(c, err.Error(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Repository interface {
|
|||||||
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
|
UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error
|
||||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||||
GetByUsername(ctx context.Context, username string) (*User, error)
|
GetByUsername(ctx context.Context, username string) (*User, error)
|
||||||
|
GetByPhone(ctx context.Context, phone string) (*User, error)
|
||||||
GetByIDs(ctx context.Context, userIDs []string) ([]User, error)
|
GetByIDs(ctx context.Context, userIDs []string) ([]User, error)
|
||||||
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error)
|
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*orm.PaginatedResult[User], error)
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,8 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
|
|||||||
// Create indexes using the ORM's index management
|
// Create indexes using the ORM's index management
|
||||||
indexes := []orm.Index{
|
indexes := []orm.Index{
|
||||||
*orm.CreateTextIndex("search_idx", "full_name", "email", "username"),
|
*orm.CreateTextIndex("search_idx", "full_name", "email", "username"),
|
||||||
|
*orm.CreateUniqueIndex("phone_idx", bson.D{{Key: "phone", Value: 1}}).
|
||||||
|
WithPartialFilterExpression(bson.M{"phone": bson.M{"$type": "string", "$ne": ""}}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create indexes
|
// Create indexes
|
||||||
@@ -250,6 +253,23 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, e
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByPhone retrieves a user by phone number
|
||||||
|
func (r *userRepository) GetByPhone(ctx context.Context, phone string) (*User, error) {
|
||||||
|
user, err := r.ormRepo.FindOne(ctx, bson.M{"phone": phone})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, orm.ErrDocumentNotFound) {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by phone", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"phone": phone,
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByUsername retrieves a user by username
|
// GetByUsername retrieves a user by username
|
||||||
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
|
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
user, err := r.ormRepo.FindOne(ctx, bson.M{"username": username})
|
user, err := r.ormRepo.FindOne(ctx, bson.M{"username": username})
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
|
|||||||
return nil, errors.New("username already exists")
|
return nil, errors.New("username already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.Phone != nil && *form.Phone != "" {
|
||||||
|
existingUser, _ = s.repository.GetByPhone(ctx, *form.Phone)
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, errors.New("phone number already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,6 +117,9 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
|
|||||||
// Save to database
|
// Save to database
|
||||||
err = s.repository.Create(ctx, user)
|
err = s.repository.Create(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if mapped := mapUserWriteError(err); mapped != err {
|
||||||
|
return nil, mapped
|
||||||
|
}
|
||||||
s.logger.Error("Failed to register user", map[string]interface{}{
|
s.logger.Error("Failed to register user", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"email": form.Email,
|
"email": form.Email,
|
||||||
@@ -184,7 +194,13 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
|
|||||||
user.Position = form.Position
|
user.Position = form.Position
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.Phone != nil {
|
if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
|
||||||
|
if *form.Phone != "" {
|
||||||
|
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
|
||||||
|
if existingUser != nil && existingUser.ID.Hex() != id {
|
||||||
|
return nil, errors.New("phone number already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
user.Phone = form.Phone
|
user.Phone = form.Phone
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +211,9 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
|
|||||||
// Update in database
|
// Update in database
|
||||||
err = s.repository.Update(ctx, user)
|
err = s.repository.Update(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if mapped := mapUserWriteError(err); mapped != err {
|
||||||
|
return nil, mapped
|
||||||
|
}
|
||||||
s.logger.Error("Failed to update user", map[string]interface{}{
|
s.logger.Error("Failed to update user", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"user_id": id,
|
"user_id": id,
|
||||||
@@ -573,7 +592,13 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up
|
|||||||
if form.Position != nil {
|
if form.Position != nil {
|
||||||
user.Position = form.Position
|
user.Position = form.Position
|
||||||
}
|
}
|
||||||
if form.Phone != nil {
|
if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
|
||||||
|
if *form.Phone != "" {
|
||||||
|
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
|
||||||
|
if existingUser != nil && existingUser.ID.Hex() != userID {
|
||||||
|
return errors.New("phone number already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
user.Phone = form.Phone
|
user.Phone = form.Phone
|
||||||
}
|
}
|
||||||
if form.ProfileImage != nil {
|
if form.ProfileImage != nil {
|
||||||
@@ -586,6 +611,9 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up
|
|||||||
// Save to database
|
// Save to database
|
||||||
err = s.repository.Update(ctx, user)
|
err = s.repository.Update(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if mapped := mapUserWriteError(err); mapped != err {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
s.logger.Error("Failed to update user profile", map[string]interface{}{
|
s.logger.Error("Failed to update user profile", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dupKeyFieldNamePattern = regexp.MustCompile(`(?:^|[,{]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
|
||||||
|
|
||||||
|
// IsDuplicateKeyError reports whether err is a MongoDB E11000 duplicate key error.
|
||||||
|
func IsDuplicateKeyError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(duplicateKeyMessages(err)) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasStructuredWriteException(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "e11000") || strings.Contains(msg, "duplicate key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field
|
||||||
|
// by parsing WriteException messages (index name and dup key document keys), not broad
|
||||||
|
// substring matching on the full error text.
|
||||||
|
func DuplicateKeyMatchesField(err error, field string) bool {
|
||||||
|
if err == nil || field == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
field = strings.ToLower(field)
|
||||||
|
msgs := duplicateKeyMessages(err)
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
if hasStructuredWriteException(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !IsDuplicateKeyError(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msgs = []string{err.Error()}
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if duplicateKeyMessageMatchesField(msg, field) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateKeyMessages(err error) []string {
|
||||||
|
var msgs []string
|
||||||
|
|
||||||
|
var writeException mongodriver.WriteException
|
||||||
|
if errors.As(err, &writeException) {
|
||||||
|
for _, writeErr := range writeException.WriteErrors {
|
||||||
|
if writeErr.Code == 11000 {
|
||||||
|
msgs = append(msgs, writeErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bulkWriteException mongodriver.BulkWriteException
|
||||||
|
if errors.As(err, &bulkWriteException) {
|
||||||
|
for _, writeErr := range bulkWriteException.WriteErrors {
|
||||||
|
if writeErr.Code == 11000 {
|
||||||
|
msgs = append(msgs, writeErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasStructuredWriteException(err error) bool {
|
||||||
|
var writeException mongodriver.WriteException
|
||||||
|
if errors.As(err, &writeException) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var bulkWriteException mongodriver.BulkWriteException
|
||||||
|
return errors.As(err, &bulkWriteException)
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateKeyMessageMatchesField(message, field string) bool {
|
||||||
|
message = strings.ToLower(message)
|
||||||
|
|
||||||
|
if indexName := extractDuplicateIndexName(message); indexName != "" {
|
||||||
|
if indexNameMatchesField(indexName, field) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range extractDuplicateKeyFields(message) {
|
||||||
|
if key == field {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDuplicateIndexName(message string) string {
|
||||||
|
const prefix = "index: "
|
||||||
|
start := strings.Index(message, prefix)
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimSpace(message[start+len(prefix):])
|
||||||
|
if rest == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.IndexByte(rest, ' ')
|
||||||
|
if end >= 0 {
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexNameMatchesField(indexName, field string) bool {
|
||||||
|
switch indexName {
|
||||||
|
case field, field + "_idx", field + "_1":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDuplicateKeyFields(message string) []string {
|
||||||
|
const prefix = "dup key:"
|
||||||
|
start := strings.Index(message, prefix)
|
||||||
|
if start < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
document := strings.TrimSpace(message[start+len(prefix):])
|
||||||
|
if !strings.HasPrefix(document, "{") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
end := -1
|
||||||
|
for i, char := range document {
|
||||||
|
switch char {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
end = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end >= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := strings.TrimSpace(document[1:end])
|
||||||
|
matches := dupKeyFieldNamePattern.FindAllStringSubmatch(inner, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]string, 0, len(matches))
|
||||||
|
seen := make(map[string]struct{}, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(match[1])
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
fields = append(fields, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsDuplicateKeyError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil error",
|
||||||
|
err: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated error",
|
||||||
|
err: errors.New("connection refused"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write exception duplicate key",
|
||||||
|
err: writeExceptionWithMessage(
|
||||||
|
11000,
|
||||||
|
`E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||||
|
),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write exception non duplicate code",
|
||||||
|
err: writeExceptionWithMessage(
|
||||||
|
121,
|
||||||
|
`Document failed validation`,
|
||||||
|
),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped write exception duplicate key",
|
||||||
|
err: fmt.Errorf(
|
||||||
|
"failed to create user: %w",
|
||||||
|
writeExceptionWithMessage(
|
||||||
|
11000,
|
||||||
|
`E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bulk write exception duplicate key",
|
||||||
|
err: bulkWriteExceptionWithMessage(
|
||||||
|
11000,
|
||||||
|
`E11000 duplicate key error collection: tm.customers index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||||
|
),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string fallback duplicate key",
|
||||||
|
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := IsDuplicateKeyError(tt.err); got != tt.want {
|
||||||
|
t.Fatalf("IsDuplicateKeyError() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateKeyMatchesField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const phoneMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`
|
||||||
|
const usernameMsg = `E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }`
|
||||||
|
const emailMsg = `E11000 duplicate key error collection: tm.customers index: email_1 dup key: { email: "user@example.com" }`
|
||||||
|
const phoneValueContainsNameMsg = `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "username-like-value" }`
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
field string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil error",
|
||||||
|
err: nil,
|
||||||
|
field: "phone",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty field",
|
||||||
|
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||||
|
field: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone duplicate matches phone field",
|
||||||
|
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||||
|
field: "phone",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone duplicate does not match email field",
|
||||||
|
err: writeExceptionWithMessage(11000, phoneMsg),
|
||||||
|
field: "email",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username duplicate matches username field",
|
||||||
|
err: writeExceptionWithMessage(11000, usernameMsg),
|
||||||
|
field: "username",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username duplicate does not match name substring",
|
||||||
|
err: writeExceptionWithMessage(11000, usernameMsg),
|
||||||
|
field: "name",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email duplicate matches email field via default index suffix",
|
||||||
|
err: writeExceptionWithMessage(11000, emailMsg),
|
||||||
|
field: "email",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dup key value substring does not match unrelated field",
|
||||||
|
err: writeExceptionWithMessage(11000, phoneValueContainsNameMsg),
|
||||||
|
field: "name",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped duplicate key matches field",
|
||||||
|
err: fmt.Errorf(
|
||||||
|
"failed to register customer: %w",
|
||||||
|
writeExceptionWithMessage(11000, phoneMsg),
|
||||||
|
),
|
||||||
|
field: "phone",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non duplicate write error does not match",
|
||||||
|
err: writeExceptionWithMessage(121, phoneMsg),
|
||||||
|
field: "phone",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated error does not match",
|
||||||
|
err: errors.New("timeout"),
|
||||||
|
field: "phone",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string fallback duplicate key matches field",
|
||||||
|
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }] `),
|
||||||
|
field: "phone",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string fallback duplicate key does not match unrelated field",
|
||||||
|
err: errors.New(`write exception: write errors: [E11000 duplicate key error collection: tm.users index: username_idx dup key: { username: "admin" }] `),
|
||||||
|
field: "name",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := DuplicateKeyMatchesField(tt.err, tt.field); got != tt.want {
|
||||||
|
t.Fatalf("DuplicateKeyMatchesField(%q) = %v, want %v", tt.field, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDuplicateIndexName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
message string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "phone index",
|
||||||
|
message: `E11000 duplicate key error collection: tm.users index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||||
|
want: "phone_idx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing index segment",
|
||||||
|
message: `E11000 duplicate key error collection: tm.users dup key: { phone: "+1234567890" }`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := extractDuplicateIndexName(tt.message); got != tt.want {
|
||||||
|
t.Fatalf("extractDuplicateIndexName() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDuplicateKeyFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
message string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single field",
|
||||||
|
message: `index: phone_idx dup key: { phone: "+1234567890" }`,
|
||||||
|
want: []string{"phone"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound dup key",
|
||||||
|
message: `index: user_company_idx dup key: { user_id: ObjectId('507f1f77bcf86cd799439011'), company_id: ObjectId('507f1f77bcf86cd799439012') }`,
|
||||||
|
want: []string{"user_id", "company_id"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "value containing colon does not add false field",
|
||||||
|
message: `index: phone_idx dup key: { phone: "office:123" }`,
|
||||||
|
want: []string{"phone"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing dup key segment",
|
||||||
|
message: `index: phone_idx`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := extractDuplicateKeyFields(tt.message)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("extractDuplicateKeyFields() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexNameMatchesField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
indexName string
|
||||||
|
field string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{indexName: "phone_idx", field: "phone", want: true},
|
||||||
|
{indexName: "phone_1", field: "phone", want: true},
|
||||||
|
{indexName: "phone", field: "phone", want: true},
|
||||||
|
{indexName: "username_idx", field: "name", want: false},
|
||||||
|
{indexName: "email_idx", field: "mail", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.indexName+"_"+tt.field, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := indexNameMatchesField(tt.indexName, tt.field); got != tt.want {
|
||||||
|
t.Fatalf("indexNameMatchesField(%q, %q) = %v, want %v", tt.indexName, tt.field, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExceptionWithMessage(code int, message string) error {
|
||||||
|
return mongodriver.WriteException{
|
||||||
|
WriteErrors: []mongodriver.WriteError{
|
||||||
|
{Code: code, Message: message},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkWriteExceptionWithMessage(code int, message string) error {
|
||||||
|
return mongodriver.BulkWriteException{
|
||||||
|
WriteErrors: []mongodriver.BulkWriteError{
|
||||||
|
{WriteError: mongodriver.WriteError{Code: code, Message: message}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user