From 66318fa737124fa6b1af624ebff112177a31a9e8 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Tue, 17 Feb 2026 13:24:01 -0800 Subject: [PATCH 01/11] feat: first pass of the scenario! changes scenario service to add new scenarios, get_browser_user to check if the user should be slow, makes accounts db aware of the scenario service to see if scenario is enabled, along with tests and a github action to trigger the scenario w/ deployment marker --- .github/workflows/flow-lcp-ab-test.yml | 132 +++++++++ accounts_service/accounts_service.py | 67 ++++- frontend_service/app/root.tsx | 11 + frontend_service/app/routes/dashboard.tsx | 29 +- scenario_service/scenario_service.py | 65 +++++ tests/test_ab_testing_scenarios.py | 312 ++++++++++++++++++++++ 6 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/flow-lcp-ab-test.yml create mode 100644 tests/test_ab_testing_scenarios.py diff --git a/.github/workflows/flow-lcp-ab-test.yml b/.github/workflows/flow-lcp-ab-test.yml new file mode 100644 index 0000000..a418328 --- /dev/null +++ b/.github/workflows/flow-lcp-ab-test.yml @@ -0,0 +1,132 @@ +name: 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/accounts_service/accounts_service.py b/accounts_service/accounts_service.py index f259e4f..dd67d77 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,27 @@ 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_enabled": False, + "lcp_slowness_percentage": 0.0, + "lcp_slowness_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 +76,9 @@ 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")}" + # Global connection pool connection_pool = None @@ -473,7 +498,26 @@ async def get_browser_user(request: Request): 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"} + + # Fetch A/B test config and assign LCP slowness cohort + ab_config = await get_ab_test_config() + lcp_delay_ms = 0 + + if ab_config.get("lcp_slowness_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_delay_ms", 0) + logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") + else: + logging.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort") + + return { + "user_id": browser_user_id, + "source": "header", + "lcp_delay_ms": lcp_delay_ms + } else: logging.warning(f"[Browser User] Header-provided ID {browser_user_id} not found in database, falling back to random") except (ValueError, Exception) as e: @@ -491,7 +535,26 @@ 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 + + if ab_config.get("lcp_slowness_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_delay_ms", 0) + logging.info(f"[Browser User] User {user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") + else: + logging.info(f"[Browser User] User {user_id} assigned to NORMAL LCP cohort") + + 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..9741545 100644 --- a/frontend_service/app/root.tsx +++ b/frontend_service/app/root.tsx @@ -134,6 +134,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..0b80ce1 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 + const [isApplyingLcpDelay, setIsApplyingLcpDelay] = useState(true); + + // 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)); + } + + setIsApplyingLcpDelay(false); + }; + + applyLcpDelay(); + }, []); // 2. Secondary Fetch: Get additional account details after initial data is set useEffect(() => { @@ -276,7 +294,16 @@ const DashboardPage = () => { stackedBarData: mockStackedBarData, }; - if (!userData) { + // Show loading spinner while applying LCP delay (A/B test) + if (isApplyingLcpDelay) { + return ( + + + + ); + } + + if (!userData) { return ; } diff --git a/scenario_service/scenario_service.py b/scenario_service/scenario_service.py index 2fe7e16..0877eae 100644 --- a/scenario_service/scenario_service.py +++ b/scenario_service/scenario_service.py @@ -33,6 +33,13 @@ "stolen_card_probability": 0.0, # 0-100 percent } +# A/B Testing scenario configuration (runtime toggleable) +AB_TEST_SCENARIOS = { + "lcp_slowness_enabled": False, + "lcp_slowness_percentage": 50.0, # 0-100 percent of users + "lcp_slowness_delay_ms": 3000, # Milliseconds of delay for LCP elements +} + # Rate limiting for chaos scenarios (abuse prevention) # Use shorter cooldown for local development environments def get_cooldown_minutes(): @@ -212,6 +219,17 @@ 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", + "description": "LCP Slowness A/B Test", + "type": "ab_test", + "enabled": AB_TEST_SCENARIOS["lcp_slowness_enabled"], + "config": { + "percentage": AB_TEST_SCENARIOS["lcp_slowness_percentage"], + "delay_ms": AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] + } + }) return scenarios_list @app.post("/scenario-runner/api/trigger_chaos/{scenario_name}") @@ -654,3 +672,50 @@ 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") +async def toggle_lcp_slowness(enabled: bool, percentage: float = 50.0, delay_ms: int = 3000): + """Enable/disable LCP slowness for A/B testing""" + 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_enabled"] = enabled + AB_TEST_SCENARIOS["lcp_slowness_percentage"] = percentage + AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] = delay_ms + + status_msg = "enabled" if enabled else "disabled" + return { + "status": "success", + "message": f"LCP slowness {status_msg} for {percentage}% of 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_enabled"] = False + AB_TEST_SCENARIOS["lcp_slowness_percentage"] = 50.0 + AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] = 3000 + + return { + "status": "success", + "message": "All A/B test scenarios reset to defaults", + "config": AB_TEST_SCENARIOS + } diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py new file mode 100644 index 0000000..11243bb --- /dev/null +++ b/tests/test_ab_testing_scenarios.py @@ -0,0 +1,312 @@ +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") + + +@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}/scenario-runner/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}/scenario-runner/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}/scenario-runner/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""" + print("\n=== Testing Enable LCP Slowness ===") + + # Enable with 50% probability and 3000ms delay + response = requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 50.0, "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 "50%" in data["message"], "Message doesn't mention 50%" + 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}/scenario-runner/api/ab-testing/config", timeout=10) + config = config_response.json()["config"] + + assert config["lcp_slowness_enabled"] is True, "LCP slowness not enabled" + assert config["lcp_slowness_percentage"] == 50.0, "Percentage not set correctly" + assert config["lcp_slowness_delay_ms"] == 3000, "Delay not set correctly" + + print("✓ LCP slowness scenario enabled successfully") + + +def test_disable_lcp_slowness(reset_ab_tests): + """Test disabling LCP slowness scenario""" + print("\n=== Testing Disable LCP Slowness ===") + + # First enable it + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + timeout=10 + ) + + # Now disable it + response = requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + 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}/scenario-runner/api/ab-testing/config", timeout=10) + config = config_response.json()["config"] + + assert config["lcp_slowness_enabled"] is False, "LCP slowness still enabled" + + print("✓ LCP slowness scenario disabled successfully") + + +def test_cohort_assignment_distribution(reset_ab_tests): + """Test that ~50% of users are assigned to slow cohort""" + print("\n=== Testing Cohort Assignment Distribution ===") + + # Enable LCP slowness for 50% of users + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + timeout=10 + ) + + print("Fetching browser user IDs for 100 users...") + + slow_users = 0 + normal_users = 0 + + for i in range(100): + response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", 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) + + if lcp_delay > 0: + slow_users += 1 + else: + normal_users += 1 + + print(f"Slow cohort: {slow_users}/100 users") + print(f"Normal cohort: {normal_users}/100 users") + + # Allow for some variance (40-60% range due to randomness) + assert 40 <= slow_users <= 60, f"Expected ~50% slow users, got {slow_users}%" + assert 40 <= normal_users <= 60, f"Expected ~50% normal users, got {normal_users}%" + + print("✓ Cohort distribution is approximately 50/50") + + +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 + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + timeout=10 + ) + + # Get a user ID with header override + test_user_id = "550e8400-e29b-41d4-a716-446655440000" + + # Fetch cohort assignment 5 times for the same user + assignments = [] + for i in range(5): + response = requests.get( + f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", + headers={"x-browser-user-id": test_user_id}, + timeout=10 + ) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + assignments.append(lcp_delay) + + print(f"User {test_user_id} assignments: {assignments}") + + # All assignments should be the same + assert len(set(assignments)) == 1, f"Assignments are not consistent: {assignments}" + + print("✓ Cohort assignment is deterministic") + + +def test_100_percent_assignment(reset_ab_tests): + """Test that 100% assignment puts all users in slow cohort""" + print("\n=== Testing 100% Cohort Assignment ===") + + # Enable LCP slowness for 100% of users + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 100.0, "delay_ms": 5000}, + timeout=10 + ) + + print("Checking 10 users...") + + for i in range(10): + response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=10) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 5000, f"User {i+1} got delay {lcp_delay}, expected 5000" + + print("✓ All users assigned to slow cohort at 100%") + + +def test_0_percent_assignment(reset_ab_tests): + """Test that 0% assignment puts all users in normal cohort""" + print("\n=== Testing 0% Cohort Assignment ===") + + # Enable LCP slowness for 0% of users (effectively disabled) + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 0.0, "delay_ms": 3000}, + timeout=10 + ) + + print("Checking 10 users...") + + for i in range(10): + response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=10) + assert response.status_code == 200 + + data = response.json() + lcp_delay = data.get("lcp_delay_ms", 0) + + assert lcp_delay == 0, f"User {i+1} got delay {lcp_delay}, expected 0" + + print("✓ All users assigned to normal cohort at 0%") + + +def test_invalid_percentage_values(reset_ab_tests): + """Test that invalid percentage values are rejected""" + print("\n=== Testing Invalid Percentage Values ===") + + # Test percentage > 100 + response = requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 150.0}, + timeout=10 + ) + + assert response.status_code == 200 # Should return 200 with error message + data = response.json() + assert data["status"] == "error", "Should reject percentage > 100" + print("✓ Rejected percentage > 100") + + # Test negative percentage + response = requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": -10.0}, + timeout=10 + ) + + assert response.status_code == 200 # Should return 200 with error message + data = response.json() + assert data["status"] == "error", "Should reject negative percentage" + print("✓ Rejected negative percentage") + + +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 + requests.post( + f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + params={"enabled": True, "percentage": 75.0, "delay_ms": 5000}, + timeout=10 + ) + + # Verify it's enabled + config = requests.get(f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/config", timeout=10).json()["config"] + assert config["lcp_slowness_enabled"] is True + + # Reset + response = requests.post(f"{SCENARIO_SERVICE_URL}/scenario-runner/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}/scenario-runner/api/ab-testing/config", timeout=10).json()["config"] + + assert config["lcp_slowness_enabled"] is False, "LCP slowness still enabled after reset" + assert config["lcp_slowness_percentage"] == 50.0, "Percentage not reset to default" + assert config["lcp_slowness_delay_ms"] == 3000, "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}") From 20cf32aab11f07e9476175b54a8dfc26f7e66b07 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Tue, 17 Feb 2026 14:10:59 -0800 Subject: [PATCH 02/11] feat: working tests --- accounts_service/accounts_service.py | 54 ++++++------ scenario_service/index.html | 121 ++++++++++++++++++++++----- tests/test_ab_testing_scenarios.py | 2 +- 3 files changed, 125 insertions(+), 52 deletions(-) diff --git a/accounts_service/accounts_service.py b/accounts_service/accounts_service.py index dd67d77..0367ac2 100644 --- a/accounts_service/accounts_service.py +++ b/accounts_service/accounts_service.py @@ -488,40 +488,36 @@ 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}") - - # Fetch A/B test config and assign LCP slowness cohort - ab_config = await get_ab_test_config() - lcp_delay_ms = 0 - - if ab_config.get("lcp_slowness_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_delay_ms", 0) - logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") - else: - logging.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort") - - return { - "user_id": browser_user_id, - "source": "header", - "lcp_delay_ms": lcp_delay_ms - } - else: - logging.warning(f"[Browser User] Header-provided ID {browser_user_id} not found in database, falling back to random") + # 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 + + if ab_config.get("lcp_slowness_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_delay_ms", 0) + logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") + else: + logging.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort") + + 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") diff --git a/scenario_service/index.html b/scenario_service/index.html index 7de856a..4fbae42 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,66 @@

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') { + configDiv.textContent = `${scenario.config.percentage}% 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') { + const percentage = scenario.config.percentage || 50.0; + const delay_ms = scenario.config.delay_ms || 3000; + endpoint = `/scenario-runner/api/ab-testing/lcp-slowness?enabled=${newState}&percentage=${percentage}&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/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py index 11243bb..7805e25 100644 --- a/tests/test_ab_testing_scenarios.py +++ b/tests/test_ab_testing_scenarios.py @@ -60,7 +60,7 @@ def test_enable_lcp_slowness(reset_ab_tests): print(f"Response: {data}") assert data["status"] == "success", "Status is not success" - assert "50%" in data["message"], "Message doesn't mention 50%" + assert ("50%" in data["message"] or "50.0%" in data["message"]), "Message doesn't mention 50%" assert "3000ms" in data["message"], "Message doesn't mention 3000ms" # Verify scenario is enabled with correct settings From d2b247a6d57a7f12a5405994f60b5e025f9a2438 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:05:26 -0800 Subject: [PATCH 03/11] fix: remove duplicate /scenario-runner path in test workflow causing 404s --- .github/workflows/test-suite.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d1c6c70..f416d3d 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -55,7 +55,7 @@ jobs: BILL_PAY_SERVICE: ${{ vars.BASE_URL }} CHATBOT_SERVICE: ${{ vars.BASE_URL }} TRANSACTION_SERVICE: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} run: | cd tests # Run scenario tests sequentially to avoid race conditions @@ -73,7 +73,7 @@ jobs: BILL_PAY_SERVICE: ${{ vars.BASE_URL }} CHATBOT_SERVICE: ${{ vars.BASE_URL }} TRANSACTION_SERVICE: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} run: | cd tests pytest test_end_to_end.py -n auto --tb=line @@ -82,7 +82,7 @@ jobs: if: ${{ github.event.inputs.test_suite == 'scenario' }} env: RELIBANK_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} run: | cd tests pytest test_scenario_service.py --tb=line @@ -91,7 +91,7 @@ jobs: if: ${{ github.event.inputs.test_suite == 'payment' }} env: RELIBANK_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} BILL_PAY_SERVICE: ${{ vars.BASE_URL }} run: | cd tests @@ -102,7 +102,7 @@ jobs: env: RELIBANK_URL: ${{ vars.BASE_URL }} BASE_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} run: | cd tests pytest test_end_to_end.py::test_frontend_loads test_scenario_service.py::test_scenario_service_health -n auto --tb=line From 9972aa1264e5e6058de6bd1d87d7f33389d646f3 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:11:43 -0800 Subject: [PATCH 04/11] corrected URLs --- .github/workflows/test-suite.yml | 10 ++++----- tests/test_ab_testing_scenarios.py | 36 +++++++++++++++--------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f416d3d..d1c6c70 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -55,7 +55,7 @@ jobs: BILL_PAY_SERVICE: ${{ vars.BASE_URL }} CHATBOT_SERVICE: ${{ vars.BASE_URL }} TRANSACTION_SERVICE: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner run: | cd tests # Run scenario tests sequentially to avoid race conditions @@ -73,7 +73,7 @@ jobs: BILL_PAY_SERVICE: ${{ vars.BASE_URL }} CHATBOT_SERVICE: ${{ vars.BASE_URL }} TRANSACTION_SERVICE: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner run: | cd tests pytest test_end_to_end.py -n auto --tb=line @@ -82,7 +82,7 @@ jobs: if: ${{ github.event.inputs.test_suite == 'scenario' }} env: RELIBANK_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner run: | cd tests pytest test_scenario_service.py --tb=line @@ -91,7 +91,7 @@ jobs: if: ${{ github.event.inputs.test_suite == 'payment' }} env: RELIBANK_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner BILL_PAY_SERVICE: ${{ vars.BASE_URL }} run: | cd tests @@ -102,7 +102,7 @@ jobs: env: RELIBANK_URL: ${{ vars.BASE_URL }} BASE_URL: ${{ vars.BASE_URL }} - SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }} + SCENARIO_SERVICE_URL: ${{ vars.BASE_URL }}/scenario-runner run: | cd tests pytest test_end_to_end.py::test_frontend_loads test_scenario_service.py::test_scenario_service_health -n auto --tb=line diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py index 7805e25..8d18365 100644 --- a/tests/test_ab_testing_scenarios.py +++ b/tests/test_ab_testing_scenarios.py @@ -13,13 +13,13 @@ def reset_ab_tests(): """Reset all A/B test scenarios before and after tests""" # Reset before test - response = requests.post(f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/reset", timeout=10) + 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}/scenario-runner/api/ab-testing/reset", timeout=10) + requests.post(f"{SCENARIO_SERVICE_URL}/api/ab-testing/reset", timeout=10) time.sleep(0.5) except: pass # Ignore cleanup errors @@ -29,7 +29,7 @@ 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}/scenario-runner/api/ab-testing/config", timeout=10) + 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}" @@ -48,7 +48,7 @@ def test_enable_lcp_slowness(reset_ab_tests): # Enable with 50% probability and 3000ms delay response = requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, timeout=10 ) @@ -64,7 +64,7 @@ def test_enable_lcp_slowness(reset_ab_tests): 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}/scenario-runner/api/ab-testing/config", timeout=10) + config_response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) config = config_response.json()["config"] assert config["lcp_slowness_enabled"] is True, "LCP slowness not enabled" @@ -80,14 +80,14 @@ def test_disable_lcp_slowness(reset_ab_tests): # First enable it requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, timeout=10 ) # Now disable it response = requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": False}, timeout=10 ) @@ -95,7 +95,7 @@ def test_disable_lcp_slowness(reset_ab_tests): 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}/scenario-runner/api/ab-testing/config", timeout=10) + config_response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) config = config_response.json()["config"] assert config["lcp_slowness_enabled"] is False, "LCP slowness still enabled" @@ -109,7 +109,7 @@ def test_cohort_assignment_distribution(reset_ab_tests): # Enable LCP slowness for 50% of users requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, timeout=10 ) @@ -147,7 +147,7 @@ def test_deterministic_cohort_assignment(reset_ab_tests): # Enable LCP slowness requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, timeout=10 ) @@ -183,7 +183,7 @@ def test_100_percent_assignment(reset_ab_tests): # Enable LCP slowness for 100% of users requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 100.0, "delay_ms": 5000}, timeout=10 ) @@ -208,7 +208,7 @@ def test_0_percent_assignment(reset_ab_tests): # Enable LCP slowness for 0% of users (effectively disabled) requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 0.0, "delay_ms": 3000}, timeout=10 ) @@ -233,7 +233,7 @@ def test_invalid_percentage_values(reset_ab_tests): # Test percentage > 100 response = requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 150.0}, timeout=10 ) @@ -245,7 +245,7 @@ def test_invalid_percentage_values(reset_ab_tests): # Test negative percentage response = requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": -10.0}, timeout=10 ) @@ -262,21 +262,21 @@ def test_reset_ab_tests_endpoint(reset_ab_tests): # Enable LCP slowness requests.post( - f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/lcp-slowness", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", params={"enabled": True, "percentage": 75.0, "delay_ms": 5000}, timeout=10 ) # Verify it's enabled - config = requests.get(f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/config", timeout=10).json()["config"] + config = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10).json()["config"] assert config["lcp_slowness_enabled"] is True # Reset - response = requests.post(f"{SCENARIO_SERVICE_URL}/scenario-runner/api/ab-testing/reset", timeout=10) + 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}/scenario-runner/api/ab-testing/config", timeout=10).json()["config"] + config = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10).json()["config"] assert config["lcp_slowness_enabled"] is False, "LCP slowness still enabled after reset" assert config["lcp_slowness_percentage"] == 50.0, "Percentage not reset to default" From bbe3773ac3cc60b2d731a1b71aea9c68f383c59d Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:18:49 -0800 Subject: [PATCH 05/11] fix: add environment to test-summary job for BASE_URL access --- .github/workflows/test-suite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d1c6c70..cda1eed 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -135,6 +135,7 @@ jobs: test-summary: runs-on: ubuntu-latest + environment: events needs: [python-tests, frontend-tests] if: always() From 4db867ede5d5678da13b6efb713a262c62c1de34 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:20:22 -0800 Subject: [PATCH 06/11] fix: update browser user test for new A/B testing behavior --- tests/test_browser_user_tracking.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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(): From b9593b65fc407cf2bd0840f308756dde3beef23c Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:24:09 -0800 Subject: [PATCH 07/11] fix: use unique UUIDs for cohort distribution test --- tests/test_ab_testing_scenarios.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py index 8d18365..e7b49b5 100644 --- a/tests/test_ab_testing_scenarios.py +++ b/tests/test_ab_testing_scenarios.py @@ -119,8 +119,15 @@ def test_cohort_assignment_distribution(reset_ab_tests): slow_users = 0 normal_users = 0 + # Generate unique UUIDs to ensure proper distribution testing + import uuid for i in range(100): - response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=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() From 461f4eaab740e19147a4946419dcd1e62ffa0714 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:35:51 -0800 Subject: [PATCH 08/11] fix: use unique UUIDs in 0% and 100% assignment tests --- tests/test_ab_testing_scenarios.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py index e7b49b5..48b7eed 100644 --- a/tests/test_ab_testing_scenarios.py +++ b/tests/test_ab_testing_scenarios.py @@ -197,8 +197,14 @@ def test_100_percent_assignment(reset_ab_tests): print("Checking 10 users...") + import uuid for i in range(10): - response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=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 data = response.json() @@ -222,8 +228,14 @@ def test_0_percent_assignment(reset_ab_tests): print("Checking 10 users...") + import uuid for i in range(10): - response = requests.get(f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", timeout=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 data = response.json() From 80bb2cd6748509b433cad91c96ac20c54775afcd Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:42:00 -0800 Subject: [PATCH 09/11] fix: run A/B test scenarios sequentially to avoid race conditions --- .github/workflows/test-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index cda1eed..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' }} From b5ab9c59b7f5282dddb7f43d549637cf8625ab9a Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 08:47:56 -0800 Subject: [PATCH 10/11] docs: add A/B testing scenarios to test suite documentation --- tests/README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/tests/README.md b/tests/README.md index 88ff1cb..89bbfc8 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, cohort assignment, deterministic distribution, percentage validation | | `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 cohort assignment, deterministic distribution, percentage validation (0%, 50%, 100%) - ✅ **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(): From cdb3aaeed55451f1c4c671b135fe7c6539b78115 Mon Sep 17 00:00:00 2001 From: jgoddard Date: Wed, 18 Feb 2026 16:01:16 -0800 Subject: [PATCH 11/11] feat: add LCP A/B testing with percentage-based and cohort-based scenarios --- .github/workflows/flow-lcp-ab-test.yml | 2 +- accounts_service/accounts_service.py | 54 ++++- frontend_service/app/root.tsx | 7 +- frontend_service/app/routes/dashboard.tsx | 87 +++++--- scenario_service/index.html | 11 +- scenario_service/scenario_service.py | 79 +++++-- tests/README.md | 4 +- tests/test_ab_testing_scenarios.py | 261 ++++++++++++++-------- 8 files changed, 348 insertions(+), 157 deletions(-) diff --git a/.github/workflows/flow-lcp-ab-test.yml b/.github/workflows/flow-lcp-ab-test.yml index a418328..0643581 100644 --- a/.github/workflows/flow-lcp-ab-test.yml +++ b/.github/workflows/flow-lcp-ab-test.yml @@ -1,4 +1,4 @@ -name: LCP A/B Test Scenario +name: Flow - LCP A/B Test Scenario on: schedule: diff --git a/accounts_service/accounts_service.py b/accounts_service/accounts_service.py index 0367ac2..6c90c11 100644 --- a/accounts_service/accounts_service.py +++ b/accounts_service/accounts_service.py @@ -59,9 +59,11 @@ async def get_ab_test_config(): # Return defaults if scenario service unavailable return { - "lcp_slowness_enabled": False, + "lcp_slowness_percentage_enabled": False, "lcp_slowness_percentage": 0.0, - "lcp_slowness_delay_ms": 0 + "lcp_slowness_percentage_delay_ms": 0, + "lcp_slowness_cohort_enabled": False, + "lcp_slowness_cohort_delay_ms": 0 } # Database connection details from environment variables @@ -79,6 +81,22 @@ async def get_ab_test_config(): # 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 @@ -501,15 +519,25 @@ async def get_browser_user(request: Request): ab_config = await get_ab_test_config() lcp_delay_ms = 0 - if ab_config.get("lcp_slowness_enabled"): + # 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_delay_ms", 0) - logging.info(f"[Browser User] User {browser_user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") + 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.info(f"[Browser User] User {browser_user_id} assigned to NORMAL LCP cohort (no scenarios enabled)") return { "user_id": browser_user_id, @@ -536,15 +564,25 @@ async def get_browser_user(request: Request): ab_config = await get_ab_test_config() lcp_delay_ms = 0 - if ab_config.get("lcp_slowness_enabled"): + # 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_delay_ms", 0) - logging.info(f"[Browser User] User {user_id} assigned to SLOW LCP cohort ({lcp_delay_ms}ms delay)") + 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, diff --git a/frontend_service/app/root.tsx b/frontend_service/app/root.tsx index 9741545..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'); diff --git a/frontend_service/app/routes/dashboard.tsx b/frontend_service/app/routes/dashboard.tsx index 0b80ce1..5cfc1ec 100644 --- a/frontend_service/app/routes/dashboard.tsx +++ b/frontend_service/app/routes/dashboard.tsx @@ -224,8 +224,8 @@ 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 - const [isApplyingLcpDelay, setIsApplyingLcpDelay] = useState(true); + // 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(() => { @@ -237,7 +237,7 @@ const DashboardPage = () => { await new Promise(resolve => setTimeout(resolve, lcpDelay)); } - setIsApplyingLcpDelay(false); + setIsLcpContentReady(true); }; applyLcpDelay(); @@ -294,15 +294,6 @@ const DashboardPage = () => { stackedBarData: mockStackedBarData, }; - // Show loading spinner while applying LCP delay (A/B test) - if (isApplyingLcpDelay) { - return ( - - - - ); - } - if (!userData) { return ; } @@ -347,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 4fbae42..cae80bc 100644 --- a/scenario_service/index.html +++ b/scenario_service/index.html @@ -289,8 +289,10 @@

Locust Load Tests

const configDiv = document.createElement('div'); configDiv.className = 'text-sm text-gray-600'; - if (scenario.name === 'lcp_slowness') { + 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); @@ -302,10 +304,13 @@

Locust Load Tests

const newState = !scenario.enabled; try { let endpoint; - if (scenario.name === 'lcp_slowness') { + 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?enabled=${newState}&percentage=${percentage}&delay_ms=${delay_ms}`; + 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' }); diff --git a/scenario_service/scenario_service.py b/scenario_service/scenario_service.py index 0877eae..6d5cbb6 100644 --- a/scenario_service/scenario_service.py +++ b/scenario_service/scenario_service.py @@ -34,10 +34,31 @@ } # 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 = { - "lcp_slowness_enabled": False, + # Percentage-based scenario (50% of all users) + "lcp_slowness_percentage_enabled": False, "lcp_slowness_percentage": 50.0, # 0-100 percent of users - "lcp_slowness_delay_ms": 3000, # Milliseconds of delay for LCP elements + "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) @@ -221,13 +242,23 @@ async def get_scenarios(): }) # A/B Testing scenarios scenarios_list.append({ - "name": "lcp_slowness", - "description": "LCP Slowness A/B Test", + "name": "lcp_slowness_percentage", + "description": "LCP Slowness A/B Test (Percentage-based)", "type": "ab_test", - "enabled": AB_TEST_SCENARIOS["lcp_slowness_enabled"], + "enabled": AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"], "config": { "percentage": AB_TEST_SCENARIOS["lcp_slowness_percentage"], - "delay_ms": AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] + "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 @@ -687,22 +718,40 @@ async def get_ab_test_config(): } -@app.post("/scenario-runner/api/ab-testing/lcp-slowness") -async def toggle_lcp_slowness(enabled: bool, percentage: float = 50.0, delay_ms: int = 3000): - """Enable/disable LCP slowness for A/B testing""" +@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_enabled"] = enabled + AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"] = enabled AB_TEST_SCENARIOS["lcp_slowness_percentage"] = percentage - AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] = delay_ms + 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 {status_msg} for {percentage}% of users ({delay_ms}ms delay)", + "message": f"LCP slowness (cohort) {status_msg} for {user_count} test users ({delay_ms}ms delay)", "config": AB_TEST_SCENARIOS } @@ -710,9 +759,11 @@ async def toggle_lcp_slowness(enabled: bool, percentage: float = 50.0, delay_ms: @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_enabled"] = False + AB_TEST_SCENARIOS["lcp_slowness_percentage_enabled"] = False AB_TEST_SCENARIOS["lcp_slowness_percentage"] = 50.0 - AB_TEST_SCENARIOS["lcp_slowness_delay_ms"] = 3000 + 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", diff --git a/tests/README.md b/tests/README.md index 89bbfc8..451b628 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,7 +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, cohort assignment, deterministic distribution, percentage validation | +| `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 | @@ -195,7 +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 cohort assignment, deterministic distribution, percentage validation (0%, 50%, 100%) +- ✅ **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) diff --git a/tests/test_ab_testing_scenarios.py b/tests/test_ab_testing_scenarios.py index 48b7eed..1e0b3d4 100644 --- a/tests/test_ab_testing_scenarios.py +++ b/tests/test_ab_testing_scenarios.py @@ -8,6 +8,10 @@ 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(): @@ -43,13 +47,13 @@ def test_ab_test_service_health(): def test_enable_lcp_slowness(reset_ab_tests): - """Test enabling LCP slowness scenario""" - print("\n=== Testing Enable LCP Slowness ===") + """Test enabling LCP slowness scenario (cohort-based)""" + print("\n=== Testing Enable LCP Slowness (Cohort) ===") - # Enable with 50% probability and 3000ms delay + # Enable cohort-based scenario with 3000ms delay (affects 11 hardcoded test users) response = requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, timeout=10 ) @@ -60,34 +64,34 @@ def test_enable_lcp_slowness(reset_ab_tests): print(f"Response: {data}") assert data["status"] == "success", "Status is not success" - assert ("50%" in data["message"] or "50.0%" in data["message"]), "Message doesn't mention 50%" + 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_enabled"] is True, "LCP slowness not enabled" - assert config["lcp_slowness_percentage"] == 50.0, "Percentage not set correctly" - assert config["lcp_slowness_delay_ms"] == 3000, "Delay not set correctly" + 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 scenario enabled successfully") + print("✓ LCP slowness cohort scenario enabled successfully") def test_disable_lcp_slowness(reset_ab_tests): - """Test disabling LCP slowness scenario""" - print("\n=== Testing Disable LCP Slowness ===") + """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", - params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + 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", + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", params={"enabled": False}, timeout=10 ) @@ -98,30 +102,59 @@ def test_disable_lcp_slowness(reset_ab_tests): config_response = requests.get(f"{SCENARIO_SERVICE_URL}/api/ab-testing/config", timeout=10) config = config_response.json()["config"] - assert config["lcp_slowness_enabled"] is False, "LCP slowness still enabled" + assert config["lcp_slowness_cohort_enabled"] is False, "LCP slowness cohort still enabled" - print("✓ LCP slowness scenario disabled successfully") + print("✓ LCP slowness cohort scenario disabled successfully") def test_cohort_assignment_distribution(reset_ab_tests): - """Test that ~50% of users are assigned to slow cohort""" + """Test that only the 11 hardcoded users are assigned to slow cohort""" print("\n=== Testing Cohort Assignment Distribution ===") - # Enable LCP slowness for 50% of users + # 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", - params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, timeout=10 ) - print("Fetching browser user IDs for 100 users...") + print("Testing all 11 hardcoded slow users...") - slow_users = 0 - normal_users = 0 + # 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}" - # Generate unique UUIDs to ensure proper distribution testing + 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(100): + for i in range(10): test_uuid = str(uuid.uuid4()) response = requests.get( f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", @@ -133,72 +166,97 @@ def test_cohort_assignment_distribution(reset_ab_tests): data = response.json() lcp_delay = data.get("lcp_delay_ms", 0) - if lcp_delay > 0: - slow_users += 1 - else: - normal_users += 1 - - print(f"Slow cohort: {slow_users}/100 users") - print(f"Normal cohort: {normal_users}/100 users") - - # Allow for some variance (40-60% range due to randomness) - assert 40 <= slow_users <= 60, f"Expected ~50% slow users, got {slow_users}%" - assert 40 <= normal_users <= 60, f"Expected ~50% normal users, got {normal_users}%" + assert lcp_delay == 0, f"Non-hardcoded user {test_uuid} got delay {lcp_delay}, expected 0" - print("✓ Cohort distribution is approximately 50/50") + 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 + # Enable LCP slowness cohort requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 50.0, "delay_ms": 3000}, + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 3000}, timeout=10 ) - # Get a user ID with header override - test_user_id = "550e8400-e29b-41d4-a716-446655440000" + # 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 - # Fetch cohort assignment 5 times for the same user - assignments = [] + 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}, + 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.append(lcp_delay) + assignments_normal.append(lcp_delay) - print(f"User {test_user_id} assignments: {assignments}") + print(f"Normal user {test_user_id_normal} assignments: {assignments_normal}") - # All assignments should be the same - assert len(set(assignments)) == 1, f"Assignments are not consistent: {assignments}" + # 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") + print("✓ Cohort assignment is deterministic for both slow and normal users") -def test_100_percent_assignment(reset_ab_tests): - """Test that 100% assignment puts all users in slow cohort""" - print("\n=== Testing 100% Cohort Assignment ===") +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 for 100% of users + # Enable LCP slowness cohort with 5000ms delay requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 100.0, "delay_ms": 5000}, + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": True, "delay_ms": 5000}, timeout=10 ) - print("Checking 10 users...") + # 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(10): + for i in range(5): test_uuid = str(uuid.uuid4()) response = requests.get( f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", @@ -210,26 +268,46 @@ def test_100_percent_assignment(reset_ab_tests): data = response.json() lcp_delay = data.get("lcp_delay_ms", 0) - assert lcp_delay == 5000, f"User {i+1} got delay {lcp_delay}, expected 5000" + assert lcp_delay == 0, f"Random user {i+1} got delay {lcp_delay}, expected 0" - print("✓ All users assigned to slow cohort at 100%") + print("✓ Random users get no delay (not in hardcoded list)") -def test_0_percent_assignment(reset_ab_tests): - """Test that 0% assignment puts all users in normal cohort""" - print("\n=== Testing 0% Cohort Assignment ===") +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 ===") - # Enable LCP slowness for 0% of users (effectively disabled) + # Disable LCP slowness cohort (scenario disabled means no delays) requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 0.0, "delay_ms": 3000}, + f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness-cohort", + params={"enabled": False, "delay_ms": 3000}, timeout=10 ) - print("Checking 10 users...") + # 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(10): + for i in range(5): test_uuid = str(uuid.uuid4()) response = requests.get( f"{ACCOUNTS_SERVICE_URL}/accounts-service/browser-user", @@ -241,54 +319,54 @@ def test_0_percent_assignment(reset_ab_tests): data = response.json() lcp_delay = data.get("lcp_delay_ms", 0) - assert lcp_delay == 0, f"User {i+1} got delay {lcp_delay}, expected 0" + assert lcp_delay == 0, f"Random user {i+1} got delay {lcp_delay}, expected 0" - print("✓ All users assigned to normal cohort at 0%") + print("✓ All users get no delay when scenario is disabled") -def test_invalid_percentage_values(reset_ab_tests): - """Test that invalid percentage values are rejected""" - print("\n=== Testing Invalid Percentage Values ===") +def test_invalid_delay_values(reset_ab_tests): + """Test that invalid delay values are rejected""" + print("\n=== Testing Invalid Delay Values ===") - # Test percentage > 100 + # Test delay > 30000ms response = requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 150.0}, + 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 percentage > 100" - print("✓ Rejected percentage > 100") + assert data["status"] == "error", "Should reject delay > 30000ms" + print("✓ Rejected delay > 30000ms") - # Test negative percentage + # Test negative delay response = requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": -10.0}, + 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 percentage" - print("✓ Rejected negative percentage") + 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 + # Enable LCP slowness cohort requests.post( - f"{SCENARIO_SERVICE_URL}/api/ab-testing/lcp-slowness", - params={"enabled": True, "percentage": 75.0, "delay_ms": 5000}, + 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_enabled"] is True + assert config["lcp_slowness_cohort_enabled"] is True # Reset response = requests.post(f"{SCENARIO_SERVICE_URL}/api/ab-testing/reset", timeout=10) @@ -297,9 +375,12 @@ def test_reset_ab_tests_endpoint(reset_ab_tests): # 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_enabled"] is False, "LCP slowness still enabled after reset" + 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_delay_ms"] == 3000, "Delay 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")