diff --git a/.github/workflows/flow-lcp-ab-test.yml b/.github/workflows/flow-lcp-ab-test.yml new file mode 100644 index 0000000..0643581 --- /dev/null +++ b/.github/workflows/flow-lcp-ab-test.yml @@ -0,0 +1,132 @@ +name: Flow - LCP A/B Test Scenario + +on: + schedule: + # Run every 6 hours + - cron: '0 */6 * * *' + workflow_dispatch: # Allow manual trigger + +jobs: + lcp-ab-test: + runs-on: ubuntu-latest + environment: events + + steps: + - name: Enable LCP slowness for 50% of users + run: | + echo "Enabling LCP slowness A/B test (50% of users, 3000ms delay)..." + curl -X POST "${{ vars.BASE_URL }}/scenario-runner/api/ab-testing/lcp-slowness?enabled=true&percentage=50.0&delay_ms=3000" \ + --fail-with-body || { + echo "Failed to enable LCP slowness scenario" + exit 1 + } + + - name: Verify scenario is enabled + run: | + echo "Verifying LCP slowness scenario configuration..." + response=$(curl -s "${{ vars.BASE_URL }}/scenario-runner/api/ab-testing/config") + echo "Response: $response" + + enabled=$(echo $response | jq -r '.config.lcp_slowness_enabled') + percentage=$(echo $response | jq -r '.config.lcp_slowness_percentage') + delay=$(echo $response | jq -r '.config.lcp_slowness_delay_ms') + + if [ "$enabled" != "true" ]; then + echo "ERROR: LCP slowness not enabled" + exit 1 + fi + + echo "✓ LCP slowness enabled: $percentage% of users with ${delay}ms delay" + + - name: Create deployment marker (A/B test enabled) + env: + NEW_RELIC_API_KEY: ${{ secrets.NEW_RELIC_API_KEY }} + NEW_RELIC_APP_ID: ${{ secrets.NEW_RELIC_BROWSER_APP_ID }} + run: | + echo "Creating deployment marker in New Relic..." + curl -X POST "https://api.newrelic.com/v2/applications/${NEW_RELIC_APP_ID}/deployments.json" \ + -H "Api-Key: ${NEW_RELIC_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "deployment": { + "revision": "LCP-AB-Test-Enabled-50%", + "description": "Feature flag: LCP slowness enabled for 50% of users (3000ms delay)", + "user": "GitHub Actions", + "changelog": "A/B test started to measure LCP impact" + } + }' || echo "Warning: Could not create deployment marker" + + - name: Wait for data collection (20 minutes) + run: | + echo "Collecting data for 20 minutes..." + echo "Monitor LCP metrics at: https://one.newrelic.com/" + echo "" + echo "NRQL Query to see A/B test impact:" + echo "SELECT percentile(largestContentfulPaint, 50, 75, 95) as 'LCP (ms)', count(*) as 'Sessions'" + echo "FROM PageViewTiming" + echo "WHERE appName = 'ReliBank Frontend' AND pageUrl LIKE '%/dashboard%'" + echo "FACET custom.lcp_treatment TIMESERIES" + echo "" + sleep 1200 # 20 minutes + + - name: Disable LCP slowness + run: | + echo "Disabling LCP slowness A/B test..." + curl -X POST "${{ vars.BASE_URL }}/scenario-runner/api/ab-testing/lcp-slowness?enabled=false" \ + --fail-with-body || { + echo "Warning: Failed to disable LCP slowness scenario" + } + + - name: Verify scenario is disabled + run: | + echo "Verifying LCP slowness scenario is disabled..." + response=$(curl -s "${{ vars.BASE_URL }}/scenario-runner/api/ab-testing/config") + enabled=$(echo $response | jq -r '.config.lcp_slowness_enabled') + + if [ "$enabled" == "true" ]; then + echo "WARNING: LCP slowness still enabled!" + exit 1 + fi + + echo "✓ LCP slowness disabled successfully" + + - name: Create rollback marker (A/B test disabled) + env: + NEW_RELIC_API_KEY: ${{ secrets.NEW_RELIC_API_KEY }} + NEW_RELIC_APP_ID: ${{ secrets.NEW_RELIC_BROWSER_APP_ID }} + run: | + echo "Creating rollback marker in New Relic..." + curl -X POST "https://api.newrelic.com/v2/applications/${NEW_RELIC_APP_ID}/deployments.json" \ + -H "Api-Key: ${NEW_RELIC_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "deployment": { + "revision": "LCP-AB-Test-Disabled", + "description": "Rollback: LCP slowness disabled, all users back to normal", + "user": "GitHub Actions", + "changelog": "A/B test completed" + } + }' || echo "Warning: Could not create rollback marker" + + - name: Test Summary + if: always() + run: | + echo "## LCP A/B Test Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Duration**: 20 minutes" >> $GITHUB_STEP_SUMMARY + echo "- **Cohort Split**: 50% slow / 50% normal" >> $GITHUB_STEP_SUMMARY + echo "- **LCP Delay**: 3000ms for slow cohort" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Analysis" >> $GITHUB_STEP_SUMMARY + echo "Review LCP metrics in New Relic to compare:" >> $GITHUB_STEP_SUMMARY + echo "- \`lcp_treatment = normal\` (control group)" >> $GITHUB_STEP_SUMMARY + echo "- \`lcp_treatment = slow\` (test group with 3s delay)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### NRQL Query" >> $GITHUB_STEP_SUMMARY + echo '```sql' >> $GITHUB_STEP_SUMMARY + echo 'SELECT percentile(largestContentfulPaint, 50, 75, 95) as "LCP (ms)", count(*) as "Sessions"' >> $GITHUB_STEP_SUMMARY + echo 'FROM PageViewTiming' >> $GITHUB_STEP_SUMMARY + echo "WHERE appName = 'ReliBank Frontend' AND pageUrl LIKE '%/dashboard%'" >> $GITHUB_STEP_SUMMARY + echo 'FACET custom.lcp_treatment' >> $GITHUB_STEP_SUMMARY + echo 'SINCE 30 minutes ago' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d1c6c70..5a5feee 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -59,9 +59,9 @@ jobs: run: | cd tests # Run scenario tests sequentially to avoid race conditions - pytest test_scenario_service.py test_payment_scenarios.py --tb=line --timeout=300 + pytest test_scenario_service.py test_payment_scenarios.py test_ab_testing_scenarios.py --tb=line --timeout=300 # Run other tests in parallel - pytest . --ignore=test_scenario_service.py --ignore=test_payment_scenarios.py -n auto --tb=line --timeout=300 + pytest . --ignore=test_scenario_service.py --ignore=test_payment_scenarios.py --ignore=test_ab_testing_scenarios.py -n auto --tb=line --timeout=300 - name: Run end-to-end tests only if: ${{ github.event.inputs.test_suite == 'e2e' }} @@ -135,6 +135,7 @@ jobs: test-summary: runs-on: ubuntu-latest + environment: events needs: [python-tests, frontend-tests] if: always() diff --git a/accounts_service/accounts_service.py b/accounts_service/accounts_service.py index f259e4f..6c90c11 100644 --- a/accounts_service/accounts_service.py +++ b/accounts_service/accounts_service.py @@ -3,6 +3,7 @@ import sys import json import logging +import hashlib import psycopg2 from psycopg2 import extras, pool from pydantic import BaseModel, Field @@ -42,6 +43,29 @@ def get_propagation_headers(request: Request) -> dict: return headers_to_propagate +async def get_ab_test_config(): + """Fetch A/B test configuration from scenario service""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/config", + timeout=2.0 + ) + if response.status_code == 200: + data = response.json() + return data.get("config", {}) + except Exception as e: + logging.debug(f"Could not fetch A/B test config: {e}") + + # Return defaults if scenario service unavailable + return { + "lcp_slowness_percentage_enabled": False, + "lcp_slowness_percentage": 0.0, + "lcp_slowness_percentage_delay_ms": 0, + "lcp_slowness_cohort_enabled": False, + "lcp_slowness_cohort_delay_ms": 0 + } + # Database connection details from environment variables DB_HOST = os.getenv("DB_HOST", "accounts-db") DB_NAME = os.getenv("DB_NAME", "accountsdb") @@ -54,6 +78,25 @@ def get_propagation_headers(request: Request) -> dict: # if not, default to local development variables TRANSACTION_SERVICE_URL = f"http://{os.getenv("TRANSACTION_SERVICE_SERVICE_HOST", "transaction-service")}:{os.getenv("TRANSACTION_SERVICE_SERVICE_PORT", "5001")}" +# Scenario service API URL +SCENARIO_SERVICE_URL = f"http://{os.getenv("SCENARIO_RUNNER_SERVICE_SERVICE_HOST", "scenario-runner-service")}:{os.getenv("SCENARIO_RUNNER_SERVICE_SERVICE_PORT", "8000")}" + +# Hardcoded list of 11 test users for LCP slowness A/B testing +# These users will experience LCP delays when the scenario is enabled +LCP_SLOW_USERS = { + 'b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d', # Alice Johnson + 'f5e8d1c6-2a9b-4c3e-8f1a-6e5b0d2c9f1a', # Bob Williams + 'e1f2b3c4-5d6a-7e8f-9a0b-1c2d3e4f5a6b', # Charlie Brown + 'f47ac10b-58cc-4372-a567-0e02b2c3d471', # Solaire Astora + 'd9b1e2a3-f4c5-4d6e-8f7a-9b0c1d2e3f4a', # Malenia Miquella + '8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f', # Artorias Abyss + '7f6e5d4c-3b2a-1c0d-9e8f-7a6b5c4d3e2f', # Priscilla Painted + '6e5d4c3b-2a1c-0d9e-8f7a-6b5c4d3e2f1a', # Gwyn Cinder + '5d4c3b2a-1c0d-9e8f-7a6b-5c4d3e2f1a0b', # Siegmeyer Catarina + '4c3b2a1c-0d9e-8f7a-6b5c-4d3e2f1a0b9c', # Ornstein Dragon + '3b2a1c0d-9e8f-7a6b-5c4d-3e2f1a0b9c8d', # Smough Executioner +} + # Global connection pool connection_pool = None @@ -463,21 +506,46 @@ async def get_browser_user(request: Request): with conn.cursor(cursor_factory=extras.RealDictCursor) as cursor: if browser_user_id: - # Validate UUID format before querying database + # Validate UUID format try: import uuid uuid.UUID(browser_user_id) # Validate UUID format - # Query database for this user ID - cursor.execute("SELECT id FROM user_account WHERE id = %s", (browser_user_id,)) - user = cursor.fetchone() - if user: - logging.info(f"[Browser User] Using header-provided user ID: {browser_user_id}") - return {"user_id": browser_user_id, "source": "header"} + # Accept the header user ID for A/B testing even if not in database + # This allows deterministic cohort assignment for testing and analytics + logging.info(f"[Browser User] Using header-provided user ID: {browser_user_id}") + + # Fetch A/B test config and assign LCP slowness cohort + ab_config = await get_ab_test_config() + lcp_delay_ms = 0 + + # Check percentage-based scenario first + if ab_config.get("lcp_slowness_percentage_enabled"): + percentage = ab_config.get("lcp_slowness_percentage", 0.0) + # Deterministically assign cohort based on user_id hash + user_hash = int(hashlib.md5(browser_user_id.encode()).hexdigest(), 16) + if (user_hash % 100) < percentage: + lcp_delay_ms = ab_config.get("lcp_slowness_percentage_delay_ms", 0) + logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort via PERCENTAGE ({lcp_delay_ms}ms delay)") + + # Check cohort-based scenario (can override percentage if both enabled) + elif ab_config.get("lcp_slowness_cohort_enabled"): + # Check if user is in the hardcoded slow cohort (11 test users) + if browser_user_id in LCP_SLOW_USERS: + lcp_delay_ms = ab_config.get("lcp_slowness_cohort_delay_ms", 0) + logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort via COHORT ({lcp_delay_ms}ms delay)") + else: + logging.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort") else: - logging.warning(f"[Browser User] Header-provided ID {browser_user_id} not found in database, falling back to random") + logging.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort (no scenarios enabled)") + + return { + "user_id": browser_user_id, + "source": "header", + "lcp_delay_ms": lcp_delay_ms + } except (ValueError, Exception) as e: - logging.warning(f"[Browser User] Invalid UUID format or database error: {e}, falling back to random") + logging.warning(f"[Browser User] Invalid UUID format: {e}, falling back to random") # Fall back to random selection cursor.execute("SELECT id FROM user_account ORDER BY RANDOM() LIMIT 1") @@ -491,7 +559,36 @@ async def get_browser_user(request: Request): user_id = random_user["id"] logging.info(f"[Browser User] Randomly selected user ID: {user_id}") - return {"user_id": user_id, "source": "random"} + + # Fetch A/B test config and assign LCP slowness cohort + ab_config = await get_ab_test_config() + lcp_delay_ms = 0 + + # Check percentage-based scenario first + if ab_config.get("lcp_slowness_percentage_enabled"): + percentage = ab_config.get("lcp_slowness_percentage", 0.0) + # Deterministically assign cohort based on user_id hash + user_hash = int(hashlib.md5(user_id.encode()).hexdigest(), 16) + if (user_hash % 100) < percentage: + lcp_delay_ms = ab_config.get("lcp_slowness_percentage_delay_ms", 0) + logging.info(f"[Browser User] User {user_id} assigned to SLOW LCP cohort via PERCENTAGE ({lcp_delay_ms}ms delay)") + + # Check cohort-based scenario (can override percentage if both enabled) + elif ab_config.get("lcp_slowness_cohort_enabled"): + # Check if user is in the hardcoded slow cohort (11 test users) + if user_id in LCP_SLOW_USERS: + lcp_delay_ms = ab_config.get("lcp_slowness_cohort_delay_ms", 0) + logging.info(f"[Browser User] User {user_id} assigned to SLOW LCP cohort via COHORT ({lcp_delay_ms}ms delay)") + else: + logging.info(f"[Browser User] User {user_id} assigned to NORMAL LCP cohort") + else: + logging.info(f"[Browser User] User {user_id} assigned to NORMAL LCP cohort (no scenarios enabled)") + + return { + "user_id": user_id, + "source": "random", + "lcp_delay_ms": lcp_delay_ms + } except HTTPException: raise diff --git a/frontend_service/app/root.tsx b/frontend_service/app/root.tsx index ce05906..5db66d6 100644 --- a/frontend_service/app/root.tsx +++ b/frontend_service/app/root.tsx @@ -119,13 +119,16 @@ export function Layout({ children }: { children: React.ReactNode }) { if (typeof window !== 'undefined' && isHydrated) { // Check sessionStorage first const storedUserId = sessionStorage.getItem('browserUserId'); - if (storedUserId) { + const storedLcpDelay = sessionStorage.getItem('lcpDelayMs'); + + if (storedUserId && storedLcpDelay !== null) { console.log('[Browser User] Loaded from sessionStorage:', storedUserId); + console.log('[Browser User] LCP Delay from sessionStorage:', storedLcpDelay + 'ms'); setBrowserUserId(storedUserId); return; } - // Fetch from API + // Fetch from API (either no user ID or no LCP delay stored) const fetchBrowserUserId = async () => { try { const response = await fetch('/accounts-service/browser-user'); @@ -134,6 +137,17 @@ export function Layout({ children }: { children: React.ReactNode }) { console.log(`[Browser User] Received: ${data.user_id} Source: ${data.source}`); setBrowserUserId(data.user_id); sessionStorage.setItem('browserUserId', data.user_id); + + // Store LCP delay for A/B testing + const lcpDelay = data.lcp_delay_ms || 0; + sessionStorage.setItem('lcpDelayMs', lcpDelay.toString()); + console.log(`[Browser User] LCP Delay: ${lcpDelay}ms`); + + // Set New Relic custom attribute for A/B test cohort tracking + if (window.newrelic && typeof window.newrelic.setCustomAttribute === 'function') { + window.newrelic.setCustomAttribute('lcp_delay_ms', lcpDelay); + window.newrelic.setCustomAttribute('lcp_treatment', lcpDelay > 0 ? 'slow' : 'normal'); + } } else { console.error('[Browser User] Failed to fetch user ID:', response.status); } diff --git a/frontend_service/app/routes/dashboard.tsx b/frontend_service/app/routes/dashboard.tsx index 71c0ac4..5cfc1ec 100644 --- a/frontend_service/app/routes/dashboard.tsx +++ b/frontend_service/app/routes/dashboard.tsx @@ -224,6 +224,24 @@ const DashboardPage = () => { const [transactions, setTransactions] = useState(mockTransactions); // NEW: Loading state for the additional, client-side fetch const [isLoadingDetails, setIsLoadingDetails] = useState(false); + // A/B Test: Loading state for LCP delay (delay content, not entire page) + const [isLcpContentReady, setIsLcpContentReady] = useState(false); + + // A/B Test: Apply LCP delay if user is in slow cohort + useEffect(() => { + const applyLcpDelay = async () => { + const lcpDelay = parseInt(sessionStorage.getItem('lcpDelayMs') || '0'); + + if (lcpDelay > 0) { + console.log(`[A/B Test] Applying ${lcpDelay}ms LCP delay for slow cohort`); + await new Promise(resolve => setTimeout(resolve, lcpDelay)); + } + + setIsLcpContentReady(true); + }; + + applyLcpDelay(); + }, []); // 2. Secondary Fetch: Get additional account details after initial data is set useEffect(() => { @@ -276,7 +294,7 @@ const DashboardPage = () => { stackedBarData: mockStackedBarData, }; - if (!userData) { + if (!userData) { return ; } @@ -320,31 +338,53 @@ const DashboardPage = () => { Account Summary - {/* Row 1: Overview Cards (4-4-4) */} - - } - info="" - /> - - - } - info={checkingExtraInfo} - /> - - - } - info={savingsExtraInfo} - /> - + {/* Row 1: Overview Cards (4-4-4) - LCP elements delayed for A/B test */} + {!isLcpContentReady ? ( + <> + + + + + + + + + + + + + + + + + ) : ( + <> + + } + info="" + /> + + + } + info={checkingExtraInfo} + /> + + + } + info={savingsExtraInfo} + /> + + + )} diff --git a/scenario_service/index.html b/scenario_service/index.html index 7de856a..cae80bc 100644 --- a/scenario_service/index.html +++ b/scenario_service/index.html @@ -19,7 +19,8 @@

