diff --git a/internal/tender_approval/errors.go b/internal/tender_approval/errors.go index e9291ea..c817d4b 100644 --- a/internal/tender_approval/errors.go +++ b/internal/tender_approval/errors.go @@ -14,4 +14,5 @@ var ( ErrInvalidCompanyID = errors.New("invalid company ID") ErrUnauthorized = errors.New("unauthorized to perform this action") ErrInvalidData = errors.New("invalid tender approval data") + ErrSubmissionSyncFailed = errors.New("failed to sync tender submission workflow") ) diff --git a/internal/tender_approval/handler.go b/internal/tender_approval/handler.go index bf65c63..b424f7c 100644 --- a/internal/tender_approval/handler.go +++ b/internal/tender_approval/handler.go @@ -66,6 +66,9 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error { if errors.Is(err, ErrTenderNotFound) { 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" { return response.Conflict(c, err.Error()) } diff --git a/internal/tender_approval/service.go b/internal/tender_approval/service.go index e26fe5c..45c5deb 100644 --- a/internal/tender_approval/service.go +++ b/internal/tender_approval/service.go @@ -908,7 +908,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T 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) } @@ -927,7 +935,9 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T 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 } @@ -950,13 +960,15 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T 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) } -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 { - return + return nil } var err error @@ -977,6 +989,7 @@ func (s *tenderApprovalService) syncSubmissionAfterToggle(ctx context.Context, t "error": err.Error(), }) } + return err } func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) { diff --git a/internal/tender_submission/entity.go b/internal/tender_submission/entity.go index 69da284..4e7815b 100644 --- a/internal/tender_submission/entity.go +++ b/internal/tender_submission/entity.go @@ -113,6 +113,12 @@ func (ts *TenderSubmission) AddStatusChange(status SubmissionStatus, reason, cha 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 { now := time.Now().Unix() ts := &TenderSubmission{ diff --git a/internal/tender_submission/errors.go b/internal/tender_submission/errors.go index 0878ecd..e4b647a 100644 --- a/internal/tender_submission/errors.go +++ b/internal/tender_submission/errors.go @@ -15,6 +15,7 @@ var ( ErrApprovalRequired = errors.New("tender must be approved before starting submission workflow") ErrInvalidStatusTransition = errors.New("invalid status transition") 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. diff --git a/internal/tender_submission/handler.go b/internal/tender_submission/handler.go index 59e7658..a7675c0 100644 --- a/internal/tender_submission/handler.go +++ b/internal/tender_submission/handler.go @@ -98,25 +98,31 @@ func (h *Handler) EnsureSubmission(c echo.Context) error { 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 -// @Description Retrieve a tender submission workflow with tender details +// @Description Retrieve a tender submission workflow with tender details for the authenticated company // @Tags TenderSubmissions // @Accept json // @Produce json // @Param id path string true "Submission ID" // @Success 200 {object} response.APIResponse{data=TenderSubmissionWithTenderResponse} +// @Failure 401 {object} response.APIResponse // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Security BearerAuth // @Router /api/v1/tender-submissions/{id} [get] 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")) if id == "" { 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 errors.Is(err, ErrTenderSubmissionNotFound) { 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") } - 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") } @@ -213,6 +214,9 @@ func (h *Handler) UpdateSubmissionStatus(c echo.Context) error { if errors.Is(err, ErrTerminalStatus) { 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") } @@ -287,7 +291,20 @@ func (h *Handler) AdminListSubmissions(c echo.Context) error { // @Security BearerAuth // @Router /admin/v1/tender-submissions/{id} [get] 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. diff --git a/internal/tender_submission/repository.go b/internal/tender_submission/repository.go index 87ce078..2f7e388 100644 --- a/internal/tender_submission/repository.go +++ b/internal/tender_submission/repository.go @@ -11,12 +11,15 @@ import ( mongodriver "go.mongodb.org/mongo-driver/v2/mongo" ) +const maxListLimit = 100 + // Repository defines data access for tender submissions. type Repository interface { Create(ctx context.Context, submission *TenderSubmission) error GetByID(ctx context.Context, id string) (*TenderSubmission, error) GetByTenderAndCompany(ctx context.Context, tenderID, companyID string) (*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 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) @@ -25,8 +28,9 @@ type Repository interface { } type tenderSubmissionRepository struct { - ormRepo mongo.Repository[TenderSubmission] - logger logger.Logger + ormRepo mongo.Repository[TenderSubmission] + collection *mongodriver.Collection + logger logger.Logger } 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) - return &tenderSubmissionRepository{ormRepo: ormRepo, logger: logger} + collection := mongoManager.GetCollection("tender_submissions") + ormRepo := mongo.NewRepository[TenderSubmission](collection, logger) + return &tenderSubmissionRepository{ormRepo: ormRepo, collection: collection, logger: logger} } 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) 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{}{ "error": err.Error(), "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 { submission.SetUpdatedAt(time.Now().Unix()) 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{}{ "error": err.Error(), "submission_id": submission.GetID(), @@ -117,8 +128,35 @@ func (r *tenderSubmissionRepository) Update(ctx context.Context, submission *Ten 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 { 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{}{ "error": err.Error(), "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) { + limit = capListLimit(limit) + if offset < 0 { + offset = 0 + } + filter := bson.M{} if tenderID != nil && *tenderID != "" { filter["tender_id"] = *tenderID @@ -291,3 +334,13 @@ func int64Value(v interface{}) int64 { return 0 } } + +func capListLimit(limit int) int { + if limit <= 0 { + return 20 + } + if limit > maxListLimit { + return maxListLimit + } + return limit +} diff --git a/internal/tender_submission/service.go b/internal/tender_submission/service.go index 4c1491e..f28eb9e 100644 --- a/internal/tender_submission/service.go +++ b/internal/tender_submission/service.go @@ -19,6 +19,7 @@ type TenderApprovalReader interface { type Service interface { EnsureForApprovedTender(ctx context.Context, tenderID, companyID, customerID 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) ListByCompanyWithTender(ctx context.Context, companyID string, form *ListTenderSubmissionsForm) (*TenderSubmissionListResponse, 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) 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) } +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) { resolvedTenderID, err := s.tenderService.ResolveMongoID(ctx, tenderID) if err != nil { @@ -153,6 +171,7 @@ func (s *tenderSubmissionService) UpdateStatus(ctx context.Context, id string, f return nil, ErrTenderSubmissionNotFound } + expectedUpdatedAt := submission.UpdatedAt if err := s.validateStatusTransition(submission.Status, form.Status); err != nil { s.logger.Error("Invalid tender submission status transition", map[string]interface{}{ "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) - 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") } @@ -206,11 +228,26 @@ func (s *tenderSubmissionService) OnApprovalSubmitted(ctx context.Context, tende return err } if existing != nil { + if IsTerminalStatus(existing.Status) { + existing.ReopenForApproval(customerID, "Tender re-approved") + return s.repository.Update(ctx, existing) + } return nil } submission := NewTenderSubmission(tenderID, companyID, customerID) 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{}{ "tender_id": tenderID, "company_id": companyID, @@ -262,10 +299,30 @@ func (s *tenderSubmissionService) OnApprovalRejected(ctx context.Context, tender 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) } +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 { approval, err := s.approvalReader.GetByTenderAndCompany(ctx, tenderID, companyID) if err != nil { @@ -330,6 +387,9 @@ func listParams(form *ListTenderSubmissionsForm) (limit, offset int, sortBy, sor if form.Limit != nil && *form.Limit > 0 { limit = *form.Limit } + if limit > maxListLimit { + limit = maxListLimit + } if form.Offset != nil && *form.Offset >= 0 { offset = *form.Offset } diff --git a/internal/tender_submission/service_test.go b/internal/tender_submission/service_test.go new file mode 100644 index 0000000..0d13007 --- /dev/null +++ b/internal/tender_submission/service_test.go @@ -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] = © + return nil +} + +func (m *mockRepository) GetByID(_ context.Context, id string) (*TenderSubmission, error) { + for _, submission := range m.byTenderCompany { + if submission.GetID() == id { + copy := *submission + return ©, 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 ©, 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] = © + 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)] = © +} + +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) + } +}