From 0da0c8b8c9834047bda6b9d931af3bd2dd533d0a Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 6 Jun 2026 12:03:04 +0330 Subject: [PATCH 1/3] Implement phone number validation for customer and user registration and updates Added functionality to check for existing customers and users by phone number during registration and updates. Updated the handler, service, and repository layers to include phone number checks, ensuring unique phone numbers across customers and users. --- internal/customer/handler.go | 2 ++ internal/customer/repository.go | 21 +++++++++++++++++++++ internal/customer/service.go | 13 +++++++++++++ internal/user/repository.go | 20 ++++++++++++++++++++ internal/user/service.go | 19 +++++++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/internal/customer/handler.go b/internal/customer/handler.go index 5431089..b7c4de2 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -55,6 +55,7 @@ func (h *Handler) CreateCustomer(c echo.Context) error { if err != nil { if err.Error() == "customer with this email already exists" || err.Error() == "customer with this username already exists" || + err.Error() == "customer with this phone number 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" { @@ -128,6 +129,7 @@ func (h *Handler) UpdateCustomer(c echo.Context) error { } if err.Error() == "customer with this email already exists" || err.Error() == "customer with this username already exists" || + err.Error() == "customer with this phone number 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" { diff --git a/internal/customer/repository.go b/internal/customer/repository.go index ed52842..cc557d4 100644 --- a/internal/customer/repository.go +++ b/internal/customer/repository.go @@ -25,6 +25,7 @@ type Repository interface { GetByID(ctx context.Context, id string) (*Customer, error) GetByEmail(ctx context.Context, email 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) GetByRole(ctx context.Context, role CustomerRole) ([]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{ *orm.CreateUniqueIndex("username_idx", bson.D{{Key: "username", Value: 1}}), *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 @@ -166,6 +169,24 @@ func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Cus 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 func (r *customerRepository) GetByUsername(ctx context.Context, username string) (*Customer, error) { filter := bson.M{"username": username} diff --git a/internal/customer/service.go b/internal/customer/service.go index a75b4fc..3a88166 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -95,6 +95,13 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm 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 if !s.validator.IsValidUsername(form.Username) { return nil, errors.New("invalid username format") @@ -373,6 +380,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 != "" { + 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 infoChanged = true } diff --git a/internal/user/repository.go b/internal/user/repository.go index 09796d3..e758a4b 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -22,6 +22,7 @@ type Repository interface { UpdatePasswordReset(ctx context.Context, id, hashedPassword, updatedBy string) error GetByEmail(ctx context.Context, email 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) 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 indexes := []orm.Index{ *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 @@ -250,6 +253,23 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, e 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 func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) { user, err := r.ormRepo.FindOne(ctx, bson.M{"username": username}) diff --git a/internal/user/service.go b/internal/user/service.go index fbfac0b..8bc6c4d 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -77,6 +77,13 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User 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 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost) if err != nil { @@ -185,6 +192,12 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor } if form.Phone != nil { + 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 } @@ -574,6 +587,12 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up user.Position = form.Position } if form.Phone != nil { + 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 } if form.ProfileImage != nil { From 22487eaef58b7f728e7c4288660f1f2d949359a3 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 6 Jun 2026 20:34:45 +0330 Subject: [PATCH 2/3] fix(user,customer): return 409 on phone duplicate-key races Map E11000 from the per-collection phone index to friendly conflict errors and skip redundant GetByPhone when the phone is unchanged. Not blocking: IAT test DB may need a one-off dedupe for index build; phone uniqueness is per collection (users vs customers), not global. --- internal/customer/errors.go | 41 ++++++++++++++++++++++++++++++--- internal/customer/handler.go | 14 ++---------- internal/customer/service.go | 6 +++++ internal/user/errors.go | 36 ++++++++++++++++++++++++++--- internal/user/handler.go | 9 ++++++++ internal/user/service.go | 13 +++++++++-- pkg/mongo/duplicate.go | 44 ++++++++++++++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 pkg/mongo/duplicate.go diff --git a/internal/customer/errors.go b/internal/customer/errors.go index 28c0e8d..ad99cbb 100644 --- a/internal/customer/errors.go +++ b/internal/customer/errors.go @@ -1,7 +1,42 @@ package customer -import "errors" +import ( + "errors" -var ( - ErrCustomerNotFound = errors.New("customer not found") + orm "tm/pkg/mongo" ) + +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 + } +} diff --git a/internal/customer/handler.go b/internal/customer/handler.go index b7c4de2..a499f8e 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -53,12 +53,7 @@ func (h *Handler) CreateCustomer(c echo.Context) error { customer, err := h.service.Register(c.Request().Context(), form) if err != nil { - if err.Error() == "customer with this email already exists" || - err.Error() == "customer with this username already exists" || - err.Error() == "customer with this phone number 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" { + if isCustomerConflictError(err) { return response.Conflict(c, err.Error()) } if strings.HasPrefix(err.Error(), "invalid company ID") || err.Error() == "invalid username format" { @@ -127,12 +122,7 @@ func (h *Handler) UpdateCustomer(c echo.Context) error { if err.Error() == "customer not found" { return response.NotFound(c, "Customer not found") } - if err.Error() == "customer with this email already exists" || - err.Error() == "customer with this username already exists" || - err.Error() == "customer with this phone number 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" { + if isCustomerConflictError(err) { return response.Conflict(c, err.Error()) } if strings.HasPrefix(err.Error(), "invalid company ID") { diff --git a/internal/customer/service.go b/internal/customer/service.go index 3a88166..8818064 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -156,6 +156,9 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm // Save to database err = s.repository.Register(ctx, customer) if err != nil { + if mapped := mapCustomerWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to create customer", map[string]interface{}{ "error": err.Error(), "email": form.Email, @@ -424,6 +427,9 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus // Update in database err = s.repository.Update(ctx, customer) if err != nil { + if mapped := mapCustomerWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to update customer", map[string]interface{}{ "error": err.Error(), "customer_id": id, diff --git a/internal/user/errors.go b/internal/user/errors.go index 2b9a669..1155b1b 100644 --- a/internal/user/errors.go +++ b/internal/user/errors.go @@ -1,7 +1,37 @@ package user -import "errors" +import ( + "errors" -var ( - ErrUserNotFound = errors.New("user not found") + orm "tm/pkg/mongo" ) + +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 + } +} diff --git a/internal/user/handler.go b/internal/user/handler.go index f2c5960..8211152 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -170,6 +170,9 @@ func (h *Handler) UpdateProfile(c echo.Context) error { user, err := h.service.Update(c.Request().Context(), userID, form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } @@ -274,6 +277,9 @@ func (h *Handler) CreateUser(c echo.Context) error { // Call service user, err := h.service.Register(c.Request().Context(), form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } @@ -386,6 +392,9 @@ func (h *Handler) UpdateUser(c echo.Context) error { // Call service user, err := h.service.Update(c.Request().Context(), idStr, form) if err != nil { + if isUserConflictError(err) { + return response.Conflict(c, err.Error()) + } return response.BadRequest(c, err.Error(), "") } diff --git a/internal/user/service.go b/internal/user/service.go index 8bc6c4d..b852df9 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -117,6 +117,9 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User // Save to database err = s.repository.Create(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to register user", map[string]interface{}{ "error": err.Error(), "email": form.Email, @@ -191,7 +194,7 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor 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 { @@ -208,6 +211,9 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor // Update in database err = s.repository.Update(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return nil, mapped + } s.logger.Error("Failed to update user", map[string]interface{}{ "error": err.Error(), "user_id": id, @@ -586,7 +592,7 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up if form.Position != nil { 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 { @@ -605,6 +611,9 @@ func (s *userService) UpdateProfile(ctx context.Context, userID string, form *Up // Save to database err = s.repository.Update(ctx, user) if err != nil { + if mapped := mapUserWriteError(err); mapped != err { + return mapped + } s.logger.Error("Failed to update user profile", map[string]interface{}{ "error": err.Error(), "user_id": userID, diff --git a/pkg/mongo/duplicate.go b/pkg/mongo/duplicate.go new file mode 100644 index 0000000..17359b3 --- /dev/null +++ b/pkg/mongo/duplicate.go @@ -0,0 +1,44 @@ +package mongo + +import ( + "errors" + "strings" + + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// IsDuplicateKeyError reports whether err is a MongoDB E11000 duplicate key error. +func IsDuplicateKeyError(err error) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), "E11000") || strings.Contains(err.Error(), "duplicate key") { + return true + } + var we mongodriver.WriteException + if errors.As(err, &we) { + for _, wErr := range we.WriteErrors { + if wErr.Code == 11000 { + return true + } + } + } + return false +} + +// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field +// (e.g. "phone" matches index phone_idx or dup key { phone: "..." }). +func DuplicateKeyMatchesField(err error, field string) bool { + if err == nil || field == "" { + return false + } + msg := strings.ToLower(err.Error()) + field = strings.ToLower(field) + if strings.Contains(msg, field+"_idx") { + return true + } + if strings.Contains(msg, "dup key") && strings.Contains(msg, field) { + return true + } + return strings.Contains(msg, `"`+field+`"`) || strings.Contains(msg, field+":") +} From 3f0935ee3fa32533e8f611f9bedc94a6d088217a Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 7 Jun 2026 14:40:42 +0330 Subject: [PATCH 3/3] Enhance duplicate key error handling in MongoDB operations - Improved the `IsDuplicateKeyError` function to utilize structured error messages for better accuracy in identifying duplicate key errors. - Introduced `duplicateKeyMessages` and `hasStructuredWriteException` functions to parse and extract relevant information from MongoDB error messages. - Updated `DuplicateKeyMatchesField` to match duplicate key errors against specific fields by analyzing error messages rather than relying on substring matching. This refactor enhances the clarity and reliability of duplicate key error handling, ensuring more precise identification of conflicts in MongoDB operations. --- pkg/mongo/duplicate.go | 188 +++++++++++++++++++--- pkg/mongo/duplicate_test.go | 303 ++++++++++++++++++++++++++++++++++++ 2 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 pkg/mongo/duplicate_test.go diff --git a/pkg/mongo/duplicate.go b/pkg/mongo/duplicate.go index 17359b3..a45b18c 100644 --- a/pkg/mongo/duplicate.go +++ b/pkg/mongo/duplicate.go @@ -2,43 +2,191 @@ 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 strings.Contains(err.Error(), "E11000") || strings.Contains(err.Error(), "duplicate key") { + + if len(duplicateKeyMessages(err)) > 0 { return true } - var we mongodriver.WriteException - if errors.As(err, &we) { - for _, wErr := range we.WriteErrors { - if wErr.Code == 11000 { - 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 } -// DuplicateKeyMatchesField reports whether a duplicate key error involves the given field -// (e.g. "phone" matches index phone_idx or dup key { phone: "..." }). -func DuplicateKeyMatchesField(err error, field string) bool { - if err == nil || field == "" { +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 } - msg := strings.ToLower(err.Error()) - field = strings.ToLower(field) - if strings.Contains(msg, field+"_idx") { - return true - } - if strings.Contains(msg, "dup key") && strings.Contains(msg, field) { - return true - } - return strings.Contains(msg, `"`+field+`"`) || strings.Contains(msg, field+":") +} + +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 } diff --git a/pkg/mongo/duplicate_test.go b/pkg/mongo/duplicate_test.go new file mode 100644 index 0000000..d81ecdb --- /dev/null +++ b/pkg/mongo/duplicate_test.go @@ -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}}, + }, + } +}