diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index abe0e1e0..937e706e 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -59,6 +62,9 @@ jobs: ci-hack: name: Cargo Hack runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - uses: actions/checkout@v4 - name: Install Rust @@ -148,3 +154,170 @@ jobs: helm dependency update helm-charts/ helm lint helm-charts/ helm template test helm-charts/ > /dev/null + + e2e-playwright: + name: E2E (Playwright) + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Install native deps for rdkafka + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + libcurl4-openssl-dev \ + libsasl2-dev \ + zlib1g-dev \ + libpq-dev + + - uses: Swatinem/rust-cache@v2.7.7 + + - uses: taiki-e/install-action@v2 + with: + tool: just + checksum: true + + - name: Install diesel CLI + run: cargo install diesel_cli --no-default-features --features postgres --locked + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install e2e dependencies + run: npm ci + + - name: Type check e2e suite + run: npm run typecheck + + - name: Install dashboard dependencies + run: npm ci + working-directory: website + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build decision-engine + run: cargo build --no-default-features --features postgres + + # Datastores only. oneclick.sh is a developer script — it kills processes on ports, prompts for + # confirmation, boots a docs preview and reinstalls dashboard deps — none of which CI wants. + # `--wait` blocks on the healthchecks already defined in docker-compose.yaml, so no custom + # polling is needed. kafka-init is excluded from --wait: it is a one-shot that creates topics + # and exits, which --wait would treat as a failure. + - name: Start datastores + run: | + COMPOSE_PROFILES= docker compose --profile postgres-ghcr --profile analytics-clickhouse \ + up -d postgresql redis kafka kafka-init clickhouse mailpit + COMPOSE_PROFILES= docker compose --profile postgres-ghcr --profile analytics-clickhouse \ + up -d --wait postgresql redis kafka clickhouse mailpit + + # KNOWN BUG WORKAROUND: migrations_pg/00000000000000_diesel_postgresql_initial_setup creates 33 + # tables (including merchant_account), but diesel reserves version 00000000000000 for its own + # setup migration and records it as applied when initialising a fresh database — so the repo's + # migration with that version is silently SKIPPED. Applying it explicitly first is the only way + # a clean Postgres gets the core schema. Remove this once the migration is renamed off 0000... + # psql comes from the postgres container, so the runner needs no postgresql-client package. + - name: Apply database schema + run: | + COMPOSE_PROFILES= docker compose --profile postgres-ghcr exec -T postgresql \ + psql -v ON_ERROR_STOP=1 -U db_user -d decision_engine_db \ + < migrations_pg/00000000000000_diesel_postgresql_initial_setup/up.sql + just migrate-pg + + - name: Seed global service configs + run: | + COMPOSE_PROFILES= docker compose --profile postgres-ghcr exec -T postgresql \ + psql -v ON_ERROR_STOP=1 -U db_user -d decision_engine_db -c " + INSERT INTO service_configuration (name, value) + SELECT v.name, v.value + FROM (VALUES + ('ENABLE_MERCHANT_ON_VOLUME_DISTRIBUTION_FEATURE_SR_V3', '{\"enableAll\":true,\"enableAllRollout\":100}'), + ('merchants_enabled_for_score_keys_unification', '{\"enableAll\":true,\"enableAllRollout\":100}'), + ('SR_V3_INPUT_CONFIG_DEFAULT', '{\"defaultLatencyThreshold\":90,\"defaultBucketSize\":125,\"defaultHedgingPercent\":5}') + ) AS v(name, value) + WHERE NOT EXISTS ( + SELECT 1 FROM service_configuration sc WHERE sc.name = v.name + );" + + # Playwright starts the API and dashboard itself (see the webServer block in + # playwright.config.ts) and tears them down afterwards. + # Preflight: prove the API can actually serve a merchant creation before spending an hour on 220 + # tests. Every failing test so far has died on exactly this call, so if it fails here we get the + # server's real response (or the hang) in seconds instead of inferring it from timeouts. Also + # dumps Postgres connection state, since the recurring Storage("db connection") warnings from the + # background workers point at the pool. + - name: Preflight — capture a backtrace of the hang + run: | + set -x + sudo apt-get update -qq && sudo apt-get install -y -qq gdb postgresql-client + + ./target/debug/open_router & + SRV=$! + for i in $(seq 1 90); do curl -sf -o /dev/null http://localhost:8080/health && break; sleep 2; done + curl -sS -o /dev/null -w 'health=%{http_code} in %{time_total}s\n' http://localhost:8080/health + + # Environment facts first — cheap, and each rules a class of cause in or out. + echo "--- resolution + listeners ---" + getent hosts localhost || true + ss -ltnp 2>/dev/null | grep -E ':5432|:8080' || true + echo "--- can the HOST reach postgres with the app's credentials? ---" + PGPASSWORD=db_pass pg_isready -h 127.0.0.1 -p 5432 -U db_user -d decision_engine_db || true + PGPASSWORD=db_pass psql -h 127.0.0.1 -U db_user -d decision_engine_db -tAqX \ + -c "select 'host->pg ok', now();" || true + + # Fire the hanging request in the background, then photograph the process while it is stuck. + echo "--- firing create (backgrounded) ---" + curl -sS -o /tmp/create.out -w '\ncreate=%{http_code} in %{time_total}s\n' --max-time 90 \ + -X POST http://localhost:8080/merchant-account/create \ + -H 'content-type: application/json' -H 'x-tenant-id: public' -H 'x-admin-secret: test_admin' \ + -d '{"merchant_id":"preflight_probe","gateway_success_rate_based_decider_input":null}' & + CURL=$! + sleep 12 # sample the process mid-hang, well before bb8 gives up at 60s + + echo "--- THREADS ---" + cat /proc/$SRV/status | grep -E '^(Threads|State)' || true + for t in /proc/$SRV/task/*; do echo "$(basename $t) $(cat $t/comm 2>/dev/null) $(cat $t/stat 2>/dev/null | awk '{print $3}')"; done || true + + echo "--- BACKTRACE (all threads) ---" + # Debug build, so symbols are present and this should name the parked future directly. + # NOTE: do NOT pipe through tail — the frames that name the blocked work are at the TOP of + # each thread's stack (#0..#40); tailing keeps only the tokio scaffolding underneath them. + sudo gdb -p $SRV -batch -ex "set pagination off" -ex "thread apply all bt" 2>&1 || true + + echo "--- postgres side ---" + PGPASSWORD=db_pass psql -h 127.0.0.1 -U db_user -d decision_engine_db \ + -c "SELECT pid, state, wait_event_type, wait_event, left(query,60) AS q FROM pg_stat_activity;" || true + + wait $CURL || true + cat /tmp/create.out || true + kill $SRV 2>/dev/null || true + env: + DECISION_ENGINE__LOG__CONSOLE__LEVEL: DEBUG + + - name: Run Playwright e2e + run: npx playwright test + env: + CI: 'true' + DECISION_ENGINE__LOG__CONSOLE__LEVEL: WARN + PW_WORKERS: '1' + + - name: Upload Playwright report + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index dca1b146..5cff1da5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ dump.rdb cypress/screenshots cypress/videos cypress/fixtures + +# Playwright generated artifacts (HTML report, traces, screenshots) +playwright-report/ +test-results/ +blob-report/ +.playwright/ .mintlify-dev.log **/gsm.csv **/scratch diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js index 90953fb8..3bc11a55 100644 --- a/cypress/scripts/run-e2e.js +++ b/cypress/scripts/run-e2e.js @@ -7,7 +7,7 @@ const path = require('path') const ROOT = path.resolve(__dirname, '../..') const VALID_MODES = new Set(['source', 'docker', 'all']) const DEFAULT_SPEC = 'cypress/e2e/**/*.cy.js' -const READINESS_TIMEOUT_MS = 180000 +const READINESS_TIMEOUT_MS = Number(process.env.E2E_READINESS_TIMEOUT_MS) || 180000 const READINESS_INTERVAL_MS = 2000 const KNOWN_COMPOSE_PROJECTS = [ 'decision-engine', @@ -24,16 +24,25 @@ const EXPECTED_CLICKHOUSE_TABLES = [ 'analytics_payment_audit_lookup_summaries', ] +const VALID_RUNNERS = new Set(['playwright', 'cypress']) + function parseArgs() { const args = process.argv.slice(2) const mode = args[0] || 'all' const keepAlive = args.includes('--keep-alive') + const runnerArg = args.find((a) => a.startsWith('--runner=')) + // Playwright is the default runner; Cypress (frozen) is still selectable via --runner=cypress + // or E2E_RUNNER=cypress for the legacy suite. + const runner = (runnerArg ? runnerArg.split('=')[1] : process.env.E2E_RUNNER) || 'playwright' if (!VALID_MODES.has(mode)) { throw new Error(`Unsupported E2E mode '${mode}'. Use source, docker, or all.`) } + if (!VALID_RUNNERS.has(runner)) { + throw new Error(`Unsupported E2E runner '${runner}'. Use playwright or cypress.`) + } - return { mode, keepAlive } + return { mode, keepAlive, runner } } function sleep(ms) { @@ -250,6 +259,43 @@ async function runCypress(runtime) { ) } +async function runPlaywright(runtime) { + console.log(`\n[${runtime.mode}] Running Playwright suite...`) + + // Pass-through for extra CLI flags (e.g. sharding in CI): PLAYWRIGHT_ARGS="--shard=1/4". + const extraArgs = process.env.PLAYWRIGHT_ARGS + ? process.env.PLAYWRIGHT_ARGS.split(' ').filter(Boolean) + : [] + + await runCommand( + `playwright-${runtime.mode}`, + 'npx', + ['playwright', 'test', ...extraArgs], + { + env: { + // playwright.config.ts reads these plain env vars (no CYPRESS_ prefix). + // This script already booted the API and dashboard, so tell Playwright not to start its own. + PW_NO_WEBSERVER: '1', + RUNTIME_MODE: runtime.mode, + API_BASE_URL: runtime.apiBaseUrl, + UI_BASE_URL: runtime.uiBaseUrl, + DOCS_BASE_URL: runtime.docsBaseUrl, + CLICKHOUSE_HTTP_URL: runtime.clickhouseHttpUrl, + CLICKHOUSE_DATABASE: runtime.clickhouseDatabase, + CLICKHOUSE_USER: runtime.clickhouseUser, + CLICKHOUSE_PASSWORD: runtime.clickhousePassword, + }, + }, + ) +} + +async function runTests(runtime, runner) { + if (runner === 'cypress') { + return runCypress(runtime) + } + return runPlaywright(runtime) +} + async function stopDockerServices(profile) { await runCommand( `docker-down-${profile}`, @@ -301,7 +347,7 @@ async function killProcessGroup(child) { } } -async function runSourceMode(keepAlive) { +async function runSourceMode(keepAlive, runner) { const runtime = { mode: 'source', apiBaseUrl: 'http://localhost:8080', @@ -330,13 +376,13 @@ async function runSourceMode(keepAlive) { try { await waitForRuntime(runtime, sourceProcess) - await runCypress(runtime) + await runTests(runtime, runner) } finally { await cleanup() } } -async function runDockerMode(keepAlive) { +async function runDockerMode(keepAlive, runner) { const runtime = { mode: 'docker', apiBaseUrl: 'http://localhost:8080', @@ -365,21 +411,23 @@ async function runDockerMode(keepAlive) { try { await waitForRuntime(runtime) - await runCypress(runtime) + await runTests(runtime, runner) } finally { await cleanup() } } async function main() { - const { mode, keepAlive } = parseArgs() + const { mode, keepAlive, runner } = parseArgs() const modes = mode === 'all' ? ['source', 'docker'] : [mode] + console.log(`[E2E] runner=${runner} mode=${mode}`) + for (const selectedMode of modes) { if (selectedMode === 'source') { - await runSourceMode(keepAlive) + await runSourceMode(keepAlive, runner) } else { - await runDockerMode(keepAlive) + await runDockerMode(keepAlive, runner) } } } diff --git a/oneclick.sh b/oneclick.sh index 706f102a..7556a0c3 100755 --- a/oneclick.sh +++ b/oneclick.sh @@ -65,7 +65,6 @@ EXPECTED_CLICKHOUSE_TABLES=( cost_fee_model cost_fee_model_segment cost_bin_product - connector_markup_overlay ) check_and_kill_ports() { @@ -560,8 +559,8 @@ run_infra_checklist if ! check_clickhouse_schema; then echo "" echo "ClickHouse schema is incomplete — attempting to (re)create cost-ingestion tables..." - # The cost tables (cost_daily_stats / cost_fee_model / connector_markup_overlay from - # 035_cost_model.sh, cost_bin_product from 036, the piecewise cost_fee_model_segment from 037, + # The cost tables (cost_daily_stats / cost_fee_model from 035_cost_model.sh, + # cost_bin_product from 036, the piecewise cost_fee_model_segment from 037, # and the card_product ALTER migration in 038) are only auto-run by the container on a fresh # clickhouse-data volume. Every one is idempotent and non-destructive — the CREATEs are # IF NOT EXISTS, and 038 is ADD COLUMN IF NOT EXISTS + a same-key MODIFY ORDER BY (a metadata-only diff --git a/package-lock.json b/package-lock.json index 58bd4374..f8486c73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@cypress/grep": "^4.0.1", + "@playwright/test": "^1.48.0", "cypress": "^13.6.0" } }, @@ -499,6 +500,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@types/node": { "version": "24.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", @@ -1594,6 +1611,21 @@ "node": ">=10" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2462,6 +2494,38 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/package.json b/package.json index 032523e4..aee25808 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,17 @@ "test:e2e": "node cypress/scripts/run-e2e.js all", "test:e2e:source": "node cypress/scripts/run-e2e.js source", "test:e2e:docker": "node cypress/scripts/run-e2e.js docker", - "test": "npm run test:all" + "test": "npm run test:all", + "e2e": "node cypress/scripts/run-e2e.js source", + "e2e:docker": "node cypress/scripts/run-e2e.js docker", + "typecheck": "tsc --noEmit", + "e2e:pw": "playwright test", + "e2e:pw:api": "playwright test --project=api", + "e2e:pw:ui": "playwright test --project=ui" }, "devDependencies": { "@cypress/grep": "^4.0.1", + "@playwright/test": "^1.48.0", "cypress": "^13.6.0" }, "dependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..36fc5ba3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,100 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright config for Decision Engine e2e. + * + * Datastores (Postgres/Redis/Kafka/ClickHouse) are brought up out of band — by `docker compose` in + * CI, or by `cypress/scripts/run-e2e.js` locally. The two APPLICATION processes are started by the + * `webServer` block below, so Playwright owns their readiness and teardown. + * + * `reuseExistingServer` is on outside CI: if you already have the API and dashboard running, Playwright + * attaches to them instead of starting its own. Set PW_NO_WEBSERVER=1 to opt out entirely (e.g. when + * run-e2e.js has already booted everything). + * + * Two projects: + * - `api` — no browser; API-contract specs in tests/api (baseURL = decision-engine API). + * - `ui` — chromium; user-journey specs in tests/e2e (baseURL = dashboard UI). + * + * Local: `node cypress/scripts/run-e2e.js source` (E2E_RUNNER defaults to playwright), + * or against an already-up stack: `npx playwright test --project=api`. + */ + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' +const UI_BASE_URL = process.env.UI_BASE_URL || 'http://localhost:5173' + +/** + * Start the app processes ourselves unless something else already owns them — `run-e2e.js` boots the + * whole stack via oneclick.sh, so it sets PW_NO_WEBSERVER to stay in charge. + */ +const webServer = process.env.PW_NO_WEBSERVER + ? undefined + : [ + { + // Built by CI's `cargo build --no-default-features --features postgres` step. + command: './target/debug/open_router', + url: `${API_BASE_URL}/health`, + reuseExistingServer: !process.env.CI, + // A debug build on a loaded runner needs well over the 60s default to finish booting. + timeout: 180_000, + stdout: 'pipe' as const, + stderr: 'pipe' as const, + }, + { + command: 'npm --prefix website run dev', + url: UI_BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'pipe' as const, + stderr: 'pipe' as const, + }, + ] + +export default defineConfig({ + testDir: 'tests', + testMatch: '**/*.spec.ts', + // Runs after `webServer` is up and before any test. `/health` is dependency-free, so it cannot tell + // a working stack from an API whose database pool is dead — this probe can, and stops the run in + // seconds instead of letting every spec time out one at a time. + globalSetup: './tests/global-setup.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // Cap workers in CI so the shared runtime stack isn't overwhelmed; unbounded locally. + // The CI runner hosts the API, dashboard, Postgres, Redis, Kafka and ClickHouse alongside the tests + // on the same few vCPUs, so this is conservative by default. Override with PW_WORKERS once a full + // run has been timed — tune from the measurement, not a guess. + workers: Number(process.env.PW_WORKERS) || (process.env.CI ? 2 : undefined), + reporter: [['list'], ['html', { open: 'never' }]], + webServer, + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + // Sent to APIRequestContext and page.goto for relative URLs. Overridden per-project. + baseURL: API_BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + actionTimeout: 15_000, + navigationTimeout: 30_000, + extraHTTPHeaders: { + 'x-tenant-id': 'public', + }, + }, + projects: [ + { + name: 'api', + testDir: 'tests/api', + use: { + baseURL: API_BASE_URL, + }, + }, + { + name: 'ui', + testDir: 'tests/e2e', + use: { + ...devices['Desktop Chrome'], + baseURL: UI_BASE_URL, + viewport: { width: 1280, height: 720 }, + }, + }, + ], +}) diff --git a/src/storage.rs b/src/storage.rs index f7154edc..1ab95aff 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -5,7 +5,6 @@ use crate::config::Database; #[cfg(feature = "postgres")] use crate::config::PgDatabase; -#[cfg(feature = "mysql")] use crate::logger; use crate::generics::StorageResult; @@ -75,7 +74,7 @@ impl Storage { schema: &str, ) -> error_stack::Result { let database_url = format!( - "postgres://{}:{}@{}:{}/{}?application_name={}&options=-c search_path%3D{}", + "postgres://{}:{}@{}:{}/{}?application_name={}&options=-c%20search_path%3D{}", database.pg_username, database.pg_password.peek(), database.pg_host, @@ -103,7 +102,13 @@ impl Storage { { match self.pg_pool.get().await { Ok(conn) => Ok(conn), - Err(_err) => Err(crate::generics::MeshError::DatabaseConnectionError), + Err(err) => { + logger::error!( + action = "DB_CONNECTION_FAILURE", + "Failed to get connection from pool: {err}" + ); + Err(crate::generics::MeshError::DatabaseConnectionError) + } } } } @@ -116,7 +121,7 @@ impl Storage { schema: &str, ) -> error_stack::Result { let database_url = format!( - "mysql://{}:{}@{}:{}/{}?application_name={}&options=-c search_path%3D{}", + "mysql://{}:{}@{}:{}/{}?application_name={}&options=-c%20search_path%3D{}", database.username, database.password.peek(), database.host, @@ -180,14 +185,48 @@ pub(crate) trait TestInterface { async fn test(&self) -> Result<(), ContainerError>; } +/// Keep one connection warm, so a database that cannot be reached fails `build()` at startup rather +/// than surfacing on the first request. Without this bb8's `min_idle` defaults to zero, `build()` +/// opens no connections at all and always succeeds — the process comes up "healthy" against a +/// database it has never actually talked to. +const DB_POOL_MIN_IDLE: u32 = 1; + +/// Handing out a pooled connection should be near-instant; a long timeout only converts an outage +/// into a hang. Kept well under the e2e suite's 15s per-test timeout so a broken pool shows up as a +/// real error response instead of a client-side stall. +const DB_POOL_CONNECTION_TIMEOUT_SECS: u64 = 5; + +/// bb8 establishes connections on a detached task, so a failure to connect is never returned to the +/// caller — `get()` just waits out `connection_timeout` and reports `TimedOut`. The default +/// `NopErrorSink` then drops the underlying error, which is how an unreachable or misconfigured +/// database ends up looking like an unexplained 60s hang. Log it instead. +#[derive(Debug, Clone, Copy)] +struct LoggingErrorSink; + +impl bb8::ErrorSink for LoggingErrorSink { + fn sink(&self, error: async_bb8_diesel::ConnectionError) { + logger::error!( + action = "DB_CONNECTION_FAILURE", + "Pool failed to establish a connection: {error}" + ); + } + + fn boxed_clone(&self) -> Box> { + Box::new(*self) + } +} + #[cfg(feature = "postgres")] pub async fn diesel_make_pg_pool( database: &PgDatabase, schema: &str, _test_transaction: bool, ) -> error_stack::Result { + // The space between `-c` and `search_path` must be percent-encoded. libpq 14 tolerates a raw + // space here; libpq 16 rejects the URI outright ("unexpected spaces found in ..."), so a build + // linked against the newer client cannot open a single connection. let database_url = format!( - "postgres://{}:{}@{}:{}/{}?application_name={}&options=-c search_path%3D{}", + "postgres://{}:{}@{}:{}/{}?application_name={}&options=-c%20search_path%3D{}", database.pg_username, database.pg_password.peek(), database.pg_host, @@ -199,7 +238,11 @@ pub async fn diesel_make_pg_pool( let manager = async_bb8_diesel::ConnectionManager::::new(database_url); let pool = bb8::Pool::builder() .max_size(50) - .connection_timeout(std::time::Duration::from_secs(60)); + .min_idle(Some(DB_POOL_MIN_IDLE)) + .error_sink(Box::new(LoggingErrorSink)) + .connection_timeout(std::time::Duration::from_secs( + DB_POOL_CONNECTION_TIMEOUT_SECS, + )); pool.build(manager) .await @@ -214,7 +257,7 @@ pub async fn diesel_make_mysql_pool( _test_transaction: bool, ) -> error_stack::Result { let database_url = format!( - "mysql://{}:{}@{}:{}/{}?application_name={}&options=-c search_path%3D{}", + "mysql://{}:{}@{}:{}/{}?application_name={}&options=-c%20search_path%3D{}", database.username, database.password.peek(), database.host, @@ -226,7 +269,11 @@ pub async fn diesel_make_mysql_pool( let manager = async_bb8_diesel::ConnectionManager::::new(database_url); let pool = bb8::Pool::builder() .max_size(50) - .connection_timeout(std::time::Duration::from_secs(60)); + .min_idle(Some(DB_POOL_MIN_IDLE)) + .error_sink(Box::new(LoggingErrorSink)) + .connection_timeout(std::time::Duration::from_secs( + DB_POOL_CONNECTION_TIMEOUT_SECS, + )); pool.build(manager) .await diff --git a/src/storage/db.rs b/src/storage/db.rs index 361f8e41..0d5ad16d 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -1,27 +1,27 @@ use super::Storage; use crate::error::{self, ContainerError}; +use async_bb8_diesel::AsyncSimpleConnection; //https://github.com/juspay/hyperswitch-card-vault/blob/main/src/storage/db.rs impl super::TestInterface for Storage { type Error = error::TestDBError; + /// Backs `/health/diagnostics`. This used to return `Ok(())` unconditionally, which made the + /// endpoint report a perfectly healthy database no matter what — the one failure mode it exists + /// to catch. `/health` is dependency-free by design, so this was the only thing standing between + /// an unreachable database and a process that looks up while every db-backed route stalls. + /// + /// Deliberately limited to connectivity plus a trivial read: writing and deleting a row on every + /// health check would cost more than the extra signal is worth. async fn test(&self) -> Result<(), ContainerError> { - // let mut conn = self.get_conn().await?; + let conn = self + .get_conn() + .await + .map_err(|_| error::TestDBError::DBError)?; - // let _data = conn - // .test_transaction(|x| { - // Box::pin(async { - // let query = - // diesel::select(diesel::dsl::sql::("1 + 1")); - // let _x: i32 = query - // .get_result(x) - // .await - // .change_error(error::StorageError::FindError)?; - - // Ok::<_, ContainerError>(()) - // }) - // }) - // .await; + conn.batch_execute_async("SELECT 1") + .await + .map_err(|_| error::TestDBError::DBReadError)?; Ok(()) } diff --git a/tests/api/analytics/analytics-extended.spec.ts b/tests/api/analytics/analytics-extended.spec.ts new file mode 100644 index 00000000..c238a60c --- /dev/null +++ b/tests/api/analytics/analytics-extended.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../../fixtures/test' +import { seedRoutedTraffic } from '../../helpers/seed' + +/** + * The five analytics endpoints analytics.spec.ts does not reach. Each one backs a dashboard panel that + * renders on page load, so the contract that matters is: authenticated, scoped to the session's + * merchant, and returning a well-formed (possibly empty) payload rather than an error. + * + * As with analytics.spec.ts, these assert SHAPE not VALUES — ClickHouse ingestion is asynchronous and + * pinning row counts here would buy flake without buying coverage. + */ +test.describe('Analytics endpoints (API)', () => { + test('every analytics panel endpoint answers for a merchant with traffic', async ({ api, merchant }) => { + await seedRoutedTraffic(api, merchant.id, { prefix: 'analytics_ext' }) + + const endpoints = [ + '/analytics/gateway-scores', + '/analytics/decisions', + '/analytics/routing-stats', + '/analytics/cost-savings', + '/analytics/routing-events', + '/analytics/log-summaries', + ] + + for (const path of endpoints) { + const r = await api.raw('GET', path, { failOnStatusCode: false, qs: { range: '1h' } }) + expect(r.status, `${path} should answer 200`).toBe(200) + expect(r.body, `${path} should return a payload`).toBeTruthy() + } + }) + + + +}) diff --git a/tests/api/analytics/analytics.spec.ts b/tests/api/analytics/analytics.spec.ts new file mode 100644 index 00000000..b430c7d7 --- /dev/null +++ b/tests/api/analytics/analytics.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '../../fixtures/test' +import { seedRoutedTraffic } from '../../helpers/seed' +import { + expectValidAnalyticsOverview, + expectValidRoutingStats, + expectValidPaymentAudit, +} from '../../helpers/assertions' + +/** + * API-contract port of cypress/e2e/api/analytics-api.cy.js. + * + * The `merchant` fixture stands in for the Cypress `ensureMerchantAccount` beforeEach (fresh merchant + * + dashboard session, auto-cleaned). The analytics endpoints derive the merchant from the session + * bearer token that the fixture sets on `api`, so merchant_id/scope are never sent as query params — + * matching commands.js `normalizeAnalyticsRequest`, which strips them. + * + * The source spec polls each analytics endpoint until ClickHouse ingestion populates specific rows. + * Analytics data may be empty in a fresh run, so per the port contract we generate the traffic and + * then assert response SHAPE/status (the expectValid* contracts), not specific data values. + * + * The seed sequence itself lives in tests/helpers/seed.ts — the three analytics UI specs need the + * identical setup. The remaining analytics endpoints are covered in analytics-extended.spec.ts. + */ +test.describe('Analytics API', () => { + test('returns populated overview, routing stats, payment audit, and preview trace after traffic is generated', async ({ + api, + merchant, + }) => { + const seeded = await seedRoutedTraffic(api, merchant.id, { + scoreStatus: 'AUTHORIZED', + gatewayLatency: 2500, + prefix: 'analytics', + }) + + // The card/250 preview must resolve through the advanced rule to a priority output. + expect(seeded.previewEvaluation.output.type).toBe('priority') + + // Analytics overview — merchant_id/scope are derived from the session token, not the query. + const overview = await api.raw('GET', '/analytics/overview', { + qs: { range: '1h' }, + failOnStatusCode: false, + }) + expect(overview.status).toBe(200) + expectValidAnalyticsOverview(overview.body) + + // Routing stats. + const routingStats = await api.raw('GET', '/analytics/routing-stats', { + qs: { range: '1h' }, + failOnStatusCode: false, + }) + expect(routingStats.status).toBe(200) + expectValidRoutingStats(routingStats.body) + + // Payment audit for the decisioned payment. + const paymentAudit = await api.raw('GET', '/analytics/payment-audit', { + qs: { range: '1h', payment_id: seeded.decisionPaymentId }, + failOnStatusCode: false, + }) + expect(paymentAudit.status).toBe(200) + expectValidPaymentAudit(paymentAudit.body) + + // Preview trace for the evaluated (preview) payment — same audit shape. + const previewTrace = await api.raw('GET', '/analytics/preview-trace', { + qs: { range: '1h', payment_id: seeded.previewPaymentId }, + failOnStatusCode: false, + }) + expect(previewTrace.status).toBe(200) + expectValidPaymentAudit(previewTrace.body) + }) +}) diff --git a/tests/api/auth/api-keys.spec.ts b/tests/api/auth/api-keys.spec.ts new file mode 100644 index 00000000..ccb47e99 --- /dev/null +++ b/tests/api/auth/api-keys.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, factory } from '../../fixtures/test' +import { expectValidGatewayResponse } from '../../helpers/assertions' + +/** + * API-key reliability. API keys are the machine-to-machine auth path for /decide-gateway, so + * create → list → authenticate → revoke must all hold, and the raw key must never leak in listings. + */ +test.describe('API keys (API)', () => { + test('create returns a key and list includes it', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'ci-test-key') + expect(created.status).toBe(200) + expect(typeof created.body.api_key).toBe('string') + expect(typeof created.body.key_id).toBe('string') + + const list = await api.listApiKeys(merchant.id) + expect(list.status).toBe(200) + const items = Array.isArray(list.body) ? list.body : list.body.keys || list.body.api_keys || [] + expect(items.some((k: any) => k.key_id === created.body.key_id)).toBe(true) + }) + + test('list never exposes the raw key material (only the prefix)', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'prefix-only') + + const list = await api.listApiKeys(merchant.id) + const items = Array.isArray(list.body) ? list.body : list.body.keys || list.body.api_keys || [] + const found = items.find((k: any) => k.key_id === created.body.key_id) + + expect(found).toBeTruthy() + expect(found.api_key).toBeUndefined() // raw key is returned only once, at creation + expect(typeof found.key_prefix).toBe('string') + }) + + test('decide-gateway authenticates with an x-api-key instead of a bearer token', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + const created = await api.createApiKey(merchant.id, 'decide-key') + const apiKey = created.body.api_key + + const req = factory.srDecideGatewayRequest({ merchantId: merchant.id, eligibleGatewayList: ['stripe', 'adyen'] }) + + const saved = api.token + api.token = null // force x-api-key auth, not the bearer token + const decide = await api.raw('POST', '/decide-gateway', { + headers: { 'x-api-key': apiKey }, + body: req, + failOnStatusCode: false, + }) + api.token = saved + + expect(decide.status).toBe(200) + expectValidGatewayResponse(decide.body) + }) + + test('an api key can be revoked', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'revoke-me') + + const revoke = await api.raw('DELETE', `/api-key/${created.body.key_id}`, { failOnStatusCode: false }) + + expect(revoke.status).toBe(200) + expect(revoke.body.key_id).toBe(created.body.key_id) + }) + + test('revoking the same key twice is idempotent', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'revoke-twice') + + const first = await api.raw('DELETE', `/api-key/${created.body.key_id}`, { failOnStatusCode: false }) + const second = await api.raw('DELETE', `/api-key/${created.body.key_id}`, { failOnStatusCode: false }) + + expect(first.status).toBe(200) + expect(second.status).toBe(200) + }) + + test('revoking an unknown key id is rejected', async ({ api, merchant }) => { + const r = await api.raw('DELETE', '/api-key/00000000-0000-0000-0000-000000000000', { + failOnStatusCode: false, + }) + + // KNOWN GAP: this currently surfaces as a 500 because the storage layer's "no rows to update" is + // not mapped to a 404. Asserting >=400 records that the call is rejected without pinning the suite + // to a status that should arguably change. + expect(r.status).toBeGreaterThanOrEqual(400) + }) +}) + +/** + * KNOWN GAP — api-key routes carry no cross-merchant authorization. + * + * `authenticate` populates an AuthContext with the caller's merchant but never compares it against the + * merchant_id in the path or body, and the api_key handlers don't check it either. Any authenticated + * session can therefore mint, list and revoke keys for ANY merchant. + * + * These tests pin the CURRENT behaviour so a future fix is a deliberate, visible change rather than a + * surprise CI failure. The correct behaviour is noted per-test. If these start failing with 403s, the + * gap has been closed — update the expectations rather than reverting the handler. + */ +test.describe('API key cross-merchant isolation (known gap)', () => { + test('a session can list another merchant\'s keys', async ({ api, merchant }) => { + const other = factory.merchantId('apikey_other') + await api.ensureMerchantAccount(other) + const otherKey = await api.createApiKey(other, 'belongs-to-other') + + // api.token is still the ORIGINAL merchant's session at this point. + const list = await api.raw('GET', `/api-key/list/${other}`, { failOnStatusCode: false }) + + // Should be 403. Metadata only — the raw key is never returned by list. + expect(list.status).toBe(200) + expect(list.body.some((k: any) => k.key_id === otherKey.body.key_id)).toBe(true) + + await api.cleanupTestData(other) + }) + + test('a session can mint a key for a merchant it does not own', async ({ api, merchant }) => { + const other = factory.merchantId('apikey_mint_other') + await api.ensureMerchantAccount(other) + + const created = await api.raw('POST', '/api-key/create', { + failOnStatusCode: false, + body: { merchant_id: other, description: 'minted-across-merchants' }, + }) + + // Should be 403. + expect(created.status).toBe(200) + expect(created.body.merchant_id).toBe(other) + + await api.cleanupTestData(other) + }) +}) diff --git a/tests/api/auth/auth-admin-sso.spec.ts b/tests/api/auth/auth-admin-sso.spec.ts new file mode 100644 index 00000000..5cfb9f10 --- /dev/null +++ b/tests/api/auth/auth-admin-sso.spec.ts @@ -0,0 +1,110 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * The HyperSwitch → Decision Engine merchant SSO handoff (PR #331). + * + * Shape: an admin-secret-authenticated caller mints a short-lived one-time CODE for a merchant, and + * the dashboard redeems that code for a session token. The token is only ever returned in the exchange + * RESPONSE BODY — it never travels in a URL — and the code is single-use, which is the property this + * spec exists to hold onto. + * + * `ApiClient` already sends `x-admin-secret` on every request (defaulting to `test_admin`, matching + * config/development.toml), so only the negative case has to set the header explicitly. + */ +test.describe('Admin merchant-token SSO (API)', () => { + test('mints a one-time code that exchanges for a session token', async ({ api, merchant }) => { + const minted = await api.raw('POST', '/auth/admin/merchant-token', { + failOnStatusCode: false, + body: { merchant_id: merchant.id }, + }) + + expect(minted.status).toBe(200) + // The code is generated with the same helper as an API key, hence the DE_ prefix + 64 hex chars. + expect(minted.body.code).toMatch(/^DE_[0-9a-f]{64}$/) + expect(minted.body.expires_in).toBe(60) + + const exchanged = await api.raw('POST', '/auth/admin/merchant-token/exchange', { + failOnStatusCode: false, + body: { code: minted.body.code }, + }) + + expect(exchanged.status).toBe(200) + expect(typeof exchanged.body.token).toBe('string') + expect(exchanged.body.merchant_id).toBe(merchant.id) + expect(exchanged.body.role).toBe('admin') + // The redirect session is synthetic — no real user row behind it. + expect(exchanged.body.user_id).toBe(`hs_${merchant.id}`) + }) + + test('a code cannot be redeemed twice', async ({ api, merchant }) => { + const minted = await api.raw('POST', '/auth/admin/merchant-token', { + body: { merchant_id: merchant.id }, + }) + + const first = await api.raw('POST', '/auth/admin/merchant-token/exchange', { + failOnStatusCode: false, + body: { code: minted.body.code }, + }) + expect(first.status).toBe(200) + + // This is the security property: the claim is atomic, so a replayed code is dead. + const second = await api.raw('POST', '/auth/admin/merchant-token/exchange', { + failOnStatusCode: false, + body: { code: minted.body.code }, + }) + expect(second.status).toBe(401) + }) + + test('an unknown code is rejected', async ({ api }) => { + const r = await api.raw('POST', '/auth/admin/merchant-token/exchange', { + failOnStatusCode: false, + body: { code: `DE_${'0'.repeat(64)}` }, + }) + + expect(r.status).toBe(401) + }) + + test('minting requires the admin secret', async ({ api, merchant }) => { + const r = await api.raw('POST', '/auth/admin/merchant-token', { + failOnStatusCode: false, + headers: { 'x-admin-secret': 'definitely-not-the-admin-secret' }, + body: { merchant_id: merchant.id }, + }) + + expect(r.status).toBe(401) + }) + + test('minting for an unknown merchant is rejected', async ({ api }) => { + const r = await api.raw('POST', '/auth/admin/merchant-token', { + failOnStatusCode: false, + body: { merchant_id: factory.merchantId('sso_missing') }, + }) + + expect(r.status).toBe(404) + }) + + test('a redirect session can read its own identity but not switch merchant', async ({ api, merchant }) => { + const minted = await api.raw('POST', '/auth/admin/merchant-token', { + body: { merchant_id: merchant.id }, + }) + const exchanged = await api.raw('POST', '/auth/admin/merchant-token/exchange', { + body: { code: minted.body.code }, + }) + const redirectToken = exchanged.body.token + + const me = await api.raw('GET', '/auth/me', { + failOnStatusCode: false, + headers: { Authorization: `Bearer ${redirectToken}` }, + }) + expect(me.status).toBe(200) + expect(me.body.merchant_id).toBe(merchant.id) + + // A redirect session is deliberately restricted — it is not a full user account. + const switched = await api.raw('POST', '/auth/switch-merchant', { + failOnStatusCode: false, + headers: { Authorization: `Bearer ${redirectToken}` }, + body: { merchant_id: merchant.id }, + }) + expect(switched.status).toBe(403) + }) +}) diff --git a/tests/api/auth/auth-flows.spec.ts b/tests/api/auth/auth-flows.spec.ts new file mode 100644 index 00000000..f6ebd056 --- /dev/null +++ b/tests/api/auth/auth-flows.spec.ts @@ -0,0 +1,233 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * The session lifecycle beyond signup/login (which auth.spec.ts covers): logout, the merchant list and + * switch, self-service onboarding, member management, and password change. + * + * These routes sit on the PUBLIC router but extract the bearer token themselves (src/routes/user_auth.rs + * `extract_bearer_token`), so an x-api-key does not authenticate them — only a JWT does. + * + * Password policy (src/auth/mod.rs `validate_password`): >=10 chars with upper, lower, digit and a + * non-alphanumeric. 'Password123!' satisfies it; the weak-password cases below deliberately do not. + */ + +const VALID_PASSWORD = 'Password123!' + +test.describe('Session lifecycle (API)', () => { + test('logout succeeds and is idempotent for the same token', async ({ api, merchant }) => { + const first = await api.raw('POST', '/auth/logout', { failOnStatusCode: false }) + expect(first.status).toBe(200) + expect(first.body.message).toBe('Logged out successfully') + + // The handler verifies the JWT without consulting the revocation denylist it just wrote, so a + // repeat logout with the same token still succeeds rather than 401ing. + const second = await api.raw('POST', '/auth/logout', { failOnStatusCode: false }) + expect(second.status).toBe(200) + }) + + test('logout without a token is rejected', async ({ api }) => { + const anon = api.anonymous() + const r = await anon.raw('POST', '/auth/logout', { failOnStatusCode: false }) + expect(r.status).toBe(401) + }) + + test('GET /auth/merchants lists the merchants the session can access', async ({ api, merchant }) => { + const r = await api.raw('GET', '/auth/merchants', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body)).toBe(true) + const found = r.body.find((m: any) => m.merchant_id === merchant.id) + expect(found, `expected ${merchant.id} in the merchant list`).toBeTruthy() + // merchant_name falls back to the id when the account row has no name. + expect(typeof found.merchant_name).toBe('string') + expect(typeof found.role).toBe('string') + }) + + test('switch-merchant is rejected for a merchant the user does not belong to', async ({ api, merchant }) => { + const foreign = factory.merchantId('auth_foreign') + await api.raw('POST', '/merchant-account/create', { + failOnStatusCode: false, + body: { merchant_id: foreign, gateway_success_rate_based_decider_input: null }, + }) + + const r = await api.raw('POST', '/auth/switch-merchant', { + failOnStatusCode: false, + body: { merchant_id: foreign }, + }) + + // Membership is the guard here — a non-member gets "merchant not found", not a 403. + expect(r.status).toBe(404) + + await api.cleanupTestData(foreign) + }) + + test('switch-merchant to the session\'s own merchant returns a fresh token', async ({ api, merchant }) => { + const before = api.token + + const r = await api.raw('POST', '/auth/switch-merchant', { + failOnStatusCode: false, + body: { merchant_id: merchant.id }, + }) + + expect(r.status).toBe(200) + expect(typeof r.body.token).toBe('string') + expect(r.body.merchant_id).toBe(merchant.id) + expect(r.body.token).not.toBe(before) + }) + + test('onboarding creates a new merchant and returns a token scoped to it', async ({ api, merchant }) => { + const r = await api.raw('POST', '/onboarding/merchant', { + failOnStatusCode: false, + body: { merchant_name: 'Playwright Onboarding Co' }, + }) + + expect(r.status).toBe(200) + expect(typeof r.body.token).toBe('string') + expect(r.body.merchant_name).toBe('Playwright Onboarding Co') + // Generated ids are `merchant_` + 12 hex chars. + expect(r.body.merchant_id).toMatch(/^merchant_[0-9a-f]{12}$/) + // The caller is now a member of both merchants. + expect(r.body.merchants.some((m: any) => m.merchant_id === r.body.merchant_id)).toBe(true) + + await api.cleanupTestData(r.body.merchant_id) + }) +}) + +test.describe('Merchant members (API)', () => { + test('members list includes the signed-up admin', async ({ api, merchant }) => { + const r = await api.raw('GET', '/merchant/members', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body)).toBe(true) + const self = r.body.find((m: any) => m.email === `${merchant.id}@example.com`) + expect(self, 'the signing-up user should be a member of its own merchant').toBeTruthy() + expect(self.role).toBe('admin') + }) + + test('inviting a new email creates the user and returns a generated password', async ({ api, merchant }) => { + const invitee = `invitee-${merchant.id}@example.com` + + const r = await api.raw('POST', '/merchant/members/invite', { + failOnStatusCode: false, + body: { email: invitee, role: 'member' }, + }) + + expect(r.status).toBe(200) + expect(r.body.email).toBe(invitee) + expect(r.body.is_new_user).toBe(true) + expect(r.body.role).toBe('member') + // The one-time password is only present for a newly created user. + expect(typeof r.body.password).toBe('string') + + const members = await api.raw('GET', '/merchant/members') + expect(members.body.some((m: any) => m.email === invitee)).toBe(true) + }) + + test('inviting the same email twice is rejected as already a member', async ({ api, merchant }) => { + const invitee = `dupe-${merchant.id}@example.com` + await api.raw('POST', '/merchant/members/invite', { body: { email: invitee } }) + + const again = await api.raw('POST', '/merchant/members/invite', { + failOnStatusCode: false, + body: { email: invitee }, + }) + + expect(again.status).toBe(409) + }) + + test('an unrecognised role falls back to member rather than erroring', async ({ api, merchant }) => { + const invitee = `role-${merchant.id}@example.com` + + const r = await api.raw('POST', '/merchant/members/invite', { + failOnStatusCode: false, + body: { email: invitee, role: 'superuser' }, + }) + + // Only 'admin' is honoured; anything else is coerced to 'member'. + expect(r.status).toBe(200) + expect(r.body.role).toBe('member') + }) +}) + +test.describe('Change password (API)', () => { + test('changing the password makes the new one work and the old one fail', async ({ api, merchant }) => { + const email = `${merchant.id}@example.com` + const newPassword = 'ChangedPass456!' + + const changed = await api.raw('POST', '/auth/change-password', { + failOnStatusCode: false, + body: { current_password: VALID_PASSWORD, new_password: newPassword }, + }) + expect(changed.status).toBe(200) + + const withNew = await api.raw('POST', '/auth/login', { + failOnStatusCode: false, + body: { email, password: newPassword }, + }) + expect(withNew.status).toBe(200) + + const withOld = await api.raw('POST', '/auth/login', { + failOnStatusCode: false, + body: { email, password: VALID_PASSWORD }, + }) + expect(withOld.status).toBe(401) + }) + + test('a wrong current password is rejected', async ({ api, merchant }) => { + const r = await api.raw('POST', '/auth/change-password', { + failOnStatusCode: false, + body: { current_password: 'NotThePassword1!', new_password: 'ChangedPass456!' }, + }) + + expect(r.status).toBe(401) + }) + + test('a weak new password is rejected', async ({ api, merchant }) => { + const r = await api.raw('POST', '/auth/change-password', { + failOnStatusCode: false, + body: { current_password: VALID_PASSWORD, new_password: 'short' }, + }) + + expect(r.status).toBe(400) + }) +}) + +test.describe('Password reset (API)', () => { + test('forgot-password never reveals whether an account exists', async ({ api, merchant }) => { + const known = await api.raw('POST', '/auth/forgot-password', { + failOnStatusCode: false, + body: { email: `${merchant.id}@example.com` }, + }) + const unknown = await api.raw('POST', '/auth/forgot-password', { + failOnStatusCode: false, + body: { email: `definitely-not-registered-${merchant.id}@example.com` }, + }) + + // Deliberately non-enumerable: identical status AND message for both. + expect(known.status).toBe(200) + expect(unknown.status).toBe(200) + expect(unknown.body.message).toBe(known.body.message) + }) + + test('reset-password rejects an invalid token', async ({ api }) => { + const r = await api.raw('POST', '/auth/reset-password', { + failOnStatusCode: false, + body: { token: 'not-a-real-reset-token', new_password: 'ChangedPass456!' }, + }) + + expect(r.status).toBe(400) + }) + + test('verify-email rejects an invalid token', async ({ api }) => { + const r = await api.raw('GET', '/auth/verify-email', { + failOnStatusCode: false, + qs: { token: 'not-a-real-verification-token' }, + }) + + // KNOWN GAP: an unknown token currently surfaces as 500 "Storage error" rather than the + // 400 "Invalid or expired verification token" the handler defines — the token lookup errors + // instead of returning "not found". Asserting >=400 records that the token is refused without + // pinning the suite to a status that should change. + expect(r.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/tests/api/auth/auth-guards.spec.ts b/tests/api/auth/auth-guards.spec.ts new file mode 100644 index 00000000..a295a932 --- /dev/null +++ b/tests/api/auth/auth-guards.spec.ts @@ -0,0 +1,135 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * Every protected route must actually be protected. + * + * SELF-VALIDATING BY DESIGN: the `authenticate` middleware short-circuits and lets requests through + * unauthenticated when `api_key_auth_enabled` is false (src/middleware.rs). If that ever gets flipped + * off in the environment under test, the first test here fails loudly rather than the whole suite + * silently passing for the wrong reason. Treat a failure in 'rejects a request with no credentials' as + * "auth is disabled", not as "one endpoint regressed". + * + * Middleware errors are plain text, not the JSON error envelope the handlers use — so these assert on + * status only. + */ + +/** A representative protected route per surface area. */ +const PROTECTED_ROUTES: Array<{ method: string; path: string; body?: unknown }> = [ + { method: 'POST', path: '/decide-gateway', body: {} }, + { method: 'GET', path: '/merchant-account/some_merchant' }, + { method: 'POST', path: '/rule/get', body: { merchant_id: 'some_merchant', algorithm: 'successRate' } }, + { method: 'GET', path: '/analytics/overview' }, + { method: 'POST', path: '/routing/create', body: {} }, + { method: 'GET', path: '/api-key/list/some_merchant' }, +] + +test.describe('Auth guards (API)', () => { + test('rejects a request with no credentials', async ({ api }) => { + const anon = api.anonymous() + + for (const route of PROTECTED_ROUTES) { + const r = await anon.raw(route.method, route.path, { + failOnStatusCode: false, + body: route.body, + }) + expect(r.status, `${route.method} ${route.path} must require authentication`).toBe(401) + } + }) + + test('rejects a malformed bearer token', async ({ api }) => { + const anon = api.anonymous() + + for (const route of PROTECTED_ROUTES) { + const r = await anon.raw(route.method, route.path, { + failOnStatusCode: false, + headers: { Authorization: 'Bearer not-a-real-jwt' }, + body: route.body, + }) + expect(r.status, `${route.method} ${route.path} must reject a bad token`).toBe(401) + } + }) + + test('rejects an unknown x-api-key', async ({ api }) => { + const anon = api.anonymous() + + const r = await anon.raw('POST', '/decide-gateway', { + failOnStatusCode: false, + headers: { 'x-api-key': `DE_${'0'.repeat(64)}` }, + body: {}, + }) + + expect(r.status).toBe(401) + }) + + test('rejects a revoked api key immediately', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'guard-revoked') + const apiKey = created.body.api_key + + // Works before revocation. + const anon = api.anonymous() + const before = await anon.raw('GET', `/api-key/list/${merchant.id}`, { + failOnStatusCode: false, + headers: { 'x-api-key': apiKey }, + }) + expect(before.status).toBe(200) + + await api.raw('DELETE', `/api-key/${created.body.key_id}`) + + // Revocation clears the key's cache entry, so it must fail now rather than after the cache TTL. + const after = await anon.raw('GET', `/api-key/list/${merchant.id}`, { + failOnStatusCode: false, + headers: { 'x-api-key': apiKey }, + }) + expect(after.status).toBe(401) + }) + + test('a bad bearer token is not rescued by a valid x-api-key', async ({ api, merchant }) => { + const created = await api.createApiKey(merchant.id, 'guard-precedence') + const anon = api.anonymous() + + const r = await anon.raw('GET', `/api-key/list/${merchant.id}`, { + failOnStatusCode: false, + headers: { + Authorization: 'Bearer not-a-real-jwt', + 'x-api-key': created.body.api_key, + }, + }) + + // The middleware checks Authorization first and returns on failure — x-api-key is never consulted. + expect(r.status).toBe(401) + }) + + test('health endpoints stay reachable without credentials', async ({ api }) => { + const anon = api.anonymous() + + const health = await anon.raw('GET', '/health', { failOnStatusCode: false }) + expect(health.status).toBe(200) + + const ready = await anon.raw('GET', '/health/ready', { failOnStatusCode: false }) + expect(ready.status).toBe(200) + }) + + // merchant-account/create sits on the PUBLIC router (no auth middleware), but the handler itself + // validates x-admin-secret — so merchant creation is admin-gated even though it bypasses the + // middleware. A caller with no credentials at all must be refused. + test('merchant-account create requires the admin secret', async ({ api, merchant }) => { + const id = factory.merchantId('guard_admin') + const anon = api.anonymous() + + const withoutSecret = await anon.raw('POST', '/merchant-account/create', { + failOnStatusCode: false, + body: { merchant_id: id, gateway_success_rate_based_decider_input: null }, + }) + expect(withoutSecret.status).toBeGreaterThanOrEqual(400) + + // The same call succeeds once the admin secret is supplied — this is how the suite bootstraps + // every merchant it needs. + const withSecret = await api.raw('POST', '/merchant-account/create', { + failOnStatusCode: false, + body: { merchant_id: id, gateway_success_rate_based_decider_input: null }, + }) + expect(withSecret.status).toBe(200) + + await api.cleanupTestData(id) + }) +}) diff --git a/tests/api/auth/auth.spec.ts b/tests/api/auth/auth.spec.ts new file mode 100644 index 00000000..1f956fe0 --- /dev/null +++ b/tests/api/auth/auth.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * Authentication reliability. The dashboard and every protected route depend on this path, so + * signup → login → session identity must be dependable, and bad credentials must be rejected. + */ +test.describe('Auth (API)', () => { + test('signup returns a session token', async ({ api }) => { + const id = factory.merchantId('auth') + await api.ensureMerchantAccount(id) + + const r = await api.raw('POST', '/auth/signup', { + failOnStatusCode: false, + body: { email: `${id}@example.com`, password: 'Password123!', merchant_id: id }, + }) + + expect(r.status).toBe(200) + expect(typeof r.body.token).toBe('string') + + api.token = r.body.token + await api.cleanupTestData(id) + }) + + test('login succeeds with valid credentials', async ({ api }) => { + const id = factory.merchantId('auth') + const email = `${id}@example.com` + const password = 'Password123!' + await api.ensureMerchantAccount(id) + const signup = await api.raw('POST', '/auth/signup', { failOnStatusCode: false, body: { email, password, merchant_id: id } }) + api.token = signup.body?.token ?? null + + const login = await api.raw('POST', '/auth/login', { failOnStatusCode: false, body: { email, password } }) + + expect(login.status).toBe(200) + expect(typeof login.body.token).toBe('string') + + await api.cleanupTestData(id) + }) + + test('login is rejected with a wrong password', async ({ api }) => { + const id = factory.merchantId('auth') + const email = `${id}@example.com` + await api.ensureMerchantAccount(id) + const signup = await api.raw('POST', '/auth/signup', { failOnStatusCode: false, body: { email, password: 'Password123!', merchant_id: id } }) + api.token = signup.body?.token ?? null + + const bad = await api.raw('POST', '/auth/login', { failOnStatusCode: false, body: { email, password: 'WrongPassword!' } }) + + expect(bad.status).toBeGreaterThanOrEqual(400) + + await api.cleanupTestData(id) + }) + + test('GET /auth/me returns the authenticated identity', async ({ api, merchant }) => { + // The `merchant` fixture already signed up and set api.token to that session. + const me = await api.raw('GET', '/auth/me', { failOnStatusCode: false }) + + expect(me.status).toBe(200) + expect(me.body.merchant_id || me.body.email).toBeTruthy() + }) +}) diff --git a/tests/api/cost/cost-ingestion.spec.ts b/tests/api/cost/cost-ingestion.spec.ts new file mode 100644 index 00000000..cb0519d5 --- /dev/null +++ b/tests/api/cost/cost-ingestion.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '../../fixtures/test' + +/** + * The cost-estimation READ surface — the endpoints the dashboard's cost pages call on load. + * + * Scope is deliberate: report/invoice UPLOAD is excluded. Those take multi-GB bodies, return 202, and + * complete asynchronously through a ClickHouse fit, so a meaningful assertion needs a poll-to-settle + * loop over a fixture file. That belongs in a dedicated ingestion suite, not here. + * + * What these tests are actually worth: a fresh merchant with no cost data must get an empty-but-valid + * 200 from every one of these, because the dashboard renders them unconditionally. A 500 from an empty + * ClickHouse table is a real bug that this catches, and it's invisible to anyone testing with seeded data. + */ +test.describe('Cost ingestion — registry (API)', () => { + test('lists the connectors that support report ingestion', async ({ api, merchant }) => { + const r = await api.raw('GET', '/cost-ingestion/connectors', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body)).toBe(true) + + const ids = r.body.map((c: any) => c.id) + // The dashboard's connector picker reads this instead of keeping its own list. + for (const expected of ['adyen', 'checkout', 'stripe']) { + expect(ids).toContain(expected) + } + expect(r.body.every((c: any) => typeof c.pull === 'boolean')).toBe(true) + }) +}) + +test.describe('Cost ingestion — empty merchant reads (API)', () => { + test('ingestion history is empty for a new merchant', async ({ api, merchant }) => { + const r = await api.raw('GET', `/merchant-account/${merchant.id}/cost-ingestions`, { + failOnStatusCode: false, + }) + + expect(r.status).toBe(200) + expect(r.body).toEqual([]) + }) + + test('list endpoints return empty arrays rather than erroring', async ({ api, merchant }) => { + const paths = [ + `/merchant-account/${merchant.id}/connector-fees`, + `/merchant-account/${merchant.id}/cost-clusters`, + `/merchant-account/${merchant.id}/cost-cluster-facets`, + `/merchant-account/${merchant.id}/cost-price-changes`, + `/merchant-account/${merchant.id}/invoice-addons`, + ] + + for (const path of paths) { + const r = await api.raw('GET', path, { failOnStatusCode: false }) + expect(r.status, `${path} should serve an empty result, not fail`).toBe(200) + expect(Array.isArray(r.body), `${path} should return an array`).toBe(true) + expect(r.body).toEqual([]) + } + }) + + test('cost coverage reports a zeroed summary for a new merchant', async ({ api, merchant }) => { + const r = await api.raw('GET', `/merchant-account/${merchant.id}/cost-coverage`, { + failOnStatusCode: false, + }) + + expect(r.status).toBe(200) + expect(r.body.total_clusters).toBe(0) + expect(r.body.total_txns).toBe(0) + expect(typeof r.body.report_date).toBe('string') + }) + +}) + +test.describe('Cost ingestion — seed costs (API)', () => { + test('seed costs fall back to the configured defaults', async ({ api, merchant }) => { + const r = await api.raw('GET', `/merchant-account/${merchant.id}/seed-costs`, { + failOnStatusCode: false, + }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body)).toBe(true) + // A merchant that never saved a table still gets the deployment's default seed costs, so cost + // estimation works before any report is uploaded. + if (r.body.length > 0) { + expect(typeof r.body[0].psp).toBe('string') + expect(typeof r.body[0].fixed).toBe('number') + } + }) + + test('saved seed costs round-trip and can be cleared', async ({ api, merchant }) => { + const path = `/merchant-account/${merchant.id}/seed-costs` + const rows = [ + { psp: 'stripe', interchange_bps: 100, scheme_bps: 10, markup_bps: 20, fixed: 0.3, is_default: true }, + ] + + const saved = await api.raw('PUT', path, { failOnStatusCode: false, body: { rows } }) + expect(saved.status).toBe(200) + expect(saved.body.some((r: any) => r.psp === 'stripe')).toBe(true) + // effective_pct_bps is recomputed server-side from the three components. + const stripe = saved.body.find((r: any) => r.psp === 'stripe') + expect(stripe.effective_pct_bps).toBe(130) + + // DELETE only succeeds because a table was saved above — see the note in the next test. + const cleared = await api.raw('DELETE', path, { failOnStatusCode: false }) + expect(cleared.status).toBe(200) + }) + + test('rejects seed cost rows with no PSP or negative fees', async ({ api, merchant }) => { + const path = `/merchant-account/${merchant.id}/seed-costs` + + // All four numeric fields are required by the deserializer — omitting one yields a 422 body + // rejection rather than the 400 domain validation these assertions are about. + const noPsp = await api.raw('PUT', path, { + failOnStatusCode: false, + body: { rows: [{ psp: '', interchange_bps: 100, scheme_bps: 10, markup_bps: 20, fixed: 0.3 }] }, + }) + expect(noPsp.status).toBe(400) + + const negative = await api.raw('PUT', path, { + failOnStatusCode: false, + body: { rows: [{ psp: 'stripe', interchange_bps: -5, scheme_bps: 10, markup_bps: 20, fixed: 0.3 }] }, + }) + expect(negative.status).toBe(400) + }) + + test('simulating seed costs prices an amount per PSP', async ({ api, merchant }) => { + const r = await api.raw('POST', `/merchant-account/${merchant.id}/seed-costs/simulate`, { + failOnStatusCode: false, + body: { amount: 100, transaction_currency: 'USD' }, + }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body)).toBe(true) + if (r.body.length > 0) { + expect(typeof r.body[0].psp).toBe('string') + expect(typeof r.body[0].cost_amount).toBe('number') + } + }) + + test('simulate rejects a non-positive amount', async ({ api, merchant }) => { + const r = await api.raw('POST', `/merchant-account/${merchant.id}/seed-costs/simulate`, { + failOnStatusCode: false, + body: { amount: 0 }, + }) + + expect(r.status).toBe(400) + }) +}) + +test.describe('Cost ingestion — column mapping (API)', () => { + const ACCOUNT = 'playwright-acct' + + test('an unset mapping reads back empty', async ({ api, merchant }) => { + const r = await api.raw( + 'GET', + `/merchant-account/${merchant.id}/connectors/stripe/report/column-mapping`, + { failOnStatusCode: false, qs: { account: ACCOUNT } }, + ) + + expect(r.status).toBe(200) + expect(r.body.columns).toEqual({}) + }) + + test('the account query param is required', async ({ api, merchant }) => { + const r = await api.raw( + 'GET', + `/merchant-account/${merchant.id}/connectors/stripe/report/column-mapping`, + { failOnStatusCode: false }, + ) + + // A mapping is per settlement source, so it is meaningless without the account. + expect(r.status).toBe(400) + }) + + test('clearing a mapping is idempotent', async ({ api, merchant }) => { + const path = `/merchant-account/${merchant.id}/connectors/stripe/report/column-mapping` + + // Deleting a mapping that was never set must not error — the dashboard's "reset" button relies on + // it. (Note the connector-level fee-override DELETE does NOT share this property today.) + const first = await api.raw('DELETE', path, { failOnStatusCode: false, qs: { account: ACCOUNT } }) + expect(first.status).toBe(204) + + const second = await api.raw('DELETE', path, { failOnStatusCode: false, qs: { account: ACCOUNT } }) + expect(second.status).toBe(204) + }) +}) diff --git a/tests/api/merchant/merchant-crud.spec.ts b/tests/api/merchant/merchant-crud.spec.ts new file mode 100644 index 00000000..a5653256 --- /dev/null +++ b/tests/api/merchant/merchant-crud.spec.ts @@ -0,0 +1,108 @@ +import { test, expect, factory } from '../../fixtures/test' +import type { ApiClient, RequestOptions } from '../../fixtures/api-client' +import { ensureDashboardSession } from '../../fixtures/session' +import { + expectValidMerchantCreateResponse, + expectValidMerchantGetResponse, + expectValidMerchantDeleteResponse, +} from '../../helpers/assertions' + +/** + * API-contract port of cypress/e2e/api/merchant-crud.cy.js. + * + * These tests create/get/DELETE their OWN merchants, so they do NOT use the shared `merchant` + * fixture. Instead they mirror the Cypress `createMerchantAccount` command: POST the create, and on + * success establish a dashboard session so subsequent protected GET/DELETE/debit-routing calls carry + * a bearer token (the merchant-account routes sit behind the authenticate middleware). Cleanup runs + * in an afterEach via `api.cleanupTestData`, matching the Cypress `cleanupTestData` afterEach. + */ + +/** Port of Cypress `cy.createMerchantAccount`: create + establish a session on success. */ +async function createMerchant(api: ApiClient, id: string, opts: RequestOptions = {}) { + const res = await api.raw('POST', '/merchant-account/create', { + ...opts, + body: { merchant_id: id, gateway_success_rate_based_decider_input: null }, + }) + if (res.status === 200) await ensureDashboardSession(api, id) + return res +} + +test.describe('Merchant CRUD API', () => { + let testMerchantId: string + + test.beforeEach(() => { + testMerchantId = factory.merchantId('merchant_crud') + }) + + test.afterEach(async ({ api }) => { + await api.cleanupTestData(testMerchantId) + }) + + test('creates, fetches, rejects duplicate create, and deletes a merchant account', async ({ api }) => { + const created = await createMerchant(api, testMerchantId) + expectValidMerchantCreateResponse(created.body) + expect(created.body.merchant_id).toBe(testMerchantId) + + const got = await api.getMerchantAccount(testMerchantId) + expectValidMerchantGetResponse(got.body) + expect(got.body.merchant_id).toBe(testMerchantId) + + const duplicate = await createMerchant(api, testMerchantId, { failOnStatusCode: false }) + expect(duplicate.status).not.toBe(200) + + const deleted = await api.deleteMerchantAccount(testMerchantId) + expectValidMerchantDeleteResponse(deleted.body) + expect(deleted.body.merchant_id).toBe(testMerchantId) + + // Must return non-200 immediately after deletion — validates cache eviction + // on delete so a cached entry doesn't ghost the deleted merchant. + const afterDelete = await api.getMerchantAccount(testMerchantId, { failOnStatusCode: false }) + expect(afterDelete.status).not.toBe(200) + }) + + test('deleted merchant is not served from cache on immediate re-fetch', async ({ api }) => { + await createMerchant(api, testMerchantId) + + // Warm the cache with a successful GET + const got = await api.getMerchantAccount(testMerchantId) + expectValidMerchantGetResponse(got.body) + + await api.deleteMerchantAccount(testMerchantId) + + // The cache must be evicted — stale hit would return 200 here + const afterDelete = await api.getMerchantAccount(testMerchantId, { failOnStatusCode: false }) + expect(afterDelete.status).not.toBe(200) + }) + + test('gets and updates the debit routing feature flag', async ({ api }) => { + const missingMerchantId = factory.merchantId('merchant_missing_debit') + + await createMerchant(api, testMerchantId) + + let flag = await api.raw('GET', `/merchant-account/${testMerchantId}/debit-routing`) + expect(flag.body.merchant_id).toBe(testMerchantId) + expect(flag.body.debit_routing_enabled).toBe(false) + + const enabled = await api.raw('POST', `/merchant-account/${testMerchantId}/debit-routing`, { + body: { enabled: true }, + }) + expect(enabled.body.merchant_id).toBe(testMerchantId) + expect(enabled.body.debit_routing_enabled).toBe(true) + + flag = await api.raw('GET', `/merchant-account/${testMerchantId}/debit-routing`) + expect(flag.body.debit_routing_enabled).toBe(true) + + const disabled = await api.raw('POST', `/merchant-account/${testMerchantId}/debit-routing`, { + body: { enabled: false }, + }) + expect(disabled.body.debit_routing_enabled).toBe(false) + + flag = await api.raw('GET', `/merchant-account/${testMerchantId}/debit-routing`) + expect(flag.body.debit_routing_enabled).toBe(false) + + const missing = await api.raw('GET', `/merchant-account/${missingMerchantId}/debit-routing`, { + failOnStatusCode: false, + }) + expect(missing.status).toBe(404) + }) +}) diff --git a/tests/api/merchant/merchant-features.spec.ts b/tests/api/merchant/merchant-features.spec.ts new file mode 100644 index 00000000..c81f512f --- /dev/null +++ b/tests/api/merchant/merchant-features.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Merchant feature flags + debit-routing toggle. These are operator-facing switches that gate real + * routing behavior, so their read/write/persist path must be reliable. + */ +test.describe('Merchant features & debit routing (API)', () => { + test('features list returns all known feature flags', async ({ api, merchant }) => { + const r = await api.raw('GET', `/merchant-account/${merchant.id}/features`) + + expect(r.status).toBe(200) + expect(r.body.merchant_id).toBe(merchant.id) + expect(Array.isArray(r.body.features)).toBe(true) + + const slugs = r.body.features.map((f: any) => f.feature) + for (const expected of ['autopilot', 'auto-calibration', 'elimination', 'multi-objective-routing']) { + expect(slugs).toContain(expected) + } + }) + + test('debit routing flag defaults to false for a new merchant', async ({ api, merchant }) => { + const get = await api.raw('GET', `/merchant-account/${merchant.id}/debit-routing`) + expect(get.status).toBe(200) + expect(get.body.debit_routing_enabled).toBe(false) + }) + + test('debit routing flag toggles on and off and persists', async ({ api, merchant }) => { + const path = `/merchant-account/${merchant.id}/debit-routing` + + const on = await api.raw('POST', path, { body: { enabled: true } }) + expect(on.status).toBe(200) + let get = await api.raw('GET', path) + expect(get.body.debit_routing_enabled).toBe(true) + + const off = await api.raw('POST', path, { body: { enabled: false } }) + expect(off.status).toBe(200) + get = await api.raw('GET', path) + expect(get.body.debit_routing_enabled).toBe(false) + }) +}) diff --git a/tests/api/multi-objective-routing/autopilot.spec.ts b/tests/api/multi-objective-routing/autopilot.spec.ts new file mode 100644 index 00000000..4f92bd78 --- /dev/null +++ b/tests/api/multi-objective-routing/autopilot.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Autopilot reliability at the API boundary. + * + * Autopilot's tuning math (bucket size / hedging %) is a background job best tested with Rust + * property tests (see docs/testing-strategy.md). What we CAN and must guard end-to-end here is the + * control surface an operator touches: + * - the autopilot / auto-calibration feature flags toggle and persist, + * - the "hard refresh" (/gateway-score/reset) flushes scores AND — critically — clears only + * autopilot-authored sub-level overrides while PRESERVING human-authored config. + */ +test.describe('Autopilot control surface (API)', () => { + test('autopilot feature toggles on and off and persists', async ({ api, merchant }) => { + const base = `/merchant-account/${merchant.id}/features` + + const enable = await api.raw('POST', `${base}/autopilot`, { body: { enabled: true } }) + expect(enable.status).toBe(200) + + let list = await api.raw('GET', base) + expect(list.body.features.find((f: any) => f.feature === 'autopilot')?.enabled).toBe(true) + + const disable = await api.raw('POST', `${base}/autopilot`, { body: { enabled: false } }) + expect(disable.status).toBe(200) + + list = await api.raw('GET', base) + expect(list.body.features.find((f: any) => f.feature === 'autopilot')?.enabled).toBe(false) + }) + + test('sr auto-calibration feature toggles independently of autopilot', async ({ api, merchant }) => { + const base = `/merchant-account/${merchant.id}/features` + + await api.raw('POST', `${base}/auto-calibration`, { body: { enabled: true } }) + + const list = await api.raw('GET', base) + expect(list.body.features.find((f: any) => f.feature === 'auto-calibration')?.enabled).toBe(true) + // Enabling auto-calibration must not implicitly flip the autopilot master flag. + expect(list.body.features.find((f: any) => f.feature === 'autopilot')?.enabled).toBe(false) + }) + + test('gateway-score reset succeeds and reports counts', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + + const r = await api.raw('POST', '/gateway-score/reset', { body: { merchant_id: merchant.id } }) + + expect(r.status).toBe(200) + expect(r.body.merchant_id).toBe(merchant.id) + expect(typeof r.body.deleted_keys).toBe('number') + expect(typeof r.body.removed_overrides).toBe('number') + }) + + test('gateway-score reset preserves human sub-level overrides', async ({ api, merchant }) => { + // srConfigData seeds a manual subLevelInputConfig entry (no `source: autopilot` marker). + await api.createSuccessRateConfig(merchant.id) + + const reset = await api.raw('POST', '/gateway-score/reset', { body: { merchant_id: merchant.id } }) + expect(reset.status).toBe(200) + // No autopilot-authored overrides exist, so none should be removed. + expect(reset.body.removed_overrides).toBe(0) + + // The human-authored config must survive the reset. + const cfg = await api.getSuccessRateConfig(merchant.id) + expect(cfg.status).toBe(200) + expect(Array.isArray(cfg.body.config.data.subLevelInputConfig)).toBe(true) + expect(cfg.body.config.data.subLevelInputConfig.length).toBeGreaterThan(0) + }) + + test('gateway-score reset requires a merchant_id', async ({ api }) => { + const r = await api.raw('POST', '/gateway-score/reset', { body: {}, failOnStatusCode: false }) + expect(r.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/tests/api/multi-objective-routing/decide-gateway.spec.ts b/tests/api/multi-objective-routing/decide-gateway.spec.ts new file mode 100644 index 00000000..dbb0a0b4 --- /dev/null +++ b/tests/api/multi-objective-routing/decide-gateway.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, factory } from '../../fixtures/test' +import { expectValidGatewayResponse } from '../../helpers/assertions' + +/** + * Core reliability of the decision endpoint (/decide-gateway) — the single most important API in + * the system. These assert invariants that must hold regardless of scoring internals: + * - a well-formed decision is always returned, + * - the decided gateway is drawn from the caller's eligible list, + * - the decision works across payment methods and elimination settings. + * + * The routing *correctness matrix* (which gateway wins for which score/segment) belongs in fast + * Rust tests, not here — see docs/testing-strategy.md. + */ +test.describe('Decide Gateway (API)', () => { + test('returns a valid ranked decision for SR routing', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + + const r = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: merchant.id, + eligibleGatewayList: ['stripe', 'adyen', 'checkout'], + }), + ) + + expect(r.status).toBe(200) + expectValidGatewayResponse(r.body) + }) + + test('decided gateway is drawn from the eligible list', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + const eligible = ['stripe', 'adyen'] + + const r = await api.decideGateway( + factory.srDecideGatewayRequest({ merchantId: merchant.id, eligibleGatewayList: eligible }), + ) + + expectValidGatewayResponse(r.body) + expect(eligible).toContain(r.body.decided_gateway) + }) + + test('a single eligible gateway is always the decided gateway', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + + const r = await api.decideGateway( + factory.srDecideGatewayRequest({ merchantId: merchant.id, eligibleGatewayList: ['stripe'] }), + ) + + expectValidGatewayResponse(r.body) + expect(r.body.decided_gateway).toBe('stripe') + }) + + test('CARD payment returns a valid decision', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + + const r = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: merchant.id, + eligibleGatewayList: ['stripe', 'adyen'], + paymentInfo: { paymentMethodType: 'CARD', paymentMethod: 'VISA' }, + }), + ) + + expectValidGatewayResponse(r.body) + }) + + test('decision succeeds with elimination disabled', async ({ api, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + + const r = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: merchant.id, + eligibleGatewayList: ['stripe', 'adyen'], + eliminationEnabled: false, + }), + ) + + expectValidGatewayResponse(r.body) + }) +}) diff --git a/tests/api/multi-objective-routing/rule-configs.spec.ts b/tests/api/multi-objective-routing/rule-configs.spec.ts new file mode 100644 index 00000000..a6c9f755 --- /dev/null +++ b/tests/api/multi-objective-routing/rule-configs.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '../../fixtures/test' +import { expectValidRuleConfigResponse } from '../../helpers/assertions' + +/** + * API-contract port of cypress/e2e/api/rule-configs.cy.js. + * + * The `merchant` fixture replaces the Cypress `waitForService` + `ensureMerchantAccount` beforeEach + * (fresh merchant + dashboard session so /rule/* protected routes authenticate) and auto-cleans the + * merchant afterwards, standing in for the `cleanupTestData` afterEach. + */ +test.describe('Rule Config CRUD API', () => { + test('creates, fetches, updates, and deletes success-rate config', async ({ api, merchant }) => { + const m = merchant.id + + const created = await api.createSuccessRateConfig(m, { + defaultSuccessRate: 0.5, + defaultBucketSize: 200, + }) + expectValidRuleConfigResponse(created.body, 'successRate') + + let got = await api.getSuccessRateConfig(m) + expectValidRuleConfigResponse(got.body, 'successRate') + expect(got.body.config.data.defaultBucketSize).toBe(200) + + const updated = await api.updateSuccessRateConfig(m, { + defaultSuccessRate: 0.7, + defaultBucketSize: 240, + }) + expectValidRuleConfigResponse(updated.body, 'successRate') + + got = await api.getSuccessRateConfig(m) + expect(got.body.config.data.defaultBucketSize).toBe(240) + // defaultHedgingPercent was not overridden, so it stays at the factory default (5). + expect(got.body.config.data.defaultHedgingPercent).toBe(5) + + const del = await api.deleteSuccessRateConfig(m) + expect(del.status).toBe(200) + + const afterDelete = await api.getSuccessRateConfig(m, { failOnStatusCode: false }) + expect(afterDelete.status).not.toBe(200) + }) + + test('creates, fetches, updates, and deletes elimination config', async ({ api, merchant }) => { + const m = merchant.id + + const created = await api.createEliminationConfig(m, { + threshold: 0.35, + txnLatency: { gatewayLatency: 4500 }, + }) + expectValidRuleConfigResponse(created.body, 'elimination') + + let got = await api.getEliminationConfig(m) + expectValidRuleConfigResponse(got.body, 'elimination') + expect(got.body.config.data.threshold).toBe(0.35) + + const updated = await api.updateEliminationConfig(m, { + threshold: 0.55, + txnLatency: { gatewayLatency: 6500 }, + }) + expectValidRuleConfigResponse(updated.body, 'elimination') + + got = await api.getEliminationConfig(m) + expect(got.body.config.data.threshold).toBe(0.55) + expect(got.body.config.data.txnLatency.gatewayLatency).toBe(6500) + + const del = await api.deleteEliminationConfig(m) + expect(del.status).toBe(200) + + const afterDelete = await api.getEliminationConfig(m, { failOnStatusCode: false }) + expect(afterDelete.status).not.toBe(200) + }) +}) diff --git a/tests/api/multi-objective-routing/sr-routing.spec.ts b/tests/api/multi-objective-routing/sr-routing.spec.ts new file mode 100644 index 00000000..c64de2eb --- /dev/null +++ b/tests/api/multi-objective-routing/sr-routing.spec.ts @@ -0,0 +1,121 @@ +import { test, expect, factory, poll } from '../../fixtures/test' +import { + expectValidGatewayResponse, + expectValidRuleConfigResponse, + expectValidScoreUpdate, +} from '../../helpers/assertions' + +/** + * API-contract port of cypress/e2e/api/sr-routing.cy.js. + * + * The `merchant` fixture replaces the Cypress `waitForService` + `ensureMerchantAccount` beforeEach + * and auto-cleans the merchant afterwards. The lazy `cy.wrap` reduce chain becomes a plain + * `for` loop with `await`. + */ +test.describe('SR Routing (API)', () => { + // Validates write-through cache: an SR config update must be immediately visible via /rule/get + // without waiting for TTL expiry. + test('SR config update is immediately visible after write (cache consistency)', async ({ api, merchant }) => { + const m = merchant.id + + const created = await api.createSuccessRateConfig(m, { defaultBucketSize: 150, defaultHedgingPercent: 3 }) + expectValidRuleConfigResponse(created.body, 'successRate') + + let got = await api.getSuccessRateConfig(m) + expect(got.body.config.data.defaultBucketSize).toBe(150) + expect(got.body.config.data.defaultHedgingPercent).toBe(3) + + const updated = await api.updateSuccessRateConfig(m, { defaultBucketSize: 300, defaultHedgingPercent: 10 }) + expectValidRuleConfigResponse(updated.body, 'successRate') + + // Immediately after update — must reflect new values, not a stale cache hit. + got = await api.getSuccessRateConfig(m) + expect(got.body.config.data.defaultBucketSize).toBe(300) + expect(got.body.config.data.defaultHedgingPercent).toBe(10) + }) + + // Validates write-through cache eviction on delete: /rule/get must return non-200 immediately. + test('SR config is not found immediately after deletion (cache eviction)', async ({ api, merchant }) => { + const m = merchant.id + + const created = await api.createSuccessRateConfig(m) + expectValidRuleConfigResponse(created.body, 'successRate') + + const got = await api.getSuccessRateConfig(m) + expectValidRuleConfigResponse(got.body, 'successRate') + + const del = await api.deleteSuccessRateConfig(m) + expect(del.status).toBe(200) + + // Must not serve the deleted config from cache. + const afterDelete = await api.getSuccessRateConfig(m, { failOnStatusCode: false }) + expect(afterDelete.status).not.toBe(200) + }) + + // Validates the explore-exploit fix: gateway scores must decrease after repeated failure feedback + // (i.e. scores are updating, not frozen at 1.0 by the top-gateway exclusion bug). + // + // Each failure requires a prior /decide-gateway with the same paymentId because the backend stores + // GatewayScoringData in Redis keyed by paymentId during decide, and /update-gateway-score looks it + // up by that key. + test('gateway score decreases after repeated failure feedback (explore-exploit fix)', async ({ api, merchant }) => { + const m = merchant.id + await api.createSuccessRateConfig(m, { defaultBucketSize: 10, defaultHedgingPercent: 50 }) + + const gateways = ['stripe', 'adyen'] + // Only the gateway a decision actually picked can be scored: /update-gateway-score looks up the + // GatewayScoringData that /decide-gateway stored in Redis under the same paymentId. With hedging + // at 50% the pick varies per iteration, so track every gateway that really received a failure + // instead of assuming the first one keeps winning. + const failed = new Set() + const FAILURES = 5 + + for (let i = 0; i < FAILURES; i++) { + const pid = factory.paymentId(`fail_${i}`) + + const decide = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: m, + eligibleGatewayList: gateways, + paymentInfo: { paymentMethodType: 'CARD', paymentMethod: 'VISA', paymentId: pid }, + }), + ) + expectValidGatewayResponse(decide.body) + + const score = await api.updateGatewayScore( + factory.updateGatewayScoreRequest({ + merchantId: m, + gateway: decide.body.decided_gateway, + paymentId: pid, + status: 'FAILURE', + }), + ) + expectValidScoreUpdate(score.body) + failed.add(decide.body.decided_gateway) + } + + // Proves scores are updating, not frozen at 1.0 by the top-gateway exclusion bug. + // + // Two things keep this stable that the original form got wrong. It asserts on the MINIMUM across + // the gateways that actually received a failure, so it doesn't depend on which one hedging picked + // first; and it POLLS, because feedback is applied asynchronously — a single read can land before + // the last update-gateway-score has propagated, which is what made this flake under parallel load. + const settled = await poll( + () => + api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: m, + eligibleGatewayList: gateways, + paymentInfo: { paymentMethodType: 'CARD', paymentMethod: 'VISA' }, + }), + ), + ({ body }) => Math.min(...[...failed].map((g) => body.gateway_priority_map[g])) < 1.0, + { + message: `Expected a penalised gateway (${[...failed].join(', ')}) to score below 1.0`, + timeout: 15_000, + interval: 1_000, + }, + ) + expectValidGatewayResponse(settled.body) + }) +}) diff --git a/tests/api/platform/health.spec.ts b/tests/api/platform/health.spec.ts new file mode 100644 index 00000000..3549bdf4 --- /dev/null +++ b/tests/api/platform/health.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Smoke check: the decision-engine API is up and healthy. This is the cheapest possible + * reliability signal — if it fails, nothing else in the suite is meaningful. + * + * All three health routes are nested OUTSIDE the auth middleware, which is what makes them usable as a + * load-balancer probe; auth-guards.spec.ts asserts that stays true. + */ +test.describe('Health (API smoke)', () => { + test('GET /health returns 200', async ({ api }) => { + const r = await api.raw('GET', '/health', { failOnStatusCode: false }) + expect(r.status).toBe(200) + expect(r.body.message).toBe('Health is good') + }) + + test('GET /health/ready reports the server as up', async ({ api }) => { + const r = await api.raw('GET', '/health/ready', { failOnStatusCode: false }) + + // Readiness answers 400 (not 503) while draining. The suite only ever runs against a live server, + // so anything other than Up here means the stack came up wrong. + expect(r.status).toBe(200) + expect(r.body.message).toBe('Up') + }) + + test('GET /health/diagnostics reports storage round-trips as working', async ({ api }) => { + const r = await api.raw('GET', '/health/diagnostics', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(r.body.key_custodian_locked).toBe(false) + // Each of these is a real connect/read/write/delete round-trip against the database. + for (const check of ['database_connection', 'database_read', 'database_write', 'database_delete']) { + expect(r.body.database[check], `${check} should be Working`).toBe('Working') + } + }) + + test('diagnostics requires a tenant header', async ({ api }) => { + // The tenant resolver runs before the handler — without it there is no database to diagnose. + const r = await api.raw('GET', '/health/diagnostics', { + failOnStatusCode: false, + headers: { 'x-tenant-id': '' }, + }) + + expect(r.status).toBe(400) + }) +}) diff --git a/tests/api/platform/runtime-smoke.spec.ts b/tests/api/platform/runtime-smoke.spec.ts new file mode 100644 index 00000000..23756060 --- /dev/null +++ b/tests/api/platform/runtime-smoke.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../../fixtures/test' +import { EXPECTED_CLICKHOUSE_TABLES, existingTables } from '../../helpers/clickhouse' + +/** + * Port of cypress/e2e/runtime/runtime-smoke.cy.js. + * + * Lives in the `api` project rather than `tests/e2e/` — despite the Cypress original sitting under + * e2e/, it never opens a browser. It checks the surfaces AROUND the app: the docs site is serving, and + * ClickHouse has the analytics schema the ingestion pipeline writes into. + * + * The ClickHouse assertion earns its place: a missing table there does not fail any API call + * synchronously — analytics endpoints just return empty forever — so without this the failure mode is + * "the dashboard is mysteriously blank" rather than a test failure. + * + * The docs tests skip when DOCS_BASE_URL is unset, so the spec still passes against a hand-started + * stack that didn't boot the docs site. + */ + +const DOCS_BASE_URL = process.env.DOCS_BASE_URL + +test.describe('Runtime surface smoke', () => { + test('ClickHouse has the analytics tables the pipeline writes to', async ({ request }) => { + const found = await existingTables(request, EXPECTED_CLICKHOUSE_TABLES) + + for (const table of EXPECTED_CLICKHOUSE_TABLES) { + expect(found.has(table), `ClickHouse table ${table} should exist`).toBe(true) + } + }) + + test('the docs site serves its landing and API reference pages', async ({ request }) => { + test.skip(!DOCS_BASE_URL, 'DOCS_BASE_URL not set — docs site not part of this run') + + for (const path of ['/introduction', '/api-reference']) { + const response = await request.get(`${DOCS_BASE_URL}${path}`) + expect(response.status(), `${path} should serve`).toBe(200) + expect(await response.text()).toContain('Decision Engine') + } + }) + + test('the docs API reference includes the health check endpoint', async ({ request }) => { + test.skip(!DOCS_BASE_URL, 'DOCS_BASE_URL not set — docs site not part of this run') + + const response = await request.get(`${DOCS_BASE_URL}/api-reference/endpoint/healthCheck`) + expect(response.status()).toBe(200) + expect((await response.text()).toLowerCase()).toContain('health') + }) + + test('the runtime reports which mode it booted in', async () => { + const mode = process.env.RUNTIME_MODE + test.skip(!mode, 'RUNTIME_MODE not set — stack was not booted by run-e2e.js') + + expect(['source', 'docker', 'manual']).toContain(mode) + }) +}) diff --git a/tests/api/routing/routing-config.spec.ts b/tests/api/routing/routing-config.spec.ts new file mode 100644 index 00000000..f9f27268 --- /dev/null +++ b/tests/api/routing/routing-config.spec.ts @@ -0,0 +1,109 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * The configuration surface the dashboard reads before it can render anything: the routing-key + * catalogue behind the rule builder, the per-merchant SR scoring dimensions, and the GSM options list. + * + * `GET /config/routing-keys` earns its own test because the whole Euclid UI suite depends on it — the + * rule builder blocks on "Loading routing keys from backend..." until it resolves, and every condition + * test selects a key by name. A backend rename should fail HERE with a clear message rather than as + * dozens of opaque UI timeouts. + */ + +/** Dimensions the backend accepts for SR sub-level scoring. */ +const ELIGIBLE_DIMENSIONS = ['currency', 'country', 'auth_type', 'card_is_in', 'card_network'] + +test.describe('Routing key catalogue (API)', () => { + test('exposes the keys the rule builder depends on', async ({ api, merchant }) => { + const r = await api.raw('GET', '/config/routing-keys', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(r.body.keys, 'routing-keys response should carry a `keys` map').toBeTruthy() + + // These three are selected by name across the Euclid UI specs — losing one breaks them all. + for (const key of ['payment_method', 'currency', 'amount']) { + expect(Object.keys(r.body.keys), `routing key '${key}' must exist`).toContain(key) + } + + // Enum keys must carry their value list, or the builder's value dropdown renders empty. + // `values` is a comma-separated STRING here, not an array. + const paymentMethod = r.body.keys.payment_method + expect(paymentMethod.type).toBe('enum') + expect(typeof paymentMethod.values).toBe('string') + expect(paymentMethod.values.split(',').map((v: string) => v.trim())).toContain('card') + }) +}) + +test.describe('SR scoring dimensions (API)', () => { + test('a merchant with no configuration returns empty defaults', async ({ api, merchant }) => { + const r = await api.raw('GET', `/config-sr-dimension/${merchant.id}`, { failOnStatusCode: false }) + + // Deliberately not a 404 — the dashboard renders the config form off this response. + expect(r.status).toBe(200) + expect(r.body.merchant_id).toBe(merchant.id) + expect(r.body.paymentInfo.udfs).toEqual([]) + expect(r.body.paymentInfo.fields).toBeNull() + }) + + test('configured dimensions round-trip', async ({ api, merchant }) => { + const fields = ['currency', 'card_network'] + + const saved = await api.raw('POST', '/config-sr-dimension', { + failOnStatusCode: false, + // `paymentInfo` is camelCase on the wire; the surrounding request is snake_case. + body: { merchant_id: merchant.id, paymentInfo: { udfs: [], fields } }, + }) + expect(saved.status).toBe(200) + + const read = await api.raw('GET', `/config-sr-dimension/${merchant.id}`) + expect(read.body.paymentInfo.fields).toEqual(fields) + }) + + test('overwriting the configuration replaces the previous dimensions', async ({ api, merchant }) => { + await api.raw('POST', '/config-sr-dimension', { + body: { merchant_id: merchant.id, paymentInfo: { udfs: [], fields: ['currency'] } }, + }) + await api.raw('POST', '/config-sr-dimension', { + body: { merchant_id: merchant.id, paymentInfo: { udfs: [], fields: ['country', 'auth_type'] } }, + }) + + const read = await api.raw('GET', `/config-sr-dimension/${merchant.id}`) + expect(read.body.paymentInfo.fields).toEqual(['country', 'auth_type']) + }) + + test('every documented dimension is accepted', async ({ api, merchant }) => { + const r = await api.raw('POST', '/config-sr-dimension', { + failOnStatusCode: false, + body: { merchant_id: merchant.id, paymentInfo: { udfs: [], fields: ELIGIBLE_DIMENSIONS } }, + }) + + expect(r.status).toBe(200) + }) + + test('an unknown dimension is rejected', async ({ api, merchant }) => { + const r = await api.raw('POST', '/config-sr-dimension', { + failOnStatusCode: false, + body: { merchant_id: merchant.id, paymentInfo: { udfs: [], fields: ['not_a_dimension'] } }, + }) + + expect(r.status).toBe(400) + expect(String(r.body?.message ?? r.body)).toContain('not_a_dimension') + }) +}) + +test.describe('GSM options (API)', () => { + test('returns the gateway status-mapping rule list', async ({ api, merchant }) => { + const r = await api.raw('GET', '/gsm/options', { failOnStatusCode: false }) + + expect(r.status).toBe(200) + expect(Array.isArray(r.body.rules)).toBe(true) + + if (r.body.rules.length > 0) { + // Rows are camelCase on the wire, unlike most of the API. + const row = r.body.rules[0] + expect(typeof row.connector).toBe('string') + expect(typeof row.flow).toBe('string') + expect(typeof row.decision).toBe('string') + } + }) +}) diff --git a/tests/api/routing/routing-hybrid.spec.ts b/tests/api/routing/routing-hybrid.spec.ts new file mode 100644 index 00000000..ecd438a8 --- /dev/null +++ b/tests/api/routing/routing-hybrid.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * /routing/hybrid combines the static (rule-based) and dynamic (success-rate) deciders in one call. + * + * The behaviour worth guarding end-to-end is its GRACEFUL DEGRADATION: a caller that supplies a + * fallback must always get a usable connector list back, even when no routing rule matches — a + * payment should never be blocked because the merchant hasn't configured routing yet. + * + * The full dynamic path (which runs the whole decider against Redis scores and gateway config) is + * covered by decide-gateway.spec.ts; here the static side plus the request contract is the target. + */ +test.describe('Hybrid routing (API)', () => { + test('rejects a request with neither sub-request', async ({ api, merchant }) => { + const r = await api.raw('POST', '/routing/hybrid', { failOnStatusCode: false, body: {} }) + + expect(r.status).toBe(400) + expect(String(r.body?.message ?? r.body)).toContain('At least one of') + }) + + test('falls back to the caller-supplied connectors when no rule is active', async ({ api, merchant }) => { + const m = merchant.id + + const r = await api.raw('POST', '/routing/hybrid', { + failOnStatusCode: false, + body: { + static_routing_request: { + created_by: m, + parameters: { + payment_method: { type: 'enum_variant', value: 'card' }, + amount: { type: 'number', value: 100 }, + }, + fallback_output: [factory.gatewayConnector('stripe')], + }, + }, + }) + + // No active algorithm exists for this fresh merchant, but the fallback keeps the call usable. + expect(r.status).toBe(200) + expect(Array.isArray(r.body.evaluated_connectors)).toBe(true) + expect(r.body.evaluated_connectors.map((c: any) => c.gateway_name)).toContain('stripe') + }) + + test('evaluates the active static rule when one exists', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('hybrid_static'), gateway: 'checkout' }), + ) + await api.activateRoutingAlgorithm(m, created.body.rule_id) + + const r = await api.raw('POST', '/routing/hybrid', { + failOnStatusCode: false, + body: { + static_routing_request: { + created_by: m, + parameters: { + payment_method: { type: 'enum_variant', value: 'card' }, + amount: { type: 'number', value: 100 }, + }, + fallback_output: [factory.gatewayConnector('stripe')], + }, + }, + }) + + expect(r.status).toBe(200) + expect(r.body.static_routing).toBeTruthy() + // The configured rule wins over the fallback. + expect(r.body.evaluated_connectors.map((c: any) => c.gateway_name)).toContain('checkout') + }) + + test('a static request with no rule and no fallback is rejected', async ({ api, merchant }) => { + const r = await api.raw('POST', '/routing/hybrid', { + failOnStatusCode: false, + body: { + static_routing_request: { + created_by: merchant.id, + parameters: { amount: { type: 'number', value: 100 } }, + }, + }, + }) + + // Nothing to route to and nothing to fall back on — the caller has to be told. + expect(r.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/tests/api/routing/routing-rule-mutations.spec.ts b/tests/api/routing/routing-rule-mutations.spec.ts new file mode 100644 index 00000000..ab344148 --- /dev/null +++ b/tests/api/routing/routing-rule-mutations.spec.ts @@ -0,0 +1,165 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * The half of the routing-rule lifecycle the existing specs never touch: update, deactivate, delete. + * + * The guarded invariant across all three is that an ACTIVE rule is immutable — an operator must + * deactivate before editing or deleting, so a live routing decision can't change under a payment + * mid-flight. That's the property worth an E2E test; the evaluation semantics are already covered by + * rule-routing.spec.ts. + */ +test.describe('Routing rule mutations (API)', () => { + test('updates an inactive rule and the change takes effect on activation', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_stripe'), gateway: 'stripe' }), + ) + const ruleId = created.body.rule_id + + const renamed = factory.ruleName('mutate_renamed') + const updated = await api.raw('POST', '/routing/update', { + failOnStatusCode: false, + body: { + created_by: m, + routing_algorithm_id: ruleId, + name: renamed, + description: 'updated by playwright', + algorithm: factory.singleRoutingPayload(m, { gateway: 'checkout' }).algorithm, + }, + }) + + expect(updated.status).toBe(200) + expect(updated.body.rule_id).toBe(ruleId) + expect(updated.body.name).toBe(renamed) + + // The updated algorithm is what evaluates once the rule goes live. + await api.activateRoutingAlgorithm(m, ruleId) + const evaluated = await api.evaluateRoutingAlgorithm(factory.ruleEvaluatePayload(m)) + expect(evaluated.body.output.connector.gateway_name).toBe('checkout') + }) + + test('an active rule cannot be updated', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_locked'), gateway: 'stripe' }), + ) + const ruleId = created.body.rule_id + await api.activateRoutingAlgorithm(m, ruleId) + + const r = await api.raw('POST', '/routing/update', { + failOnStatusCode: false, + body: { + created_by: m, + routing_algorithm_id: ruleId, + name: factory.ruleName('mutate_blocked'), + description: '', + algorithm: factory.singleRoutingPayload(m, { gateway: 'adyen' }).algorithm, + }, + }) + + expect(r.status).toBe(400) + }) + + test('deactivating removes the rule from the active list', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_deactivate'), gateway: 'stripe' }), + ) + const ruleId = created.body.rule_id + await api.activateRoutingAlgorithm(m, ruleId) + + const active = await api.listActiveRoutingAlgorithms(m) + expect(active.body.some((r: any) => r.id === ruleId)).toBe(true) + + // The handler returns unit, so the response has an empty body — status is the only signal. + const deactivated = await api.raw('POST', '/routing/deactivate', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: ruleId }, + }) + expect(deactivated.status).toBe(200) + + const afterActive = await api.listActiveRoutingAlgorithms(m) + expect(afterActive.body.some((r: any) => r.id === ruleId)).toBe(false) + + // It still exists — deactivate is not delete. + const all = await api.listRoutingAlgorithms(m) + expect(all.body.some((r: any) => r.id === ruleId)).toBe(true) + }) + + test('deleting an inactive rule removes it from the list', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_delete'), gateway: 'stripe' }), + ) + const ruleId = created.body.rule_id + + const deleted = await api.raw('POST', '/routing/delete', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: ruleId }, + }) + + expect(deleted.status).toBe(200) + expect(deleted.body.status).toBe('deleted') + expect(deleted.body.routing_algorithm_id).toBe(ruleId) + + const all = await api.listRoutingAlgorithms(m) + expect(all.body.some((r: any) => r.id === ruleId)).toBe(false) + }) + + test('an active rule cannot be deleted until it is deactivated', async ({ api, merchant }) => { + const m = merchant.id + const created = await api.createRoutingAlgorithm( + factory.singleRoutingPayload(m, { name: factory.ruleName('mutate_del_active'), gateway: 'stripe' }), + ) + const ruleId = created.body.rule_id + await api.activateRoutingAlgorithm(m, ruleId) + + const blocked = await api.raw('POST', '/routing/delete', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: ruleId }, + }) + expect(blocked.status).toBe(400) + + // Deactivate first, then the delete goes through — this is the operator's actual path. + await api.raw('POST', '/routing/deactivate', { + body: { created_by: m, routing_algorithm_id: ruleId }, + }) + const deleted = await api.raw('POST', '/routing/delete', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: ruleId }, + }) + expect(deleted.status).toBe(200) + }) + + test('mutating an unknown rule id is rejected', async ({ api, merchant }) => { + const m = merchant.id + const unknown = 'routing_algorithm_that_does_not_exist' + + const updated = await api.raw('POST', '/routing/update', { + failOnStatusCode: false, + body: { + created_by: m, + routing_algorithm_id: unknown, + name: factory.ruleName('ghost'), + description: '', + algorithm: factory.singleRoutingPayload(m, { gateway: 'stripe' }).algorithm, + }, + }) + expect(updated.status).toBe(400) + + const deactivated = await api.raw('POST', '/routing/deactivate', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: unknown }, + }) + expect(deactivated.status).toBe(400) + + // NOTE: delete of an unknown id currently surfaces as a 500 (the storage layer's "no rows to + // delete" is not mapped to a 404). Asserting >=400 documents "it is rejected" without pinning the + // suite to a status that is arguably a bug. + const deleted = await api.raw('POST', '/routing/delete', { + failOnStatusCode: false, + body: { created_by: m, routing_algorithm_id: unknown }, + }) + expect(deleted.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/tests/api/routing/routing-rules.spec.ts b/tests/api/routing/routing-rules.spec.ts new file mode 100644 index 00000000..e4e21831 --- /dev/null +++ b/tests/api/routing/routing-rules.spec.ts @@ -0,0 +1,185 @@ +import { test, expect, factory, poll } from '../../fixtures/test' +import { + expectValidAnalyticsOverview, + expectValidGatewayResponse, + expectValidScoreUpdate, +} from '../../helpers/assertions' + +/** + * API-contract port of three Cypress specs: + * - cypress/e2e/api/dynamic-routing.cy.js + * - cypress/e2e/api/volume-split.cy.js + * - cypress/e2e/api/routing-mutation.cy.js + * + * The `merchant` fixture replaces the Cypress `waitForService` + `ensureMerchantAccount` beforeEach + * and auto-cleans the merchant afterwards. Lazy `cy.wrap`/`Cypress._.times` chains become plain + * `for`/`await` loops, and `cy.pollRequest` becomes the shared `poll` helper (tests/helpers/poll.ts). + */ + +/** Port of the volume-split spec's `extractConnector` helper (operates on the response body). */ +function extractConnector(body: any): string | null { + return ( + body.evaluated_output?.[0]?.gateway_name || + body.output.connector?.gateway_name || + body.output.connectors?.[0]?.gateway_name || + null + ) +} + +test.describe('Dynamic Routing API', () => { + test('decides a gateway, updates connector feedback, and records analytics trail', async ({ api, merchant }) => { + // Poll payment-audit + analytics-overview twice at up to 30s each; give the test room past the 60s default. + test.setTimeout(120_000) + const m = merchant.id + // Replaces the beforeEach `cy.createSuccessRateConfig(merchantId)`. + await api.createSuccessRateConfig(m) + + const firstPaymentId = factory.paymentId('dynamic_first') + const secondPaymentId = factory.paymentId('dynamic_second') + + const decideFirst = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: m, + paymentInfo: { + paymentId: firstPaymentId, + paymentMethodType: 'UPI', + paymentMethod: 'UPI_PAY', + }, + }), + ) + expectValidGatewayResponse(decideFirst.body) + const chosenGateway: string = decideFirst.body.decided_gateway + const initialScore = decideFirst.body.gateway_priority_map[chosenGateway] + expect(typeof chosenGateway).toBe('string') + + const score = await api.updateGatewayScore( + factory.updateGatewayScoreRequest({ + merchantId: m, + gateway: chosenGateway, + paymentId: firstPaymentId, + status: 'FAILURE', + txnLatency: { gatewayLatency: 8000 }, + }), + ) + expectValidScoreUpdate(score.body) + expect(score.body.gateway).toBe(chosenGateway) + expect(score.body.payment_id).toBe(firstPaymentId) + + const decideSecond = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId: m, + paymentInfo: { + paymentId: secondPaymentId, + paymentMethodType: 'UPI', + paymentMethod: 'UPI_PAY', + }, + }), + ) + expectValidGatewayResponse(decideSecond.body) + expect(decideSecond.body.gateway_priority_map[chosenGateway]).toBeLessThanOrEqual(initialScore) + + // Poll the payment-audit trail until both the decision and the score-update events land. + // `failOnStatusCode: false` keeps transient non-2xx responses from throwing so the predicate can retry. + const audit = await poll( + () => + api.raw('GET', '/analytics/payment-audit', { + failOnStatusCode: false, + qs: { range: '1h', payment_id: firstPaymentId }, + }), + ({ body }) => + Array.isArray(body.timeline) && + body.timeline.some((event: any) => event.flow_type === 'decide_gateway_decision') && + body.timeline.some((event: any) => event.flow_type === 'update_gateway_score_update'), + { message: 'Expected payment audit decision + gateway update trail' }, + ) + const flowTypes = audit.body.timeline.map((event: any) => event.flow_type) + expect(flowTypes).toContain('decide_gateway_decision') + expect(flowTypes).toContain('update_gateway_score_update') + + // Poll the analytics overview until the dynamic-routing route hits are recorded. + const overview = await poll( + () => + api.raw('GET', '/analytics/overview', { + failOnStatusCode: false, + qs: { range: '1h' }, + }), + ({ body }) => + Array.isArray(body.route_hits) && + body.route_hits.some((hit: any) => hit.route === '/decide_gateway' && hit.count >= 2) && + body.route_hits.some((hit: any) => hit.route === '/update_gateway' && hit.count >= 1), + { message: 'Expected dynamic routing route hits in analytics overview' }, + ) + expectValidAnalyticsOverview(overview.body) + }) +}) + +test.describe('Volume Split Routing API', () => { + test('creates, activates, evaluates, and approximates configured volume split', async ({ api, merchant }) => { + // 100 sequential evaluations can outlast the 60s default timeout. + test.setTimeout(120_000) + const m = merchant.id + const payload = factory.volumeSplitRoutingPayload(m, { + name: factory.ruleName('volume_split'), + data: [ + { split: 70, output: factory.gatewayConnector('stripe') }, + { split: 30, output: factory.gatewayConnector('paytm') }, + ], + }) + const counts = new Map() + + const create = await api.createRoutingAlgorithm(payload) + const routingAlgorithmId = create.body.rule_id + await api.activateRoutingAlgorithm(m, routingAlgorithmId) + + for (let index = 0; index < 100; index++) { + const evaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, {}, { payment_id: factory.paymentId(`volume_eval_${index}`) }), + ) + expect(evaluation.body.output.type).toBe('volume_split') + const connector = extractConnector(evaluation.body) + expect(['stripe', 'paytm']).toContain(connector) + counts.set(connector, (counts.get(connector) || 0) + 1) + } + + const stripeCount = counts.get('stripe') || 0 + const paytmCount = counts.get('paytm') || 0 + + // Tolerance of ±20 around the configured 70/30 split (≈ ±4σ for n=100,p=0.7) + // keeps the failure rate below 0.001% while still catching a broken distribution. + expect(stripeCount).toBeGreaterThanOrEqual(50) + expect(stripeCount).toBeLessThanOrEqual(90) + expect(paytmCount).toBeGreaterThanOrEqual(10) + expect(paytmCount).toBeLessThanOrEqual(50) + }) +}) + +test.describe('Routing Mutation Regression API', () => { + test('changes the selected connector after the active routing rule is replaced', async ({ api, merchant }) => { + const m = merchant.id + const firstPayload = factory.singleRoutingPayload(m, { + name: factory.ruleName('single_stripe'), + gateway: 'stripe', + }) + const secondPayload = factory.singleRoutingPayload(m, { + name: factory.ruleName('single_checkout'), + gateway: 'checkout', + }) + + const createFirst = await api.createRoutingAlgorithm(firstPayload) + const firstRuleId = createFirst.body.rule_id + await api.activateRoutingAlgorithm(m, firstRuleId) + + const evalFirst = await api.evaluateRoutingAlgorithm(factory.ruleEvaluatePayload(m)) + expect(evalFirst.body.output.type).toBe('straight_through') + expect(evalFirst.body.output.connector.gateway_name).toBe('stripe') + + const createSecond = await api.createRoutingAlgorithm(secondPayload) + const secondRuleId = createSecond.body.rule_id + expect(secondRuleId).not.toBe(firstRuleId) + await api.activateRoutingAlgorithm(m, secondRuleId) + + const evalSecond = await api.evaluateRoutingAlgorithm(factory.ruleEvaluatePayload(m)) + expect(evalSecond.body.output.type).toBe('straight_through') + expect(evalSecond.body.output.connector.gateway_name).toBe('checkout') + }) +}) diff --git a/tests/api/routing/rule-routing.spec.ts b/tests/api/routing/rule-routing.spec.ts new file mode 100644 index 00000000..8c0a5c01 --- /dev/null +++ b/tests/api/routing/rule-routing.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, factory } from '../../fixtures/test' +import { + expectValidRoutingAlgorithmCreateResponse, + expectValidRoutingAlgorithmList, +} from '../../helpers/assertions' + +/** + * API-contract port of three Cypress specs: + * - cypress/e2e/api/rule-routing-single.cy.js + * - cypress/e2e/api/rule-routing-priority.cy.js + * - cypress/e2e/api/rule-routing-advanced.cy.js + * + * The `merchant` fixture replaces the Cypress `waitForService` + `ensureMerchantAccount` beforeEach + * and auto-cleans the merchant afterwards. The `createdBy` for each routing algorithm is the merchant id, + * matching the Cypress source. Chained `cy.then` flows become plain `await` sequences. + */ + +test.describe('Single Connector Routing API', () => { + test('creates, activates, lists, and evaluates a single connector algorithm', async ({ api, merchant }) => { + const m = merchant.id + const payload = factory.singleRoutingPayload(m, { + name: factory.ruleName('single_rule'), + gateway: 'stripe', + }) + + const create = await api.createRoutingAlgorithm(payload) + expectValidRoutingAlgorithmCreateResponse(create.body) + const routingAlgorithmId = create.body.rule_id + + const list = await api.listRoutingAlgorithms(m) + expectValidRoutingAlgorithmList(list.body) + expect(list.body.some((rule: any) => rule.id === routingAlgorithmId)).toBe(true) + + const activate = await api.activateRoutingAlgorithm(m, routingAlgorithmId) + expect(activate.status).toBe(200) + + const active = await api.listActiveRoutingAlgorithms(m) + expectValidRoutingAlgorithmList(active.body) + expect(active.body.some((rule: any) => rule.id === routingAlgorithmId)).toBe(true) + + const evaluation = await api.evaluateRoutingAlgorithm(factory.ruleEvaluatePayload(m)) + expect(['success', 'default_selection']).toContain(evaluation.body.status) + expect(evaluation.body.output.type).toBe('straight_through') + expect(evaluation.body.output.connector.gateway_name).toBe('stripe') + }) +}) + +test.describe('Priority Routing API', () => { + test('creates, activates, lists, and evaluates a priority algorithm preserving order', async ({ api, merchant }) => { + const m = merchant.id + const payload = factory.priorityRoutingPayload(m, { + name: factory.ruleName('priority_rule'), + connectors: [ + factory.gatewayConnector('stripe'), + factory.gatewayConnector('razorpay'), + factory.gatewayConnector('adyen'), + ], + }) + + const create = await api.createRoutingAlgorithm(payload) + expectValidRoutingAlgorithmCreateResponse(create.body) + const routingAlgorithmId = create.body.rule_id + + const activate = await api.activateRoutingAlgorithm(m, routingAlgorithmId) + expect(activate.status).toBe(200) + + const list = await api.listRoutingAlgorithms(m) + expectValidRoutingAlgorithmList(list.body) + const created = list.body.find((rule: any) => rule.id === routingAlgorithmId) + expect(created).toBeTruthy() + + const active = await api.listActiveRoutingAlgorithms(m) + expect(active.body.some((rule: any) => rule.id === routingAlgorithmId)).toBe(true) + + const evaluation = await api.evaluateRoutingAlgorithm(factory.ruleEvaluatePayload(m)) + expect(evaluation.body.output.type).toBe('priority') + const gateways = evaluation.body.output.connectors.map((connector: any) => connector.gateway_name) + expect(gateways).toEqual(['stripe', 'razorpay', 'adyen']) + }) +}) + +test.describe('Advanced Routing API', () => { + test('evaluates default-selection and matched rule paths for a simple advanced algorithm', async ({ api, merchant }) => { + const m = merchant.id + const payload = factory.advancedRoutingPayload(m, { + name: factory.ruleName('advanced_simple'), + }) + + const create = await api.createRoutingAlgorithm(payload) + const routingAlgorithmId = create.body.rule_id + await api.activateRoutingAlgorithm(m, routingAlgorithmId) + + const cardEvaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, { + payment_method: { type: 'enum_variant', value: 'card' }, + amount: { type: 'number', value: 150 }, + }), + ) + expect(['success', 'default_selection']).toContain(cardEvaluation.body.status) + expect(cardEvaluation.body.output.type).toBe('priority') + expect(cardEvaluation.body.output.connectors[0].gateway_name).toBe('checkout') + + const upiEvaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, { + payment_method: { type: 'enum_variant', value: 'upi' }, + amount: { type: 'number', value: 50 }, + }), + ) + expect(['success', 'default_selection']).toContain(upiEvaluation.body.status) + expect(upiEvaluation.body.output.type).toBe('priority') + expect(upiEvaluation.body.output.connectors[0].gateway_name).toBe('stripe') + }) + + test('supports nested AND/OR style routing evaluation via nested statements', async ({ api, merchant }) => { + const m = merchant.id + const payload = factory.advancedNestedAndOrRoutingPayload(m, { + name: factory.ruleName('advanced_nested'), + }) + + const create = await api.createRoutingAlgorithm(payload) + const routingAlgorithmId = create.body.rule_id + await api.activateRoutingAlgorithm(m, routingAlgorithmId) + + const cardVisaEvaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, { + payment_method: { type: 'enum_variant', value: 'card' }, + card_network: { type: 'enum_variant', value: 'visa' }, + }), + ) + expect(['success', 'default_selection']).toContain(cardVisaEvaluation.body.status) + expect(cardVisaEvaluation.body.output.connectors[0].gateway_name).toBe('stripe') + + const cardUsdEvaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, { + payment_method: { type: 'enum_variant', value: 'card' }, + currency: { type: 'enum_variant', value: 'USD' }, + }), + ) + expect(['success', 'default_selection']).toContain(cardUsdEvaluation.body.status) + expect(cardUsdEvaluation.body.output.connectors[0].gateway_name).toBe('stripe') + + const upiUsdEvaluation = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload(m, { + payment_method: { type: 'enum_variant', value: 'upi' }, + currency: { type: 'enum_variant', value: 'USD' }, + }), + ) + expect(['success', 'default_selection']).toContain(upiUsdEvaluation.body.status) + expect(upiUsdEvaluation.body.output.connectors[0].gateway_name).toBe('checkout') + }) +}) diff --git a/tests/e2e/analytics/analytics-page.spec.ts b/tests/e2e/analytics/analytics-page.spec.ts new file mode 100644 index 00000000..0273b74f --- /dev/null +++ b/tests/e2e/analytics/analytics-page.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../fixtures/test' +import { seedRoutedTraffic, waitForOverviewRouteHits } from '../../helpers/seed' + +/** + * Port of cypress/e2e/ui/analytics-page.cy.js. + * + * The Analytics page has two independent views — multi-objective (success-rate routing) and + * rule/volume based — and the toggle between them is the thing worth guarding: each reads a different + * set of endpoints, so a regression in one is invisible while looking at the other. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Analytics UI', () => { + test('renders transaction and rule-based analytics with refresh', async ({ + api, + authedPage, + merchant, + }) => { + test.setTimeout(120_000) + + await seedRoutedTraffic(api, merchant.id, { prefix: 'analytics_ui' }) + await waitForOverviewRouteHits(api, ['/decide_gateway', '/update_gateway']) + + await authedPage.goto('/analytics') + + await expect(authedPage.getByRole('heading', { level: 1, name: 'Analytics' })).toBeVisible() + await expect(authedPage.getByRole('button', { name: 'Multi-objective', exact: true })).toBeVisible() + await expect( + authedPage.getByRole('button', { name: 'Rule based / Volume based', exact: true }), + ).toBeVisible() + + // Change the window and force a reload of both panels. + await authedPage.getByRole('button', { name: '1w', exact: true }).click() + const overview = authedPage.waitForResponse((r) => r.url().includes('/analytics/overview')) + const routingStats = authedPage.waitForResponse((r) => r.url().includes('/analytics/routing-stats')) + await authedPage.getByRole('button', { name: 'Refresh' }).click() + await overview + await routingStats + + await expect(authedPage.getByText('Decide Gateway')).toBeVisible({ timeout: 30_000 }) + + // Switching views must swap in the rule-based panel. + await authedPage + .getByRole('button', { name: 'Rule based / Volume based', exact: true }) + .click({ force: true }) + await expect(authedPage.getByText('Latest decisions from')).toBeVisible({ timeout: 30_000 }) + }) +}) diff --git a/tests/e2e/analytics/dashboard-overview.spec.ts b/tests/e2e/analytics/dashboard-overview.spec.ts new file mode 100644 index 00000000..70a966d7 --- /dev/null +++ b/tests/e2e/analytics/dashboard-overview.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '../../fixtures/test' +import { seedRoutedTraffic, waitForOverviewRouteHits } from '../../helpers/seed' + +/** + * Port of cypress/e2e/ui/dashboard-overview.cy.js — the Overview page at `/`. + * + * The seed + poll dance is not incidental: the page renders analytics that only exist once ClickHouse + * has ingested the generated traffic, so the test waits for the data to land before asserting on it + * rather than racing the pipeline. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Dashboard Overview UI', () => { + test('renders overview content and shows the refresh state on range change', async ({ + api, + authedPage, + merchant, + }) => { + // Seeding + two ClickHouse ingestion waits can outlast the 60s default. + test.setTimeout(120_000) + + await seedRoutedTraffic(api, merchant.id, { + withAdvancedRule: false, + withPreviewEvaluation: false, + gatewayLatency: 2200, + prefix: 'overview', + }) + await waitForOverviewRouteHits(api, ['/decide_gateway']) + + await authedPage.goto('/') + + await expect(authedPage.getByRole('heading', { level: 1, name: 'Overview' })).toBeVisible() + await expect(authedPage.getByText('Setup', { exact: true }).first()).toBeVisible() + await expect(authedPage.getByText('Gateway activity').first()).toBeVisible() + await expect(authedPage.getByText(merchant.id).first()).toBeVisible() + + // Wait for the first analytics load to settle before triggering a refresh. + await expect(authedPage.getByText('Top gateway').first()).toBeVisible() + + // Changing the range must re-query analytics rather than re-render stale numbers. The Cypress + // original asserted a "Loading" text badge; the refresh affordance is now a 2px progress bar and a + // dimmed KPI grid, so assert the refetch itself — that's the behaviour, the badge was the cosmetic. + const refetch = authedPage.waitForResponse( + (r) => r.url().includes('/analytics/overview') && r.url().includes('range='), + { timeout: 30_000 }, + ) + await authedPage.getByRole('button', { name: '1 week' }).click() + await refetch + + await expect(authedPage.getByText('Top gateway').first()).toBeVisible({ timeout: 30_000 }) + + await authedPage.getByRole('button', { name: 'Analytics' }).click() + await expect(authedPage).toHaveURL(/\/analytics/) + await expect(authedPage.getByRole('heading', { level: 1, name: 'Analytics' })).toBeVisible() + }) +}) diff --git a/tests/e2e/analytics/payment-audit.spec.ts b/tests/e2e/analytics/payment-audit.spec.ts new file mode 100644 index 00000000..47fac1fd --- /dev/null +++ b/tests/e2e/analytics/payment-audit.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../../fixtures/test' +import { seedRoutedTraffic, waitForAuditFlowType, waitForPreviewFlowType } from '../../helpers/seed' + +/** + * Port of cypress/e2e/ui/payment-audit.cy.js. + * + * The audit page is the support tool: given a payment id, show why it routed the way it did. It has two + * modes reading two different traces — the live decision trail (`/analytics/payment-audit`) and the + * rule-preview trail (`/analytics/preview-trace`) — and both are exercised here because a broken lookup + * in either one means an operator cannot answer "why did this payment go to that PSP". + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Payment Audit UI', () => { + test('searches transaction and rule-based audit trails from the UI', async ({ + api, + authedPage, + merchant, + }) => { + test.setTimeout(120_000) + + const seeded = await seedRoutedTraffic(api, merchant.id, { + scoreStatus: 'FAILURE', + prefix: 'audit', + }) + + // Both trails are populated asynchronously — wait for each before driving the UI at it. + await waitForAuditFlowType(api, seeded.decisionPaymentId, 'decide_gateway_decision') + await waitForPreviewFlowType(api, seeded.previewPaymentId!, 'routing_evaluate_advanced') + + // NOTE: the Cypress original asserts on "Search Decision Trail" / "Search Rule Decision Trail". + // Those strings still exist in the page's content object but are no longer rendered anywhere, so + // this asserts on the heading and the search input, which are what a user actually sees. + await authedPage.goto('/audit') + + await expect(authedPage.getByRole('heading', { level: 1, name: 'Decision Audit' })).toBeVisible() + await authedPage.getByPlaceholder('PaymentID').fill(seeded.decisionPaymentId) + await authedPage.keyboard.press('Enter') + + await expect(authedPage.getByText(seeded.decisionPaymentId).first()).toBeVisible({ timeout: 20_000 }) + + // Rule-based mode is a separate view reading the preview trace instead of the decision trail. + await authedPage.goto('/audit?mode=rule_based') + + const ruleSearch = authedPage.getByPlaceholder('Decision payment ID') + await expect(ruleSearch).toBeVisible({ timeout: 20_000 }) + await ruleSearch.fill(seeded.previewPaymentId!) + await authedPage.keyboard.press('Enter') + + await expect(authedPage.getByText(seeded.previewPaymentId!).first()).toBeVisible({ timeout: 20_000 }) + }) +}) diff --git a/tests/e2e/auth/auth-page.spec.ts b/tests/e2e/auth/auth-page.spec.ts new file mode 100644 index 00000000..c3af658b --- /dev/null +++ b/tests/e2e/auth/auth-page.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '../../fixtures/test' +import { stubApi } from '../../helpers/stub' + +/** + * Port of cypress/e2e/ui/auth-page.cy.js. + * + * Uses the plain `page` fixture, not `authedPage` — Playwright gives every test a fresh browser + * context, so the Cypress `onBeforeLoad(win) { win.localStorage.removeItem('auth-store') }` boilerplate + * is unnecessary and is deliberately NOT ported. + * + * DEVIATION FROM SOURCE: the Cypress spec's first test renders /login unauthenticated and then visits / + * with a session in the same test. `addInitScript` is per-page and permanent, so one page cannot do + * both — it is split into two tests here. A fourth test (AuthGuard redirect) is new; the Cypress suite + * never covered it. + */ + +test.describe('Auth UI', () => { + test('renders the login page when unauthenticated', async ({ page }) => { + await page.goto('/login') + + await expect( + page.getByRole('heading', { name: 'Manage routing, analytics, and audits from one dashboard.' }), + ).toBeVisible() + await expect(page.getByText('Welcome back')).toBeVisible() + await expect(page.getByRole('button', { name: 'Enter dashboard' })).toBeVisible() + }) + + test('renders the dashboard for a seeded session', async ({ authedPage, merchant }) => { + await authedPage.goto('/') + + await expect(authedPage.getByText(`${merchant.id}@example.com`)).toBeVisible({ timeout: 20_000 }) + await expect(authedPage.getByText(merchant.id).first()).toBeVisible() + }) + + test('redirects an unauthenticated visit to a protected route back to login', async ({ page }) => { + await page.goto('/routing') + + // AuthGuard has no token to validate, so the dashboard must never render. + await expect(page).toHaveURL(/\/login/) + }) + + test('keeps the sign-up tab active across refresh', async ({ page }) => { + await page.goto('/login') + + await page.getByRole('button', { name: 'Sign up' }).click() + await expect(page).toHaveURL(/\/signup/) + await expect(page.getByText('Create account').first()).toBeVisible() + + await page.reload() + + // The tab is encoded in the URL, so a refresh must not silently drop the user back to sign-in. + await expect(page).toHaveURL(/\/signup/) + await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible() + }) + + test('switches duplicate sign-up attempts to sign-in with email preserved', async ({ page }) => { + const duplicateEmail = 'duplicate-user@example.com' + + // Register the stub BEFORE navigating, or the route handler misses the request. + await stubApi(page, '**/auth/signup', { + status: 409, + body: { message: 'Email already registered' }, + }) + + await page.goto('/login') + await page.getByRole('button', { name: 'Sign up' }).click() + + await page.locator('input[type="email"]').fill(duplicateEmail) + await page.getByPlaceholder('e.g. Acme Corp').fill('Venom') + await page.getByPlaceholder('Enter your password').fill('ValidPass1!') + await page.getByRole('button', { name: 'Create account' }).click() + + // The recovery is the point: an existing account should drop the user into sign-in with their + // email already filled in and the cursor in the password field, not strand them on an error. + await expect(page.getByText('Welcome back')).toBeVisible() + await expect(page.getByText('Account already exists. Sign in with this email.')).toBeVisible() + await expect(page.locator('input[type="email"]')).toHaveValue(duplicateEmail) + await expect(page.getByPlaceholder('Enter your password')).toBeFocused() + }) +}) diff --git a/tests/e2e/decision-explorer/decision-explorer.spec.ts b/tests/e2e/decision-explorer/decision-explorer.spec.ts new file mode 100644 index 00000000..b766848d --- /dev/null +++ b/tests/e2e/decision-explorer/decision-explorer.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Port of cypress/e2e/ui/decision-explorer.cy.js. + * + * The Decision Explorer is the "try before you route" surface — four tabs, each simulating a different + * routing mode against the merchant's real configuration. These assert each tab's controls render with + * backend-valid defaults, plus one end-to-end run of the debit tab (the only mode whose result depends + * on a merchant feature flag being on). + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Decision Explorer UI', () => { + test.beforeEach(async ({ authedPage }) => { + await authedPage.goto('/decisions') + await expect(authedPage.getByRole('heading', { level: 1, name: 'Decision Explorer' })).toBeVisible() + // Interacting before the routing config lands gives false failures on empty dropdowns. + await expect(authedPage.getByText('Loading routing config from backend...')).toHaveCount(0) + }) + + // Each tab button renders a title and a subtitle, so its accessible name is both lines joined. + // Matching the full name is what makes it unique — the bare title also matches other buttons on the + // page (e.g. the always-visible 'Reset Auth-Rate Based Routing'). + test('renders the auth-rate simulation surface', async ({ authedPage }) => { + await authedPage.getByRole('button', { name: 'Auth-rate Score simulation' }).click() + + await expect(authedPage.getByRole('button', { name: 'Run Auth-Rate Simulation' }).first()).toBeVisible() + await expect(authedPage.getByText('Payments').first()).toBeVisible() + await expect(authedPage.locator('input[type="range"]').first()).toBeVisible() + }) + + test('renders the rule-based evaluation surface', async ({ authedPage }) => { + await authedPage.getByRole('button', { name: 'Rule based Policy evaluator' }).click() + + await expect(authedPage.getByRole('button', { name: 'Evaluate Rules' }).first()).toBeVisible() + await expect(authedPage.getByText('Rule Evaluation Parameters').first()).toBeVisible() + await expect(authedPage.getByText('Fallback Gateways').first()).toBeVisible() + await expect(authedPage.getByPlaceholder('gateway name').first()).toBeVisible() + await expect(authedPage.getByPlaceholder('gateway id (optional)').first()).toBeVisible() + await expect(authedPage.getByText('Add Parameter').first()).toBeVisible() + }) + + test('renders the volume split evaluation surface', async ({ authedPage }) => { + await authedPage.getByRole('button', { name: 'Volume split Distribution run' }).click() + + await expect(authedPage.getByText('Evaluation count').first()).toBeVisible() + await authedPage.locator('input[type="number"]').first().fill('20') + // The volume tab's run button is labelled just "Run" (the frozen Cypress spec still expects the + // older "Run Volume Evaluation" wording), so match it exactly to keep it unique. + await expect(authedPage.getByRole('button', { name: 'Run', exact: true })).toBeVisible() + await expect(authedPage.getByText('Volume Split Configuration').first()).toBeVisible() + await expect(authedPage.getByText('/routing/evaluate').first()).toBeVisible() + }) + + test('renders the debit routing surface with backend-valid defaults', async ({ authedPage }) => { + await authedPage.getByRole('button', { name: 'Debit routing Network decision' }).click() + + await expect(authedPage.getByText('Debit Routing Parameters').first()).toBeVisible() + await expect(authedPage.getByText('Debit routing is disabled.').first()).toBeVisible() + await expect(authedPage.getByRole('button', { name: 'Enable Debit Routing' }).first()).toBeVisible() + await expect(authedPage.locator('input[value="merchant_category_code_0001"]')).toBeVisible() + await expect(authedPage.locator('input[value="VISA, NYCE, PULSE, STAR"]')).toBeVisible() + // The run button stays disabled until the feature is enabled — no misleading empty results. + await expect(authedPage.getByRole('button', { name: 'Run Debit Routing' }).first()).toBeDisabled() + }) + + test('runs debit routing through decide-gateway when enabled', async ({ api, authedPage, merchant }) => { + await api.raw('POST', `/merchant-account/${merchant.id}/debit-routing`, { body: { enabled: true } }) + await authedPage.reload() + await expect(authedPage.getByText('Loading routing config from backend...')).toHaveCount(0) + + await authedPage.getByRole('button', { name: 'Debit routing Network decision' }).click() + await expect(authedPage.getByText('Debit routing is enabled.').first()).toBeVisible() + + await authedPage.getByRole('button', { name: 'Run Debit Routing' }).first().click() + + await expect(authedPage.getByText('Debit Routing Result').first()).toBeVisible({ timeout: 20_000 }) + await expect(authedPage.getByText('Ranked Debit Networks').first()).toBeVisible() + await expect(authedPage.getByRole('cell', { name: 'VISA' }).first()).toBeVisible() + }) +}) diff --git a/tests/e2e/routing/debit-routing-page.spec.ts b/tests/e2e/routing/debit-routing-page.spec.ts new file mode 100644 index 00000000..e912b176 --- /dev/null +++ b/tests/e2e/routing/debit-routing-page.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Port of cypress/e2e/ui/debit-routing-page.cy.js. + * + * Debit routing is runtime-access only from the dashboard: the operator flips a flag, and the network + * configuration itself (MCC, accepted networks) is NOT editable here. The negative assertions are the + * point — they pin that the config-editing UI stays absent, since exposing it would imply the + * dashboard can change routing behaviour it does not actually own. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Debit Routing UI', () => { + test('toggles merchant debit routing access without exposing unsupported config editing', async ({ + api, + authedPage, + merchant, + }) => { + await authedPage.goto('/routing/debit') + + await expect( + authedPage.getByRole('heading', { level: 1, name: 'Network / Debit Routing' }), + ).toBeVisible() + await expect(authedPage.getByText('Debit Routing Runtime Access')).toBeVisible() + await expect(authedPage.getByText('Save Config')).toHaveCount(0) + await expect(authedPage.getByText('Merchant Category Code (MCC)')).toHaveCount(0) + + await authedPage.getByRole('button', { name: 'Enable Debit Routing' }).click() + await expect(authedPage.getByText('Debit routing enabled.')).toBeVisible() + + // Confirm the toggle actually persisted server-side, not just in local UI state. + let flag = await api.raw('GET', `/merchant-account/${merchant.id}/debit-routing`) + expect(flag.body.debit_routing_enabled).toBe(true) + + await authedPage.getByRole('button', { name: 'Disable Debit Routing' }).click() + await expect(authedPage.getByText('Debit routing disabled.')).toBeVisible() + + flag = await api.raw('GET', `/merchant-account/${merchant.id}/debit-routing`) + expect(flag.body.debit_routing_enabled).toBe(false) + }) +}) diff --git a/tests/e2e/routing/rules/euclid-builder.spec.ts b/tests/e2e/routing/rules/euclid-builder.spec.ts new file mode 100644 index 00000000..29878b1e --- /dev/null +++ b/tests/e2e/routing/rules/euclid-builder.spec.ts @@ -0,0 +1,357 @@ +import { test, expect } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' + +/** + * Port of cypress/e2e/ui/euclid-rules-builder.cy.js. + * + * The rule builder FORM only: page rendering, rule blocks, conditions, OR groups, gateways, the JSON + * preview, and client-side validation. No rule reaches the backend — the one Create Rule click is the + * empty-name case, which is blocked before it submits. + * + * Because nothing here mutates merchant state, these run against the worker-scoped `sharedMerchant` + * (one signup per worker rather than one per test). Do not add a test to this file that creates a rule. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Rule Builder — UI interactions', () => { + let euclid: EuclidRuleBuilder + + test.beforeEach(async ({ sharedPage }) => { + euclid = new EuclidRuleBuilder(sharedPage) + await euclid.goto('/routing/rules') + }) + + test.describe('Page rendering', () => { + test('shows the rule builder form and an empty existing-rules panel', async ({ sharedPage }) => { + await expect(sharedPage.getByRole('heading', { name: 'Rule Builder' })).toBeVisible() + await expect(sharedPage.getByRole('heading', { name: 'Existing Rules' })).toBeVisible() + await expect(sharedPage.getByPlaceholder('my-rule')).toBeVisible() + await expect(sharedPage.getByPlaceholder('Optional description')).toBeVisible() + await expect(sharedPage.getByText('No rule-based rules yet.')).toBeVisible() + }) + + test('shows the Default Fallback section below the rule list', async ({ sharedPage }) => { + await expect(sharedPage.getByText('Default Fallback')).toBeVisible() + await expect(sharedPage.getByText('Used when no rule matches')).toBeVisible() + }) + + test('shows Create Rule and Preview JSON buttons', async ({ sharedPage }) => { + await expect(sharedPage.getByRole('button', { name: 'Create Rule' })).toBeVisible() + await expect(sharedPage.getByRole('button', { name: 'Preview JSON' })).toBeVisible() + }) + }) + + test.describe('Rule block management', () => { + test('adds a rule block when clicking Add Rule', async ({ sharedPage }) => { + await euclid.addRuleBlock() + + await expect(sharedPage.getByPlaceholder('Rule name')).toHaveCount(1) + await expect(euclid.ruleBlock(0).getByText('If', { exact: true })).toBeVisible() + await expect(euclid.ruleBlock(0).getByText('Then route')).toBeVisible() + }) + + test('adds multiple rule blocks independently', async ({ sharedPage }) => { + await euclid.addRuleBlock() + await euclid.addRuleBlock() + + const names = sharedPage.getByPlaceholder('Rule name') + await expect(names).toHaveCount(2) + await expect(names.nth(0)).toHaveValue('Rule 1') + await expect(names.nth(1)).toHaveValue('Rule 2') + }) + + test('allows renaming a rule block inline', async ({ sharedPage }) => { + await euclid.addRuleBlock() + + const name = euclid.ruleBlock(0).getByPlaceholder('Rule name') + await name.fill('card-rule') + await expect(name).toHaveValue('card-rule') + }) + + test('collapses and expands a rule block', async ({ sharedPage }) => { + await euclid.addRuleBlock() + const block = euclid.ruleBlock(0) + await expect(block.getByText('If', { exact: true })).toBeVisible() + + await block.locator('button[aria-label="Collapse rule"]').click() + await expect(block.getByText('If', { exact: true })).toHaveCount(0) + + await block.locator('button[aria-label="Expand rule"]').click() + await expect(block.getByText('If', { exact: true })).toBeVisible() + }) + + test('removes a rule block with the delete button', async ({ sharedPage }) => { + await euclid.addRuleBlock() + await euclid.addRuleBlock() + await expect(sharedPage.getByPlaceholder('Rule name')).toHaveCount(2) + + await euclid.ruleBlock(0).locator('button[aria-label="Delete rule"]').click() + + await expect(sharedPage.getByPlaceholder('Rule name')).toHaveCount(1) + }) + }) + + test.describe('Condition editing', () => { + test.beforeEach(async ({ sharedPage }) => { + await euclid.addRuleBlock() + }) + + test('shows one condition row by default in a new rule block', async () => { + const block = euclid.ruleBlock(0) + expect(await block.locator('.cond-select').count()).toBeGreaterThanOrEqual(2) + await expect(block.getByRole('button', { name: 'Add condition' })).toBeVisible() + }) + + test('adds a second AND condition', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add condition' }).click() + + await expect(block.getByText('IF', { exact: true })).toBeVisible() + await expect(block.getByText('AND', { exact: true })).toBeVisible() + }) + + test('adds a third AND condition', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add condition' }).click() + await block.getByRole('button', { name: 'Add condition' }).click() + + await expect(block.getByText('AND', { exact: true }).first()).toBeVisible() + await expect( + block.locator('.rounded-lg.border').first().locator('[class*="divide-y"] > div'), + ).toHaveCount(3) + }) + + test('removes a condition when there are multiple', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add condition' }).click() + await expect(block.getByText('AND', { exact: true })).toBeVisible() + + await block.locator('button[aria-label="Remove condition"]').first().click() + + await expect(block.getByText('AND', { exact: true })).toHaveCount(0) + }) + + test('shows enum value dropdown for an enum-type field', async () => { + await euclid.selectCondLhs(0, 'payment_method') + + const block = euclid.ruleBlock(0) + await expect(block.locator('.cond-select')).toHaveCount(3) + await expect(block.locator('input[type="number"]')).toHaveCount(0) + }) + + test('shows numeric input and extra comparison operators for amount field', async () => { + await euclid.selectCondLhs(0, 'amount') + + const block = euclid.ruleBlock(0) + const operators = block.locator('select.cond-select').first().locator('option') + expect(await operators.count()).toBeGreaterThanOrEqual(4) + await expect(block.locator('input[type="number"]')).toBeVisible() + // The value picker becomes a number input, so only LHS + operator remain as cond-selects. + await expect(block.locator('.cond-select')).toHaveCount(2) + }) + + test('shows human-readable labels in the field dropdown', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('[data-cy="cond-lhs"] button.cond-select').first().click() + + // The dropdown renders through a portal at the document root, not inside the rule block. + const options = sharedPage.locator('button[data-value]:not(.cond-select)') + expect(await options.count()).toBeGreaterThan(0) + for (const label of await options.allTextContents()) { + expect(label.trim(), 'field labels should be humanised, not raw snake_case').not.toMatch(/_/) + } + + await sharedPage.locator('body').click({ force: true }) + }) + + test('shows human-readable labels in the enum value dropdown', async ({ sharedPage }) => { + await euclid.selectCondLhs(0, 'payment_method') + await euclid.ruleBlock(0).locator('[data-cy="cond-val"] button.cond-select').first().click() + + const options = sharedPage.locator('button[data-value]:not(.cond-select)') + expect(await options.count()).toBeGreaterThan(0) + for (const label of await options.allTextContents()) { + expect(label.trim(), 'enum labels should be humanised').not.toMatch(/_/) + } + + await sharedPage.locator('body').click({ force: true }) + }) + + test('can select a different field and choose a value', async ({ sharedPage }) => { + await euclid.selectCondLhs(0, 'currency') + + const value = euclid.ruleBlock(0).locator('[data-cy="cond-val"]').first() + await expect(value).toBeVisible() + await value.locator('button.cond-select').click() + + expect( + await sharedPage.locator('button[data-value]:not(.cond-select)').count(), + ).toBeGreaterThan(1) + + await sharedPage.locator('body').click({ force: true }) + }) + }) + + test.describe('OR group management', () => { + test.beforeEach(async ({ sharedPage }) => { + await euclid.addRuleBlock() + }) + + test('does not show Remove group when only one group exists', async () => { + await expect(euclid.ruleBlock(0).getByRole('button', { name: 'Remove group' })).toHaveCount(0) + }) + + test('adds an OR group when clicking Add OR group', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add OR group' }).click() + + await expect(block.getByRole('button', { name: 'Add condition' })).toHaveCount(2) + await expect(block.getByText('or', { exact: true })).toBeVisible() + }) + + test('shows OR separator between groups', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add OR group' }).click() + + await expect(block.getByText('or', { exact: true })).toBeVisible() + }) + + test('shows Remove group button when multiple groups exist', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add OR group' }).click() + + await expect(block.getByRole('button', { name: 'Remove group' }).first()).toBeVisible() + }) + + test('removes an OR group', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add OR group' }).click() + await expect(block.getByRole('button', { name: 'Add condition' })).toHaveCount(2) + + await block.getByRole('button', { name: 'Remove group' }).first().click() + + await expect(block.getByRole('button', { name: 'Add condition' })).toHaveCount(1) + await expect(block.getByRole('button', { name: 'Remove group' })).toHaveCount(0) + }) + + test('can configure each OR group independently', async () => { + const block = euclid.ruleBlock(0) + await block.getByRole('button', { name: 'Add OR group' }).click() + + // Setting the first group's field must not propagate to the second. + await euclid.selectCondLhs(0, 'currency') + + const secondGroupLhs = block.locator('[data-cy="cond-lhs"] button.cond-select').nth(1) + await expect(secondGroupLhs).not.toHaveAttribute('data-value', 'currency') + }) + }) + + test.describe('Gateway output', () => { + test.beforeEach(async ({ sharedPage }) => { + await euclid.addRuleBlock() + }) + + test('adds a gateway to the priority output', async () => { + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + + await expect(euclid.ruleBlock(0).getByText('stripe')).toBeVisible() + }) + + test('adds multiple gateways and shows them in order', async () => { + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addGatewayToBlock(0, 'adyen', 'mca_adyen') + + const block = euclid.ruleBlock(0) + await expect(block.getByText('1. stripe')).toBeVisible() + await expect(block.getByText('2. adyen')).toBeVisible() + }) + + test('removes a gateway from the priority list', async ({ sharedPage }) => { + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addGatewayToBlock(0, 'adyen', 'mca_adyen') + + const block = euclid.ruleBlock(0) + await block + .locator('div') + .filter({ hasText: /^1\. stripe/ }) + .last() + .locator('button') + .first() + .click() + + await expect(block.getByText('stripe')).toHaveCount(0) + await expect(block.getByText('1. adyen')).toBeVisible() + }) + + test('shows gateway name suggestions from other entries', async ({ sharedPage }) => { + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + const input = euclid.ruleBlock(0).getByPlaceholder('Gateway name') + const listId = await input.getAttribute('list') + await expect(sharedPage.locator(`datalist#${listId} option[value="stripe"]`)).toHaveCount(1) + }) + }) + + test.describe('Default Fallback', () => { + test('adds a gateway to the default fallback', async ({ sharedPage }) => { + await euclid.addFallbackGateway('checkout', 'mca_checkout') + + const section = sharedPage + .locator('.rounded-xl') + .filter({ has: sharedPage.getByText('Default Fallback') }) + await expect(section.getByText('checkout').first()).toBeVisible() + }) + + test('shows correct description text', async ({ sharedPage }) => { + await expect(sharedPage.getByText('Used when no rule matches')).toBeVisible() + await expect(sharedPage.getByText('fallback_output')).toBeVisible() + }) + }) + + test.describe('Preview JSON', () => { + test('toggles the JSON preview panel', async ({ sharedPage }) => { + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + await expect(sharedPage.getByRole('heading', { name: 'JSON Preview' })).toBeVisible() + + await sharedPage.getByRole('button', { name: 'Hide JSON' }).click() + await expect(sharedPage.getByRole('heading', { name: 'JSON Preview' })).toHaveCount(0) + }) + + test('reflects the rule name in the JSON preview', async ({ sharedPage }) => { + const ruleName = 'preview-name-rule' + await sharedPage.getByPlaceholder('my-rule').fill(ruleName) + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + await expect(sharedPage.locator('pre')).toContainText(ruleName) + }) + + test('reflects added gateways in the JSON preview', async ({ sharedPage }) => { + await euclid.addRuleBlock() + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await sharedPage.getByPlaceholder('my-rule').fill('preview-gateway-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + await expect(sharedPage.locator('pre')).toContainText('stripe') + }) + + test('reflects conditions in the JSON preview', async ({ sharedPage }) => { + await euclid.addRuleBlock() + await sharedPage.getByPlaceholder('my-rule').fill('preview-condition-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + const preview = sharedPage.locator('pre') + await expect(preview).toContainText('statements') + await expect(preview).toContainText('condition') + }) + }) + + test.describe('Validation', () => { + test('blocks submission and shows an error when rule name is empty', async ({ sharedPage }) => { + await expect(sharedPage.getByPlaceholder('my-rule')).toHaveValue('') + + await sharedPage.getByRole('button', { name: 'Create Rule' }).click() + + await expect(sharedPage.getByText('Rule name is required')).toBeVisible() + }) + }) +}) diff --git a/tests/e2e/routing/rules/euclid-e2e.spec.ts b/tests/e2e/routing/rules/euclid-e2e.spec.ts new file mode 100644 index 00000000..847501dc --- /dev/null +++ b/tests/e2e/routing/rules/euclid-e2e.spec.ts @@ -0,0 +1,192 @@ +import { test, expect, factory } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' +import { expectApiCall } from '../../../helpers/network' + +/** + * Port of cypress/e2e/ui/euclid-rules-e2e.cy.js. + * + * The highest-value Euclid tests: they drive the builder AND assert on the exact JSON payload it sends + * to POST /routing/create. That's the contract between the rule editor and the routing engine — a UI + * that renders correctly but emits `enum_variant` where the backend expects `enum_variant_array` + * produces a rule that silently never matches, and only an assertion on the request body catches it. + * + * Per-test `merchant` fixture: every test here creates a rule. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('End-to-end creation', () => { + let euclid: EuclidRuleBuilder + let ruleName: string + + test.beforeEach(async ({ authedPage }) => { + ruleName = factory.ruleName('adv_rule') + euclid = new EuclidRuleBuilder(authedPage) + await euclid.goto('/routing/rules') + await euclid.addRuleBlock() + }) + + /** Submit and return the request body the UI built, failing loudly if the backend rejected it. */ + async function submitAndCapture(page: any) { + const call = expectApiCall(page, '/routing/create') + await page.getByRole('button', { name: 'Create Rule' }).click() + const { status, requestBody, body } = await call + expect(status, `POST /routing/create failed: ${JSON.stringify(body)}`).toBe(200) + await expect(page.getByText('Rule created')).toBeVisible() + return requestBody + } + + const firstStatement = (requestBody: any) => + requestBody?.algorithm?.data?.rules?.[0]?.statements?.[0] + + test('creates a rule using "is one of" — backend receives enum_variant_array', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'payment_method') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + await euclid.selectMultiCondVals(0, ['card', 'bank_transfer']) + + // Confirm the UI reflects both selections before submitting. + const value = authedPage.locator('[data-cy="cond-val"]').first() + await expect(value.getByText('Card')).toBeVisible() + await expect(value.getByText('Bank Transfer')).toBeVisible() + + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addFallbackGateway('adyen', 'mca_adyen') + + const requestBody = await submitAndCapture(authedPage) + + const condition = firstStatement(requestBody).condition[0] + expect(condition.value.type).toBe('enum_variant_array') + expect(condition.value.value).toHaveLength(2) + expect(condition.comparison).toBe('equal') + }) + + test('creates a rule using "is not one of" — backend receives not_equal + enum_variant_array', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'payment_method') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is not one of' }) + + await euclid.selectMultiCondVals(0, ['card']) + + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addFallbackGateway('adyen', 'mca_adyen') + + const requestBody = await submitAndCapture(authedPage) + + const condition = firstStatement(requestBody).condition[0] + expect(condition.value.type).toBe('enum_variant_array') + expect(condition.comparison).toBe('not_equal') + }) + + test('creates a rule with one nested AND+OR branch — backend receives nested array', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'amount') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'greater than' }) + await euclid.ruleBlock(0).locator('input[type="number"]').fill('10') + + await euclid.addNestedBranch(0) + const branch = euclid.nestedBranch(0, 0) + await euclid.selectCondLhs(0, 'payment_method', branch) + await euclid.selectCondVal(0, 'card', branch) + + await euclid.addGatewayToBlock(0, 'rbl', 'mca_rbl') + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + const requestBody = await submitAndCapture(authedPage) + + const statement = firstStatement(requestBody) + expect(statement.condition[0].lhs).toBe('amount') + expect(statement.nested).toHaveLength(1) + }) + + test('creates a rule with two nested OR branches — backend receives nested array of length 2', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'amount') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'greater than' }) + await euclid.ruleBlock(0).locator('input[type="number"]').fill('10') + + await euclid.addNestedBranch(0) + const branch = euclid.nestedBranch(0, 0) + await euclid.selectCondLhs(0, 'payment_method', branch) + await euclid.selectCondVal(0, 'card', branch) + + await euclid.addNestedBranch(0) + const second = euclid.nestedBranch(0, 1) + await euclid.selectCondLhs(0, 'currency', second) + await euclid.selectCondVal(0, 'AED', second) + + await euclid.addGatewayToBlock(0, 'rbl', 'mca_rbl') + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + const requestBody = await submitAndCapture(authedPage) + + expect(firstStatement(requestBody).nested).toHaveLength(2) + }) + + test('creates a rule with volume split output — backend receives routing_type: volume_split', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'payment_method') + await euclid.selectCondVal(0, 'card') + + await euclid.switchOutputType(0, 'Volume Split') + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + await euclid.addVolumeSplitEntry(0, 40, 'adyen', 'mca_adyen') + await euclid.addFallbackGateway('checkout', 'mca_checkout') + + const requestBody = await submitAndCapture(authedPage) + + const rule = requestBody.algorithm.data.rules[0] + expect(rule.routing_type).toBe('volume_split') + expect(rule.output.volume_split).toHaveLength(2) + expect(rule.output.volume_split[0].split).toBe(60) + expect(rule.output.volume_split[0].output.gateway_name).toBe('stripe') + expect(rule.output.volume_split[1].split).toBe(40) + expect(rule.output.volume_split[1].output.gateway_name).toBe('adyen') + }) + + test('creates a rule combining nested AND+OR with volume split output', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'amount') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'greater than' }) + await euclid.ruleBlock(0).locator('input[type="number"]').fill('100') + + await euclid.addNestedBranch(0) + const branch = euclid.nestedBranch(0, 0) + await euclid.selectCondLhs(0, 'payment_method', branch) + await euclid.selectCondVal(0, 'card', branch) + + await euclid.switchOutputType(0, 'Volume Split') + await euclid.addVolumeSplitEntry(0, 70, 'stripe', 'mca_stripe') + await euclid.addVolumeSplitEntry(0, 30, 'adyen', 'mca_adyen') + await euclid.addFallbackGateway('checkout', 'mca_checkout') + + const requestBody = await submitAndCapture(authedPage) + + const rule = requestBody.algorithm.data.rules[0] + expect(rule.routing_type).toBe('volume_split') + expect(rule.statements[0].nested).toHaveLength(1) + expect(rule.output.volume_split).toHaveLength(2) + }) + + test('creates a rule combining "is one of" with nested AND+OR', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.selectCondLhs(0, 'payment_method') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + await euclid.selectMultiCondVals(0, ['card', 'bank_transfer']) + + await euclid.addNestedBranch(0) + const branch = euclid.nestedBranch(0, 0) + await euclid.selectCondLhs(0, 'currency', branch) + await euclid.selectCondVal(0, 'AED', branch) + + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addFallbackGateway('adyen', 'mca_adyen') + + const requestBody = await submitAndCapture(authedPage) + + const statement = firstStatement(requestBody) + expect(statement.condition[0].value.type).toBe('enum_variant_array') + expect(statement.nested).toHaveLength(1) + }) +}) diff --git a/tests/e2e/routing/rules/euclid-enum-operators.spec.ts b/tests/e2e/routing/rules/euclid-enum-operators.spec.ts new file mode 100644 index 00000000..e38f96fc --- /dev/null +++ b/tests/e2e/routing/rules/euclid-enum-operators.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' + +/** + * Port of cypress/e2e/ui/euclid-rules-enum-operators.cy.js. + * + * The "is one of" / "is not one of" operators, which swap the single-value dropdown for a multi-select + * and change the emitted condition from `enum_variant` to `enum_variant_array`. Pure UI, so this runs + * on the worker-scoped merchant (matching the Cypress `before()` hook). + * + * The multi-select renders through a portal at the document root, so its locators are page-level rather + * than scoped to the rule block — the Cypress original used `{ withinSubject: null }` for the same reason. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +/** Selected values render as pills in the trigger; this is how the original counted them. */ +const PILL = 'span[class*="bg-brand-100"]' + +test.describe('"is one of" / "is not one of" operator', () => { + let euclid: EuclidRuleBuilder + + test.beforeEach(async ({ sharedPage }) => { + euclid = new EuclidRuleBuilder(sharedPage) + await euclid.goto('/routing/rules') + await euclid.addRuleBlock() + await euclid.selectCondLhs(0, 'payment_method') + }) + + test('exposes "is one of" and "is not one of" in the operator dropdown for enum fields', async () => { + const operator = euclid.ruleBlock(0).locator('select.cond-select').first() + + await expect(operator.locator('option', { hasText: 'is one of' }).first()).toHaveCount(1) + await expect(operator.locator('option', { hasText: 'is not one of' }).first()).toHaveCount(1) + }) + + test('does not offer "is one of" for numeric fields', async () => { + await euclid.selectCondLhs(0, 'amount') + + const operator = euclid.ruleBlock(0).locator('select.cond-select').first() + await expect(operator.locator('option', { hasText: 'is one of' })).toHaveCount(0) + }) + + test('shows a multi-value picker when "is one of" is selected', async () => { + const block = euclid.ruleBlock(0) + await block.locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + // LHS button + operator select; the single-value button is replaced by the multi-select. + await expect(block.locator('.cond-select')).toHaveCount(2) + await expect(block.locator('[data-cy="cond-val"]')).toHaveCount(1) + }) + + test('shows a multi-value picker when "is not one of" is selected', async () => { + const block = euclid.ruleBlock(0) + await block.locator('select.cond-select').first().selectOption({ label: 'is not one of' }) + + await expect(block.locator('.cond-select')).toHaveCount(2) + await expect(block.locator('[data-cy="cond-val"]')).toHaveCount(1) + }) + + test('each option is independently toggleable', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + // Switching operators preserves the single value, so clear the carried-over pill first. + const value = sharedPage.locator('[data-cy="cond-val"]').first() + await value.locator(`${PILL} button`).click() + + await value.click() + const options = sharedPage.locator('button[data-value]:not(.cond-select)') + const chosen = await options.first().getAttribute('data-value') + await options.first().click({ force: true }) + await sharedPage.locator('body').click({ force: true }) + await expect(value.locator(PILL)).toHaveCount(1) + + // Re-open and click the same option to deselect it. + await value.click() + await sharedPage.locator(`button[data-value="${chosen}"]:not(.cond-select)`).click({ force: true }) + await sharedPage.locator('body').click({ force: true }) + + await expect(value.locator(PILL)).toHaveCount(0) + }) + + test('multiple options can be selected simultaneously', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + const value = sharedPage.locator('[data-cy="cond-val"]').first() + await value.locator(`${PILL} button`).click() + + await value.click() + const options = sharedPage.locator('button[data-value]:not(.cond-select)') + expect(await options.count()).toBeGreaterThanOrEqual(2) + const first = await options.nth(0).getAttribute('data-value') + const second = await options.nth(1).getAttribute('data-value') + await sharedPage.locator(`button[data-value="${first}"]:not(.cond-select)`).click({ force: true }) + await sharedPage.locator(`button[data-value="${second}"]:not(.cond-select)`).click({ force: true }) + await sharedPage.locator('body').click({ force: true }) + + await expect(value.locator(PILL)).toHaveCount(2) + }) + + test('switching back to "equals" replaces the multi-picker with the single-value dropdown', async () => { + const block = euclid.ruleBlock(0) + const operator = block.locator('select.cond-select').first() + + await operator.selectOption({ label: 'is one of' }) + await expect(block.locator('.cond-select')).toHaveCount(2) + + await operator.selectOption({ label: 'equals' }) + // LHS button + operator select + value button. + await expect(block.locator('.cond-select')).toHaveCount(3) + }) + + test('preserves the previously selected single value when switching to "is one of"', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('[data-cy="cond-val"] button.cond-select').first().click() + await sharedPage.locator('button[data-value]:not(.cond-select)').nth(1).click() + + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + await expect(sharedPage.locator('[data-cy="cond-val"]').first().locator(PILL)).toHaveCount(1) + }) + + test('JSON preview emits enum_variant_array type with the selected values', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is one of' }) + + const value = sharedPage.locator('[data-cy="cond-val"]').first() + await value.click() + const options = sharedPage.locator('button[data-value]:not(.cond-select)') + await options.nth(0).click() + await options.nth(1).click() + await sharedPage.locator('body').click({ force: true }) + + await euclid.addGatewayToBlock(0, 'stripe') + await sharedPage.getByPlaceholder('my-rule').fill('enum-array-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + const preview = sharedPage.locator('pre') + await expect(preview).toContainText('"type": "enum_variant_array"') + await expect(preview).toContainText('"value": [') + }) + + test('JSON preview for "is not one of" uses not_equal comparison', async ({ sharedPage }) => { + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'is not one of' }) + + const value = sharedPage.locator('[data-cy="cond-val"]').first() + await value.click() + await sharedPage.locator('button[data-value]:not(.cond-select)').nth(0).click() + await sharedPage.locator('body').click({ force: true }) + + await euclid.addGatewayToBlock(0, 'stripe') + await sharedPage.getByPlaceholder('my-rule').fill('enum-not-array-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + const preview = sharedPage.locator('pre') + await expect(preview).toContainText('"comparison": "not_equal"') + await expect(preview).toContainText('"type": "enum_variant_array"') + }) +}) diff --git a/tests/e2e/routing/rules/euclid-lifecycle.spec.ts b/tests/e2e/routing/rules/euclid-lifecycle.spec.ts new file mode 100644 index 00000000..f03baaa3 --- /dev/null +++ b/tests/e2e/routing/rules/euclid-lifecycle.spec.ts @@ -0,0 +1,231 @@ +import { test, expect, factory } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' +import { expectApiCall } from '../../../helpers/network' + +/** + * Port of cypress/e2e/ui/euclid-rules-lifecycle.cy.js. + * + * Rule creation through the form and management from the existing-rules panel — these actually hit + * POST /routing/create and the activate/deactivate endpoints, so they use the PER-TEST `merchant` + * fixture (never the shared one: each test needs a clean rule list). + * + * `cy.intercept('POST','**\/routing/create').as('createRule')` + `cy.wait('@createRule')` becomes + * `expectApiCall`, which must be created BEFORE the click that triggers the request. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Rule Lifecycle — creation and management', () => { + let euclid: EuclidRuleBuilder + let ruleName: string + + test.beforeEach(async ({ authedPage }) => { + ruleName = factory.ruleName('ui_rule') + euclid = new EuclidRuleBuilder(authedPage) + await euclid.goto('/routing/rules') + }) + + /** Click Create Rule and assert the backend accepted it, surfacing the body on failure. */ + async function createRuleAndExpectSuccess(page: any) { + const call = expectApiCall(page, '/routing/create') + await page.getByRole('button', { name: 'Create Rule' }).click() + const { status, body } = await call + expect(status, `POST /routing/create failed: ${JSON.stringify(body)}`).toBe(200) + return body + } + + test.describe('Rule creation', () => { + test('creates a minimal rule with name and default fallback only', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + }) + + test('creates a rule with one condition and one gateway', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await authedPage.getByPlaceholder('Optional description').fill('Playwright test rule') + + await euclid.addRuleBlock() + await euclid.ruleBlock(0).getByPlaceholder('Rule name').fill('card-rule') + await euclid.selectCondLhs(0, 'payment_method') + await euclid.selectCondVal(0, 'card') + + await euclid.addGatewayToBlock(0, 'adyen', 'mca_adyen') + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + await expect(authedPage.getByText(ruleName).first()).toBeVisible() + }) + + test('creates a rule with two AND conditions', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.addRuleBlock() + + await euclid.selectCondLhs(0, 'payment_method') + await euclid.selectCondVal(0, 'card') + + await euclid.ruleBlock(0).getByRole('button', { name: 'Add condition' }).click() + await euclid.selectCondLhs(1, 'currency') + await euclid.selectCondVal(1, 'USD') + + await expect(euclid.ruleBlock(0).getByText('AND', { exact: true })).toBeVisible() + + await euclid.addGatewayToBlock(0, 'checkout', 'mca_checkout') + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + }) + + test('creates a rule with two OR groups', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.addRuleBlock() + + await euclid.selectCondLhs(0, 'payment_method') + await euclid.selectCondVal(0, 'card') + + await euclid.ruleBlock(0).getByRole('button', { name: 'Add OR group' }).click() + // The second group's condition row is the next cond-lhs on the page. + await euclid.selectCondLhs(1, 'currency') + await euclid.selectCondVal(1, 'USD') + + await expect(euclid.ruleBlock(0).getByText('or', { exact: true })).toBeVisible() + + await euclid.addGatewayToBlock(0, 'adyen', 'mca_adyen') + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + }) + + test('creates two rule blocks each targeting a different gateway', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + + await euclid.addRuleBlock() + await euclid.ruleBlock(0).getByPlaceholder('Rule name').fill('card-rule') + await euclid.selectCondLhs(0, 'payment_method') + await euclid.selectCondVal(0, 'card') + await euclid.addGatewayToBlock(0, 'adyen', 'mca_adyen') + + await euclid.addRuleBlock() + await euclid.ruleBlock(1).getByPlaceholder('Rule name').fill('upi-rule') + await euclid.selectCondLhs(1, 'payment_method') + await euclid.selectCondVal(1, 'upi') + await euclid.addGatewayToBlock(1, 'razorpay', 'mca_razorpay') + + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + }) + + test('creates a rule with an amount (integer) condition', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(ruleName) + await euclid.addRuleBlock() + + await euclid.selectCondLhs(0, 'amount') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'greater than' }) + await euclid.ruleBlock(0).locator('input[type="number"]').fill('100') + + await euclid.addGatewayToBlock(0, 'stripe', 'mca_stripe') + await euclid.addFallbackGateway('adyen', 'mca_adyen') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByText('Rule created')).toBeVisible() + }) + }) + + test.describe('Existing rules panel', () => { + /** Locate the panel row for a rule by name. */ + const ruleRow = (page: any, name: string) => + page.getByText(name).first().locator('xpath=ancestor::*[contains(@class,"flex-col")][1]') + + test.beforeEach(async ({ api, merchant, authedPage }) => { + // Seed a rule through the API so the panel has something to manage. + const created = await api.createRoutingAlgorithm( + factory.advancedRoutingPayload(merchant.id, { name: ruleName }), + ) + expect(created.status).toBe(200) + await euclid.goto('/routing/rules') + }) + + test('shows the created rule as Inactive', async ({ authedPage }) => { + await expect(authedPage.getByText(ruleName).first()).toBeVisible() + await expect(ruleRow(authedPage, ruleName).getByText('Inactive')).toBeVisible() + }) + + test('shows the rule description under the rule name', async ({ authedPage }) => { + // The Cypress original asserted a `p.text-xs` condition summary; the panel now renders the + // algorithm's description at text-[11px] instead, so assert on the content rather than the class. + await expect( + ruleRow(authedPage, ruleName).getByText('advanced routing rule'), + ).toBeVisible() + }) + + test('expands rule details when rule header is clicked', async ({ authedPage }) => { + await authedPage.getByText(ruleName).first().click() + + await expect(ruleRow(authedPage, ruleName).locator('.border-t').first()).toBeVisible() + }) + + test('hides rule details when rule header is clicked again', async ({ authedPage }) => { + await authedPage.getByText(ruleName).first().click() + await expect(ruleRow(authedPage, ruleName).locator('.border-t').first()).toBeVisible() + + await authedPage.getByText(ruleName).first().click() + + await expect(ruleRow(authedPage, ruleName).locator('.border-t')).toHaveCount(0) + }) + + test('activates the rule', async ({ authedPage }) => { + await ruleRow(authedPage, ruleName).getByRole('button', { name: 'Activate' }).click() + + await expect(authedPage.getByText('Rule activated successfully.')).toBeVisible() + await expect(ruleRow(authedPage, ruleName).getByText('● Active')).toBeVisible() + }) + + test('deactivates an active rule', async ({ authedPage }) => { + await ruleRow(authedPage, ruleName).getByRole('button', { name: 'Activate' }).click() + await expect(authedPage.getByText('Rule activated successfully.')).toBeVisible() + + await ruleRow(authedPage, ruleName).getByRole('button', { name: 'Deactivate' }).click() + + // Deactivation is behind a confirmation dialog — turning off live routing is not a stray click. + await expect(authedPage.getByText('Deactivate this rule?')).toBeVisible() + await authedPage.locator('.fixed.inset-0').getByRole('button', { name: 'Deactivate' }).click() + + await expect(authedPage.getByText('Rule deactivated successfully.')).toBeVisible() + await expect(ruleRow(authedPage, ruleName).getByText('Inactive')).toBeVisible() + }) + + test('shows Activate Now immediately after creating from the form', async ({ authedPage }) => { + await authedPage.getByPlaceholder('my-rule').fill(factory.ruleName('quick')) + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + + await expect(authedPage.getByRole('button', { name: 'Activate Now' })).toBeVisible() + }) + + test('activates a newly created rule via Activate Now', async ({ authedPage }) => { + const quickRule = factory.ruleName('quick') + await authedPage.getByPlaceholder('my-rule').fill(quickRule) + await euclid.addFallbackGateway('stripe', 'mca_stripe') + + await createRuleAndExpectSuccess(authedPage) + await authedPage.getByRole('button', { name: 'Activate Now' }).click() + + await expect(authedPage.getByText('Rule activated successfully.')).toBeVisible({ timeout: 15_000 }) + await expect(ruleRow(authedPage, quickRule).getByText('● Active')).toBeVisible() + }) + }) +}) diff --git a/tests/e2e/routing/rules/euclid-nested-branches.spec.ts b/tests/e2e/routing/rules/euclid-nested-branches.spec.ts new file mode 100644 index 00000000..819e7c70 --- /dev/null +++ b/tests/e2e/routing/rules/euclid-nested-branches.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' + +/** + * Port of cypress/e2e/ui/euclid-rules-nested-branches.cy.js. + * + * Nested AND+OR branches inside a rule block: adding, indenting, the OR separator, the depth cap of 1, + * and removal. Pure UI — nothing reaches the backend — so this uses the worker-scoped merchant, which + * matches the Cypress original's suite-level `before()` hook. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Nested AND+OR branches', () => { + let euclid: EuclidRuleBuilder + + test.beforeEach(async ({ sharedPage }) => { + euclid = new EuclidRuleBuilder(sharedPage) + await euclid.goto('/routing/rules') + await euclid.addRuleBlock() + }) + + test('shows "Add nested branch" in each condition group footer', async () => { + await expect(euclid.ruleBlock(0).getByRole('button', { name: 'Add nested branch' })).toBeVisible() + }) + + test('adds a nested branch section on click', async () => { + await euclid.addNestedBranch(0) + + await expect(euclid.ruleBlock(0).getByText('Then match any of')).toBeVisible() + }) + + test('renders the nested group indented with a sky left border', async () => { + await euclid.addNestedBranch(0) + + await expect(euclid.nestedBranches(0)).toHaveCount(1) + }) + + test('a second nested branch shows an OR separator', async () => { + await euclid.addNestedBranch(0) + await euclid.addNestedBranch(0) + + await expect(euclid.nestedBranches(0)).toHaveCount(2) + await expect(euclid.ruleBlock(0).getByText('OR', { exact: true })).toBeVisible() + }) + + test('"Add nested branch" does not appear inside a nested group (depth capped at 1)', async () => { + await euclid.addNestedBranch(0) + + // Still exactly one button in the whole block — a nested group cannot itself nest. + await expect(euclid.ruleBlock(0).getByRole('button', { name: 'Add nested branch' })).toHaveCount(1) + }) + + test('allows adding AND conditions inside a nested branch', async () => { + await euclid.addNestedBranch(0) + + const branch = euclid.nestedBranch(0, 0) + await branch.getByRole('button', { name: 'Add condition' }).click() + + await expect(branch.getByText('AND', { exact: true })).toBeVisible() + }) + + test('nested branch can target a different field from the parent condition', async ({ sharedPage }) => { + await euclid.selectCondLhs(0, 'payment_method') + await euclid.addNestedBranch(0) + + // The nested branch's LHS is the second cond-lhs on the page. + await euclid.selectCondLhs(1, 'currency') + + // The parent's field must be untouched. + await expect( + euclid.ruleBlock(0).locator('[data-cy="cond-lhs"] button.cond-select').first(), + ).toHaveAttribute('data-value', 'payment_method') + }) + + test('removes a nested branch via Remove group', async () => { + await euclid.addNestedBranch(0) + await euclid.addNestedBranch(0) + await expect(euclid.nestedBranches(0)).toHaveCount(2) + + await euclid.nestedBranch(0, 0).getByRole('button', { name: 'Remove group' }).click() + + await expect(euclid.nestedBranches(0)).toHaveCount(1) + await expect(euclid.ruleBlock(0).getByText('OR', { exact: true })).toHaveCount(0) + }) + + test('hides the nested section when all branches are removed', async () => { + await euclid.addNestedBranch(0) + + await euclid.nestedBranch(0, 0).getByRole('button', { name: 'Remove group' }).click() + + await expect(euclid.ruleBlock(0).getByText('Then match any of')).toHaveCount(0) + }) + + test('OR groups each get their own independent "Add nested branch" button', async () => { + await euclid.ruleBlock(0).getByRole('button', { name: 'Add OR group' }).click() + + await expect(euclid.ruleBlock(0).getByRole('button', { name: 'Add nested branch' })).toHaveCount(2) + }) + + test('JSON preview emits a non-null nested array when a branch is added', async ({ sharedPage }) => { + await euclid.selectCondLhs(0, 'amount') + await euclid.ruleBlock(0).locator('select.cond-select').first().selectOption({ label: 'greater than' }) + await euclid.ruleBlock(0).locator('input[type="number"]').fill('10') + + await euclid.addNestedBranch(0) + await euclid.selectCondLhs(1, 'payment_method') + + await euclid.addGatewayToBlock(0, 'rbl') + await sharedPage.getByPlaceholder('my-rule').fill('nested-preview-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + await expect(sharedPage.locator('pre')).toContainText('"nested": [') + }) +}) diff --git a/tests/e2e/routing/rules/euclid-volume-split-output.spec.ts b/tests/e2e/routing/rules/euclid-volume-split-output.spec.ts new file mode 100644 index 00000000..a5e0c483 --- /dev/null +++ b/tests/e2e/routing/rules/euclid-volume-split-output.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '../../../fixtures/test' +import { EuclidRuleBuilder } from '../../../pages/euclid-page' + +/** + * Port of cypress/e2e/ui/euclid-rules-volume-split.cy.js. + * + * The volume-split OUTPUT MODE inside the rule builder at /routing/rules — distinct from the standalone + * /routing/volume page, which volume-split-page.spec.ts covers. Hence the `-output` suffix. + * + * The behaviour that matters is the 100% guard: a split that doesn't total 100 is a misconfigured rule + * that would silently drop traffic, so the editor has to say so before the rule can be created. + * Pure UI, so this runs on the worker-scoped merchant. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Volume split output', () => { + let euclid: EuclidRuleBuilder + + test.beforeEach(async ({ sharedPage }) => { + euclid = new EuclidRuleBuilder(sharedPage) + await euclid.goto('/routing/rules') + await euclid.addRuleBlock() + await euclid.switchOutputType(0, 'Volume Split') + }) + + test('switches the THEN section to volume split mode', async () => { + const then = euclid.thenSection(0) + + await expect(then.getByPlaceholder('Split %')).toBeVisible() + await expect(then.getByPlaceholder('Gateway name')).toBeVisible() + // Priority numbering disappears in split mode. + await expect(then.getByText('1.', { exact: true })).toHaveCount(0) + }) + + test('adds a volume split entry and shows split % with gateway name', async () => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + + const then = euclid.thenSection(0) + await expect(then.getByText('60%').first()).toBeVisible() + await expect(then.getByText('stripe').first()).toBeVisible() + }) + + test('shows a running total after adding entries', async () => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + + await expect(euclid.thenSection(0).getByText('Total: 60%')).toBeVisible() + }) + + test('shows a warning when the total is not 100%', async () => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + + await expect(euclid.thenSection(0).getByText('must equal 100%')).toBeVisible() + }) + + test('shows a success indicator when the total reaches exactly 100%', async () => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + await euclid.addVolumeSplitEntry(0, 40, 'adyen', 'mca_adyen') + + const then = euclid.thenSection(0) + await expect(then.getByText('Total: 100%')).toBeVisible() + await expect(then.getByText('✓')).toBeVisible() + await expect(then.getByText('must equal 100%')).toHaveCount(0) + }) + + test('removes a split entry via its delete button', async ({ sharedPage }) => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + await euclid.addVolumeSplitEntry(0, 40, 'adyen', 'mca_adyen') + + const then = euclid.thenSection(0) + await then + .locator('div') + .filter({ hasText: /^60%stripe/ }) + .last() + .locator('button') + .first() + .click() + + await expect(then.getByText('stripe')).toHaveCount(0) + await expect(then.getByText('Total: 40%')).toBeVisible() + }) + + test('switching back to Priority mode hides the split editor', async () => { + await euclid.addVolumeSplitEntry(0, 60, 'stripe', 'mca_stripe') + + await euclid.switchOutputType(0, 'Priority') + + const then = euclid.thenSection(0) + await expect(then.getByPlaceholder('Split %')).toHaveCount(0) + await expect(then.getByPlaceholder('Gateway name')).toBeVisible() + }) + + test('JSON preview emits routing_type: volume_split with correct split values', async ({ sharedPage }) => { + await euclid.addVolumeSplitEntry(0, 70, 'stripe', 'mca_stripe') + await euclid.addVolumeSplitEntry(0, 30, 'adyen', 'mca_adyen') + await sharedPage.getByPlaceholder('my-rule').fill('volume-split-preview-rule') + await sharedPage.getByRole('button', { name: 'Preview JSON' }).click() + + const preview = sharedPage.locator('pre') + await expect(preview).toContainText('"routing_type": "volume_split"') + await expect(preview).toContainText('"split": 70') + await expect(preview).toContainText('"split": 30') + await expect(preview).toContainText('"volume_split": [') + }) +}) diff --git a/tests/e2e/routing/volume-split-page.spec.ts b/tests/e2e/routing/volume-split-page.spec.ts new file mode 100644 index 00000000..7f453453 --- /dev/null +++ b/tests/e2e/routing/volume-split-page.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../../fixtures/test' + +/** + * UI-journey port of cypress/e2e/ui/volume-split-page.cy.js. + * + * `authedPage` (page pre-seeded with the merchant's auth) replaces Cypress `visitWithMerchant`; + * the `merchant` fixture creates + cleans the account. `cy.intercept(...).as()` + `cy.wait('@...')` + * becomes `page.waitForResponse(...)`. + */ +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('Volume Split (UI)', () => { + test('creates and activates a volume split rule from the page', async ({ authedPage, merchant }) => { + const page = authedPage + + await page.goto('/routing/volume') + + await expect(page.getByRole('heading', { level: 1, name: 'Volume Split Routing' })).toBeVisible() + await page.getByPlaceholder('e.g. ab-test-split').fill('ui-volume-split') + + await page.getByPlaceholder('e.g. stripe').nth(0).fill('stripe') + await page.getByPlaceholder('optional gateway_id').nth(0).fill('mca_stripe_ui') + await page.getByPlaceholder('e.g. stripe').nth(1).fill('adyen') + await page.getByPlaceholder('optional gateway_id').nth(1).fill('mca_adyen_ui') + + const createResponse = page.waitForResponse( + (r) => r.url().includes('/routing/create') && r.request().method() === 'POST', + { timeout: 20000 }, + ) + await page.getByRole('button', { name: 'Create Rule' }).click() + await createResponse + + // On success the component shows "Rule created: " + an "Activate Now" button. + await expect(page.getByText('Rule created:')).toBeVisible({ timeout: 15000 }) + await page.getByRole('button', { name: 'Activate Now' }).click() + await expect(page.getByText('Rule activated.')).toBeVisible({ timeout: 15000 }) + await expect(page.getByText('Saved Rules').first()).toBeVisible() + }) +}) diff --git a/tests/e2e/settings/api-keys-page.spec.ts b/tests/e2e/settings/api-keys-page.spec.ts new file mode 100644 index 00000000..5457c90d --- /dev/null +++ b/tests/e2e/settings/api-keys-page.spec.ts @@ -0,0 +1,83 @@ +import { test, expect, factory } from '../../fixtures/test' + +/** + * Port of cypress/e2e/ui/api-keys-page.cy.js. + * + * The key property under test is show-once: a created key is displayed exactly one time and never + * again, so the "creates a key and uses it" test proves the value shown in the UI is a genuinely usable + * credential rather than a truncated display string — the thing a merchant would discover the hard way. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +test.describe('API Keys UI', () => { + test('renders the page with a create form and the default key', async ({ authedPage }) => { + await authedPage.goto('/api-keys') + + await expect(authedPage.getByRole('heading', { level: 1, name: 'API Keys' })).toBeVisible() + await expect(authedPage.getByText('x-api-key')).toBeVisible() + await expect(authedPage.locator('input[placeholder*="Description"]')).toBeVisible() + await expect(authedPage.getByRole('button', { name: 'Create API Key' })).toBeVisible() + + // A new merchant is provisioned with a "Default API key" at signup, so the list is never empty — + // the merchant can call the API before ever visiting this page. (The Cypress original still + // asserts "No active API keys", which predates that behaviour.) + await expect(authedPage.getByText('Default API key')).toBeVisible() + await expect(authedPage.getByRole('cell', { name: /^DE_/ }).first()).toBeVisible() + }) + + test('creates an API key, shows it once, and lists it', async ({ authedPage }) => { + await authedPage.goto('/api-keys') + + await authedPage.locator('input[placeholder*="Description"]').fill('playwright-integration-key') + await authedPage.getByRole('button', { name: 'Create API Key' }).click() + + const keyValue = authedPage.getByTestId('api-key-value') + await expect(keyValue).toBeVisible({ timeout: 10_000 }) + expect((await keyValue.textContent())?.trim()).toMatch(/^DE_/) + + await expect(authedPage.getByText('API key created — copy it now')).toBeVisible() + await expect(authedPage.getByText('playwright-integration-key').first()).toBeVisible() + await expect(authedPage.getByRole('button', { name: 'Revoke' }).first()).toBeVisible() + }) + + test('a key created in the UI authenticates a routing call', async ({ api, authedPage, merchant }) => { + await api.createSuccessRateConfig(merchant.id) + await authedPage.goto('/api-keys') + + await authedPage.locator('input[placeholder*="Description"]').fill('routing-test-key') + await authedPage.getByRole('button', { name: 'Create API Key' }).click() + + const keyValue = authedPage.getByTestId('api-key-value') + await expect(keyValue).toBeVisible({ timeout: 10_000 }) + const apiKey = (await keyValue.textContent())!.trim() + expect(apiKey).toMatch(/^DE_/) + + // Call the API with only that key — no bearer token — exactly as a merchant integration would. + const anon = api.anonymous() + const decide = await anon.raw('POST', '/decide-gateway', { + failOnStatusCode: false, + headers: { 'x-api-key': apiKey }, + body: factory.srDecideGatewayRequest({ + merchantId: merchant.id, + paymentInfo: { paymentId: factory.paymentId('apikey') }, + }), + }) + + expect(decide.status).toBe(200) + expect(decide.body).toHaveProperty('decided_gateway') + }) + + test('revokes an API key and removes it from the list', async ({ authedPage }) => { + await authedPage.goto('/api-keys') + + await authedPage.locator('input[placeholder*="Description"]').fill('to-be-revoked') + await authedPage.getByRole('button', { name: 'Create API Key' }).click() + await expect(authedPage.getByTestId('api-key-value')).toBeVisible({ timeout: 10_000 }) + + const row = authedPage.getByRole('row', { name: /to-be-revoked/ }) + await row.getByRole('button', { name: 'Revoke' }).click() + + await expect(authedPage.getByRole('cell', { name: 'to-be-revoked' })).toHaveCount(0) + }) +}) diff --git a/tests/e2e/smoke/nav-smoke.spec.ts b/tests/e2e/smoke/nav-smoke.spec.ts new file mode 100644 index 00000000..51e1410e --- /dev/null +++ b/tests/e2e/smoke/nav-smoke.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '../../fixtures/test' + +/** + * Every dashboard route that has no dedicated spec, checked for the one failure that matters most: does + * the page render at all for an authenticated user, or does it throw and hit the ErrorBoundary? + * + * This is deliberately shallow. These pages have deep functionality that belongs in their own specs; + * what this catches is the class of regression that takes a whole page down — a bad import, a null + * dereference on empty data, a route that silently redirects. That failure is currently invisible in CI. + * + * Table-driven on purpose: adding a page should be a one-line diff. Runs on the worker-scoped merchant + * since nothing here mutates state. + * + * `/` is NOT listed — dashboard-overview.spec.ts covers Overview properly. + */ + +test.use({ viewport: { width: 1600, height: 1200 } }) + +const PAGES = [ + { path: '/routing', heading: 'Routing Hub' }, + { path: '/routing/sr', heading: 'Multi Objective Routing' }, + { path: '/routing/ab-testing', heading: 'A/B Testing' }, + { path: '/decisions/simulator', heading: 'Decision Simulator' }, + { path: '/events', heading: 'Routing events' }, + { path: '/members', heading: 'Members' }, + { path: '/account', heading: 'Account' }, +] + +test.describe('Dashboard route smoke', () => { + for (const { path, heading } of PAGES) { + test(`${path} renders without crashing`, async ({ sharedPage }) => { + const pageErrors: Error[] = [] + sharedPage.on('pageerror', (error) => pageErrors.push(error)) + + await sharedPage.goto(path) + + await expect(sharedPage.getByRole('heading', { level: 1, name: heading })).toBeVisible({ + timeout: 20_000, + }) + // The ErrorBoundary fallback — if this is present the page threw during render. + await expect(sharedPage.getByText('Dashboard Error')).toHaveCount(0) + expect(pageErrors, `${path} raised uncaught errors: ${pageErrors.map((e) => e.message).join('; ')}`) + .toHaveLength(0) + }) + } + + test('an unknown dashboard route redirects rather than 404ing', async ({ sharedPage }) => { + await sharedPage.goto('/this-route-does-not-exist') + + // The catch-all sends the user back to Overview instead of a dead end. + await expect(sharedPage.getByRole('heading', { level: 1, name: 'Overview' })).toBeVisible({ + timeout: 20_000, + }) + }) + + test('the legacy cost route still resolves for old bookmarks', async ({ sharedPage }) => { + await sharedPage.goto('/routing/cost') + + // Cost estimation moved into Multi Objective Routing as a tab; the old path must keep working. + await expect(sharedPage).toHaveURL(/\/routing\/sr\?tab=cost/) + }) +}) diff --git a/tests/fixtures/api-client.ts b/tests/fixtures/api-client.ts new file mode 100644 index 00000000..c05f91db --- /dev/null +++ b/tests/fixtures/api-client.ts @@ -0,0 +1,236 @@ +import type { APIRequestContext } from '@playwright/test' +import factory from './factory' + +/** + * Playwright port of the Cypress `requestApi` command + the endpoint helpers + * (cypress/support/commands.js). Talks directly to the decision-engine API on :8080, + * independent of the Playwright project's `baseURL` (so the same client works in both the + * `api` and `ui` projects). Auth is a mutable bearer token set by the session handshake. + */ + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' +const ADMIN_SECRET = process.env.ADMIN_SECRET || 'test_admin' + +export interface ApiResponse { + status: number + body: T + headers: Record +} + +export interface RequestOptions { + body?: unknown + failOnStatusCode?: boolean + headers?: Record + qs?: Record +} + +function resolveApiUrl(path: string): string { + if (/^https?:\/\//.test(path)) return path + return `${API_BASE_URL}${path}` +} + +function cleanParams(qs?: RequestOptions['qs']): Record | undefined { + if (!qs) return undefined + const out: Record = {} + for (const [k, v] of Object.entries(qs)) { + if (v !== undefined && v !== null) out[k] = v + } + return out +} + +export class ApiClient { + readonly request: APIRequestContext + /** Bearer token for the active session; auto-attached to every request once set. */ + token: string | null = null + /** + * Whether to send `x-admin-secret`. Since #345 that header is accepted as service-to-service auth on + * protected routes, so an "anonymous" client must drop it too — otherwise it is still authenticated + * and every auth-guard assertion silently passes for the wrong reason. + */ + sendAdminSecret = true + + constructor(request: APIRequestContext) { + this.request = request + } + + /** + * A client sharing this request context but carrying no session token — for auth-guard tests that + * assert protected routes reject unauthenticated callers. + * + * Note it still sends `x-admin-secret`, which is what /auth/admin/* checks; only the bearer token is + * dropped. + */ + anonymous(): ApiClient { + const client = new ApiClient(this.request) + client.sendAdminSecret = false + return client + } + + /** Core request primitive — mirrors Cypress `requestApi`. */ + async raw(method: string, path: string, options: RequestOptions = {}): Promise> { + const { body, failOnStatusCode = true, headers = {}, qs } = options + const requestHeaders: Record = { + 'Content-Type': 'application/json', + 'x-tenant-id': 'public', + ...(this.sendAdminSecret ? { 'x-admin-secret': ADMIN_SECRET } : {}), + // Explicit Authorization always wins over the session token. + ...(this.token && !headers.Authorization ? { Authorization: `Bearer ${this.token}` } : {}), + ...headers, + } + + const response = await this.request.fetch(resolveApiUrl(path), { + method, + headers: requestHeaders, + params: cleanParams(qs), + data: body === undefined || body === null ? undefined : (body as any), + }) + + const status = response.status() + const text = await response.text() + let parsed: any = text + const contentType = response.headers()['content-type'] || '' + if (contentType.includes('application/json')) { + try { + parsed = JSON.parse(text) + } catch { + parsed = text + } + } + + if (failOnStatusCode && (status < 200 || status >= 400)) { + throw new Error( + `API request failed (${method} ${path}) with status ${status}: ${JSON.stringify(parsed)}`, + ) + } + + return { status, body: parsed, headers: response.headers() } + } + + // ---- Merchant ---------------------------------------------------------- + + ensureMerchantAccount(merchantId: string) { + return this.raw('POST', '/merchant-account/create', { + failOnStatusCode: false, + body: { merchant_id: merchantId, gateway_success_rate_based_decider_input: null }, + }) + } + + getMerchantAccount(merchantId: string, options: RequestOptions = {}) { + return this.raw('GET', `/merchant-account/${merchantId}`, options) + } + + deleteMerchantAccount(merchantId: string, options: RequestOptions = {}) { + return this.raw('DELETE', `/merchant-account/${merchantId}`, options) + } + + cleanupTestData(merchantId: string) { + if (!merchantId) return Promise.resolve(undefined) + return this.raw('DELETE', `/merchant-account/${merchantId}`, { failOnStatusCode: false }) + } + + // ---- Rule config (SR / elimination) ------------------------------------ + + createRuleConfig(merchantId: string, config: unknown, options: RequestOptions = {}) { + return this.raw('POST', '/rule/create', { ...options, body: { merchant_id: merchantId, config } }) + } + + getRuleConfig(merchantId: string, algorithm: string, options: RequestOptions = {}) { + return this.raw('POST', '/rule/get', { ...options, body: { merchant_id: merchantId, algorithm } }) + } + + updateRuleConfig(merchantId: string, config: unknown, options: RequestOptions = {}) { + return this.raw('POST', '/rule/update', { ...options, body: { merchant_id: merchantId, config } }) + } + + deleteRuleConfig(merchantId: string, algorithm: string, options: RequestOptions = {}) { + return this.raw('POST', '/rule/delete', { ...options, body: { merchant_id: merchantId, algorithm } }) + } + + createSuccessRateConfig(merchantId: string, overrides: Record = {}, options: RequestOptions = {}) { + return this.createRuleConfig(merchantId, { type: 'successRate', data: factory.srConfigData(overrides) }, options) + } + + getSuccessRateConfig(merchantId: string, options: RequestOptions = {}) { + return this.getRuleConfig(merchantId, 'successRate', options) + } + + updateSuccessRateConfig(merchantId: string, overrides: Record = {}, options: RequestOptions = {}) { + return this.updateRuleConfig(merchantId, { type: 'successRate', data: factory.srConfigData(overrides) }, options) + } + + deleteSuccessRateConfig(merchantId: string, options: RequestOptions = {}) { + return this.deleteRuleConfig(merchantId, 'successRate', options) + } + + createEliminationConfig(merchantId: string, overrides: Record = {}, options: RequestOptions = {}) { + return this.createRuleConfig(merchantId, { type: 'elimination', data: factory.eliminationConfigData(overrides) }, options) + } + + getEliminationConfig(merchantId: string, options: RequestOptions = {}) { + return this.getRuleConfig(merchantId, 'elimination', options) + } + + updateEliminationConfig(merchantId: string, overrides: Record = {}, options: RequestOptions = {}) { + return this.updateRuleConfig(merchantId, { type: 'elimination', data: factory.eliminationConfigData(overrides) }, options) + } + + deleteEliminationConfig(merchantId: string, options: RequestOptions = {}) { + return this.deleteRuleConfig(merchantId, 'elimination', options) + } + + // ---- Decision + feedback ---------------------------------------------- + + decideGateway(decisionRequest: Record = {}, options: RequestOptions = {}) { + const request = { + ...factory.srDecideGatewayRequest(), + ...decisionRequest, + paymentInfo: { ...factory.paymentInfo(), ...(decisionRequest.paymentInfo || {}) }, + } + return this.raw('POST', '/decide-gateway', { ...options, body: request }) + } + + updateGatewayScore(scoreUpdate: Record = {}, options: RequestOptions = {}) { + const base = factory.updateGatewayScoreRequest() + const request = { + ...base, + ...scoreUpdate, + txnLatency: { ...base.txnLatency, ...(scoreUpdate.txnLatency || {}) }, + } + return this.raw('POST', '/update-gateway-score', { ...options, body: request }) + } + + // ---- Routing algorithms (advanced/priority/volume-split) --------------- + + createRoutingAlgorithm(payload: unknown, options: RequestOptions = {}) { + return this.raw('POST', '/routing/create', { ...options, body: payload }) + } + + listRoutingAlgorithms(createdBy: string, options: RequestOptions = {}) { + return this.raw('POST', `/routing/list/${createdBy}`, options) + } + + activateRoutingAlgorithm(createdBy: string, routingAlgorithmId: string, options: RequestOptions = {}) { + return this.raw('POST', '/routing/activate', { + ...options, + body: { created_by: createdBy, routing_algorithm_id: routingAlgorithmId }, + }) + } + + listActiveRoutingAlgorithms(createdBy: string, options: RequestOptions = {}) { + return this.raw('POST', `/routing/list/active/${createdBy}`, options) + } + + evaluateRoutingAlgorithm(payload: unknown, options: RequestOptions = {}) { + return this.raw('POST', '/routing/evaluate', { ...options, body: payload }) + } + + // ---- API keys ---------------------------------------------------------- + + createApiKey(merchantId: string, description: string | null = null) { + return this.raw('POST', '/api-key/create', { body: { merchant_id: merchantId, description } }) + } + + listApiKeys(merchantId: string) { + return this.raw('GET', `/api-key/list/${merchantId}`) + } +} diff --git a/tests/fixtures/factory.ts b/tests/fixtures/factory.ts new file mode 100644 index 00000000..948e7613 --- /dev/null +++ b/tests/fixtures/factory.ts @@ -0,0 +1,34 @@ +/** + * Test-data factory. + * + * The pure, runner-agnostic payload/data builders currently live alongside the (now frozen) + * Cypress suite in `cypress/support/test-data-factory.js`. That file contains ZERO `cy.*` calls — + * it is plain CommonJS — so we re-export it verbatim to keep a SINGLE source of truth for request + * payloads across both the Playwright and Cypress suites during the transition. + * + * Do not fork this. If a builder needs to change, change it in the shared file. + */ +// @ts-ignore - pure JS CommonJS module without type declarations +import factory from '../../cypress/support/test-data-factory.js' + +export default factory as { + CONNECTORS: Record + merchantId: (suite?: string) => string + paymentId: (prefix?: string) => string + customerId: (prefix?: string) => string + ruleName: (prefix?: string) => string + gatewayConnector: (name: string, gatewayId?: string | null) => { gateway_name: string; gateway_id: string | null } + connectorNames: (...names: string[]) => string[] + srConfigData: (overrides?: Record) => any + eliminationConfigData: (overrides?: Record) => any + debitRoutingConfigData: (overrides?: Record) => any + paymentInfo: (overrides?: Record) => any + srDecideGatewayRequest: (overrides?: Record) => any + updateGatewayScoreRequest: (overrides?: Record) => any + singleRoutingPayload: (createdBy: string, overrides?: Record) => any + priorityRoutingPayload: (createdBy: string, overrides?: Record) => any + advancedRoutingPayload: (createdBy: string, overrides?: Record) => any + advancedNestedAndOrRoutingPayload: (createdBy: string, overrides?: Record) => any + volumeSplitRoutingPayload: (createdBy: string, overrides?: Record) => any + ruleEvaluatePayload: (createdBy: string, parameters?: Record, overrides?: Record) => any +} diff --git a/tests/fixtures/session.ts b/tests/fixtures/session.ts new file mode 100644 index 00000000..0e0679e5 --- /dev/null +++ b/tests/fixtures/session.ts @@ -0,0 +1,68 @@ +import type { ApiClient } from './api-client' + +export interface Session { + token: string + user: { + userId?: string + email?: string + merchantId?: string + role?: string + } +} + +// Per-worker cache so repeated merchant setups within a worker reuse the session. +const sessionCache = new Map() + +/** + * Drop a merchant's cached session. Call from fixture teardown alongside `cleanupTestData` — the + * Cypress original evicts here too (cypress/support/commands.js `cleanupTestData`), and without it the + * map grows unbounded across a long UI run. + */ +export function clearSession(merchantId: string): void { + sessionCache.delete(merchantId) +} + +function toSession(body: any): Session { + return { + token: body.token, + user: { + userId: body.user_id, + email: body.email, + merchantId: body.merchant_id, + role: body.role, + }, + } +} + +/** + * Port of Cypress `ensureDashboardSession` (commands.js). Establishes a dashboard session for a + * merchant: POST /auth/signup, falling back to POST /auth/login when the account already exists. + * Sets the bearer token on the client so subsequent protected calls authenticate automatically. + */ +export async function ensureDashboardSession(client: ApiClient, merchantId: string): Promise { + const cached = sessionCache.get(merchantId) + if (cached) { + client.token = cached.token + return cached + } + + const email = `${merchantId}@example.com` + const password = 'Password123!' + + const signup = await client.raw('POST', '/auth/signup', { + failOnStatusCode: false, + body: { email, password, merchant_id: merchantId }, + }) + + let session: Session + if (signup.status === 200 && signup.body?.token) { + session = toSession(signup.body) + } else { + const login = await client.raw('POST', '/auth/login', { body: { email, password } }) + session = toSession(login.body) + } + + sessionCache.set(merchantId, session) + client.token = session.token + return session +} diff --git a/tests/fixtures/storage.ts b/tests/fixtures/storage.ts new file mode 100644 index 00000000..f7ba1ddd --- /dev/null +++ b/tests/fixtures/storage.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test' +import type { Session } from './session' + +/** + * Port of Cypress `seedDashboardStorage` (commands.js). Injects the zustand-persisted auth and + * merchant stores into localStorage BEFORE the app's scripts run, so the dashboard boots + * authenticated (AuthGuard validates the seeded token via GET /auth/me) with the merchant context + * already selected. + * + * `page.addInitScript` runs on every navigation before page scripts, so call this once before the + * first `page.goto()`. + */ +export async function seedDashboardStorage(page: Page, merchantId: string, session: Session): Promise { + await page.addInitScript( + ({ merchantId, session }) => { + window.localStorage.setItem( + 'merchant-store', + JSON.stringify({ state: { merchantId }, version: 0 }), + ) + window.localStorage.setItem( + 'auth-store', + JSON.stringify({ state: { token: session.token, user: session.user }, version: 0 }), + ) + }, + { merchantId, session }, + ) +} diff --git a/tests/fixtures/test.ts b/tests/fixtures/test.ts new file mode 100644 index 00000000..47faca0c --- /dev/null +++ b/tests/fixtures/test.ts @@ -0,0 +1,84 @@ +import { test as base, expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import factory from './factory' +import { ApiClient } from './api-client' +import { ensureDashboardSession, clearSession, type Session } from './session' +import { seedDashboardStorage } from './storage' +import { poll } from '../helpers/poll' + +export type Fixtures = { + /** Authenticated API client bound to this test's isolated request context. */ + api: ApiClient + /** A freshly created merchant + dashboard session, auto-cleaned after the test. */ + merchant: { id: string; session: Session } + /** A page pre-seeded with the merchant's auth — protected routes render without a login flow. */ + authedPage: Page + /** `page` seeded with the SHARED (worker) merchant's auth. Pairs with `sharedMerchant`. */ + sharedPage: Page +} + +export type WorkerFixtures = { + /** + * One merchant per worker process, created once and reused by every test that worker runs. + * + * ONLY for specs that never mutate merchant-scoped backend state — no rule creation, no config + * writes, no feature-flag toggles. A mutating test would leak state into every later test on the + * same worker. Everything else uses the per-test `merchant` fixture, and a spec file should pick one + * or the other, never both (mixing gives two ApiClients with different tokens in one test). + * + * Motivation: a per-test merchant costs three round trips, dominated by the signup's bcrypt hash + * (DEFAULT_COST = 12) against a debug build. Sharing across the pure-UI specs turns ~63 signups into + * one per worker. The browser context is still fresh per test — only the merchant is shared. + */ + sharedMerchant: { id: string; session: Session; api: ApiClient } +} + +export const test = base.extend({ + api: async ({ request }, use) => { + await use(new ApiClient(request)) + }, + + merchant: async ({ api }, use) => { + const id = factory.merchantId('pw') + await api.ensureMerchantAccount(id) + const session = await ensureDashboardSession(api, id) + await use({ id, session }) + // Teardown: best-effort cleanup so parallel runs don't accumulate merchants. + await api.cleanupTestData(id) + clearSession(id) + }, + + authedPage: async ({ page, merchant }, use) => { + await seedDashboardStorage(page, merchant.id, merchant.session) + await use(page) + }, + + sharedMerchant: [ + async ({ playwright }, use, workerInfo) => { + // Worker-scoped fixtures cannot depend on the test-scoped `request`, so build our own context. + // ApiClient resolves absolute URLs itself, so no baseURL is needed here. + const request = await playwright.request.newContext({ + extraHTTPHeaders: { 'x-tenant-id': 'public' }, + }) + const api = new ApiClient(request) + // parallelIndex in the id makes a CI failure traceable back to a specific worker. + const id = factory.merchantId(`pww${workerInfo.parallelIndex}`) + await api.ensureMerchantAccount(id) + const session = await ensureDashboardSession(api, id) + + await use({ id, session, api }) + + await api.cleanupTestData(id) + clearSession(id) + await request.dispose() + }, + { scope: 'worker' }, + ], + + sharedPage: async ({ page, sharedMerchant }, use) => { + await seedDashboardStorage(page, sharedMerchant.id, sharedMerchant.session) + await use(page) + }, +}) + +export { expect, factory, poll } diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 00000000..1634eac2 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,55 @@ +/** + * Fail the whole run, in seconds, when the API is up but its database is not. + * + * `webServer.url` points at `/health`, which is deliberately dependency-free — it answers 200 while + * the Postgres pool is completely unusable. Without this gate that shows up as 220 individual 15s + * timeouts and a ~40 minute red run whose logs say nothing more useful than "test timeout exceeded". + * + * `/health/diagnostics` actually exercises the pool (connect + read + write + delete) and reports the + * outcome per operation. It always answers 200, so the body — not the status — is what matters. + */ + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' +const PROBE_TIMEOUT_MS = 30_000 + +type HealthState = 'Working' | 'Failing' + +interface Diagnostics { + key_custodian_locked: boolean + database: Record +} + +export default async function globalSetup(): Promise { + const url = `${API_BASE_URL}/health/diagnostics` + + let body: Diagnostics + try { + // The handler resolves tenant state from this header and rejects the request without it. + const res = await fetch(url, { + headers: { 'x-tenant-id': 'public' }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`) + } + body = (await res.json()) as Diagnostics + } catch (err) { + throw new Error( + `Preflight: could not read ${url} (${err instanceof Error ? err.message : String(err)}).\n` + + 'The API is answering /health but cannot serve diagnostics — check the open_router logs.', + ) + } + + const failing = Object.entries(body.database ?? {}) + .filter(([, state]) => state !== 'Working') + .map(([operation]) => operation) + + if (failing.length > 0) { + throw new Error( + `Preflight: the API cannot reach its database — ${failing.join(', ')} reported Failing.\n` + + `${url} returned ${JSON.stringify(body.database)}.\n` + + 'Every db-backed test would fail on timeout, so the run is being stopped here. Look for\n' + + 'DB_CONNECTION_FAILURE in the open_router output for the underlying connection error.', + ) + } +} diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts new file mode 100644 index 00000000..53efcb5a --- /dev/null +++ b/tests/helpers/assertions.ts @@ -0,0 +1,90 @@ +import { expect } from '@playwright/test' + +/** + * Ports of the custom chai assertions in cypress/support/e2e.js, expressed as plain + * functions over Playwright's `expect`. Each takes the response body object. + */ + +function expectObject(obj: any, label = 'response') { + expect(obj, `${label} should be an object`).toBeTruthy() + expect(typeof obj, `${label} should be an object`).toBe('object') +} + +export function expectValidMerchantCreateResponse(obj: any) { + expectObject(obj, 'merchant create') + expect(obj.message).toBe('Merchant account created successfully') + expect(typeof obj.merchant_id).toBe('string') +} + +export function expectValidMerchantGetResponse(obj: any) { + expectObject(obj, 'merchant get') + expect(typeof obj.merchant_id).toBe('string') + expect(obj).toHaveProperty('gateway_success_rate_based_decider_input') +} + +export function expectValidMerchantDeleteResponse(obj: any) { + expectObject(obj, 'merchant delete') + expect(obj.message).toBe('Merchant account deleted successfully') + expect(typeof obj.merchant_id).toBe('string') +} + +export function expectValidGatewayResponse(obj: any) { + expectObject(obj, 'gateway decision') + expect(typeof obj.decided_gateway).toBe('string') + expect(obj.gateway_priority_map, 'gateway_priority_map should be present').toBeTruthy() + expect(typeof obj.gateway_priority_map).toBe('object') + expect(typeof obj.routing_approach).toBe('string') +} + +export function expectValidScoreUpdate(obj: any) { + expectObject(obj, 'score update') + expect(obj.message).toBe('Gateway score updated successfully') + expect(typeof obj.merchant_id).toBe('string') + expect(typeof obj.gateway).toBe('string') + expect(typeof obj.payment_id).toBe('string') +} + +export function expectValidRuleConfigResponse(obj: any, expectedType?: string) { + expectObject(obj, 'rule config') + expect(typeof obj.merchant_id).toBe('string') + expect(obj.config, 'config should be present').toBeTruthy() + expect(typeof obj.config).toBe('object') + if (expectedType) expect(obj.config.type).toBe(expectedType) +} + +export function expectValidRoutingAlgorithmCreateResponse(obj: any) { + expectObject(obj, 'routing algorithm create') + expect(typeof obj.rule_id).toBe('string') + expect(typeof obj.name).toBe('string') +} + +export function expectValidRoutingAlgorithmList(obj: any) { + expect(Array.isArray(obj), 'routing algorithm list should be an array').toBe(true) + for (const item of obj) { + expect(typeof item.id).toBe('string') + expect(typeof item.name).toBe('string') + expect(typeof item.created_by).toBe('string') + } +} + +export function expectValidAnalyticsOverview(obj: any) { + expectObject(obj, 'analytics overview') + expect(typeof obj.merchant_id).toBe('string') + expect(Array.isArray(obj.kpis)).toBe(true) + expect(Array.isArray(obj.route_hits)).toBe(true) +} + +export function expectValidRoutingStats(obj: any) { + expectObject(obj, 'routing stats') + expect(typeof obj.merchant_id).toBe('string') + expect(Array.isArray(obj.gateway_share)).toBe(true) + expect(Array.isArray(obj.sr_trend)).toBe(true) +} + +export function expectValidPaymentAudit(obj: any) { + expectObject(obj, 'payment audit') + expect(Array.isArray(obj.results)).toBe(true) + expect(obj).toHaveProperty('page') + expect(obj).toHaveProperty('page_size') + expect(obj).toHaveProperty('total_results') +} diff --git a/tests/helpers/clickhouse.ts b/tests/helpers/clickhouse.ts new file mode 100644 index 00000000..ace1913f --- /dev/null +++ b/tests/helpers/clickhouse.ts @@ -0,0 +1,61 @@ +import type { APIRequestContext } from '@playwright/test' + +/** + * Port of the Cypress `clickhouseQuery` task (cypress.config.js). Playwright has no plugin process, so + * the query goes over ClickHouse's HTTP interface using the test's own request context. + * + * `run-e2e.js` passes CLICKHOUSE_HTTP_URL / _DATABASE / _USER / _PASSWORD through to Playwright, so + * these defaults only apply when running against a hand-started stack. + */ + +const HTTP_URL = process.env.CLICKHOUSE_HTTP_URL || 'http://localhost:8123' +const DATABASE = process.env.CLICKHOUSE_DATABASE || 'default' +const USER = process.env.CLICKHOUSE_USER || 'decision_engine' +const PASSWORD = process.env.CLICKHOUSE_PASSWORD || 'decision_engine' + +/** Tables the analytics pipeline needs, mirroring EXPECTED_CLICKHOUSE_TABLES in cypress.config.js. */ +export const EXPECTED_CLICKHOUSE_TABLES = [ + 'analytics_api_events_queue', + 'analytics_domain_events_queue', + 'analytics_api_events', + 'analytics_domain_events', + 'analytics_payment_audit_summary_buckets', + 'analytics_payment_audit_lookup_summaries', +] + +/** Run a query and return the raw response text (use `FORMAT TSV` for line-parseable output). */ +export async function clickhouseQuery(request: APIRequestContext, query: string): Promise { + const url = new URL(HTTP_URL) + url.searchParams.set('database', DATABASE) + url.searchParams.set('query', query) + + const response = await request.get(url.toString(), { + headers: { + Authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString('base64')}`, + }, + }) + + const body = await response.text() + if (!response.ok()) { + throw new Error(`ClickHouse query failed (${response.status()}): ${body}`) + } + return body +} + +/** Names of the given tables that actually exist in the current database. */ +export async function existingTables( + request: APIRequestContext, + tables: string[], +): Promise> { + const quoted = tables.map((t) => `'${t}'`).join(', ') + const raw = await clickhouseQuery( + request, + `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN (${quoted}) ORDER BY name FORMAT TSV`, + ) + return new Set( + raw + .split('\n') + .map((v) => v.trim()) + .filter(Boolean), + ) +} diff --git a/tests/helpers/network.ts b/tests/helpers/network.ts new file mode 100644 index 00000000..54dc0bab --- /dev/null +++ b/tests/helpers/network.ts @@ -0,0 +1,54 @@ +import type { Page } from '@playwright/test' + +/** + * Port of the Cypress `cy.intercept(...).as('x')` + `cy.wait('@x')` pattern, which the rule-creation + * specs use ~15 times — every one of them asserting on BOTH the response status and the request body + * the UI built. Playwright exposes both off a single `Response`, so one helper covers the pattern. + * + * ORDERING MATTERS: create the promise BEFORE the action that triggers the request, then await it + * after. A `waitForResponse` registered after the click has already missed it. + * + * const created = expectApiCall(page, '/routing/create') + * await page.getByRole('button', { name: 'Create Rule' }).click() + * const { status, requestBody } = await created + * + * Matches on a PATH SUFFIX rather than a full URL: the dashboard calls the API under + * `/decision-engine-api` in dev and `/decision-engine/api` in production builds, so anchoring on the + * prefix would break in one mode or the other. + */ + +export interface ApiCall { + status: number + /** The JSON body the UI sent, or null for GETs / non-JSON requests. */ + requestBody: any + /** The parsed JSON response, or null when the response wasn't JSON. */ + body: T +} + +export async function expectApiCall( + page: Page, + pathSuffix: string, + method = 'POST', + options: { timeout?: number } = {}, +): Promise> { + const response = await page.waitForResponse( + (r) => r.url().includes(pathSuffix) && r.request().method() === method, + { timeout: options.timeout ?? 20_000 }, + ) + + let requestBody: any = null + try { + requestBody = response.request().postDataJSON() + } catch { + // GET, or a non-JSON body — leave null. + } + + let body: any = null + try { + body = await response.json() + } catch { + // Empty or non-JSON response (e.g. /routing/deactivate returns no body). + } + + return { status: response.status(), requestBody, body } +} diff --git a/tests/helpers/poll.ts b/tests/helpers/poll.ts new file mode 100644 index 00000000..6388cb9a --- /dev/null +++ b/tests/helpers/poll.ts @@ -0,0 +1,45 @@ +/** + * Port of Cypress `cy.pollRequest` (cypress/support/commands.js): re-run a request until a predicate + * passes or the timeout elapses. + * + * Prefer Playwright's built-in `expect.poll()` for plain "eventually true" assertions. Reach for this + * when the SETTLED RESPONSE is needed afterwards — `expect.poll` returns a matcher, not the value, and + * most analytics assertions need to read the body they waited for. + * + * The `{ status, body }` constraint is what lets a timeout print the last response it saw, which is + * the single most useful thing this helper does when ClickHouse ingestion is lagging behind the test. + */ + +export interface PollOptions { + /** Included in the thrown error, followed by the last observed status + body. */ + message: string + /** Give up after this long. Default 30s. */ + timeout?: number + /** Wait between attempts. Default 2s. */ + interval?: number +} + +export async function poll( + request: () => Promise, + predicate: (result: T) => boolean, + options: PollOptions, +): Promise { + const { message, timeout = 30_000, interval = 2_000 } = options + const startedAt = Date.now() + let last: T | null = null + + for (;;) { + const result = await request() + last = result + if (predicate(result)) return result + + if (Date.now() - startedAt >= timeout) { + const context = last + ? ` Last result: ${JSON.stringify({ status: last.status, response: last.body }).slice(0, 1000)}` + : '' + throw new Error(`${message}.${context}`) + } + + await new Promise((resolve) => setTimeout(resolve, interval)) + } +} diff --git a/tests/helpers/seed.ts b/tests/helpers/seed.ts new file mode 100644 index 00000000..b521838a --- /dev/null +++ b/tests/helpers/seed.ts @@ -0,0 +1,152 @@ +import type { ApiClient, ApiResponse } from '../fixtures/api-client' +import factory from '../fixtures/factory' +import { poll } from './poll' + +/** + * Shared analytics seeding. + * + * Four places grew a near-identical copy of this sequence: cypress/e2e/ui/dashboard-overview.cy.js, + * analytics-page.cy.js, payment-audit.cy.js, and inline in tests/api/analytics.spec.ts. They differ in + * exactly three parameters — whether an advanced rule is created, whether a preview evaluation runs, + * and whether the score feedback is a success or a failure — so they collapse into one function with + * an options bag. + * + * The `waitFor*` helpers below are the other half of the duplication: every consumer follows the seed + * with the same poll-until-ClickHouse-catches-up loop. + */ + +export interface SeedOptions { + /** Create + activate an advanced routing algorithm. Default true. */ + withAdvancedRule?: boolean + /** Run a /routing/evaluate preview so preview-trace has something to find. Default true. */ + withPreviewEvaluation?: boolean + /** Feedback status posted to /update-gateway-score. Default 'AUTHORIZED'. */ + scoreStatus?: 'AUTHORIZED' | 'FAILURE' + /** Reported gateway latency on the score update. */ + gatewayLatency?: number + /** Prefix for generated payment ids, to keep failures traceable to a spec. */ + prefix?: string +} + +export interface SeededTraffic { + decisionPaymentId: string + previewPaymentId?: string + decidedGateway: string + ruleId?: string + /** Body of the preview /routing/evaluate call, when one ran — lets callers assert on the output. */ + previewEvaluation?: any +} + +/** Generate a decision + score-update (and optionally a rule + preview evaluation) for a merchant. */ +export async function seedRoutedTraffic( + api: ApiClient, + merchantId: string, + options: SeedOptions = {}, +): Promise { + const { + withAdvancedRule = true, + withPreviewEvaluation = true, + scoreStatus = 'AUTHORIZED', + gatewayLatency, + prefix = 'seed', + } = options + + await api.createSuccessRateConfig(merchantId) + + let ruleId: string | undefined + if (withAdvancedRule) { + const created = await api.createRoutingAlgorithm( + factory.advancedRoutingPayload(merchantId, { name: factory.ruleName(`${prefix}_adv`) }), + ) + ruleId = created.body.rule_id + await api.activateRoutingAlgorithm(merchantId, ruleId!) + } + + const decisionPaymentId = factory.paymentId(`${prefix}_decision`) + const decide = await api.decideGateway( + factory.srDecideGatewayRequest({ + merchantId, + paymentInfo: { paymentId: decisionPaymentId }, + }), + ) + const decidedGateway: string = decide.body.decided_gateway + + await api.updateGatewayScore( + factory.updateGatewayScoreRequest({ + merchantId, + gateway: decidedGateway, + paymentId: decisionPaymentId, + status: scoreStatus, + ...(gatewayLatency === undefined ? {} : { txnLatency: { gatewayLatency } }), + }), + ) + + let previewPaymentId: string | undefined + let previewEvaluation: any + if (withPreviewEvaluation && withAdvancedRule) { + previewPaymentId = factory.paymentId(`${prefix}_preview`) + const evaluated = await api.evaluateRoutingAlgorithm( + factory.ruleEvaluatePayload( + merchantId, + { + payment_method: { type: 'enum_variant', value: 'card' }, + amount: { type: 'number', value: 250 }, + }, + { payment_id: previewPaymentId }, + ), + ) + previewEvaluation = evaluated.body + } + + return { decisionPaymentId, previewPaymentId, decidedGateway, ruleId, previewEvaluation } +} + +/** + * Poll /analytics/overview until every named route has been recorded. Routes are the internal names + * the API reports, e.g. '/decide_gateway', '/update_gateway', '/rule_evaluate'. + */ +export function waitForOverviewRouteHits(api: ApiClient, routes: string[]): Promise { + return poll( + () => api.raw('GET', '/analytics/overview', { failOnStatusCode: false, qs: { range: '1h' } }), + ({ body }) => + Array.isArray(body?.route_hits) && + routes.every((route) => body.route_hits.some((hit: any) => hit.route === route)), + { message: `Expected analytics overview to record route hits: ${routes.join(', ')}` }, + ) +} + +/** Poll /analytics/payment-audit until the payment's timeline contains a given flow type. */ +export function waitForAuditFlowType( + api: ApiClient, + paymentId: string, + flowType: string, +): Promise { + return poll( + () => + api.raw('GET', '/analytics/payment-audit', { + failOnStatusCode: false, + qs: { range: '1h', payment_id: paymentId }, + }), + ({ body }) => + Array.isArray(body?.timeline) && body.timeline.some((e: any) => e.flow_type === flowType), + { message: `Expected payment audit timeline for ${paymentId} to contain ${flowType}` }, + ) +} + +/** Poll /analytics/preview-trace until the preview payment's timeline contains a given flow type. */ +export function waitForPreviewFlowType( + api: ApiClient, + paymentId: string, + flowType: string, +): Promise { + return poll( + () => + api.raw('GET', '/analytics/preview-trace', { + failOnStatusCode: false, + qs: { range: '1h', payment_id: paymentId }, + }), + ({ body }) => + Array.isArray(body?.timeline) && body.timeline.some((e: any) => e.flow_type === flowType), + { message: `Expected preview trace for ${paymentId} to contain ${flowType}` }, + ) +} diff --git a/tests/helpers/stub.ts b/tests/helpers/stub.ts new file mode 100644 index 00000000..b1948fe2 --- /dev/null +++ b/tests/helpers/stub.ts @@ -0,0 +1,42 @@ +import type { Page } from '@playwright/test' + +/** + * Ports of the two `cy.intercept` STUBBING shapes the UI specs use (as opposed to the + * observe-and-assert shape, which lives in network.ts). + * + * Globs here are deliberately prefix-agnostic (`**\/auth/signup`, not the Cypress + * `**\/decision-engine-api/auth/signup`): the dashboard calls the API under `/decision-engine-api` in + * dev and `/decision-engine/api` in production builds. Playwright's `**` matches `/`, so the short + * form covers both. + * + * Register these BEFORE `page.goto` — a route added after navigation misses in-flight requests. + */ + +/** Replace a response outright. Port of `cy.intercept(method, url, { statusCode, body })`. */ +export async function stubApi( + page: Page, + pathGlob: string, + response: { status: number; body: unknown }, +): Promise { + await page.route(pathGlob, (route) => + route.fulfill({ + status: response.status, + contentType: 'application/json', + body: JSON.stringify(response.body), + }), + ) +} + +/** + * Slow a real request down so a transient loading state stays observable. Port of + * `cy.intercept(url, req => req.continue(res => res.setDelay(ms)))`. + * + * Note the semantic difference: Cypress delays the RESPONSE, this delays the REQUEST. Equivalent for + * observing a loading indicator (the UI is waiting either way), but not identical. + */ +export async function delayApi(page: Page, pathGlob: string, ms: number): Promise { + await page.route(pathGlob, async (route) => { + await new Promise((resolve) => setTimeout(resolve, ms)) + await route.continue() + }) +} diff --git a/tests/pages/euclid-page.ts b/tests/pages/euclid-page.ts new file mode 100644 index 00000000..a11dc178 --- /dev/null +++ b/tests/pages/euclid-page.ts @@ -0,0 +1,158 @@ +import { expect, type Page, type Locator } from '@playwright/test' + +/** + * Page object for the Euclid rule builder — Playwright port of cypress/support/euclid-helpers.js. + * The selectors are carried over verbatim (data-cy hooks, placeholders, the portal-rendered + * SearchableSelect); only the transport changes from `cy.*` to Playwright locators. Cypress's + * `.within()` scoping becomes chained locators, and the `{withinSubject: null}` portal trick becomes + * a plain page-level locator (the dropdown renders at the document root). + */ +export class EuclidRuleBuilder { + constructor(private readonly page: Page) {} + + /** Navigate to the rule builder and wait for the routing-key fetch to settle. */ + async goto(path = '/routing/rules'): Promise { + await this.page.goto(path) + await this.waitUntilReady() + } + + /** + * The builder renders a "Loading routing keys from backend..." placeholder until GET + * /config/routing-keys resolves. Every condition interaction depends on those keys, so each spec + * gates on this first. + */ + async waitUntilReady(): Promise { + await expect(this.page.getByText('Loading routing keys from backend...')).toHaveCount(0, { + timeout: 15_000, + }) + } + + async addRuleBlock(): Promise { + await this.page.getByRole('button', { name: 'Add Rule', exact: true }).click() + } + + /** The nth nested (OR) branch inside a rule block — the sky-bordered, indented group. */ + nestedBranch(blockIndex: number, index: number): Locator { + return this.ruleBlock(blockIndex).locator('.border-l-2.border-sky-200').nth(index) + } + + /** All nested branches in a rule block, for count assertions. */ + nestedBranches(blockIndex: number): Locator { + return this.ruleBlock(blockIndex).locator('.border-l-2.border-sky-200') + } + + async addNestedBranch(blockIndex: number): Promise { + await this.ruleBlock(blockIndex).getByRole('button', { name: 'Add nested branch' }).click() + } + + /** Scope to the nth rule block (0-indexed). */ + ruleBlock(index = 0): Locator { + return this.page + .locator('.rounded-xl.overflow-hidden') + .filter({ has: this.page.locator('input[placeholder="Rule name"]') }) + .nth(index) + } + + /** THEN section of the nth rule block, regardless of current output type. */ + thenSection(blockIndex = 0): Locator { + return this.ruleBlock(blockIndex) + .locator('div[class*="py-4"]') + .filter({ has: this.page.locator('p', { hasText: 'Then route' }) }) + } + + async setRuleName(index: number, name: string): Promise { + await this.page.locator('input[placeholder="Rule name"]').nth(index).fill(name) + } + + /** typeLabel: 'Priority' | 'Volume Split' | 'Split + Priority' */ + async switchOutputType(blockIndex: number, typeLabel: string): Promise { + await this.thenSection(blockIndex).getByRole('button', { name: typeLabel, exact: true }).click() + } + + async addGatewayToBlock(blockIndex: number, gatewayName: string, gatewayId = ''): Promise { + const then = this.thenSection(blockIndex) + await then.locator('input[placeholder="Gateway name"]').fill(gatewayName) + if (gatewayId) await then.locator('input[placeholder="Gateway ID (optional)"]').fill(gatewayId) + await then.getByRole('button', { name: 'Add', exact: true }).click() + } + + async addVolumeSplitEntry(blockIndex: number, split: number, gatewayName: string, gatewayId = ''): Promise { + const then = this.thenSection(blockIndex) + await then.locator('input[placeholder="Split %"]').fill(String(split)) + await then.locator('input[placeholder="Gateway name"]').fill(gatewayName) + if (gatewayId) await then.locator('input[placeholder="Gateway ID (optional)"]').fill(gatewayId) + await then.getByRole('button', { name: 'Add', exact: true }).click() + } + + async addVolumeSplitPriorityRow(blockIndex: number, split: number): Promise { + const then = this.thenSection(blockIndex) + await then.locator('input[placeholder="Split %"]').fill(String(split)) + await then.getByRole('button', { name: 'Add split', exact: true }).click() + } + + async addGatewayToSplitRow(blockIndex: number, rowIndex: number, gatewayName: string, gatewayId = ''): Promise { + const row = this.thenSection(blockIndex) + .locator('[class*="p-3"]') + .filter({ has: this.page.locator('p', { hasText: 'Priority list for this split' }) }) + .nth(rowIndex) + await row.locator('input[placeholder="Gateway name"]').fill(gatewayName) + if (gatewayId) await row.locator('input[placeholder="Gateway ID (optional)"]').fill(gatewayId) + await row.getByRole('button', { name: 'Add', exact: true }).click() + } + + async addFallbackGateway(gatewayName: string, gatewayId = ''): Promise { + const section = this.page + .locator('.rounded-xl') + .filter({ has: this.page.locator('p', { hasText: 'Default Fallback' }) }) + await section.locator('input[placeholder="Gateway name"]').fill(gatewayName) + if (gatewayId) await section.locator('input[placeholder="Gateway ID (optional)"]').fill(gatewayId) + await section.getByRole('button', { name: 'Add', exact: true }).click() + } + + /** + * Select a routing-key (LHS) from the SearchableSelect dropdown (portal-rendered). + * + * `scope` restricts the index to one container — the equivalent of Cypress's `.within()`. Pass a + * nested branch (see `nestedBranch`) when addressing a condition inside one, because page-wide + * indices shift depending on what the other conditions look like. + */ + async selectCondLhs(index: number, value: string, scope?: Locator): Promise { + await (scope ?? this.page).locator('[data-cy="cond-lhs"] button.cond-select').nth(index).click() + await this.selectFromPortal(value) + } + + /** + * Select an enum value from the SearchableSelect value dropdown (portal-rendered). + * + * Numeric keys (e.g. `amount`) render a plain number input rather than a `cond-val` button, so a + * page-wide index here is NOT the same as the matching `selectCondLhs` index. Prefer passing a + * `scope`. + */ + async selectCondVal(index: number, value: string, scope?: Locator): Promise { + await (scope ?? this.page).locator('[data-cy="cond-val"] button.cond-select').nth(index).click() + await this.selectFromPortal(value) + } + + /** Select multiple enum values in the SearchableMultiSelect (skips already-checked options). */ + async selectMultiCondVals(index: number, values: string[]): Promise { + await this.page.locator('[data-cy="cond-val"]').nth(index).click() + const search = this.page.locator('input[placeholder="Search…"]') + for (const value of values) { + await search.fill(value) + const opt = this.page.locator(`button[data-value="${value}"]:not(.cond-select)`).first() + await opt.waitFor({ state: 'visible' }) + const cls = (await opt.getAttribute('class')) || '' + // text-brand-600 marks the checked state — skip so we don't deselect it. + if (!cls.includes('text-brand-600')) await opt.click({ force: true }) + await search.focus() + } + await this.page.locator('body').click({ force: true }) + } + + private async selectFromPortal(value: string): Promise { + const search = this.page.locator('input[placeholder="Search…"]') + await search.waitFor({ state: 'visible' }) + await search.fill(value) + await this.page.locator(`button[data-value="${value}"]:not(.cond-select)`).first().click() + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..535a6109 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + // Scoped to the Playwright e2e suite only. The dashboard has its own config at website/tsconfig.json + // and is typechecked separately (the `frontend-typecheck` CI job runs there). + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["tests/**/*.ts", "playwright.config.ts"], + "exclude": ["node_modules", "website", "docs", "target"] +}