E2E Test Plan — SCRUM-93 / 94 / 97 / 98

Traceable plan mapping every acceptance criterion of both tickets to concrete test cases across all test types, with the execution method, expected result, and data-cleanup strategy for each. The automated suite lives beside this file (tests/e2e/); cases not automatable via the public API are called out with where they are covered (unit tests / DB smoke) so AC coverage is 100% and honest.

  • SCRUM-93 — Schema & Migration Foundation
  • SCRUM-94 — Segment-ID Driven CRM Sync Engine

Run a case by its id below: CASES="S94-02 R-A1" ./test.sh (space-separated; a prefix like CASES=S94 runs the whole group; ./test.sh alone runs everything). Each automated test is tagged with @pytest.mark.case("<id>") matching the ID column, so the ids here are the selectors.

1. Environment & auth

TargetCustomer API — https://beta.leocdp.com/c360api; email tracking — https://beta.leocdp.com/data
AuthKeycloak (/auth, realm customer360, client customer360-api) password grant → Bearer JWT with tenant_id/user_id/roles. SSO-off targets: X-Tenant-Id/X-User-Id headers.
Tenant11111111-1111-1111-1111-111111111111 (shared UAT test tenant)
Runner./test.sh (loads tests/e2e/.env, mints a fresh token, runs pytest; S98 uses E2E_TRACKING_BASE_URL)
Configtests/e2e/.env (git-ignored) — see README.md

2. Test types covered

Positive (happy path) · Negative (guards/validation) · Boundary (empty/zero/limits) · Idempotency (replay) · Tenant isolation · Security/AuthZ · Data integrity (FK/CHECK/RLS) · Audit/observability · Dry-run vs real.

3. Data-safety & cleanup strategy

The suite runs against a shared UAT tenant, so every case cleans up after itself:

  1. Ephemeral fixtures (segments, campaigns, content-items) are created with an e2e- tag/name prefix and registered with a per-test track fixture that DELETEs them in teardown (LIFO, tolerant of already-deleted), even on failure.
  2. Segment delete cascades its crm_segment_sync_runs rows (ON DELETE CASCADE), so audit rows never linger.
  3. Default suite writes no crm_* target rows: it uses an impossible segment predicate (engagement_score < -1) → matched = 0. Contract, dry-run, idempotency, audit, guards, isolation and schema round-trips are all validated with zero footprint.
  4. Routing tests (test_routing_e2e.py) that do write to crm_lead/crm_contact/ crm_customer_contacts/crm_transactions/crm_lead_source are opt-in (E2E_ALLOW_DATA_WRITES=1) and self-clean by computing the sync’s deterministic uuid5 PKs (imported from core.crud.crm_sync) and DELETE-ing exactly those ids — but only rows absent before the test ran (pre-existing rows for a real profile are left untouched).
  5. Sweeper cleanup.py (CLEANUP=1 ./test.sh or python cleanup.py) removes any residual e2e- segments/campaigns/content-items for the tenant, in case a run was killed mid-teardown.

4. SCRUM-93 — Schema & Migration Foundation

ACs: (a) migrations apply & rollback cleanly; (b) new FKs enforce consistency (lead-source, campaign↔template/segment/content); (c) RLS on all new tenant-scoped tables; (d) SQLAlchemy models + Pydantic schemas updated.

IDACScenarioTypeMethodExpectedCleanup
S93-01dCreate campaign with new EM columns (segment_id, approval_status, strategy_summary, ai_plan) and read backPositiveE2E201; all fields persist + round-trip on GETdelete campaign
S93-02dai_plan JSONB round-trips as a nested objectPositiveE2EGET returns identical dictdelete campaign
S93-03dGeneric POST /campaigns rejects non-Draft approval_status valuesNegativeUnit + deploy-gated E2E422 once the draft-only guard is deployed; unit suite is the source-of-truthnone (or delete probe row)
S93-04dapproval_status = "Bogus" rejectedNegativeE2E422 (Pydantic pattern)none (not created)
S93-05bcrm_campaign.segment_id FK → non-existent segmentIntegrityE2E4xx (FK violation surfaced) or SET NULL semantics documenteddelete campaign if created
S93-06dcrm_lead.lead_source_id set on create + read backPositiveE2E201; lead_source_id persistsdelete lead, lead-source
S93-07bcrm_lead.lead_source_id → non-existent lead-sourceIntegrityE2E4xx FK violationdelete lead if created
S93-08c/dcrm_segment_sync_runs list endpoint queryable + tenant-scopedPositiveE2E200 list ([] for empty tenant)none
S93-09cRLS: a sync-run created under tenant A not visible/editable to tenant BIsolationE2EGET run as other tenant → 404segment delete cascades run
S93-10aForward migration 002_*.sql applies (idempotent, re-runnable)PositiveDB smoketables/columns/FK/index/RLS present; second run no-opscratch DB dropped
S93-11aRollback 002_*.down.sql reverses cleanlyPositiveDB smokeall objects dropped in dependency-safe orderscratch DB dropped
S93-12cRLS ENABLE+FORCE+tenant_policy on the 3 new tablesIntegrityDB smokepg_policies shows tenant_policy; blank app.tenant_id → 0 rowsscratch DB dropped
S93-13dModels importable & mapped (EmailTemplate, CampaignContentItem, SegmentSyncRun)PositiveUnitimport + metadata assertn/a
S93-14bcrm_campaign_content_items unique (campaign_id, content_item_id)IntegrityDB smoke / Unitduplicate link rejectedscratch DB

