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

- 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:
kitos
2026-07-16 11:09:10 +02:00
parent 0b60531677
commit cf4a6c3cde
9 changed files with 291 additions and 101 deletions
+30 -4
View File
@@ -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)
+85 -1
View File
@@ -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
# ═══════════════════════════════════════════════════════════════════════