Relibank Scenario Runner<

Trigger chaos and load testing scenarios.

-
+ +

Chaos Mesh Experiments

@@ -37,34 +38,49 @@

Stress Tests

+
- -
-

Payment Scenarios

-

Toggle payment failure scenarios for card transactions.

-
- + +
+

Feature Flag Scenarios

+

Toggle runtime scenarios for payment failures and A/B testing experiments.

+ +
+ +
+

Payment Scenarios

+
+ +
-
- -
-

Locust Load Tests

-

Simulate concurrent users to stress test your services and measure performance.

-
- - + +
+

A/B Testing

+
+ +
+ +
+

Locust Load Tests

+

Simulate concurrent users to stress test your services and measure performance.

+
+ + +
+
+
@@ -75,6 +91,7 @@

Locust Load Tests

const chaosButtonsContainer = document.getElementById('chaos-buttons-container'); const stressButtonsContainer = document.getElementById('stress-buttons-container'); const paymentButtonsContainer = document.getElementById('payment-buttons-container'); + const abTestButtonsContainer = document.getElementById('ab-test-buttons-container'); const runLocustBtn = document.getElementById('run-locust-test'); const userCountSelect = document.getElementById('user-count-select'); @@ -248,6 +265,71 @@

Locust Load Tests

showMessage(`Network error: ${e.message}`, 'error'); } }); + } else if (scenario.type === 'ab_test') { + const container = document.createElement('div'); + container.className = 'bg-white p-4 rounded-lg border border-gray-300'; + + const titleDiv = document.createElement('div'); + titleDiv.className = 'flex items-center justify-between mb-2'; + + const title = document.createElement('span'); + title.className = 'font-semibold text-gray-800'; + title.textContent = scenario.description; + + const toggle = document.createElement('button'); + toggle.className = scenario.enabled + ? 'bg-green-500 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-600 transition' + : 'bg-gray-300 text-gray-700 px-3 py-1 rounded-md text-sm font-medium hover:bg-gray-400 transition'; + toggle.textContent = scenario.enabled ? 'ON' : 'OFF'; + toggle.id = `toggle-${scenario.name}`; + + titleDiv.appendChild(title); + titleDiv.appendChild(toggle); + container.appendChild(titleDiv); + + const configDiv = document.createElement('div'); + configDiv.className = 'text-sm text-gray-600'; + if (scenario.name === 'lcp_slowness_percentage') { + configDiv.textContent = `${scenario.config.percentage}% users, ${scenario.config.delay_ms}ms delay`; + } else if (scenario.name === 'lcp_slowness_cohort') { + configDiv.textContent = `${scenario.config.user_count} test users, ${scenario.config.delay_ms}ms delay`; + } + container.appendChild(configDiv); + + abTestButtonsContainer.appendChild(container); + + toggle.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const newState = !scenario.enabled; + try { + let endpoint; + if (scenario.name === 'lcp_slowness_percentage') { + const percentage = scenario.config.percentage || 50.0; + const delay_ms = scenario.config.delay_ms || 3000; + endpoint = `/scenario-runner/api/ab-testing/lcp-slowness-percentage?enabled=${newState}&percentage=${percentage}&delay_ms=${delay_ms}`; + } else if (scenario.name === 'lcp_slowness_cohort') { + const delay_ms = scenario.config.delay_ms || 3000; + endpoint = `/scenario-runner/api/ab-testing/lcp-slowness-cohort?enabled=${newState}&delay_ms=${delay_ms}`; + } + + const response = await fetch(endpoint, { method: 'POST' }); + const result = await response.json(); + + if (result.status === 'success') { + scenario.enabled = newState; + toggle.textContent = newState ? 'ON' : 'OFF'; + toggle.className = newState + ? 'bg-green-500 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-600 transition' + : 'bg-gray-300 text-gray-700 px-3 py-1 rounded-md text-sm font-medium hover:bg-gray-400 transition'; + showMessage(result.message, 'success'); + } else { + showMessage(`Error: ${result.message}`, 'error'); + } + } catch (e) { + showMessage(`Network error: ${e.message}`, 'error'); + } + }); } }); diff --git a/scenario_service/scenario_service.py b/scenario_service/scenario_service.py index 2fe7e16..6d5cbb6 100644 --- a/scenario_service/scenario_service.py +++ b/scenario_service/scenario_service.py @@ -33,6 +33,34 @@ "stolen_card_probability": 0.0, # 0-100 percent } +# A/B Testing scenario configuration (runtime toggleable) +# Hardcoded list of 11 test users who will experience LCP slowness when cohort scenario is enabled +LCP_SLOW_USERS = { + 'b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d', # Alice Johnson + 'f5e8d1c6-2a9b-4c3e-8f1a-6e5b0d2c9f1a', # Bob Williams + 'e1f2b3c4-5d6a-7e8f-9a0b-1c2d3e4f5a6b', # Charlie Brown + 'f47ac10b-58cc-4372-a567-0e02b2c3d471', # Solaire Astora + 'd9b1e2a3-f4c5-4d6e-8f7a-9b0c1d2e3f4a', # Malenia Miquella + '8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f', # Artorias Abyss + '7f6e5d4c-3b2a-1c0d-9e8f-7a6b5c4d3e2f', # Priscilla Painted + '6e5d4c3b-2a1c-0d9e-8f7a-6b5c4d3e2f1a', # Gwyn Cinder + '5d4c3b2a-1c0d-9e8f-7a6b-5c4d3e2f1a0b', # Siegmeyer Catarina + '4c3b2a1c-0d9e-8f7a-6b5c-4d3e2f1a0b9c', # Ornstein Dragon + '3b2a1c0d-9e8f-7a6b-5c4d-3e2f1a0b9c8d', # Smough Executioner +} + +AB_TEST_SCENARIOS = { + # Percentage-based scenario (50% of all users) + "lcp_slowness_percentage_enabled": False, + "lcp_slowness_percentage": 50.0, # 0-100 percent of users + "lcp_slowness_percentage_delay_ms": 3000, # Milliseconds of delay + + # Cohort-based scenario (11 hardcoded test users) + "lcp_slowness_cohort_enabled": False, + "lcp_slowness_cohort_delay_ms": 3000, # Milliseconds of delay + "lcp_slowness_cohort_user_count": len(LCP_SLOW_USERS), # Number of users in cohort +} + # Rate limiting for chaos scenarios (abuse prevention) # Use shorter cooldown for local development environments def get_cooldown_minutes(): @@ -212,6 +240,27 @@ async def get_scenarios(): "enabled": PAYMENT_SCENARIOS["stolen_card_enabled"], "config": {"probability": PAYMENT_SCENARIOS["stolen_card_probability"]} }) + # A/B Testing scenarios + scenarios_list.append({ + "name": "lcp_slowness_percentage", + "description": "LCP Slowness A/B Test (Percentage-based)", + "type": "ab_test", + "enabled": AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"], + "config": { + "percentage": AB_TEST_SCENARIOS["lcp_slowness_percentage"], + "delay_ms": AB_TEST_SCENARIOS["lcp_slowness_percentage_delay_ms"] + } + }) + scenarios_list.append({ + "name": "lcp_slowness_cohort", + "description": "LCP Slowness A/B Test (Cohort-based - 11 users)", + "type": "ab_test", + "enabled": AB_TEST_SCENARIOS["lcp_slowness_cohort_enabled"], + "config": { + "user_count": AB_TEST_SCENARIOS["lcp_slowness_cohort_user_count"], + "delay_ms": AB_TEST_SCENARIOS["lcp_slowness_cohort_delay_ms"] + } + }) return scenarios_list @app.post("/scenario-runner/api/trigger_chaos/{scenario_name}") @@ -654,3 +703,70 @@ async def reset_payment_scenarios(): "message": "All payment scenarios reset to defaults", "scenarios": PAYMENT_SCENARIOS } + + +# ============================================================================ +# A/B Testing Scenario Control Endpoints +# ============================================================================ + +@app.get("/scenario-runner/api/ab-testing/config") +async def get_ab_test_config(): + """Get current A/B testing configuration""" + return { + "status": "success", + "config": AB_TEST_SCENARIOS + } + + +@app.post("/scenario-runner/api/ab-testing/lcp-slowness-percentage") +async def toggle_lcp_slowness_percentage(enabled: bool, percentage: float = 50.0, delay_ms: int = 3000): + """Enable/disable LCP slowness for A/B testing (percentage-based: affects X% of all users)""" + if percentage < 0 or percentage > 100: + return {"status": "error", "message": "Percentage must be between 0 and 100"} + if delay_ms < 0 or delay_ms > 30000: + return {"status": "error", "message": "Delay must be between 0 and 30000 milliseconds"} + + AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"] = enabled + AB_TEST_SCENARIOS["lcp_slowness_percentage"] = percentage + AB_TEST_SCENARIOS["lcp_slowness_percentage_delay_ms"] = delay_ms + + status_msg = "enabled" if enabled else "disabled" + return { + "status": "success", + "message": f"LCP slowness (percentage) {status_msg} for {percentage}% of users ({delay_ms}ms delay)", + "config": AB_TEST_SCENARIOS + } + + +@app.post("/scenario-runner/api/ab-testing/lcp-slowness-cohort") +async def toggle_lcp_slowness_cohort(enabled: bool, delay_ms: int = 3000): + """Enable/disable LCP slowness for A/B testing (cohort-based: affects 11 hardcoded test users)""" + if delay_ms < 0 or delay_ms > 30000: + return {"status": "error", "message": "Delay must be between 0 and 30000 milliseconds"} + + AB_TEST_SCENARIOS["lcp_slowness_cohort_enabled"] = enabled + AB_TEST_SCENARIOS["lcp_slowness_cohort_delay_ms"] = delay_ms + + status_msg = "enabled" if enabled else "disabled" + user_count = AB_TEST_SCENARIOS["lcp_slowness_cohort_user_count"] + return { + "status": "success", + "message": f"LCP slowness (cohort) {status_msg} for {user_count} test users ({delay_ms}ms delay)", + "config": AB_TEST_SCENARIOS + } + + +@app.post("/scenario-runner/api/ab-testing/reset") +async def reset_ab_test_scenarios(): + """Reset all A/B test scenarios to default values""" + AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"] = False + AB_TEST_SCENARIOS["lcp_slowness_percentage"] = 50.0 + AB_TEST_SCENARIOS["lcp_slowness_percentage_delay_ms"] = 3000 + AB_TEST_SCENARIOS["lcp_slowness_cohort_enabled"] = False + AB_TEST_SCENARIOS["lcp_slowness_cohort_delay_ms"] = 3000 + + return { + "status": "success", + "message": "All A/B test scenarios reset to defaults", + "config": AB_TEST_SCENARIOS + } diff --git a/tests/README.md b/tests/README.md index 88ff1cb..451b628 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,6 +31,7 @@ export RELIBANK_URL="http://your-server.example.com" | `test_apm_user_tracking.py` | APM user ID header propagation tests | Header acceptance, multi-service chains, concurrent requests | | `test_scenario_service.py` | Scenario service API tests | Payment scenarios, chaos scenarios, locust load testing - all via API | | `test_payment_scenarios.py` | Payment failure scenarios | Gateway timeout, card decline, stolen card with probabilities | +| `test_ab_testing_scenarios.py` | A/B testing scenarios | LCP slowness (percentage-based and cohort-based), 11 hardcoded test users, cohort assignment, deterministic distribution | | `test_stress_scenarios.py` | Stress chaos experiments | CPU stress, memory stress, combined stress testing with Chaos Mesh | | `../frontend_service/app/**/*.test.tsx` | Frontend functional tests (Vitest) | Login, transfers, bill payment (Stripe), chatbot, form validation, API integration | @@ -194,6 +195,7 @@ These tests can be added to GitHub Actions or other CI pipelines: - ✅ **User Tracking**: Browser user ID assignment (random/header-based), APM header propagation across all services, multi-service request chains - ✅ **Scenario API**: Enable/disable/reset payment scenarios, chaos scenarios (smoke tests), locust load testing (smoke tests) - ✅ **Payment Scenarios**: Timeout, decline, stolen card with probabilities +- ✅ **A/B Testing**: LCP slowness percentage-based (affects X% of all users) and cohort-based (affects 11 hardcoded test users), deterministic cohort assignment - ✅ **Stress Chaos**: CPU stress, memory stress, combined stress testing with Chaos Mesh, service resilience under load - ✅ **Frontend Functional Tests**: Login flow, fund transfers, bill payment with Stripe, chatbot support, form validation, API integration, error handling (Vitest) @@ -203,18 +205,20 @@ The test suite uses `pytest-xdist` to run tests in parallel (with `-n auto`). Th ### When Tests Need Sequential Execution -Some tests modify shared application state (like the scenario service configuration) and cannot run in parallel. These tests are grouped using the `@pytest.mark.xdist_group` decorator. +Some tests modify shared application state (like the scenario service configuration) and cannot run in parallel. These tests are run sequentially by explicitly separating them in the test workflow. -**Current Groups:** -- `scenario_service` - All tests in `test_scenario_service.py` and `test_payment_scenarios.py` that interact with the scenario service +**Tests that run sequentially:** +- `test_scenario_service.py` - Scenario service API tests +- `test_payment_scenarios.py` - Payment failure scenario tests +- `test_ab_testing_scenarios.py` - A/B testing scenario tests -**Example:** -```python -@pytest.mark.xdist_group(name="scenario_service") -def test_enable_gateway_timeout(): - """Test enabling gateway timeout scenario""" - # Test modifies shared scenario service state - ... +**Why sequential?** These tests all modify the scenario service's in-memory configuration state. Running them in parallel causes race conditions where tests overwrite each other's settings. + +**Implementation:** +```bash +# In .github/workflows/test-suite.yml +pytest test_scenario_service.py test_payment_scenarios.py test_ab_testing_scenarios.py --tb=line --timeout=300 +pytest . --ignore=test_scenario_service.py --ignore=test_payment_scenarios.py --ignore=test_ab_testing_scenarios.py -n auto --tb=line --timeout=300 ``` ### Adding New Sequential Tests @@ -226,15 +230,9 @@ When writing new tests that modify shared state: - Database records that aren't isolated per test - Global service settings -2. **Use the appropriate group** - If your test interacts with the scenario service: - ```python - @pytest.mark.xdist_group(name="scenario_service") - def test_my_new_scenario(): - # Your test here - ... - ``` +2. **Add to sequential test list** - If your test interacts with the scenario service, add it to the first pytest command in the workflow -3. **Create a new group if needed** - For other shared resources: +3. **Add to ignore list** - Also add it to the `--ignore` flags in the second pytest command to prevent duplicate execution ```python @pytest.mark.xdist_group(name="database_setup") def test_database_migration(): diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py new file mode 100644 index 0000000..1e0b3d4 --- /dev/null +++ b/tests/test_ab_testing_scenarios.py @@ -0,0 +1,412 @@ +import pytest +import requests +import time +import os +from typing import Dict + +# Configuration - use environment variables with local defaults +SCENARIO_SERVICE_URL = os.getenv("SCENARIO_SERVICE_URL", "http://localhost:8000") +ACCOUNTS_SERVICE_URL = os.getenv("ACCOUNTS_SERVICE", "http://localhost:5002") + +# NOTE: This test suite only tests the COHORT-BASED LCP slowness scenario +# (affects 11 hardcoded test users). The PERCENTAGE-BASED scenario +# (affects X% of all users) is not currently tested in this suite. + + +@pytest.fixture +def reset_ab_tests(): + """Reset all A/B test scenarios before and after tests""" + # Reset before test + response = requests.post(f"{SCENARIO_SERVICE_URL}/api/ab-testing/reset", timeout=10) + assert response.status_code == 200 + time.sleep(0.5) + yield + # Cleanup after test + try: + requests.post(f"{SCENARIO_SERVICE_URL}/api/ab-testing/reset", timeout=10) + time.sleep(0.5) + except: + pass # Ignore cleanup errors + + +def test_ab_test_service_health(): + """Test that A/B testing endpoints are accessible""" + print("\n=== Testing A/B Test Service Health ===") + + response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) + print(f"Status: {response.status_code}") + + assert response.status_code == 200, f"A/B test config endpoint failed: {response.status_code}" + + data = response.json() + assert "status" in data, "Response missing status field" + assert "config" in data, "Response missing config field" + assert data["status"] == "success", "Status is not success" + + print("✓ A/B test service is healthy") + + +def test_enable_lcp_slowness(reset_ab_tests): + """Test enabling LCP slowness scenario (cohort-based)""" + print("\n=== Testing Enable LCP Slowness (Cohort) ===") + + # Enable cohort-based scenario with 3000ms delay (affects 11 hardcoded test users) + response = requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, + timeout=10 + ) + + print(f"Status: {response.status_code}") + assert response.status_code == 200, f"Failed to enable LCP slowness: {response.status_code}" + + data = response.json() + print(f"Response: {data}") + + assert data["status"] == "success", "Status is not success" + assert "11" in data["message"], "Message doesn't mention 11 users" + assert "3000ms" in data["message"], "Message doesn't mention 3000ms" + + # Verify scenario is enabled with correct settings + config_response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) + config = config_response.json()["config"] + + assert config["lcp_slowness_cohort_enabled"] is True, "LCP slowness cohort not enabled" + assert config["lcp_slowness_cohort_user_count"] == 11, "User count not set correctly" + assert config["lcp_slowness_cohort_delay_ms"] == 3000, "Delay not set correctly" + + print("✓ LCP slowness cohort scenario enabled successfully") + + +def test_disable_lcp_slowness(reset_ab_tests): + """Test disabling LCP slowness scenario (cohort-based)""" + print("\n=== Testing Disable LCP Slowness (Cohort) ===") + + # First enable it + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, + timeout=10 + ) + + # Now disable it + response = requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": False}, + timeout=10 + ) + + assert response.status_code == 200, f"Failed to disable LCP slowness: {response.status_code}" + + # Verify scenario is disabled + config_response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) + config = config_response.json()["config"] + + assert config["lcp_slowness_cohort_enabled"] is False, "LCP slowness cohort still enabled" + + print("✓ LCP slowness cohort scenario disabled successfully") + + +def test_cohort_assignment_distribution(reset_ab_tests): + """Test that only the 11 hardcoded users are assigned to slow cohort""" + print("\n=== Testing Cohort Assignment Distribution ===") + + # Hardcoded list of 11 test users (same as in accounts_service.py) + LCP_SLOW_USERS = [ + 'b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d', # Alice Johnson + 'f5e8d1c6-2a9b-4c3e-8f1a-6e5b0d2c9f1a', # Bob Williams + 'e1f2b3c4-5d6a-7e8f-9a0b-1c2d3e4f5a6b', # Charlie Brown + 'f47ac10b-58cc-4372-a567-0e02b2c3d471', # Solaire Astora + 'd9b1e2a3-f4c5-4d6e-8f7a-9b0c1d2e3f4a', # Malenia Miquella + '8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f', # Artorias Abyss + '7f6e5d4c-3b2a-1c0d-9e8f-7a6b5c4d3e2f', # Priscilla Painted + '6e5d4c3b-2a1c-0d9e-8f7a-6b5c4d3e2f1a', # Gwyn Cinder + '5d4c3b2a-1c0d-9e8f-7a6b-5c4d3e2f1a0b', # Siegmeyer Catarina + '4c3b2a1c-0d9e-8f7a-6b5c-4d3e2f1a0b9c', # Ornstein Dragon + '3b2a1c0d-9e8f-7a6b-5c4d-3e2f1a0b9c8d', # Smough Executioner + ] + + # Enable LCP slowness cohort for hardcoded 11 users + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, + timeout=10 + ) + + print("Testing all 11 hardcoded slow users...") + + # Test that all 11 hardcoded users get the delay + for test_uuid in LCP_SLOW_USERS: + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_uuid}, + timeout=10 + ) + assert response.status_code == 200, f"Failed to get browser user: {response.status_code}" + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 3000, f"Hardcoded slow user {test_uuid} got delay {lcp_delay}, expected 3000" + + print(f"✓ All 11 hardcoded users assigned to slow cohort") + + # Test that random users NOT in the list get no delay + print("Testing 10 random users NOT in hardcoded list...") + import uuid + for i in range(10): + test_uuid = str(uuid.uuid4()) + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_uuid}, + timeout=10 + ) + assert response.status_code == 200, f"Failed to get browser user: {response.status_code}" + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 0, f"Non-hardcoded user {test_uuid} got delay {lcp_delay}, expected 0" + + print("✓ Random users NOT in hardcoded list assigned to normal cohort") + + +def test_deterministic_cohort_assignment(reset_ab_tests): + """Test that same user always gets same cohort assignment""" + print("\n=== Testing Deterministic Cohort Assignment ===") + + # Enable LCP slowness cohort + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, + timeout=10 + ) + + # Test with a hardcoded slow user (should always get delay) + test_user_id_slow = "b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d" # Alice Johnson + + # Fetch cohort assignment 5 times for the same slow user + assignments_slow = [] + for i in range(5): + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_user_id_slow}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + assignments_slow.append(lcp_delay) + + print(f"Slow user {test_user_id_slow} assignments: {assignments_slow}") + + # All assignments should be 3000ms + assert len(set(assignments_slow)) == 1, f"Assignments are not consistent: {assignments_slow}" + assert assignments_slow[0] == 3000, f"Expected 3000ms delay, got {assignments_slow[0]}" + + # Test with a non-hardcoded user (should always get no delay) + test_user_id_normal = "550e8400-e29b-41d4-a716-446655440000" # Not in hardcoded list + + assignments_normal = [] + for i in range(5): + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_user_id_normal}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + assignments_normal.append(lcp_delay) + + print(f"Normal user {test_user_id_normal} assignments: {assignments_normal}") + + # All assignments should be 0ms + assert len(set(assignments_normal)) == 1, f"Assignments are not consistent: {assignments_normal}" + assert assignments_normal[0] == 0, f"Expected 0ms delay, got {assignments_normal[0]}" + + print("✓ Cohort assignment is deterministic for both slow and normal users") + + +def test_enabled_scenario_affects_hardcoded_users(reset_ab_tests): + """Test that enabled scenario affects only the 11 hardcoded users""" + print("\n=== Testing Enabled Scenario Affects Hardcoded Users ===") + + # Enable LCP slowness cohort with 5000ms delay + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 5000}, + timeout=10 + ) + + # Test one hardcoded user (should get delay) + slow_user = "b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d" # Alice Johnson + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": slow_user}, + timeout=10 + ) + assert response.status_code == 200 + data = response.json() + assert data.get("lcp_delay_ms", 0) == 5000, f"Hardcoded user got delay {data.get('lcp_delay_ms')}, expected 5000" + + print("✓ Hardcoded user gets 5000ms delay when enabled") + + # Test random users (should NOT get delay) + import uuid + for i in range(5): + test_uuid = str(uuid.uuid4()) + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_uuid}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 0, f"Random user {i+1} got delay {lcp_delay}, expected 0" + + print("✓ Random users get no delay (not in hardcoded list)") + + +def test_disabled_scenario_affects_no_users(reset_ab_tests): + """Test that disabled scenario affects no users (including hardcoded ones)""" + print("\n=== Testing Disabled Scenario Affects No Users ===") + + # Disable LCP slowness cohort (scenario disabled means no delays) + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": False, "delay_ms": 3000}, + timeout=10 + ) + + # Test hardcoded users (should NOT get delay when disabled) + hardcoded_users = [ + "b2a5c9f1-3d7f-4b0d-9a8c-9c7b5a1f2e4d", # Alice Johnson + "f5e8d1c6-2a9b-4c3e-8f1a-6e5b0d2c9f1a", # Bob Williams + ] + + for test_uuid in hardcoded_users: + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_uuid}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 0, f"Hardcoded user {test_uuid} got delay {lcp_delay}, expected 0 when disabled" + + print("✓ Hardcoded users get no delay when scenario is disabled") + + # Test random users (should also get no delay) + import uuid + for i in range(5): + test_uuid = str(uuid.uuid4()) + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_uuid}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 0, f"Random user {i+1} got delay {lcp_delay}, expected 0" + + print("✓ All users get no delay when scenario is disabled") + + +def test_invalid_delay_values(reset_ab_tests): + """Test that invalid delay values are rejected""" + print("\n=== Testing Invalid Delay Values ===") + + # Test delay > 30000ms + response = requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 50000}, + timeout=10 + ) + + assert response.status_code == 200 # Should return 200 with error message + data = response.json() + assert data["status"] == "error", "Should reject delay > 30000ms" + print("✓ Rejected delay > 30000ms") + + # Test negative delay + response = requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": -1000}, + timeout=10 + ) + + assert response.status_code == 200 # Should return 200 with error message + data = response.json() + assert data["status"] == "error", "Should reject negative delay" + print("✓ Rejected negative delay") + + +def test_reset_ab_tests_endpoint(reset_ab_tests): + """Test resetting all A/B test scenarios""" + print("\n=== Testing Reset All A/B Tests ===") + + # Enable LCP slowness cohort + requests.post( + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 5000}, + timeout=10 + ) + + # Verify it's enabled + config = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10).json()["config"] + assert config["lcp_slowness_cohort_enabled"] is True + + # Reset + response = requests.post(f"{SCENARIO_SERVICE_URL}/api/ab-testing/reset", timeout=10) + assert response.status_code == 200 + + # Verify all scenarios are disabled with default values + config = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10).json()["config"] + + assert config["lcp_slowness_cohort_enabled"] is False, "LCP slowness cohort still enabled after reset" + assert config["lcp_slowness_cohort_user_count"] == 11, "User count not correct" + assert config["lcp_slowness_cohort_delay_ms"] == 3000, "Cohort delay not reset to default" + assert config["lcp_slowness_percentage_enabled"] is False, "LCP slowness percentage still enabled after reset" + assert config["lcp_slowness_percentage"] == 50.0, "Percentage not reset to default" + assert config["lcp_slowness_percentage_delay_ms"] == 3000, "Percentage delay not reset to default" + + print("✓ All A/B test scenarios reset successfully") + + +def test_scenario_service_unavailable_graceful_fallback(): + """Test that accounts service handles scenario service unavailability gracefully""" + print("\n=== Testing Graceful Fallback When Scenario Service Unavailable ===") + + # Note: This test assumes scenario service might be down or unreachable + # In that case, accounts service should still return user_id with lcp_delay_ms = 0 + + try: + response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=10) + + if response.status_code == 200: + data = response.json() + + # Should have user_id even if scenario service is down + assert "user_id" in data, "Missing user_id in response" + + # Should have lcp_delay_ms field (defaults to 0 if scenario service unavailable) + assert "lcp_delay_ms" in data, "Missing lcp_delay_ms in response" + + print(f"✓ Graceful fallback working: user_id={data['user_id']}, lcp_delay_ms={data.get('lcp_delay_ms', 0)}") + else: + print(f"⚠ Accounts service returned {response.status_code}, skipping graceful fallback test") + + except Exception as e: + print(f"⚠ Could not test graceful fallback: {e}") diff --git a/tests/test_browser_user_tracking.py b/tests/test_browser_user_tracking.py index ef10f2b..f7fc664 100644 --- a/tests/test_browser_user_tracking.py +++ b/tests/test_browser_user_tracking.py @@ -101,10 +101,10 @@ def test_browser_user_header_override(): def test_browser_user_invalid_header_fallback(): - """Test that endpoint falls back to random ID when invalid header is provided""" + """Test that endpoint accepts valid UUID format even if not in database (for A/B testing)""" print("\n=== Testing Invalid Header Fallback ===") - # Use a non-existent UUID + # Use a non-existent UUID (valid format, but not in database) invalid_user_id = str(uuid.uuid4()) headers = {"x-browser-user-id": invalid_user_id} @@ -119,13 +119,13 @@ def test_browser_user_invalid_header_fallback(): data = response.json() print(f"Response: {data}") - # Should fall back to random selection - assert data["source"] == "random", \ - f"Expected fallback to source='random', got '{data['source']}'" - assert data["user_id"] != invalid_user_id, \ - "Should not return the invalid user ID" + # Should accept the header UUID even if not in database (for A/B testing) + assert data["source"] == "header", \ + f"Expected source='header', got '{data['source']}'" + assert data["user_id"] == invalid_user_id, \ + "Should return the header user ID for deterministic A/B testing" - print(f"✓ Invalid header correctly fell back to random: {data['user_id']}") + print(f"✓ Header UUID accepted for A/B testing: {data['user_id']}") def test_browser_user_malformed_header():