Not E2E-automatable: S93-10/11/12/14 have no HTTP surface (migrations run via run-sql.sh; crm_campaign_content_items has no router). They are covered by a DB smoke (psql apply-then-rollback on a scratch DB — see §6) and by the unit suite. S93-13 is covered by models/__init__.py import + existing unit tests.


5. SCRUM-94 — Segment-ID Driven CRM Sync Engine

ACs: (a) one segment_id syncs with deterministic per-route counts; (b) routing rules match exactly (A customer / B lead / C contact); (c) idempotent on replay; (d) tenant isolation end-to-end. Plus DoD: audit evidence.

5.1 Endpoint contract, dry-run, audit, guards (default suite — zero write footprint)

IDACScenarioTypeMethodExpectedCleanup
S94-01/health reachable (unauth)PositiveE2E200none
S94-02aPOST /sync-segment/{id}?dry_run=truePositiveE2E200; dry_run=true; detail.recomputed=false; error=0; count keys presentsegment (cascade)
S94-03aReal POST /sync-segment/{id} on zero-match segmentPositive/BoundaryE2E200; Completed; all counts 0; matched=0segment (cascade)
S94-04aCount invariant holdsIntegrityE2Ecustomer+lead+contact+skipped == matched; error ≤ matchedsegment
S94-05DoDRun audited: retrievable by id + listed for segmentAuditE2EGET /sync-runs/{id} 200; appears in /sync-runs?segment_id=segment (cascade)
S94-06DoDAudit row fields populated (status, counts, started_at, dry_run, metadata)AuditE2Efields present & typedsegment
S94-07cReplay same segment → identical per-route countsIdempotencyE2Ecounts equal across 2 runssegment
S94-08aUnknown segment_idNegativeE2E404none
S94-09aSegment with no sql_rulesNegativeE2E400delete segment
S94-10aSegment with unsafe sql_rules (;/DML) rejected at create OR syncSecurityE2E422 on create (validator) / 400 on syncdelete segment if created
S94-11dNo auth / no tenant contextSecurityE2Enot 200 → 401/403 (SSO) or 400 (no tenant)none
S94-12dNon-admin token (no tenant-admin role)SecurityE2E (opt)403none
S94-13dCross-tenant: sync a segment owned by another tenantIsolationE2E404none
S94-14dCross-tenant: read another tenant’s sync-runIsolationE2E404segment (cascade)
S94-15/sync-runs list respects limit boundsBoundaryE2Elimit=0/limit=101 → 422none
S94-16/sync-runs?segment_id= filter scopes resultsPositiveE2Eonly that segment’s runssegment

5.2 Routing behaviour (opt-in E2E_ALLOW_DATA_WRITES=1 — self-cleaning writes)

Targets an existing UAT profile per lifecycle stage via sql_rules = master_profile_id = '<uuid>' (matches exactly one). Skips if no profile of that stage exists. Deletes only rows it created (pre-existence-checked).

IDACScenarioTypeMethodExpectedCleanup
R-A1bRoute A: lifecycle_stage='customer'crm_customer_contacts (if eligible signal)PositiveE2E-writecustomer=1; customer-contact row exists at deterministic iddelete customer-contact + segment
R-A2bRoute A: no transaction facts → no fabricated crm_transactionsBoundaryE2E-writetransactions_written=0; no tx rowdelete customer-contact + segment
R-A3bRoute A: profile with attributes.transactions → tx upserted verbatim (amount not fabricated)PositiveE2E-writetx row amount == sourcedelete tx + customer-contact + segment
R-B1bRoute B: lifecycle_stage='lead'crm_lead + crm_lead_source from acquisition_sourcePositiveE2E-writelead=1; lead row w/ lead_source_id; lead-source rowdelete lead, lead-source, segment
R-B2bRoute B: lead with no acquisition_source → fallback source segment_syncBoundaryE2E-writelead-source name == segment_syncdelete lead, lead-source, segment
R-B3bRoute B: lead with no identity field → skippedBoundaryE2E-writeskipped=1, lead=0segment
R-C1bRoute C: other stage (prospect/vip/…) → crm_contactPositiveE2E-writecontact=1; contact row existsdelete contact + segment
R-D1cIdempotent writes: replay → same deterministic ids, no duplicatesIdempotencyE2E-writerow count unchanged; same idsdelete created rows + segment

5.3 Routing logic exhaustive coverage (unit — already in repo)

