"""Phase 7.4 — Campaign Engine tests.""" from __future__ import annotations import datetime as dt import pytest from app.events.publisher import get_event_publisher from app.events.types import LoyaltyEventType from app.tests.conftest import TENANT_A, tenant_headers async def _setup( client, *, suffix: str, rules: dict | None = None, starts_on: str | None = None, ends_on: str | None = None, ) -> dict: program = await client.post( "/api/v1/programs", json={"code": f"cmp{suffix}", "name": f"Campaign {suffix}"}, headers=tenant_headers(TENANT_A), ) assert program.status_code == 201, program.text program_id = program.json()["id"] member = await client.post( "/api/v1/members", json={"program_id": program_id, "display_name": "Member", "enroll": True}, headers=tenant_headers(TENANT_A), ) assert member.status_code == 201, member.text member_id = member.json()["id"] account = await client.post( "/api/v1/point-accounts", json={"program_id": program_id, "member_id": member_id}, headers=tenant_headers(TENANT_A), ) assert account.status_code == 201, account.text account_id = account.json()["id"] payload = { "program_id": program_id, "code": f"camp{suffix}", "name": f"Campaign {suffix}", "rules": rules if rules is not None else {"earn_bonus_points": 50}, } if starts_on is not None: payload["starts_on"] = starts_on if ends_on is not None: payload["ends_on"] = ends_on campaign = await client.post( "/api/v1/campaigns", json=payload, headers=tenant_headers(TENANT_A) ) assert campaign.status_code == 201, campaign.text return { "program_id": program_id, "member_id": member_id, "account_id": account_id, "campaign_id": campaign.json()["id"], } @pytest.mark.asyncio async def test_full_lifecycle_schedule_activate_pause_resume_expire(client): ctx = await _setup(client, suffix="l1", starts_on="2026-01-01") campaign_id = ctx["campaign_id"] scheduled = await client.post( f"/api/v1/campaigns/{campaign_id}/schedule", headers=tenant_headers(TENANT_A), ) assert scheduled.status_code == 200, scheduled.text assert scheduled.json()["status"] == "scheduled" activated = await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) assert activated.status_code == 200, activated.text assert activated.json()["status"] == "active" paused = await client.post( f"/api/v1/campaigns/{campaign_id}/pause", headers=tenant_headers(TENANT_A), ) assert paused.status_code == 200, paused.text assert paused.json()["status"] == "paused" resumed = await client.post( f"/api/v1/campaigns/{campaign_id}/resume", headers=tenant_headers(TENANT_A), ) assert resumed.status_code == 200, resumed.text assert resumed.json()["status"] == "active" expired = await client.post( f"/api/v1/campaigns/{campaign_id}/expire", headers=tenant_headers(TENANT_A), ) assert expired.status_code == 200, expired.text assert expired.json()["status"] == "expired" events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.CAMPAIGN_SCHEDULED.value in events assert LoyaltyEventType.CAMPAIGN_ACTIVATED.value in events assert LoyaltyEventType.CAMPAIGN_PAUSED.value in events assert LoyaltyEventType.CAMPAIGN_RESUMED.value in events assert LoyaltyEventType.CAMPAIGN_EXPIRED.value in events @pytest.mark.asyncio async def test_schedule_requires_starts_on(client): ctx = await _setup(client, suffix="l2") bad = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/schedule", headers=tenant_headers(TENANT_A), ) assert bad.status_code == 422 assert bad.json()["error"]["code"] == "campaign_starts_on_required" @pytest.mark.asyncio async def test_invalid_transition_rejected(client): ctx = await _setup(client, suffix="l3") # draft campaign cannot pause directly bad = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/pause", headers=tenant_headers(TENANT_A), ) assert bad.status_code == 409 assert bad.json()["error"]["code"] == "invalid_campaign_transition" @pytest.mark.asyncio async def test_active_campaign_cannot_be_soft_deleted(client): ctx = await _setup(client, suffix="l4") activated = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/activate", headers=tenant_headers(TENANT_A), ) assert activated.status_code == 200, activated.text deleted = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/delete", headers=tenant_headers(TENANT_A), ) assert deleted.status_code == 409 assert deleted.json()["error"]["code"] == "campaign_active_not_deletable" @pytest.mark.asyncio async def test_rules_update_bumps_version_and_emits_event(client): ctx = await _setup(client, suffix="l5") campaign_id = ctx["campaign_id"] updated = await client.post( f"/api/v1/campaigns/{campaign_id}/rules", json={"rules": {"earn_bonus_points": 75, "earn_multiplier": 2.0}}, headers=tenant_headers(TENANT_A), ) assert updated.status_code == 200, updated.text assert updated.json()["rule_version"] == 2 assert updated.json()["rules"]["earn_bonus_points"] == 75 updated_again = await client.post( f"/api/v1/campaigns/{campaign_id}/rules", json={"rules": {"earn_bonus_points": 90}}, headers=tenant_headers(TENANT_A), ) assert updated_again.status_code == 200 assert updated_again.json()["rule_version"] == 3 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.CAMPAIGN_RULES_UPDATED.value in events @pytest.mark.asyncio async def test_apply_grants_bonus_points_via_ledger(client): ctx = await _setup(client, suffix="a1", rules={"earn_bonus_points": 50}) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 201, apply_resp.text body = apply_resp.json() assert body["points_granted"] == 50 assert body["status"] == "applied" assert body["ledger_entry_id"] is not None assert body["rule_version"] == 1 balance = await client.get( f"/api/v1/point-accounts/{ctx['account_id']}/balance", headers=tenant_headers(TENANT_A), ) assert balance.json()["balance"] == 50 events = {e.event_type for e in get_event_publisher().published} assert LoyaltyEventType.CAMPAIGN_APPLIED.value in events assert LoyaltyEventType.POINTS_EARNED.value in events @pytest.mark.asyncio async def test_apply_with_base_amount_uses_multiplier(client): ctx = await _setup( client, suffix="a2", rules={"earn_bonus_points": 10, "earn_multiplier": 1.5}, ) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"], "base_amount": 100}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 201, apply_resp.text # floor(100 * 1.5) + 10 = 160 assert apply_resp.json()["points_granted"] == 160 @pytest.mark.asyncio async def test_apply_rejects_inactive_campaign(client): ctx = await _setup(client, suffix="a3") apply_resp = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 409 assert apply_resp.json()["error"]["code"] == "campaign_not_active" @pytest.mark.asyncio async def test_apply_rejects_before_start_window(client): future = (dt.date.today() + dt.timedelta(days=30)).isoformat() ctx = await _setup(client, suffix="a4", starts_on=future) await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 409 assert apply_resp.json()["error"]["code"] == "campaign_not_started" @pytest.mark.asyncio async def test_apply_rejects_after_end_window(client): past_start = (dt.date.today() - dt.timedelta(days=60)).isoformat() past_end = (dt.date.today() - dt.timedelta(days=1)).isoformat() ctx = await _setup(client, suffix="a5", starts_on=past_start, ends_on=past_end) await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{ctx['campaign_id']}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 409 assert apply_resp.json()["error"]["code"] == "campaign_ended" @pytest.mark.asyncio async def test_apply_respects_max_applications_per_member(client): ctx = await _setup( client, suffix="a6", rules={"earn_bonus_points": 10, "max_applications_per_member": 1}, ) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) first = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert first.status_code == 201, first.text second = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert second.status_code == 409 assert second.json()["error"]["code"] == "campaign_max_applications_exceeded" @pytest.mark.asyncio async def test_apply_idempotency_returns_same_application_and_single_grant(client): ctx = await _setup(client, suffix="a7", rules={"earn_bonus_points": 20}) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) first = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"], "idempotency_key": "camp-idem-1"}, headers=tenant_headers(TENANT_A), ) assert first.status_code == 201, first.text second = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"], "idempotency_key": "camp-idem-1"}, headers=tenant_headers(TENANT_A), ) assert second.status_code == 201 assert second.json()["id"] == first.json()["id"] balance = await client.get( f"/api/v1/point-accounts/{ctx['account_id']}/balance", headers=tenant_headers(TENANT_A), ) assert balance.json()["balance"] == 20 @pytest.mark.asyncio async def test_apply_zero_grant_rejected(client): ctx = await _setup(client, suffix="a8", rules={"earn_bonus_points": 0}) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 422 assert apply_resp.json()["error"]["code"] == "campaign_grant_invalid" @pytest.mark.asyncio async def test_list_campaign_applications(client): ctx = await _setup(client, suffix="a9", rules={"earn_bonus_points": 15}) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) applied = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={"member_id": ctx["member_id"]}, headers=tenant_headers(TENANT_A), ) assert applied.status_code == 201, applied.text listed = await client.get( f"/api/v1/campaigns/{campaign_id}/applications", headers=tenant_headers(TENANT_A), ) assert listed.status_code == 200, listed.text assert listed.json()["total"] >= 1 assert listed.json()["items"][0]["campaign_id"] == campaign_id @pytest.mark.asyncio async def test_apply_explicit_point_account_id(client): ctx = await _setup(client, suffix="a10", rules={"earn_bonus_points": 30}) campaign_id = ctx["campaign_id"] await client.post( f"/api/v1/campaigns/{campaign_id}/activate", headers=tenant_headers(TENANT_A), ) apply_resp = await client.post( f"/api/v1/campaigns/{campaign_id}/apply", json={ "member_id": ctx["member_id"], "point_account_id": ctx["account_id"], }, headers=tenant_headers(TENANT_A), ) assert apply_resp.status_code == 201, apply_resp.text assert apply_resp.json()["point_account_id"] == ctx["account_id"]