Enhance tender approval and submission handling

- Added a new error for submission sync failures, improving error handling in the tender approval process.
- Updated the `ToggleTenderApproval` method to handle the new error, providing clearer responses for sync issues.
- Introduced a method to reopen terminal submissions when tender approvals are resubmitted, enhancing submission state management.
- Implemented optimistic locking in the repository to prevent concurrent updates, improving data integrity.
- Added unit tests for new submission handling logic, ensuring robust functionality and error management.

This update strengthens the tender approval workflow and submission handling, ensuring better error reporting and state management.
This commit is contained in:
Mazyar
2026-07-12 21:41:53 +03:30
parent 0cce9ef1b5
commit fa3d466579
9 changed files with 436 additions and 21 deletions
+1
View File
@@ -14,4 +14,5 @@ var (
ErrInvalidCompanyID = errors.New("invalid company ID") ErrInvalidCompanyID = errors.New("invalid company ID")
ErrUnauthorized = errors.New("unauthorized to perform this action") ErrUnauthorized = errors.New("unauthorized to perform this action")
ErrInvalidData = errors.New("invalid tender approval data") ErrInvalidData = errors.New("invalid tender approval data")
ErrSubmissionSyncFailed = errors.New("failed to sync tender submission workflow")
) )
+3
View File
@@ -66,6 +66,9 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error {
if errors.Is(err, ErrTenderNotFound) { if errors.Is(err, ErrTenderNotFound) {
return response.NotFound(c, "Tender not found") return response.NotFound(c, "Tender not found")
} }
if errors.Is(err, ErrSubmissionSyncFailed) {
return response.InternalServerError(c, "Failed to sync tender submission workflow")
}
if err.Error() == "tender approval already exists for this tender and company" { if err.Error() == "tender approval already exists for this tender and company" {
return response.Conflict(c, err.Error()) return response.Conflict(c, err.Error())
} }
+18 -5
View File
@@ -908,7 +908,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err return nil, err
} }
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true) if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
if deleteErr := s.repository.Delete(ctx, approval.GetID()); deleteErr != nil {
s.logger.Error("Failed to rollback tender approval after submission sync failure", map[string]interface{}{
"tender_approval_id": approval.GetID(),
"error": deleteErr.Error(),
})
}
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, approval) return s.toggleResponseWithTender(ctx, approval)
} }
@@ -927,7 +935,9 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
existing.Status = "unrejected" existing.Status = "unrejected"
} }
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false) if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, false); err != nil {
return nil, ErrSubmissionSyncFailed
}
return existing.ToResponseWithTender(nil), nil return existing.ToResponseWithTender(nil), nil
} }
@@ -950,13 +960,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T
return nil, err return nil, err
} }
s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true) if err := s.syncSubmissionAfterToggle(ctx, tenderID, companyID, customerID, req.Status, true); err != nil {
return nil, ErrSubmissionSyncFailed
}
return s.toggleResponseWithTender(ctx, existing) return s.toggleResponseWithTender(ctx, existing)
} }
func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, tenderID, companyID, customerID string, status ApprovalStatus, active bool) { func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, tenderID, companyID, customerID string, status ApprovalStatus, active bool) error {
if s.submissionHook == nil { if s.submissionHook == nil {
return return nil
} }
var err error var err error
@@ -977,6 +989,7 @@ func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, t
"error": err.Error(), "error": err.Error(),
}) })
} }
return err
} }
func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) { func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) {
+6
View File
@@ -113,6 +113,12 @@ func (ts *TenderSubmission) AddStatusChange(status SubmissionStatus, reason, cha
ts.SetUpdatedAt(time.Now().Unix()) ts.SetUpdatedAt(time.Now().Unix())
} }
// ReopenForApproval resets a terminal submission when the tender is approved again.
// This is only used by the approval sync path, not by user-initiated status updates.
func (ts *TenderSubmission) ReopenForApproval(changedBy, description string) {
ts.AddStatusChange(StatusOpportunity, "approval_resubmitted", changedBy, description)
}
func NewTenderSubmission(tenderID, companyID, customerID string) *TenderSubmission { func NewTenderSubmission(tenderID, companyID, customerID string) *TenderSubmission {
now := time.Now().Unix() now := time.Now().Unix()
ts := &TenderSubmission{ ts := &TenderSubmission{
+1
View File
@@ -15,6 +15,7 @@ var (
ErrApprovalRequired = errors.New("tender must be approved before starting submission workflow") ErrApprovalRequired = errors.New("tender must be approved before starting submission workflow")
ErrInvalidStatusTransition = errors.New("invalid status transition") ErrInvalidStatusTransition = errors.New("invalid status transition")
ErrTerminalStatus = errors.New("tender submission is in a terminal state and cannot be updated") ErrTerminalStatus = errors.New("tender submission is in a terminal state and cannot be updated")
ErrConcurrentUpdate = errors.New("tender submission was modified concurrently")
) )
// InvalidStatusTransitionError is returned when a status change is not allowed. // InvalidStatusTransitionError is returned when a status change is not allowed.
+26 -9
View File
@@ -98,25 +98,31 @@ func (h *Handler) EnsureSubmission(c echo.Context) error {
return response.Success(c, result, "Tender submission ensured successfully") return response.Success(c, result, "Tender submission ensured successfully")
} }
// GetSubmissionByID retrieves a tender submission by ID. // GetSubmissionByID retrieves a tender submission by ID for the authenticated company.
// @Summary Get tender submission by ID // @Summary Get tender submission by ID
// @Description Retrieve a tender submission workflow with tender details // @Description Retrieve a tender submission workflow with tender details for the authenticated company
// @Tags TenderSubmissions // @Tags TenderSubmissions
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path string true "Submission ID" // @Param id path string true "Submission ID"
// @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse} // @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse}
// @Failure 401 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse // @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/tender-submissions/{id} [get] // @Router /api/v1/tender-submissions/{id} [get]
func (h *Handler) GetSubmissionByID(c echo.Context) error { func (h *Handler) GetSubmissionByID(c echo.Context) error {
companyID, ok := c.Get("company_id").(string)
if !ok || companyID == "" {
return response.Unauthorized(c, "Unauthorized")
}
id := strings.TrimSpace(c.Param("id")) id := strings.TrimSpace(c.Param("id"))
if id == "" { if id == "" {
return response.BadRequest(c, "Submission ID is required", "") return response.BadRequest(c, "Submission ID is required", "")
} }
result, err := h.service.GetByIDWithTender(c.Request().Context(), id) result, err := h.service.GetByIDForCompanyWithTender(c.Request().Context(), id, companyID)
if err != nil { if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) { if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found") return response.NotFound(c, "Tender submission not found")
@@ -124,11 +130,6 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error {
return response.InternalServerError(c, "Failed to get tender submission") return response.InternalServerError(c, "Failed to get tender submission")
} }
companyID, _ := c.Get("company_id").(string)
if companyID != "" && result.CompanyID != companyID {
return response.NotFound(c, "Tender submission not found")
}
return response.Success(c, result, "Tender submission retrieved successfully") return response.Success(c, result, "Tender submission retrieved successfully")
} }
@@ -213,6 +214,9 @@ func (h *Handler) UpdateSubmissionStatus(c echo.Context) error {
if errors.Is(err, ErrTerminalStatus) { if errors.Is(err, ErrTerminalStatus) {
return response.ValidationError(c, "Submission is in a terminal state", err.Error()) return response.ValidationError(c, "Submission is in a terminal state", err.Error())
} }
if errors.Is(err, ErrConcurrentUpdate) {
return response.Conflict(c, "Tender submission was modified concurrently")
}
return response.InternalServerError(c, "Failed to update tender submission status") return response.InternalServerError(c, "Failed to update tender submission status")
} }
@@ -287,7 +291,20 @@ func (h *Handler) AdminListSubmissions(c echo.Context) error {
// @Security BearerAuth // @Security BearerAuth
// @Router /admin/v1/tender-submissions/{id} [get] // @Router /admin/v1/tender-submissions/{id} [get]
func (h *Handler) AdminGetSubmissionByID(c echo.Context) error { func (h *Handler) AdminGetSubmissionByID(c echo.Context) error {
return h.GetSubmissionByID(c) id := strings.TrimSpace(c.Param("id"))
if id == "" {
return response.BadRequest(c, "Submission ID is required", "")
}
result, err := h.service.GetByIDWithTender(c.Request().Context(), id)
if err != nil {
if errors.Is(err, ErrTenderSubmissionNotFound) {
return response.NotFound(c, "Tender submission not found")
}
return response.InternalServerError(c, "Failed to get tender submission")
}
return response.Success(c, result, "Tender submission retrieved successfully")
} }
// AdminGetSubmissionStats returns global submission statistics. // AdminGetSubmissionStats returns global submission statistics.
+57 -4
View File
@@ -11,12 +11,15 @@ import (
mongodriver "go.mongodb.org/mongo-driver/v2/mongo" mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
) )
const maxListLimit = 100
// Repository defines data access for tender submissions. // Repository defines data access for tender submissions.
type Repository interface { type Repository interface {
Create(ctx context.Context, submission *TenderSubmission) error Create(ctx context.Context, submission *TenderSubmission) error
GetByID(ctx context.Context, id string) (*TenderSubmission, error) GetByID(ctx context.Context, id string) (*TenderSubmission, error)
GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error) GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*TenderSubmission, error)
Update(ctx context.Context, submission *TenderSubmission) error Update(ctx context.Context, submission *TenderSubmission) error
UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) ListByCompany(ctx context.Context, companyID string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error)
@@ -25,8 +28,9 @@ type Repository interface {
} }
type tenderSubmissionRepository struct { type tenderSubmissionRepository struct {
ormRepo mongo.Repository[TenderSubmission] ormRepo mongo.Repository[TenderSubmission]
logger logger.Logger collection *mongodriver.Collection
logger logger.Logger
} }
func NewRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository { func NewRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository {
@@ -46,8 +50,9 @@ func NewRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger)
}) })
} }
ormRepo := mongo.NewRepository[TenderSubmission](mongoManager.GetCollection("tender_submissions"), logger) collection := mongoManager.GetCollection("tender_submissions")
return &tenderSubmissionRepository{ormRepo: ormRepo, logger: logger} ormRepo := mongo.NewRepository[TenderSubmission](collection, logger)
return &tenderSubmissionRepository{ormRepo: ormRepo, collection: collection, logger: logger}
} }
func (r *tenderSubmissionRepository) Create(ctx context.Context, submission *TenderSubmission) error { func (r *tenderSubmissionRepository) Create(ctx context.Context, submission *TenderSubmission) error {
@@ -56,6 +61,9 @@ func (r *tenderSubmissionRepository) Create(ctx context.Context, submission *Ten
submission.SetUpdatedAt(now) submission.SetUpdatedAt(now)
if err := r.ormRepo.Create(ctx, submission); err != nil { if err := r.ormRepo.Create(ctx, submission); err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrTenderSubmissionExists
}
r.logger.Error("Failed to create tender submission", map[string]interface{}{ r.logger.Error("Failed to create tender submission", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"tender_id": submission.TenderID, "tender_id": submission.TenderID,
@@ -108,6 +116,9 @@ func (r *tenderSubmissionRepository) GetByTenderAndCompany(ctx context.Context,
func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *TenderSubmission) error { func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *TenderSubmission) error {
submission.SetUpdatedAt(time.Now().Unix()) submission.SetUpdatedAt(time.Now().Unix())
if err := r.ormRepo.Update(ctx, submission); err != nil { if err := r.ormRepo.Update(ctx, submission); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to update tender submission", map[string]interface{}{ r.logger.Error("Failed to update tender submission", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"submission_id": submission.GetID(), "submission_id": submission.GetID(),
@@ -117,8 +128,35 @@ func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *Ten
return nil return nil
} }
func (r *tenderSubmissionRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
objectID, err := bson.ObjectIDFromHex(submission.GetID())
if err != nil {
return err
}
newUpdatedAt := time.Now().Unix()
submission.SetUpdatedAt(newUpdatedAt)
filter := bson.M{"_id": objectID, "updated_at": expectedUpdatedAt}
result, err := r.collection.UpdateOne(ctx, filter, bson.M{"$set": submission})
if err != nil {
r.logger.Error("Failed to update tender submission with optimistic lock", map[string]interface{}{
"error": err.Error(),
"submission_id": submission.GetID(),
})
return err
}
if result.MatchedCount == 0 {
return ErrConcurrentUpdate
}
return nil
}
func (r *tenderSubmissionRepository) Delete(ctx context.Context, id string) error { func (r *tenderSubmissionRepository) Delete(ctx context.Context, id string) error {
if err := r.ormRepo.Delete(ctx, id); err != nil { if err := r.ormRepo.Delete(ctx, id); err != nil {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return ErrTenderSubmissionNotFound
}
r.logger.Error("Failed to delete tender submission", map[string]interface{}{ r.logger.Error("Failed to delete tender submission", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"submission_id": id, "submission_id": id,
@@ -134,6 +172,11 @@ func (r *tenderSubmissionRepository) ListByCompany(ctx context.Context, companyI
} }
func (r *tenderSubmissionRepository) Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) { func (r *tenderSubmissionRepository) Search(ctx context.Context, tenderID, companyID *string, statuses []SubmissionStatus, stages []SubmissionStage, from, to *int64, limit, offset int, sortBy, sortOrder string) ([]*TenderSubmission, int64, error) {
limit = capListLimit(limit)
if offset < 0 {
offset = 0
}
filter := bson.M{} filter := bson.M{}
if tenderID != nil && *tenderID != "" { if tenderID != nil && *tenderID != "" {
filter["tender_id"] = *tenderID filter["tender_id"] = *tenderID
@@ -291,3 +334,13 @@ func int64Value(v interface{}) int64 {
return 0 return 0
} }
} }
func capListLimit(limit int) int {
if limit <= 0 {
return 20
}
if limit > maxListLimit {
return maxListLimit
}
return limit
}
+63 -3
View File
@@ -19,6 +19,7 @@ type TenderApprovalReader interface {
type Service interface { type Service interface {
EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error) EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID string) (*TenderSubmissionWithTenderResponse, error)
GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error) GetByIDWithTender(ctx context.Context, id string) (*TenderSubmissionWithTenderResponse, error)
GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error)
GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error) GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error)
ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error) ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, error)
UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error) UpdateStatus(ctx context.Context, id string, form *UpdateSubmissionStatusForm, companyID, changedBy string) (*TenderSubmissionWithTenderResponse, error)
@@ -69,7 +70,13 @@ func (s *tenderSubmissionService) EnsureForApprovedTender(ctx context.Context, t
} }
submission = NewTenderSubmission(resolvedTenderID, companyID, customerID) submission = NewTenderSubmission(resolvedTenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil { if err := s.repository.Create(ctx, submission); err != nil {
return nil, err if !errors.Is(err, ErrTenderSubmissionExists) {
return nil, err
}
submission, err = s.repository.GetByTenderAndCompany(ctx, resolvedTenderID, companyID)
if err != nil {
return nil, err
}
} }
} }
@@ -84,6 +91,17 @@ func (s *tenderSubmissionService) GetByIDWithTender(ctx context.Context, id stri
return s.responseWithTender(ctx, submission, true) return s.responseWithTender(ctx, submission, true)
} }
func (s *tenderSubmissionService) GetByIDForCompanyWithTender(ctx context.Context, id, companyID string) (*TenderSubmissionWithTenderResponse, error) {
submission, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if submission.CompanyID != companyID {
return nil, ErrTenderSubmissionNotFound
}
return s.responseWithTender(ctx, submission, true)
}
func (s *tenderSubmissionService) GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error) { func (s *tenderSubmissionService) GetByTenderAndCompanyWithTender(ctx context.Context, tenderID, companyID string) (*TenderSubmissionWithTenderResponse, error) {
resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID) resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID)
if err != nil { if err != nil {
@@ -153,6 +171,7 @@ func (s *tenderSubmissionService) UpdateStatus(ctx context.Context, id string, f
return nil, ErrTenderSubmissionNotFound return nil, ErrTenderSubmissionNotFound
} }
expectedUpdatedAt := submission.UpdatedAt
if err := s.validateStatusTransition(submission.Status, form.Status); err != nil { if err := s.validateStatusTransition(submission.Status, form.Status); err != nil {
s.logger.Error("Invalid tender submission status transition", map[string]interface{}{ s.logger.Error("Invalid tender submission status transition", map[string]interface{}{
"submission_id": id, "submission_id": id,
@@ -164,7 +183,10 @@ func (s *tenderSubmissionService) UpdateStatus(ctx context.Context, id string, f
} }
submission.AddStatusChange(form.Status, form.Reason, changedBy, form.Description) submission.AddStatusChange(form.Status, form.Reason, changedBy, form.Description)
if err := s.repository.Update(ctx, submission); err != nil { if err := s.repository.UpdateIfUpdatedAt(ctx, submission, expectedUpdatedAt); err != nil {
if errors.Is(err, ErrConcurrentUpdate) {
return nil, ErrConcurrentUpdate
}
return nil, errors.New("failed to update tender submission status") return nil, errors.New("failed to update tender submission status")
} }
@@ -206,11 +228,26 @@ func (s *tenderSubmissionService) OnApprovalSubmitted(ctx context.Context, tende
return err return err
} }
if existing != nil { if existing != nil {
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil return nil
} }
submission := NewTenderSubmission(tenderID, companyID, customerID) submission := NewTenderSubmission(tenderID, companyID, customerID)
if err := s.repository.Create(ctx, submission); err != nil { if err := s.repository.Create(ctx, submission); err != nil {
if errors.Is(err, ErrTenderSubmissionExists) {
existing, getErr := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID)
if getErr != nil {
return getErr
}
if IsTerminalStatus(existing.Status) {
existing.ReopenForApproval(customerID, "Tender re-approved")
return s.repository.Update(ctx, existing)
}
return nil
}
s.logger.Error("Failed to create submission on approval", map[string]interface{}{ s.logger.Error("Failed to create submission on approval", map[string]interface{}{
"tender_id": tenderID, "tender_id": tenderID,
"company_id": companyID, "company_id": companyID,
@@ -262,10 +299,30 @@ func (s *tenderSubmissionService) OnApprovalRejected(ctx context.Context, tender
return nil return nil
} }
submission.AddStatusChange(StatusNotApplied, "approval_rejected", changedBy, "Tender approval was rejected") if err := s.transitionSubmission(submission, StatusNotApplied, "approval_rejected", changedBy, "Tender approval was rejected"); err != nil {
var transitionErr *InvalidStatusTransitionError
if errors.Is(err, ErrTerminalStatus) || errors.As(err, &transitionErr) {
s.logger.Warn("Skipping approval rejection sync due to invalid transition", map[string]interface{}{
"tender_id": tenderID,
"company_id": companyID,
"current_status": submission.Status,
"target_status": StatusNotApplied,
})
return nil
}
return err
}
return s.repository.Update(ctx, submission) return s.repository.Update(ctx, submission)
} }
func (s *tenderSubmissionService) transitionSubmission(submission *TenderSubmission, next SubmissionStatus, reason, changedBy, description string) error {
if err := s.validateStatusTransition(submission.Status, next); err != nil {
return err
}
submission.AddStatusChange(next, reason, changedBy, description)
return nil
}
func (s *tenderSubmissionService) requireSubmittedApproval(ctx context.Context, tenderID, companyID string) error { func (s *tenderSubmissionService) requireSubmittedApproval(ctx context.Context, tenderID, companyID string) error {
approval, err := s.approvalReader.GetByTenderAndCompany(ctx, tenderID, companyID) approval, err := s.approvalReader.GetByTenderAndCompany(ctx, tenderID, companyID)
if err != nil { if err != nil {
@@ -330,6 +387,9 @@ func listParams(form *ListTenderSubmissionsForm) (limit, offset int, sortBy, sor
if form.Limit != nil && *form.Limit > 0 { if form.Limit != nil && *form.Limit > 0 {
limit = *form.Limit limit = *form.Limit
} }
if limit > maxListLimit {
limit = maxListLimit
}
if form.Offset != nil && *form.Offset >= 0 { if form.Offset != nil && *form.Offset >= 0 {
offset = *form.Offset offset = *form.Offset
} }
+261
View File
@@ -0,0 +1,261 @@
package tender_submission
import (
"context"
"errors"
"testing"
"tm/pkg/logger"
)
type mockRepository struct {
byTenderCompany map[string]*TenderSubmission
createErr error
updateErr error
deleteErr error
}
func submissionKey(tenderID, companyID string) string {
return tenderID + ":" + companyID
}
func newMockRepository() *mockRepository {
return &mockRepository{byTenderCompany: map[string]*TenderSubmission{}}
}
func (m *mockRepository) Create(_ context.Context, submission *TenderSubmission) error {
if m.createErr != nil {
return m.createErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, exists := m.byTenderCompany[key]; exists {
return ErrTenderSubmissionExists
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) GetByID(_ context.Context, id string) (*TenderSubmission, error) {
for _, submission := range m.byTenderCompany {
if submission.GetID() == id {
copy := *submission
return &copy, nil
}
}
return nil, ErrTenderSubmissionNotFound
}
func (m *mockRepository) GetByTenderAndCompany(_ context.Context, tenderID, companyID string) (*TenderSubmission, error) {
submission, ok := m.byTenderCompany[submissionKey(tenderID, companyID)]
if !ok {
return nil, ErrTenderSubmissionNotFound
}
copy := *submission
return &copy, nil
}
func (m *mockRepository) Update(_ context.Context, submission *TenderSubmission) error {
if m.updateErr != nil {
return m.updateErr
}
key := submissionKey(submission.TenderID, submission.CompanyID)
if _, ok := m.byTenderCompany[key]; !ok {
return ErrTenderSubmissionNotFound
}
copy := *submission
m.byTenderCompany[key] = &copy
return nil
}
func (m *mockRepository) UpdateIfUpdatedAt(ctx context.Context, submission *TenderSubmission, expectedUpdatedAt int64) error {
current, err := m.GetByTenderAndCompany(ctx, submission.TenderID, submission.CompanyID)
if err != nil {
return err
}
if current.UpdatedAt != expectedUpdatedAt {
return ErrConcurrentUpdate
}
return m.Update(ctx, submission)
}
func (m *mockRepository) Delete(_ context.Context, id string) error {
if m.deleteErr != nil {
return m.deleteErr
}
for key, submission := range m.byTenderCompany {
if submission.GetID() == id {
delete(m.byTenderCompany, key)
return nil
}
}
return ErrTenderSubmissionNotFound
}
func (m *mockRepository) ListByCompany(context.Context, string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) Search(context.Context, *string, *string, []SubmissionStatus, []SubmissionStage, *int64, *int64, int, int, string, string) ([]*TenderSubmission, int64, error) {
return nil, 0, nil
}
func (m *mockRepository) GetCompanyStats(context.Context, string) (*CompanySubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) GetGlobalStats(context.Context) (*AdminSubmissionStatsResponse, error) {
return nil, nil
}
func (m *mockRepository) seed(submission *TenderSubmission) {
copy := *submission
m.byTenderCompany[submissionKey(submission.TenderID, submission.CompanyID)] = &copy
}
func newTestService(repo Repository) *tenderSubmissionService {
return &tenderSubmissionService{
repository: repo,
approvalReader: nil,
tenderService: nil,
logger: testLogger{},
}
}
type testLogger struct{}
func (testLogger) Debug(string, map[string]interface{}) {}
func (testLogger) Info(string, map[string]interface{}) {}
func (testLogger) Warn(string, map[string]interface{}) {}
func (testLogger) Error(string, map[string]interface{}) {}
func (testLogger) Fatal(string, map[string]interface{}) {}
func (testLogger) WithFields(map[string]interface{}) logger.Logger {
return testLogger{}
}
func (testLogger) Sync() error { return nil }
func TestOnApprovalSubmittedReopensTerminalSubmission(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusNotApplied, "approval_rejected", "customer-1", "Rejected")
repo.seed(existing)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-2"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", updated.Status, StatusOpportunity)
}
}
func TestOnApprovalSubmittedCreatesWhenMissing(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
if err := service.OnApprovalSubmitted(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalSubmitted() error = %v", err)
}
created, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if created.Status != StatusOpportunity {
t.Fatalf("status = %q, want %q", created.Status, StatusOpportunity)
}
}
func TestOnApprovalRejectedUsesValidatedTransition(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusNotApplied {
t.Fatalf("status = %q, want %q", updated.Status, StatusNotApplied)
}
}
func TestOnApprovalRejectedSkipsInvalidTransitionFromSentWaiting(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
existing := NewTenderSubmission("tender-1", "company-1", "customer-1")
existing.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
existing.AddStatusChange(StatusApplying, "", "customer-1", "Ready")
existing.AddStatusChange(StatusSentWaiting, "", "customer-1", "Submitted")
repo.seed(existing)
if err := service.OnApprovalRejected(context.Background(), "tender-1", "company-1", "customer-1"); err != nil {
t.Fatalf("OnApprovalRejected() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusSentWaiting {
t.Fatalf("status = %q, want %q", updated.Status, StatusSentWaiting)
}
}
func TestOnApprovalRemovedDeletesOpportunityOnly(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
opportunity := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(opportunity)
if err := service.OnApprovalRemoved(context.Background(), "tender-1", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
if _, err := repo.GetByTenderAndCompany(context.Background(), "tender-1", "company-1"); !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected submission to be deleted, got err = %v", err)
}
inProgress := NewTenderSubmission("tender-2", "company-1", "customer-1")
inProgress.AddStatusChange(StatusValidating, "", "customer-1", "Accepted")
repo.seed(inProgress)
if err := service.OnApprovalRemoved(context.Background(), "tender-2", "company-1"); err != nil {
t.Fatalf("OnApprovalRemoved() error = %v", err)
}
updated, err := repo.GetByTenderAndCompany(context.Background(), "tender-2", "company-1")
if err != nil {
t.Fatalf("GetByTenderAndCompany() error = %v", err)
}
if updated.Status != StatusValidating {
t.Fatalf("status = %q, want %q", updated.Status, StatusValidating)
}
}
func TestGetByIDForCompanyWithTenderEnforcesOwnership(t *testing.T) {
repo := newMockRepository()
service := newTestService(repo)
submission := NewTenderSubmission("tender-1", "company-1", "customer-1")
repo.seed(submission)
_, err := service.GetByIDForCompanyWithTender(context.Background(), submission.GetID(), "company-2")
if !errors.Is(err, ErrTenderSubmissionNotFound) {
t.Fatalf("expected not found for other company, got %v", err)
}
}