tests/test_crm_sync_crud.py covers routing/idempotency/dry-run/error-isolation with a faked session (no DB), including classify_route for every stage, no-fabrication, skip-without-identity, savepoint rollback accounting, and recompute-failure auditing. Referenced here so §5.2 opt-in tests stay small and low-footprint.


6. Cases requiring DB access (not HTTP)

Run once on a scratch Postgres (or a UAT maintenance window) — not part of the per-run E2E suite:

# apply-then-rollback smoke (S93-10/11/12)
psql "$DB" -f database-init/migrations/002_email_marketing_schema_foundation.sql   # apply (idempotent)
psql "$DB" -f database-init/migrations/002_email_marketing_schema_foundation.sql   # re-apply: no-op
psql "$DB" -c "\d+ customer360.crm_segment_sync_runs"                              # columns/index present
psql "$DB" -c "SELECT polname FROM pg_policies WHERE tablename='crm_segment_sync_runs';"  # tenant_policy
psql "$DB" -f database-init/migrations/002_email_marketing_schema_foundation.down.sql     # rollback clean

7. Coverage summary

ACCovered by
S93-a migrations apply/rollbackDB smoke (§6)
S93-b FK consistencyE2E S93-05/07 + DB smoke S93-14
S93-c RLS on new tablesE2E S93-08/09 + DB smoke S93-12
S93-d models/schemas updatedE2E S93-01..08 + unit S93-13
S94-a deterministic countsE2E S94-02..09, S94-16
S94-b routing rules exactE2E-write R-A/B/C + unit §5.3
S94-c idempotent replayE2E S94-07 + E2E-write R-D1 + unit
S94-d tenant isolation/authzE2E S94-11..14 + unit
DoD audit evidenceE2E S94-05/06

Automated E2E (default, no writes): S93-01..09, S94-01..16. Automated E2E (opt-in writes, self-cleaning): R-A1..R-D1. DB smoke / unit: S93-10..14, S94 routing logic.


8. SCRUM-97 — Dagster Execution Modernization (campaign activation + email dispatch)

Deploy gate: S97 targets campaign activation on customer360-api. S98 targets data-tracking-api through E2E_TRACKING_BASE_URL. Run: CASES=S97 ./test.sh or CASES=S98 ./test.sh.

IDACScenarioTypeMethodExpectedCleanup
S97-01guardPOST /admin/campaigns/{unknown}/activateNegativeE2E404none
S97-02authzActivate without authSecurityE2E401/403 (SSO) or 400none
S97-03approval gateActivate a Draft campaignNegativeE2E409 (not Approved)delete campaign
S97-04integrityActivate Approved campaign missing template/segmentNegativeUnit409 (needs both)n/a
S97-05auditGET /admin/campaigns/{id}/dispatch-logs for a fresh campaignPositiveE2E200 []delete campaign
S97-06configGET /admin/email-provider-configPositiveE2E200 (config or null)none
S97-07DoDActivate a real Approved campaign (template+segment) → Dagster runPositiveE2E (opt-in E2E_CAMPAIGN_ID)200 run_id (or 503 if Dagster down)run is idempotent; ledger dedups
S97-Uidempotency/retry/failuresend-pipeline logic (dispatch idempotency, savepoints, suppression, adapters)Unitemail_engine/tests/*n/a

The full send (render → dispatch → cdp_campaign_dispatch_logs) is orchestrated by Dagster and needs an Approved campaign + Approved template + segment + recipients; the template has no public CRUD endpoint, so a full E2E can’t be seeded via API — S97-07 is opt-in against an existing campaign, and the dispatch/idempotency/adapter logic is covered exhaustively by the email_engine unit suite.

9. SCRUM-98 — Tracking, Webhooks & Compliance (public endpoints)

Tokens are minted for synthetic random (tenant, campaign, profile) ids using E2E_EMAIL_TRACKING_SECRET (must match data-tracking-api), so these exercise the contract + security fixes with zero database footprint. Run: E2E_TRACKING_BASE_URL=https://<host>/data CASES=S98 ./test.sh.

IDACScenarioTypeMethodExpected
S98-01trackingOpen pixel returns a 1×1 GIF (valid / missing / bad token)Positive/BoundaryE2E200 image/gif always
S98-02trackingClick redirects to the signed destinationPositiveE2E302 → the URL
S98-03securityOpen redirect blocked — forged/absent k or non-http schemeSecurityE2E302 → /
S98-04securityWebhook fail-closed — forged/unsigned callbackSecurityE2E401 (bad sig) or 503 (disabled); never 200-suppress
S98-05complianceUnsubscribe confirms (valid token) / rejects invalid tokenPositive/NegativeE2E200 HTML / 400
S98-Udedup/suppressiondedup atomicity, suppression ON CONFLICT, ledger-address resolution, signature verifyUnittests/test_email_tracking.py

Automated E2E (deploy-gated, no footprint): S97-01..06, S98-01..05. Opt-in: S97-07 (E2E_CAMPAIGN_ID). Unit: S97-U, S98-U.