fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Approve endpoint now only creates Jira tickets immediately when start_date is now/past; a new periodic job (every 15 min) catches campaigns whose scheduled start_date has since arrived. - Recurring campaign clones now go to pending_approval instead of active, routing through the same manager-approval gate as any other campaign; managers are notified instead of red_tech. - Fix UTC conversion for the campaign approval start_date input and extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
This commit is contained in:
@@ -45,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
@@ -53,10 +53,13 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
assert body["start_date"] is not None
|
||||
|
||||
|
||||
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||
def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
|
||||
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||
):
|
||||
"""The normal manager-approval path must create Jira tickets too, not just
|
||||
the admin-only emergency /activate override — this was the Block 1 gap:
|
||||
campaigns approved through the standard flow never got a Jira ticket."""
|
||||
campaigns approved through the standard flow never got a Jira ticket.
|
||||
Uses a past start_date so ticket creation is due immediately."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
mock_create_test.assert_called_once()
|
||||
|
||||
|
||||
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
|
||||
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||
):
|
||||
"""A campaign scheduled to start in the future must not get its Jira
|
||||
tickets created at approval time — that must wait for the periodic
|
||||
due-campaign sync job, once the scheduled start_date actually arrives."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
|
||||
resp = api(
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2099-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_get_key.assert_not_called()
|
||||
mock_create_campaign.assert_not_called()
|
||||
|
||||
|
||||
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
|
||||
from app.services.campaign_scheduler_service import (
|
||||
calculate_next_run,
|
||||
check_and_run_recurring_campaigns,
|
||||
sync_due_campaign_jira_tickets,
|
||||
)
|
||||
from app.services.snapshot_service import (
|
||||
create_snapshot,
|
||||
@@ -199,7 +200,10 @@ class TestCampaigns:
|
||||
)
|
||||
assert child is not None
|
||||
assert "Run" in child.name
|
||||
assert child.status == "active"
|
||||
# Recurrence only automates spawning the run — it still needs a
|
||||
# manager's approval like any other campaign, same as the manual
|
||||
# draft -> submit -> approve path.
|
||||
assert child.status == "pending_approval"
|
||||
|
||||
# Check child tests are fresh copies (new IDs, draft state)
|
||||
child_cts = (
|
||||
@@ -217,12 +221,92 @@ class TestCampaigns:
|
||||
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
||||
assert test.state == TestState.draft
|
||||
|
||||
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
|
||||
"""Managers must hear about a spawned recurring run — it's sitting in
|
||||
their approval queue just like a manually-submitted campaign."""
|
||||
from app.models.notification import Notification
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.is_recurring = True
|
||||
campaign.recurrence_pattern = "monthly"
|
||||
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
check_and_run_recurring_campaigns(db)
|
||||
|
||||
notif = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
|
||||
.first()
|
||||
)
|
||||
assert notif is not None
|
||||
|
||||
# Check parent was updated
|
||||
db.refresh(campaign)
|
||||
assert campaign.last_run_at is not None
|
||||
assert campaign.next_run_at > datetime.utcnow()
|
||||
|
||||
|
||||
class TestDueCampaignJiraSync:
|
||||
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
|
||||
campaigns approved with a future start_date whose Jira tickets were
|
||||
deliberately skipped at approval time."""
|
||||
|
||||
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
|
||||
from unittest.mock import patch
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||
) as mock_get_key, patch(
|
||||
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
|
||||
) as mock_create_campaign, patch(
|
||||
"app.services.jira_service.get_test_jira_key", return_value=None
|
||||
), patch(
|
||||
"app.services.jira_service.auto_create_test_issue"
|
||||
) as mock_create_test:
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
|
||||
assert processed == 1
|
||||
mock_get_key.assert_called_once()
|
||||
mock_create_campaign.assert_called_once()
|
||||
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
|
||||
|
||||
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() + timedelta(days=30)
|
||||
db.commit()
|
||||
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
assert processed == 0
|
||||
|
||||
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
|
||||
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
|
||||
db.add(JiraLink(
|
||||
entity_type=JiraLinkEntityType.campaign,
|
||||
entity_id=campaign.id,
|
||||
jira_issue_key="PT-1",
|
||||
jira_project_key="PT",
|
||||
created_by=admin_user.id,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
assert processed == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Snapshot Tests
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user