From 6d52251a0daff31cb2cdc148ad8655c1e2e1d003 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 25 Sep 2026 15:34:02 +0100 Subject: [PATCH] feat: complete assigned reliability and analytics issues --- .github/workflows/backup.yml | 15 +++- .github/workflows/k6-load-test.yml | 41 +++++++++ backend/docs/COHORT_ANALYTICS.md | 11 ++- backend/src/routes/cohort-analytics.ts | 7 ++ backend/src/routes/reports.ts | 16 ++++ .../__tests__/cohort-analytics.test.ts | 10 +++ backend/src/services/cohort-analytics.ts | 35 ++++++++ .../services/reports/report-export.test.ts | 33 +++++++ backend/src/services/reports/report-export.ts | 85 +++++++++++++++++++ docs/DISASTER_RECOVERY.md | 64 ++++++++++++++ package.json | 2 + scripts/backup.sh | 36 ++++++-- scripts/disaster-recovery-drill.sh | 29 +++++++ tests/load/k6/README.md | 14 +++ tests/load/k6/payment-flow.js | 39 +++++++++ tests/load/k6/smoke.js | 22 +++++ 16 files changed, 448 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/k6-load-test.yml create mode 100644 backend/src/services/reports/report-export.test.ts create mode 100644 backend/src/services/reports/report-export.ts create mode 100644 docs/DISASTER_RECOVERY.md create mode 100755 scripts/disaster-recovery-drill.sh create mode 100644 tests/load/k6/README.md create mode 100644 tests/load/k6/payment-flow.js create mode 100644 tests/load/k6/smoke.js diff --git a/.github/workflows/backup.yml b/.github/workflows/backup.yml index ecb7195f..d8284b33 100644 --- a/.github/workflows/backup.yml +++ b/.github/workflows/backup.yml @@ -17,6 +17,7 @@ on: - full - incremental - verify + - drill env: BACKUP_DIR: /tmp/backups @@ -82,6 +83,18 @@ jobs: export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/agenticpay" bash scripts/backup.sh verify + - name: Run isolated restore drill + if: ${{ github.event.inputs.type == 'drill' }} + run: | + export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/agenticpay" + createdb -h localhost -U postgres agenticpay_drill + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c 'CREATE TABLE IF NOT EXISTS dr_evidence (id integer primary key);' + bash scripts/backup.sh full + export DRILL_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/agenticpay_drill" + bash scripts/disaster-recovery-drill.sh + env: + PGPASSWORD: postgres + - name: Upload backup artifacts uses: actions/upload-artifact@v4 with: @@ -101,4 +114,4 @@ jobs: }] } env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} \ No newline at end of file + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/k6-load-test.yml b/.github/workflows/k6-load-test.yml new file mode 100644 index 00000000..dd995df1 --- /dev/null +++ b/.github/workflows/k6-load-test.yml @@ -0,0 +1,41 @@ +name: k6 Load Test + +on: + workflow_dispatch: + inputs: + base_url: + description: Isolated or staging AgenticPay URL + required: true + type: string + target_rate: + description: Requests per second at the steady stage + required: false + default: '25' + type: string + +permissions: + contents: read + +jobs: + load-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: grafana/setup-k6-action@v1 + - name: Run health smoke test + env: + BASE_URL: ${{ inputs.base_url }} + run: k6 run tests/load/k6/smoke.js + - name: Run staged payment load test + env: + BASE_URL: ${{ inputs.base_url }} + TARGET_RATE: ${{ inputs.target_rate }} + run: k6 run --summary-export=k6-summary.json tests/load/k6/payment-flow.js + - name: Upload load-test summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: k6-summary + path: k6-summary.json + if-no-files-found: ignore diff --git a/backend/docs/COHORT_ANALYTICS.md b/backend/docs/COHORT_ANALYTICS.md index d4031bbd..4fae0d16 100644 --- a/backend/docs/COHORT_ANALYTICS.md +++ b/backend/docs/COHORT_ANALYTICS.md @@ -1,6 +1,6 @@ # Subscription Cohort Retention Analytics -Issue #629. In-memory, event-sourced cohort analytics for on-chain-managed +Issues #629 and #851. In-memory, event-sourced cohort analytics for on-chain-managed subscriptions (see `backend/src/jobs/subscription.service.ts`). There is no Prisma-persisted subscription/customer table in this codebase — subscriptions are managed on-chain — so this service, like `backend/src/services/analytics.ts` @@ -195,6 +195,12 @@ List all cohort months present with their size. } ``` +### `GET /api/v1/analytics/cohorts/retention` + +Returns every cohort as an aligned retention matrix suitable for a heatmap. +Offsets without enough observed history are `null`, not zero, so future months +are not misclassified as complete churn. + ### `GET /api/v1/analytics/cohorts/:cohortMonth/retention` `cohortMonth` must match `YYYY-MM`. @@ -285,8 +291,7 @@ cohortMonth,monthOffset,activeCustomers,retentionPct ## Mounting -This route module is not wired into `src/index.ts` by this change (per task -scope — index.ts is owned by another workstream). To enable it, mount: +The route is mounted in `src/index.ts` at: ```ts import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; diff --git a/backend/src/routes/cohort-analytics.ts b/backend/src/routes/cohort-analytics.ts index c97a36d6..e13e5c82 100644 --- a/backend/src/routes/cohort-analytics.ts +++ b/backend/src/routes/cohort-analytics.ts @@ -77,6 +77,13 @@ cohortAnalyticsRouter.get( }), ); +cohortAnalyticsRouter.get( + '/retention', + asyncHandler(async (_req, res) => { + res.json({ data: cohortAnalyticsService.getRetentionMatrix() }); + }), +); + cohortAnalyticsRouter.get( '/:cohortMonth/retention', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts index 4a329451..12c42803 100644 --- a/backend/src/routes/reports.ts +++ b/backend/src/routes/reports.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { asyncHandler } from '../middleware/errorHandler.js'; import { customReportService } from '../services/reports/custom-report.js'; import { AppError } from '../middleware/errorHandler.js'; +import { exportReportRows, type ReportExportFormat } from '../services/reports/report-export.js'; export const reportsRouter = Router(); @@ -78,3 +79,18 @@ reportsRouter.get('/:id/data', asyncHandler(async (req, res) => { const data = await customReportService.generateReportData(req.params.id); res.json(data); })); + +reportsRouter.get('/:id/export', asyncHandler(async (req, res) => { + const format = req.query.format as ReportExportFormat | undefined; + if (format !== 'csv' && format !== 'json' && format !== 'excel') { + throw new AppError(400, 'format must be csv, json, or excel', 'INVALID_EXPORT_FORMAT'); + } + + const generated = await customReportService.generateReportData(req.params.id); + const exported = exportReportRows(generated.data as Record[], format); + const safeName = generated.report.name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'report'; + + res.setHeader('Content-Type', exported.contentType); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${exported.extension}"`); + res.send(exported.content); +})); diff --git a/backend/src/services/__tests__/cohort-analytics.test.ts b/backend/src/services/__tests__/cohort-analytics.test.ts index 9b79cf22..e1783012 100644 --- a/backend/src/services/__tests__/cohort-analytics.test.ts +++ b/backend/src/services/__tests__/cohort-analytics.test.ts @@ -148,6 +148,16 @@ describe('CohortAnalyticsService', () => { expect(comparison.summary.averageRetentionPctByCohort['2025-01']).toBe(100); expect(comparison.summary.averageRetentionPctByCohort['2025-02']).toBe(75); }); + + it('builds an aligned retention matrix without treating unavailable months as churn', () => { + const matrix = service.getRetentionMatrix(); + + expect(matrix.maxMonthOffset).toBe(1); + expect(matrix.cohorts).toEqual([ + { cohortMonth: '2025-01', cohortSize: 1, retentionByMonth: [100, 100] }, + { cohortMonth: '2025-02', cohortSize: 2, retentionByMonth: [100, 50] }, + ]); + }); }); describe('exportToCsv', () => { diff --git a/backend/src/services/cohort-analytics.ts b/backend/src/services/cohort-analytics.ts index 14389ed2..8488dbc8 100644 --- a/backend/src/services/cohort-analytics.ts +++ b/backend/src/services/cohort-analytics.ts @@ -27,6 +27,10 @@ export interface CohortSummary { cohortSize: number; } +export interface RetentionMatrixRow extends CohortSummary { + retentionByMonth: Array; +} + export interface RetentionPoint { monthOffset: number; activeCustomers: number; @@ -127,6 +131,37 @@ export class CohortAnalyticsService { .sort((a, b) => a.cohortMonth.localeCompare(b.cohortMonth)); } + /** + * Returns an aligned retention matrix for dashboard heatmaps. Missing future + * offsets are represented by null rather than zero so they are not mistaken + * for churn. + */ + getRetentionMatrix(): { maxMonthOffset: number; cohorts: RetentionMatrixRow[] } { + const summaries = this.getCohorts(); + const curves = summaries.map((summary) => ({ + summary, + curve: this.getRetentionCurve(summary.cohortMonth), + })); + const maxMonthOffset = curves.reduce( + (max, { curve }) => Math.max(max, curve.at(-1)?.monthOffset ?? 0), + 0, + ); + + return { + maxMonthOffset, + cohorts: curves.map(({ summary, curve }) => { + const byOffset = new Map(curve.map((point) => [point.monthOffset, point.retentionPct])); + return { + ...summary, + retentionByMonth: Array.from( + { length: maxMonthOffset + 1 }, + (_, offset) => byOffset.get(offset) ?? null, + ), + }; + }), + }; + } + /** * Retention curve for a cohort: for each month-offset N, the % of the cohort's * original customers still active in that offset month. diff --git a/backend/src/services/reports/report-export.test.ts b/backend/src/services/reports/report-export.test.ts new file mode 100644 index 00000000..7901d87f --- /dev/null +++ b/backend/src/services/reports/report-export.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { exportReportRows } from './report-export.js'; + +const rows = [ + { merchant: 'Alice, Inc.', payments: 2, note: 'quoted "value"' }, + { merchant: 'Björk & Co', payments: 1, note: '=HYPERLINK("https://example.com")' }, +]; + +describe('exportReportRows', () => { + it('exports valid JSON', () => { + const result = exportReportRows(rows, 'json'); + expect(result.extension).toBe('json'); + expect(JSON.parse(result.content)).toEqual(rows); + }); + + it('exports RFC-compatible CSV with an Excel UTF-8 BOM', () => { + const result = exportReportRows(rows, 'csv'); + expect(result.contentType).toContain('text/csv'); + expect(result.content.startsWith('\uFEFFmerchant,payments,note')).toBe(true); + expect(result.content).toContain('"Alice, Inc."'); + expect(result.content).toContain('"quoted ""value"""'); + expect(result.content).toContain("'=HYPERLINK"); + }); + + it('exports an Excel-readable SpreadsheetML workbook with escaped cells', () => { + const result = exportReportRows(rows, 'excel'); + expect(result.extension).toBe('xls'); + expect(result.contentType).toContain('application/vnd.ms-excel'); + expect(result.content).toContain('ss:Type="Number">2'); + expect(result.content).toContain('Björk & Co'); + expect(result.content).toContain(''=HYPERLINK'); + }); +}); diff --git a/backend/src/services/reports/report-export.ts b/backend/src/services/reports/report-export.ts new file mode 100644 index 00000000..0c0054d3 --- /dev/null +++ b/backend/src/services/reports/report-export.ts @@ -0,0 +1,85 @@ +export type ReportExportFormat = 'csv' | 'json' | 'excel'; + +export interface ReportExportResult { + content: string; + contentType: string; + extension: 'csv' | 'json' | 'xls'; +} + +type ReportRow = Record; + +function stringifyCell(value: unknown): string { + if (value === null || value === undefined) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function neutralizeSpreadsheetFormula(value: unknown): string { + const text = stringifyCell(value); + return /^[=+\-@]/.test(text) ? `'${text}` : text; +} + +function escapeCsv(value: unknown): string { + const text = neutralizeSpreadsheetFormula(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function escapeXml(value: unknown, neutralizeFormula = false): string { + return (neutralizeFormula ? neutralizeSpreadsheetFormula(value) : stringifyCell(value)) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function columnsFor(rows: ReportRow[]): string[] { + const columns = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) columns.add(key); + } + return [...columns]; +} + +/** Serialize report rows without evaluating formulas or emitting unsafe HTML. */ +export function exportReportRows(rows: ReportRow[], format: ReportExportFormat): ReportExportResult { + const columns = columnsFor(rows); + + if (format === 'json') { + return { + content: JSON.stringify(rows, null, 2), + contentType: 'application/json; charset=utf-8', + extension: 'json', + }; + } + + if (format === 'csv') { + const lines = [columns.map(escapeCsv).join(',')]; + for (const row of rows) lines.push(columns.map((column) => escapeCsv(row[column])).join(',')); + return { + // UTF-8 BOM keeps non-ASCII data intact when opened directly in Excel. + content: `\uFEFF${lines.join('\r\n')}`, + contentType: 'text/csv; charset=utf-8', + extension: 'csv', + }; + } + + const header = columns.map((column) => `${escapeXml(column)}`).join(''); + const body = rows + .map((row) => { + const cells = columns.map((column) => { + const value = row[column]; + const numeric = typeof value === 'number' && Number.isFinite(value); + return `${escapeXml(value, !numeric)}`; + }).join(''); + return `${cells}`; + }) + .join(''); + + return { + content: `${header}${body}
`, + contentType: 'application/vnd.ms-excel; charset=utf-8', + extension: 'xls', + }; +} diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md new file mode 100644 index 00000000..f725a175 --- /dev/null +++ b/docs/DISASTER_RECOVERY.md @@ -0,0 +1,64 @@ +# Disaster recovery runbook + +This runbook defines the recovery process for the AgenticPay PostgreSQL data +plane. The objectives are an RPO of one hour and an RTO of four hours. Incident +commanders must record actual recovery times and data loss in the incident log. + +## Preparation and ownership + +- Primary owner: on-call platform engineer. +- Approver for production restore: incident commander plus database owner. +- Full backups run daily; incremental backups run every six hours. Production + deployments should use WAL archiving to meet the one-hour RPO. +- Backups require SHA-256 verification before upload or restore. +- Quarterly restore drills must target an isolated database and preserve the + workflow artifact/log as evidence. + +## Incident procedure + +1. Declare the incident, freeze database migrations and payment writes, and + record the suspected corruption time. +2. Confirm whether the primary can be recovered in place. Prefer failover to a + healthy replica for infrastructure-only failures. +3. List and verify restore candidates: + + ```bash + BACKUP_DIR=/var/backups/agenticpay bash scripts/backup.sh verify + ``` + +4. Select the newest verified full backup before the incident, then either: + + ```bash + DATABASE_URL="$RECOVERY_DATABASE_URL" bash scripts/backup.sh restore /path/to/full_backup.sql.gz + DATABASE_URL="$RECOVERY_DATABASE_URL" bash scripts/backup.sh pitr '2026-09-25 10:00:00' + ``` + +5. Validate migrations, table counts, a read-only payment query, queue health, + and reconciliation totals before directing traffic to the recovered system. +6. Rotate credentials exposed during response, re-enable writes gradually, and + monitor payment failures, lag, and reconciliation mismatches. +7. Publish the achieved RPO/RTO, missing transactions, and follow-up actions. + +## Automated restore drill + +The drill refuses to run when source and target URLs are identical: + +```bash +DATABASE_URL="$SOURCE_DATABASE_URL" \ +DRILL_DATABASE_URL="$ISOLATED_DATABASE_URL" \ +BACKUP_DIR=/tmp/agenticpay-drill \ +bash scripts/disaster-recovery-drill.sh +``` + +Use the `Database Backup` workflow's `drill` dispatch option for CI evidence. +The target database is disposable; never use a production connection string as +`DRILL_DATABASE_URL`. + +## Recovery verification checklist + +- [ ] Backup gzip and SHA-256 validation passed. +- [ ] Restore used an isolated target first. +- [ ] Schema and migration state match the release being recovered. +- [ ] Payment totals reconcile against ledger/provider records. +- [ ] Background queues and webhook delivery resume without duplication. +- [ ] RPO and RTO measurements are attached to the incident. diff --git a/package.json b/package.json index 5d48966a..4add86e6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test:integration": "docker compose -f docker-compose.integration.yml run --rm integration-tests", "test:integration:down": "docker compose -f docker-compose.integration.yml down -v", "test:smoke": "node scripts/smoke/production.mjs", + "load:k6": "k6 run tests/load/k6/payment-flow.js", + "load:k6:smoke": "k6 run tests/load/k6/smoke.js", "test:mutation": "npm --workspace backend run test:mutation", "sandbox:faucet": "node scripts/sandbox/faucet.js", "dev:contracts": "bash scripts/dev/watch-contracts.sh", diff --git a/scripts/backup.sh b/scripts/backup.sh index ae9ad39e..30b638a1 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -82,10 +82,12 @@ do_full_backup() { local size=$(du -h "$filepath" | cut -f1) log "Full backup completed: $filepath ($size)" + # Persist the checksum before verification; verification intentionally + # treats a missing checksum as an invalid backup. + sha256sum "$filepath" > "${filepath}.sha256" + log "Checksum created: ${filepath}.sha256" + if verify_backup_integrity "$filepath"; then - # Create checksum - sha256sum "$filepath" > "${filepath}.sha256" - log "Checksum created: ${filepath}.sha256" # Upload to S3 if command -v aws &>/dev/null; then @@ -129,8 +131,8 @@ do_incremental_backup() { local size=$(du -h "$filepath" | cut -f1) log "Incremental backup completed: $filepath ($size)" + sha256sum "$filepath" > "${filepath}.sha256" if verify_backup_integrity "$filepath"; then - sha256sum "$filepath" > "${filepath}.sha256" if command -v aws &>/dev/null; then aws s3 cp "$filepath" "s3://$S3_BUCKET/incremental/$filename" --region "$S3_REGION" @@ -162,6 +164,11 @@ do_restore() { return 1 fi + if ! verify_backup_integrity "$restore_file"; then + log "ERROR: Refusing to restore an unverified backup: $restore_file" + return 1 + fi + log "Starting restore from: $restore_file" notify_slack "🔄 Starting database restore from: $(basename $restore_file)" "warning" @@ -169,9 +176,16 @@ do_restore() { log "Restore completed successfully from: $restore_file" # Apply incremental backups if available - for incr in $(ls -t "$BACKUP_DIR/incremental/"*.sql.gz 2>/dev/null); do + for incr in $(ls -tr "$BACKUP_DIR/incremental/"*.sql.gz 2>/dev/null); do log "Applying incremental backup: $incr" - gunzip -c "$incr" | psql "$DB_URL" || true + if ! verify_backup_integrity "$incr"; then + log "ERROR: Refusing to apply an unverified incremental backup: $incr" + return 1 + fi + if ! gunzip -c "$incr" | psql "$DB_URL"; then + log "ERROR: Failed to apply incremental backup: $incr" + return 1 + fi done notify_slack "✅ Database restore completed successfully" "good" @@ -304,6 +318,10 @@ do_pitr() { notify_slack "🔄 Starting PITR: base full backup $(basename "$best_full")" "warning" # Restore base full backup + if ! verify_backup_integrity "$best_full"; then + log "ERROR: Refusing PITR from an unverified base backup: $best_full" + return 1 + fi if gunzip -c "$best_full" | psql "$DB_URL"; then log "Base full backup restored successfully." else @@ -338,6 +356,10 @@ do_pitr() { for item in "${sorted_incr[@]}"; do local file="${item#*|}" log "Applying incremental backup: $(basename "$file")" + if ! verify_backup_integrity "$file"; then + log "ERROR: Refusing to apply an unverified incremental backup: $file" + return 1 + fi if gunzip -c "$file" | psql "$DB_URL"; then log "Applied: $(basename "$file")" else @@ -383,4 +405,4 @@ case "${1:-full}" in echo " pitr - Point-In-Time Restore to a specific timestamp (e.g. YYYYMMDD_HHMMSS)" exit 1 ;; -esac \ No newline at end of file +esac diff --git a/scripts/disaster-recovery-drill.sh b/scripts/disaster-recovery-drill.sh new file mode 100755 index 00000000..a53d7bc5 --- /dev/null +++ b/scripts/disaster-recovery-drill.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +# Restores the newest full backup into an explicitly separate drill database +# and verifies that PostgreSQL can read the restored schema. The target guard +# prevents an operator from accidentally drilling against the source database. + +SOURCE_DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}" +TARGET_DATABASE_URL="${DRILL_DATABASE_URL:?DRILL_DATABASE_URL is required}" +BACKUP_ROOT="${BACKUP_DIR:-/var/backups/agenticpay}" + +if [ "$SOURCE_DATABASE_URL" = "$TARGET_DATABASE_URL" ]; then + echo "Refusing disaster-recovery drill: source and target databases are identical" >&2 + exit 1 +fi + +latest_backup=$(find "$BACKUP_ROOT/full" -maxdepth 1 -type f -name 'full_backup_*.sql.gz' -print | sort | tail -1) +if [ -z "$latest_backup" ]; then + echo "No full backup found under $BACKUP_ROOT/full" >&2 + exit 1 +fi + +DATABASE_URL="$SOURCE_DATABASE_URL" bash scripts/backup.sh verify +DATABASE_URL="$TARGET_DATABASE_URL" bash scripts/backup.sh restore "$latest_backup" + +psql "$TARGET_DATABASE_URL" -v ON_ERROR_STOP=1 -c 'SELECT 1' >/dev/null +schema_count=$(psql "$TARGET_DATABASE_URL" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'") + +echo "Disaster-recovery drill passed: restored $(basename "$latest_backup") with $schema_count public tables" diff --git a/tests/load/k6/README.md b/tests/load/k6/README.md new file mode 100644 index 00000000..6b036cf8 --- /dev/null +++ b/tests/load/k6/README.md @@ -0,0 +1,14 @@ +# k6 load tests + +The k6 suite provides a fast health smoke test and a staged payment-read load +test with enforced latency, error-rate, and check thresholds. + +```bash +BASE_URL=https://staging.example.com npm run load:k6:smoke +BASE_URL=https://staging.example.com npm run load:k6 +``` + +Tune traffic with `START_RATE`, `TARGET_RATE`, `PREALLOCATED_VUS`, `MAX_VUS`, +`RAMP_DURATION`, and `HOLD_DURATION`. A threshold breach exits non-zero. Run +against staging or an isolated environment; never point the suite at production +without incident-owner approval. diff --git a/tests/load/k6/payment-flow.js b/tests/load/k6/payment-flow.js new file mode 100644 index 00000000..19850f8d --- /dev/null +++ b/tests/load/k6/payment-flow.js @@ -0,0 +1,39 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const baseUrl = __ENV.BASE_URL || 'http://127.0.0.1:3000'; + +export const options = { + scenarios: { + payment_reads: { + executor: 'ramping-arrival-rate', + startRate: Number(__ENV.START_RATE || 5), + timeUnit: '1s', + preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 20), + maxVUs: Number(__ENV.MAX_VUS || 100), + stages: [ + { target: Number(__ENV.TARGET_RATE || 25), duration: __ENV.RAMP_DURATION || '30s' }, + { target: Number(__ENV.TARGET_RATE || 25), duration: __ENV.HOLD_DURATION || '1m' }, + { target: 0, duration: '15s' }, + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<750', 'p(99)<1500'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const health = http.get(`${baseUrl}/health`); + check(health, { 'health status is 2xx': (res) => res.status >= 200 && res.status < 300 }); + + const strategies = http.get(`${baseUrl}/api/v1/payment-strategies`); + check(strategies, { + 'payment strategies status is 2xx': (res) => res.status >= 200 && res.status < 300, + 'payment strategies response is bounded': (res) => res.body.length < 2_000_000, + }); + + sleep(0.1); +} diff --git a/tests/load/k6/smoke.js b/tests/load/k6/smoke.js new file mode 100644 index 00000000..35c13dd4 --- /dev/null +++ b/tests/load/k6/smoke.js @@ -0,0 +1,22 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const baseUrl = __ENV.BASE_URL || 'http://127.0.0.1:3000'; + +export const options = { + vus: Number(__ENV.VUS || 2), + duration: __ENV.DURATION || '15s', + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<500'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const response = http.get(`${baseUrl}/health`); + check(response, { + 'health responds successfully': (res) => res.status >= 200 && res.status < 300, + }); + sleep(0.25); +}