diff --git a/.gitignore b/.gitignore index b2c61feb..9b17cbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,11 @@ packages/**/.turbo CHANGELOG-*.md *.mdx +# Load test results & secrets +load-tests/results/*.json +load-tests/results/*.csv +load-tests/.env + # Desktop app build outputs apps/desktop/out/ apps/desktop/release/ diff --git a/apps/backend/src/core/server.ts b/apps/backend/src/core/server.ts index 6a6f3eef..23bae273 100644 --- a/apps/backend/src/core/server.ts +++ b/apps/backend/src/core/server.ts @@ -117,7 +117,7 @@ export class Server { // Add middleware to attach user info to request before all handlers this.server.addHook('preHandler', addUserToRequestMiddleware); - if (!IN_TEST || env.DISABLE_RATE_LIMITING === true) { + if (!IN_TEST || env.DISABLE_RATE_LIMITING !== true) { await this.server.register(fastifyRateLimit, { hook: 'preHandler', keyGenerator: (request) => { @@ -143,6 +143,10 @@ export class Server { throw new TooManyRequestsError(); }, }); + } else { + this.logger.warn( + 'Rate limiting is disabled. This should not be used in production environments.', + ); } this.server.addContentTypeParser('*', function (request, payload, done) { diff --git a/load-tests/.env.example b/load-tests/.env.example new file mode 100644 index 00000000..04ef040a --- /dev/null +++ b/load-tests/.env.example @@ -0,0 +1,38 @@ +# ============================================================================= +# QRcodly Load Tests — Environment Variables +# Copy this file to .env and fill in your values +# ============================================================================= + +# --- Clerk Authentication --- +# Staging Clerk secret key (from Clerk Dashboard → API Keys) +CLERK_SECRET_KEY=sk_test_... + +# --- Staging URLs --- +# Backend API +BASE_URL= + +# Frontend (for scan simulation) +FRONTEND_URL= + +# --- HTAccess Protection (Frontend) --- +# If the staging frontend is behind htaccess basic auth +HTACCESS_USER= +HTACCESS_PASS= + +# --- Test Users --- +# Comma-separated Clerk user IDs for token generation +# These are the 5 staging smoke test users +TEST_USER_IDS= + +# --- Short Codes for Scan Simulation --- +# Comma-separated short codes to scan (create some on staging first) +# Example: SHORT_CODES=abc12,def34,ghi56 +SHORT_CODES= + +# --- Load Test Profile --- +# smoke | light | medium | heavy | spike +PROFILE=smoke + +# --- Mode --- +# full (CRUD + scans) | scan-only +MODE=full diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 00000000..57c7b878 --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,142 @@ +# QRcodly Load Tests (k6) + +## Setup + +```bash +# Install k6 +brew install k6 + +# Configure environment +cp .env.example .env +# → Fill in CLERK_SECRET_KEY, HTACCESS_USER/PASS +``` + +## Quick Start + +```bash +# Smoke test (5 VUs, 30s) +./run.sh + +# Medium load (250 VUs) +./run.sh medium + +# Heavy load (1000 VUs) +./run.sh heavy + +# Spike test (sudden surge to 1000) +./run.sh spike + +# Save results as JSON +SAVE_RESULTS=1 ./run.sh heavy +``` + +The `run.sh` script automatically reads `.env`, generates Clerk tokens, and starts k6. + +Test data (QR codes + short URLs) is **created automatically** in the `setup()` phase and cleaned up in `teardown()`. No manual short code entry needed. + +## .env Configuration + +| Variable | Description | +| ------------------ | ----------------------------------------------------------------------- | +| `CLERK_SECRET_KEY` | Clerk secret key (staging) — for token generation | +| `BASE_URL` | Backend API URL (default: `https://stage-api.qrcodly.de/api/v1`) | +| `FRONTEND_URL` | Frontend URL for scan simulation (default: `https://stage.qrcodly.de`) | +| `HTACCESS_USER` | HTAccess username (if staging frontend is protected) | +| `HTACCESS_PASS` | HTAccess password | +| `TEST_USER_IDS` | Comma-separated Clerk user IDs | +| `SHORT_CODES` | Additional short codes for scanning (optional, auto-created by default) | +| `PROFILE` | Default load profile | +| `MODE` | `full` (CRUD + scans) or `scan-only` | + +## Modes + +### Full Mode (default) + +Requires: `CLERK_SECRET_KEY` + +Simulates complete user flows: + +- 25% QR code CRUD (create, edit, delete) +- 10% QR code read (dashboard browsing) +- 10% Short URL CRUD +- 5% Template CRUD +- 5% Tag CRUD +- 35% Scans (diverse browsers/devices) +- 10% Burst scans (viral QR code simulation) + +### Scan-Only Mode + +Requires: `SHORT_CODES` in `.env` (or auto-created with `CLERK_SECRET_KEY`) + +100% scan traffic with realistic user agents: + +- 70% Mobile (iPhone, Android, Samsung, Huawei) +- 30% Desktop (Chrome, Firefox, Safari, Edge) +- 16 languages (de, en, fr, es, it, nl, pl, ru, ...) +- Various referrers (Google, social media, direct) +- Randomized IPs + +```bash +MODE=scan-only ./run.sh heavy +``` + +## Profiles + +| Profile | Max VUs | Duration | Purpose | +| -------- | ------- | -------- | --------------------- | +| `smoke` | 5 | 30s | Verify endpoints work | +| `light` | 50 | 3 min | Normal traffic | +| `medium` | 250 | 5 min | Peak hours | +| `heavy` | 1000 | 9 min | Stress test | +| `spike` | 1000 | 3.5 min | Sudden traffic surge | + +## Automatic Test Data + +The `setup()` function runs once before all VUs and: + +1. Creates 5 dynamic QR codes (with short URLs for scanning) +2. Creates 5 standalone short URLs +3. Passes all short codes to VUs for scan simulation + +The `teardown()` function cleans up all created test data after the run. + +## Token Refresh + +Clerk JWTs expire after ~60 seconds. The system refreshes tokens automatically: + +- Each VU monitors token expiry +- 10 seconds before expiry, a new token is fetched via Clerk REST API +- Requires `CLERK_SECRET_KEY` to be passed through to k6 + +## Interpreting Results + +Key metrics in k6 output: + +| Metric | Good | Concerning | +| ------------------------------ | --------------- | ------------------- | +| `http_req_duration (p95)` | < 2s | > 5s | +| `http_req_failed` | < 5% | > 10% | +| `http_reqs` | high throughput | dropping under load | +| `http_req_duration{type:scan}` | < 1.5s | > 3s | + +## File Structure + +``` +├── .env # Secrets & config (gitignored) +├── .env.example # Template +├── run.sh # One-click runner +├── generate-tokens.mjs # Clerk token generation (Node.js) +├── main.js # k6 entry point (setup/teardown/default) +├── config.js # Profiles & thresholds +├── auth.js # Token management & auto-refresh +├── helpers.js # HTTP helpers +├── data/ +│ ├── payloads.js # Test data generators +│ └── user-agents.js # Browser/device simulation +├── scenarios/ +│ ├── qr-codes.js # QR code CRUD + read +│ ├── short-urls.js # Short URL CRUD +│ ├── templates-tags.js # Templates + tags CRUD +│ └── scan-traffic.js # Scan & burst simulation +└── results/ # Output (gitignored) +``` diff --git a/load-tests/auth.js b/load-tests/auth.js new file mode 100644 index 00000000..5eac032e --- /dev/null +++ b/load-tests/auth.js @@ -0,0 +1,131 @@ +// ============================================================================= +// Clerk Auth Token Management for k6 +// +// Strategy: +// - setup() creates one Clerk session per user and passes session IDs to VUs +// - VUs reuse these sessions to get fresh JWTs (1 API call, not 2) +// - Random jitter prevents thundering herd on token refresh +// - On 429 (rate limit), VU backs off and keeps using the old token +// ============================================================================= + +import http from 'k6/http'; +import encoding from 'k6/encoding'; +import { CLERK_TOKENS, CLERK_SECRET_KEY, CLERK_API, JWT_TEMPLATE } from './config.js'; + +// Per-VU token state +const vuState = { + token: null, + sessionId: null, + expiresAt: 0, + refreshJitter: Math.random() * 15_000, // 0-15s random offset per VU + backoffUntil: 0, +}; + +/** + * Decode JWT payload to read expiry + */ +function decodeJwtPayload(jwt) { + try { + const parts = jwt.split('.'); + if (parts.length !== 3) return null; + + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (payload.length % 4 !== 0) payload += '='; + + const decoded = String.fromCharCode(...new Uint8Array(encoding.b64decode(payload, 'std', 's'))); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Get token expiry time in ms + */ +function getTokenExpiry(token) { + const payload = decodeJwtPayload(token); + if (payload && payload.exp) { + return payload.exp * 1000; + } + return Date.now() + 60_000; +} + +/** + * Get a fresh JWT from an existing Clerk session (single API call) + */ +function refreshTokenFromSession(sessionId) { + if (!CLERK_SECRET_KEY || !sessionId) return null; + + const res = http.post(`${CLERK_API}/sessions/${sessionId}/tokens/${JWT_TEMPLATE}`, '{}', { + headers: { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + tags: { type: 'auth_refresh' }, + }); + + if (res.status === 429) { + // Rate limited — back off for 30-60s + vuState.backoffUntil = Date.now() + 30_000 + Math.random() * 30_000; + return null; + } + + if (res.status !== 200) { + return null; + } + + try { + const data = JSON.parse(res.body); + return data.jwt; + } catch { + return null; + } +} + +/** + * Initialize auth state from setupData. + * Call once at the start of each VU iteration. + */ +export function initAuth(setupData) { + if (vuState.token) return; // Already initialized + + if (CLERK_TOKENS.length === 0) return; + + const idx = __VU % CLERK_TOKENS.length; + vuState.token = CLERK_TOKENS[idx]; + vuState.expiresAt = getTokenExpiry(vuState.token); + + // Get session ID from setupData (created in setup()) + if (setupData && setupData.sessionIds && setupData.sessionIds[idx]) { + vuState.sessionId = setupData.sessionIds[idx]; + } +} + +/** + * Get a valid token for the current VU. + * Automatically refreshes from the existing session when near expiry. + * Requires initAuth(setupData) to be called once before (in main default function). + */ +export function getValidToken() { + if (!vuState.token) return null; + + const now = Date.now(); + + // Still in backoff period — use existing token + if (now < vuState.backoffUntil) { + return vuState.token; + } + + // Check if token needs refresh (with per-VU jitter to spread out calls) + const refreshAt = vuState.expiresAt - 5_000 - vuState.refreshJitter; + if (now >= refreshAt && vuState.sessionId) { + const newToken = refreshTokenFromSession(vuState.sessionId); + if (newToken) { + vuState.token = newToken; + vuState.expiresAt = getTokenExpiry(newToken); + } + // If refresh fails, keep using old token + } + + return vuState.token; +} diff --git a/load-tests/config.js b/load-tests/config.js new file mode 100644 index 00000000..19c1cf9c --- /dev/null +++ b/load-tests/config.js @@ -0,0 +1,84 @@ +// ============================================================================= +// k6 Load Test Configuration +// ============================================================================= + +// --- Environment --- +export const BASE_URL = __ENV.BASE_URL || 'https://stage-api.qrcodly.de/api/v1'; + +// --- Clerk --- +export const CLERK_SECRET_KEY = __ENV.CLERK_SECRET_KEY || ''; +export const CLERK_API = 'https://api.clerk.com/v1'; +export const JWT_TEMPLATE = 'QRcodly'; +export const USER_IDS = __ENV.TEST_USER_IDS + ? __ENV.TEST_USER_IDS.split(',').map((id) => id.trim()) + : []; + +// Clerk Bearer tokens for authenticated users +// Generate these from your Clerk dashboard or by logging in and extracting the JWT +// You can provide multiple tokens (comma-separated) to simulate different users +const tokenString = __ENV.CLERK_TOKENS || ''; +export const CLERK_TOKENS = tokenString ? tokenString.split(',').map((t) => t.trim()) : []; + +// --- Load Profiles --- +export const PROFILES = { + // Quick smoke test - verify endpoints work + smoke: { + stages: [{ duration: '30s', target: 5 }], + }, + + // Light load - normal traffic + light: { + stages: [ + { duration: '30s', target: 50 }, + { duration: '2m', target: 50 }, + { duration: '30s', target: 0 }, + ], + }, + + // Medium load - busy hours + medium: { + stages: [ + { duration: '1m', target: 100 }, + { duration: '3m', target: 250 }, + { duration: '1m', target: 0 }, + ], + }, + + // Heavy load - stress test + heavy: { + stages: [ + { duration: '1m', target: 100 }, + { duration: '2m', target: 500 }, + { duration: '3m', target: 1000 }, + { duration: '2m', target: 1000 }, + { duration: '1m', target: 0 }, + ], + }, + + // Spike test - sudden traffic surge + spike: { + stages: [ + { duration: '30s', target: 50 }, + { duration: '10s', target: 1000 }, + { duration: '1m', target: 1000 }, + { duration: '10s', target: 50 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + }, +}; + +// --- Thresholds --- +export const THRESHOLDS = { + http_req_duration: [ + 'p(95)<2000', // 95% of requests under 2s + 'p(99)<5000', // 99% under 5s + ], + http_req_failed: [ + 'rate<0.05', // Less than 5% errors + ], + 'http_req_duration{type:api_list}': ['p(95)<1500'], + 'http_req_duration{type:api_create}': ['p(95)<3000'], + 'http_req_duration{type:api_update}': ['p(95)<2000'], + 'http_req_duration{type:api_delete}': ['p(95)<1000'], +}; diff --git a/load-tests/data/payloads.js b/load-tests/data/payloads.js new file mode 100644 index 00000000..e5925cf8 --- /dev/null +++ b/load-tests/data/payloads.js @@ -0,0 +1,199 @@ +// ============================================================================= +// Test data generators for realistic payloads +// ============================================================================= + +import { randomString, uniqueName, randomItem, randomHexColor, randomInt } from '../helpers.js'; + +// --- QR Code Payloads --- + +const DOT_TYPES = ['dots', 'rounded', 'classy', 'classy-rounded', 'square', 'extra-rounded']; +const CORNER_SQUARE_TYPES = ['dot', 'square', 'extra-rounded']; +const CORNER_DOT_TYPES = ['dot', 'square']; + +function hexStyle(color) { + return { type: 'hex', value: color || randomHexColor() }; +} + +function defaultConfig() { + return { + width: 300, + height: 300, + margin: 10, + imageOptions: { hideBackgroundDots: true }, + dotsOptions: { + type: randomItem(DOT_TYPES), + style: hexStyle(randomHexColor()), + }, + cornersSquareOptions: { + type: randomItem(CORNER_SQUARE_TYPES), + style: hexStyle(randomHexColor()), + }, + cornersDotOptions: { + type: randomItem(CORNER_DOT_TYPES), + style: hexStyle(randomHexColor()), + }, + backgroundOptions: { + style: hexStyle('#ffffff'), + }, + }; +} + +export function createUrlQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'url', + data: { + url: `https://example.com/${randomString(10)}`, + isEditable: Math.random() > 0.5, + }, + }, + }; +} + +export function createTextQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'text', + data: `Load test message ${randomString(20)}`, + }, + }; +} + +export function createWifiQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'wifi', + data: { + ssid: `TestNetwork-${randomString(4)}`, + password: randomString(12), + encryption: randomItem(['WPA', 'WEP', 'nopass']), + }, + }, + }; +} + +export function createEmailQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'email', + data: { + email: `test-${randomString(5)}@example.com`, + subject: `Load Test ${randomString(8)}`, + body: `This is a load test email body ${randomString(20)}`, + }, + }, + }; +} + +export function createVCardQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'vCard', + data: { + firstName: `Test${randomString(4)}`, + lastName: `User${randomString(4)}`, + email: `test-${randomString(5)}@example.com`, + phone: `+49170${randomInt(1000000, 9999999)}`, + company: `K6 Corp ${randomString(3)}`, + }, + }, + }; +} + +export function createLocationQrCode() { + return { + name: uniqueName('qr'), + config: defaultConfig(), + content: { + type: 'location', + data: { + address: `${randomInt(1, 999)} Test Street, Berlin`, + latitude: 52.52 + Math.random() * 0.1, + longitude: 13.405 + Math.random() * 0.1, + }, + }, + }; +} + +/** + * Generate a random QR code creation payload (mixed content types) + */ +export function randomQrCodePayload() { + const generators = [ + createUrlQrCode, + createTextQrCode, + createWifiQrCode, + createEmailQrCode, + createVCardQrCode, + createLocationQrCode, + ]; + return randomItem(generators)(); +} + +// --- Short URL Payloads --- + +export function createShortUrlPayload() { + return { + destinationUrl: `https://example.com/page/${randomString(10)}`, + name: uniqueName('url'), + isActive: true, + }; +} + +export function updateShortUrlPayload() { + return { + destinationUrl: `https://example.com/updated/${randomString(10)}`, + }; +} + +// --- Config Template Payloads --- + +export function createConfigTemplatePayload() { + return { + name: uniqueName('tpl'), + config: defaultConfig(), + }; +} + +export function updateConfigTemplatePayload() { + return { + name: uniqueName('tpl-upd'), + }; +} + +// --- Tag Payloads --- + +const TAG_COLORS = [ + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#3b82f6', + '#8b5cf6', + '#ec4899', + '#06b6d4', +]; + +export function createTagPayload() { + return { + name: uniqueName('tag'), + color: randomItem(TAG_COLORS), + }; +} + +export function updateTagPayload() { + return { + name: uniqueName('tag-upd'), + color: randomItem(TAG_COLORS), + }; +} diff --git a/load-tests/data/user-agents.js b/load-tests/data/user-agents.js new file mode 100644 index 00000000..170f796f --- /dev/null +++ b/load-tests/data/user-agents.js @@ -0,0 +1,140 @@ +// ============================================================================= +// Realistic User-Agent strings for scan simulation +// Mix of mobile & desktop, various browsers, OS versions, and countries +// ============================================================================= + +import { randomItem, randomInt } from '../helpers.js'; + +// --- Mobile User-Agents (70% of QR scans come from mobile) --- + +const MOBILE_USER_AGENTS = [ + // iPhone Safari (various models & iOS versions) + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.103 Mobile/15E148 Safari/604.1', + + // iPad Safari + 'Mozilla/5.0 (iPad; CPU OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + + // Android Chrome (various devices) + 'Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.102 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.100 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 14; Redmi Note 13 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 14; SAMSUNG SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.6167.143 Mobile Safari/537.36', + + // Android Firefox + 'Mozilla/5.0 (Android 14; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0', + 'Mozilla/5.0 (Android 13; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0', + + // Huawei Browser + 'Mozilla/5.0 (Linux; Android 12; HarmonyOS; ANA-NX9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.88 HuaweiBrowser/14.0 Mobile Safari/537.36', +]; + +// --- Desktop User-Agents (30% of scans) --- + +const DESKTOP_USER_AGENTS = [ + // Chrome (Windows) + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', + + // Chrome (macOS) + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + + // Firefox (Windows) + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0', + + // Firefox (macOS) + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0', + + // Safari (macOS) + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15', + + // Edge (Windows) + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70', + + // Chrome (Linux) + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', +]; + +// --- Accept-Language headers (simulates users from different countries) --- + +const LANGUAGES = [ + 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', + 'de,en-US;q=0.7,en;q=0.3', + 'en-US,en;q=0.9', + 'en-GB,en;q=0.9,de;q=0.8', + 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', + 'es-ES,es;q=0.9,en;q=0.8', + 'it-IT,it;q=0.9,en;q=0.8', + 'nl-NL,nl;q=0.9,en;q=0.8', + 'pl-PL,pl;q=0.9,en;q=0.8', + 'ru-RU,ru;q=0.9,en;q=0.8', + 'pt-BR,pt;q=0.9,en;q=0.8', + 'ja-JP,ja;q=0.9,en;q=0.8', + 'zh-CN,zh;q=0.9,en;q=0.8', + 'tr-TR,tr;q=0.9,en;q=0.8', + 'ar-SA,ar;q=0.9,en;q=0.8', + 'ko-KR,ko;q=0.9,en;q=0.8', +]; + +// --- Referrers (where the scan "came from") --- + +const REFERRERS = [ + '', // direct scan (most common for QR) + '', + '', + '', + '', + 'https://www.google.com/', + 'https://www.google.de/', + 'https://t.co/', + 'https://www.facebook.com/', + 'https://www.instagram.com/', + 'https://www.linkedin.com/', +]; + +// --- Random IP generator --- + +function randomIp() { + return `${randomInt(1, 223)}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`; +} + +/** + * Generate a realistic scan request profile + * 70% mobile, 30% desktop — matching real QR code scan patterns + */ +export function randomScanProfile() { + const isMobile = Math.random() < 0.7; + const userAgent = isMobile ? randomItem(MOBILE_USER_AGENTS) : randomItem(DESKTOP_USER_AGENTS); + + return { + userAgent, + language: randomItem(LANGUAGES), + referrer: randomItem(REFERRERS), + ip: randomIp(), + }; +} + +/** + * Get all scan-related headers for a request + */ +export function getScanHeaders(profile) { + const headers = { + 'User-Agent': profile.userAgent, + 'Accept-Language': profile.language, + 'X-Forwarded-For': profile.ip, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }; + + if (profile.referrer) { + headers['Referer'] = profile.referrer; + } + + return headers; +} diff --git a/load-tests/generate-tokens.mjs b/load-tests/generate-tokens.mjs new file mode 100644 index 00000000..e9462864 --- /dev/null +++ b/load-tests/generate-tokens.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// ============================================================================= +// Generate Clerk JWT tokens for k6 load tests +// +// Uses the Clerk REST API directly — no SDK dependency needed. +// +// Usage: +// CLERK_SECRET_KEY=sk_test_... node load-tests/generate-tokens.mjs +// +// Output: +// Prints comma-separated JWT tokens to stdout (ready for k6 -e CLERK_TOKENS=...) +// ============================================================================= + +const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; +const CLERK_API = 'https://api.clerk.com/v1'; +const JWT_TEMPLATE = 'QRcodly'; + +if (!CLERK_SECRET_KEY) { + console.error('Error: CLERK_SECRET_KEY env var is required'); + console.error(''); + console.error('Usage:'); + console.error(' CLERK_SECRET_KEY=sk_test_... node load-tests/generate-tokens.mjs'); + process.exit(1); +} + +// Staging load test users (set TEST_USER_IDS in .env) +const USER_IDS = process.env.TEST_USER_IDS + ? process.env.TEST_USER_IDS.split(',').map((id) => id.trim()) + : []; + +if (USER_IDS.length === 0) { + console.error('Error: TEST_USER_IDS env var is required'); + console.error('Set comma-separated Clerk user IDs in load-tests/.env'); + process.exit(1); +} + +async function clerkPost(path, body) { + const res = await fetch(`${CLERK_API}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Clerk API ${path} failed (${res.status}): ${text}`); + } + + return res.json(); +} + +async function generateToken(userId) { + try { + // 1. Create a session for the user + const session = await clerkPost('/sessions', { user_id: userId }); + + // 2. Get a JWT from that session using the custom template + const tokenRes = await clerkPost(`/sessions/${session.id}/tokens/${JWT_TEMPLATE}`, {}); + + if (!tokenRes?.jwt) { + throw new Error('No JWT in response'); + } + + return { userId, token: tokenRes.jwt, sessionId: session.id }; + } catch (error) { + console.error(` ✗ ${userId}: ${error.message}`); + return null; + } +} + +async function main() { + console.error(`Generating tokens for ${USER_IDS.length} users...`); + console.error(''); + + const results = await Promise.all(USER_IDS.map(generateToken)); + const successful = results.filter(Boolean); + + if (successful.length === 0) { + console.error(''); + console.error('Error: No tokens could be generated.'); + console.error('Check that CLERK_SECRET_KEY matches your staging Clerk instance'); + console.error('and that the user IDs exist.'); + process.exit(1); + } + + for (const { userId, sessionId } of successful) { + console.error(` ✓ ${userId} (session: ${sessionId})`); + } + + console.error(''); + console.error(`Generated ${successful.length}/${USER_IDS.length} tokens`); + + // Print comma-separated tokens to stdout (for piping into k6) + console.log(successful.map((r) => r.token).join(',')); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/load-tests/helpers.js b/load-tests/helpers.js new file mode 100644 index 00000000..12a9ab40 --- /dev/null +++ b/load-tests/helpers.js @@ -0,0 +1,114 @@ +// ============================================================================= +// Shared helpers for k6 load tests +// ============================================================================= + +import http from 'k6/http'; + +/** + * Build request headers with optional Clerk auth + */ +export function getHeaders(clerkToken) { + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + if (clerkToken) { + headers['Authorization'] = `Bearer ${clerkToken}`; + } + + return headers; +} + +/** + * Perform a GET request + */ +export function apiGet(url, token, tags = {}) { + return http.get(url, { + headers: getHeaders(token), + tags, + }); +} + +/** + * Perform a POST request + */ +export function apiPost(url, body, token, tags = {}) { + return http.post(url, JSON.stringify(body), { + headers: getHeaders(token), + tags, + }); +} + +/** + * Perform a PATCH request + */ +export function apiPatch(url, body, token, tags = {}) { + return http.patch(url, JSON.stringify(body), { + headers: getHeaders(token), + tags, + }); +} + +/** + * Perform a PUT request + */ +export function apiPut(url, body, token, tags = {}) { + return http.put(url, JSON.stringify(body), { + headers: getHeaders(token), + tags, + }); +} + +/** + * Perform a DELETE request (no Content-Type header to avoid empty body error) + */ +export function apiDelete(url, token, tags = {}) { + const headers = { Accept: 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + return http.del(url, null, { headers, tags }); +} + +/** + * Generate a random string + */ +export function randomString(length = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Generate a unique name with prefix + */ +export function uniqueName(prefix = 'k6') { + return `${prefix}-${randomString(6)}`; +} + +/** + * Random integer between min and max (inclusive) + */ +export function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Pick a random item from an array + */ +export function randomItem(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * Random hex color + */ +export function randomHexColor() { + const hex = Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, '0'); + return `#${hex}`; +} diff --git a/load-tests/main.js b/load-tests/main.js new file mode 100644 index 00000000..dd65026f --- /dev/null +++ b/load-tests/main.js @@ -0,0 +1,242 @@ +// ============================================================================= +// k6 Load Test — QRcodly Full User Flow +// +// Usage: +// ./run.sh # smoke test +// ./run.sh medium # 250 VUs +// ./run.sh heavy # 1000 VUs +// +// Profiles: smoke | light | medium | heavy | spike +// Modes: full (default) | scan-only +// ============================================================================= + +import http from 'k6/http'; +import { + PROFILES, + THRESHOLDS, + BASE_URL, + CLERK_TOKENS, + CLERK_SECRET_KEY, + CLERK_API, + JWT_TEMPLATE, + USER_IDS, +} from './config.js'; +import { qrCodeCrudFlow, qrCodeReadFlow } from './scenarios/qr-codes.js'; +import { shortUrlCrudFlow } from './scenarios/short-urls.js'; +import { configTemplateCrudFlow, tagCrudFlow } from './scenarios/templates-tags.js'; +import { scanTrafficFlow, scanBurstFlow } from './scenarios/scan-traffic.js'; +import { randomItem, getHeaders } from './helpers.js'; +import { createUrlQrCode, createShortUrlPayload } from './data/payloads.js'; +import { initAuth } from './auth.js'; + +// --- k6 Options --- +const profileName = __ENV.PROFILE || 'smoke'; +const profile = PROFILES[profileName] || PROFILES.smoke; +const mode = __ENV.MODE || 'full'; + +export const options = { + stages: profile.stages, + thresholds: { + ...THRESHOLDS, + 'http_req_duration{type:scan}': ['p(95)<1500'], + }, + insecureSkipTLSVerify: true, + tags: { testProfile: profileName }, +}; + +// --- How many test resources to create in setup --- +const SETUP_QR_CODES = 5; +const SETUP_SHORT_URLS = 5; + +// ============================================================================= +// setup() — runs ONCE before all VUs +// Creates Clerk sessions, QR codes + short URLs +// ============================================================================= +export function setup() { + const data = { + shortCodes: [], + createdQrCodeIds: [], + createdShortCodes: [], + sessionIds: [], // Clerk session IDs for token refresh + }; + + if (CLERK_TOKENS.length === 0) { + console.log('No CLERK_TOKENS — skipping test data creation.'); + if (__ENV.SHORT_CODES) { + data.shortCodes = __ENV.SHORT_CODES.split(',').map((c) => c.trim()); + } + return data; + } + + // 1. Create persistent Clerk sessions for each user (for token refresh) + if (CLERK_SECRET_KEY && USER_IDS.length > 0) { + console.log(`Creating Clerk sessions for ${USER_IDS.length} users...`); + const clerkHeaders = { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }; + + for (const userId of USER_IDS) { + const res = http.post(`${CLERK_API}/sessions`, JSON.stringify({ user_id: userId }), { + headers: clerkHeaders, + tags: { type: 'setup' }, + }); + + if (res.status === 200) { + try { + const session = JSON.parse(res.body); + data.sessionIds.push(session.id); + console.log(` ✓ Session for ${userId}: ${session.id}`); + } catch { + data.sessionIds.push(null); + } + } else { + console.warn(` ✗ Session for ${userId}: status=${res.status}`); + data.sessionIds.push(null); + } + } + console.log(''); + } + + const token = CLERK_TOKENS[0]; + const headers = getHeaders(token); + + // 2. Create dynamic QR codes (isEditable: true → generates a short URL) + console.log(`Creating ${SETUP_QR_CODES} dynamic QR codes...`); + for (let i = 0; i < SETUP_QR_CODES; i++) { + const payload = createUrlQrCode(); + payload.content.data.isEditable = true; + + const res = http.post(`${BASE_URL}/qr-code`, JSON.stringify(payload), { + headers, + tags: { type: 'setup' }, + }); + + if (res.status === 200 || res.status === 201) { + try { + const qr = JSON.parse(res.body); + data.createdQrCodeIds.push(qr.id); + if (qr.shortUrl && qr.shortUrl.shortCode) { + data.shortCodes.push(qr.shortUrl.shortCode); + console.log(` ✓ QR code ${i + 1}: shortCode=${qr.shortUrl.shortCode}`); + } + } catch { + console.warn(` ✗ QR code ${i + 1}: failed to parse response`); + } + } else { + console.warn(` ✗ QR code ${i + 1}: status=${res.status}`); + } + } + + // 3. Create standalone short URLs + console.log(`Creating ${SETUP_SHORT_URLS} standalone short URLs...`); + for (let i = 0; i < SETUP_SHORT_URLS; i++) { + const payload = createShortUrlPayload(); + + const res = http.post(`${BASE_URL}/short-url`, JSON.stringify(payload), { + headers, + tags: { type: 'setup' }, + }); + + if (res.status === 200 || res.status === 201) { + try { + const url = JSON.parse(res.body); + data.createdShortCodes.push(url.shortCode); + data.shortCodes.push(url.shortCode); + console.log(` ✓ Short URL ${i + 1}: shortCode=${url.shortCode}`); + } catch { + console.warn(` ✗ Short URL ${i + 1}: failed to parse response`); + } + } else { + console.warn(` ✗ Short URL ${i + 1}: status=${res.status}`); + } + } + + // 4. Add manually specified short codes from env + if (__ENV.SHORT_CODES) { + const envCodes = __ENV.SHORT_CODES.split(',').map((c) => c.trim()); + data.shortCodes.push(...envCodes); + } + + console.log(''); + console.log( + `Setup complete: ${data.shortCodes.length} short codes, ${data.sessionIds.length} sessions`, + ); + console.log(''); + + return data; +} + +// ============================================================================= +// teardown() — runs ONCE after all VUs finish +// ============================================================================= +export function teardown(data) { + if (CLERK_TOKENS.length === 0) return; + + const token = CLERK_TOKENS[0]; + const headers = getHeaders(token); + + console.log('Cleaning up test data...'); + + for (const id of data.createdQrCodeIds) { + http.del(`${BASE_URL}/qr-code/${id}`, null, { headers, tags: { type: 'teardown' } }); + } + + for (const shortCode of data.createdShortCodes) { + http.del(`${BASE_URL}/short-url/${shortCode}`, null, { + headers, + tags: { type: 'teardown' }, + }); + } + + console.log( + `Cleaned up ${data.createdQrCodeIds.length} QR codes + ${data.createdShortCodes.length} short URLs`, + ); +} + +// ============================================================================= +// Scenario Distribution +// ============================================================================= + +const SCENARIO_FLOWS = { + qrCrud: qrCodeCrudFlow, + qrRead: qrCodeReadFlow, + shortUrlCrud: shortUrlCrudFlow, + templateCrud: configTemplateCrudFlow, + tagCrud: tagCrudFlow, + scan: scanTrafficFlow, + scanBurst: scanBurstFlow, +}; + +const SCAN_ONLY_WEIGHTS = { scan: 80, scanBurst: 20 }; +const FULL_WEIGHTS = { + qrCrud: 25, + qrRead: 10, + shortUrlCrud: 10, + templateCrud: 5, + tagCrud: 5, + scan: 35, + scanBurst: 10, +}; + +function buildPool(weights) { + const pool = []; + for (const [scenario, weight] of Object.entries(weights)) { + for (let i = 0; i < weight; i++) pool.push(scenario); + } + return pool; +} + +const hasTokens = CLERK_TOKENS.length > 0; +const pool = + mode === 'scan-only' || !hasTokens + ? buildPool(SCAN_ONLY_WEIGHTS) + : buildPool(FULL_WEIGHTS); + +// --- Main VU Function --- +export default function (setupData) { + initAuth(setupData); + + const scenario = randomItem(pool); + SCENARIO_FLOWS[scenario](setupData); +} diff --git a/load-tests/results/.gitkeep b/load-tests/results/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/load-tests/run.sh b/load-tests/run.sh new file mode 100755 index 00000000..31f45316 --- /dev/null +++ b/load-tests/run.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# ============================================================================= +# QRcodly Load Test Runner +# +# Usage: +# ./load-tests/run.sh # Smoke test (default) +# ./load-tests/run.sh medium # Medium load +# ./load-tests/run.sh heavy # 1000 VUs stress test +# ./load-tests/run.sh spike # Spike test +# SAVE_RESULTS=1 ./load-tests/run.sh heavy # Save results to JSON +# +# Reads configuration from load-tests/.env +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Load .env file +if [ -f "$ENV_FILE" ]; then + echo -e "${CYAN}Loading config from $ENV_FILE${NC}" + set -a + source "$ENV_FILE" + set +a +else + echo -e "${YELLOW}No .env file found. Copy .env.example to .env and fill in your values.${NC}" +fi + +# CLI argument overrides .env PROFILE +PROFILE="${1:-${PROFILE:-smoke}}" + +echo -e "${GREEN}╔══════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ QRcodly Load Test — ${PROFILE}${NC}" +echo -e "${GREEN}╚══════════════════════════════════════╝${NC}" +echo "" + +# Check prerequisites +if ! command -v k6 &>/dev/null; then + echo -e "${RED}Error: k6 is not installed. Run: brew install k6${NC}" + exit 1 +fi + +# Build k6 env args +K6_ENVS="-e PROFILE=$PROFILE" +K6_ENVS="$K6_ENVS -e BASE_URL=${BASE_URL:-https://stage-api.qrcodly.de/api/v1}" +K6_ENVS="$K6_ENVS -e FRONTEND_URL=${FRONTEND_URL:-https://stage.qrcodly.de}" +K6_ENVS="$K6_ENVS -e MODE=${MODE:-full}" + +# HTAccess credentials +if [ -n "${HTACCESS_USER:-}" ] && [ -n "${HTACCESS_PASS:-}" ]; then + K6_ENVS="$K6_ENVS -e HTACCESS_USER=$HTACCESS_USER -e HTACCESS_PASS=$HTACCESS_PASS" + echo -e "${CYAN}HTAccess auth: enabled${NC}" +fi + +# Short codes +if [ -n "${SHORT_CODES:-}" ]; then + K6_ENVS="$K6_ENVS -e SHORT_CODES=$SHORT_CODES" + CODE_COUNT=$(echo "$SHORT_CODES" | tr ',' '\n' | wc -l | tr -d ' ') + echo -e "${CYAN}Short codes: $CODE_COUNT codes for scan simulation${NC}" +fi + +# Generate Clerk tokens (if secret key is set) +if [ -n "${CLERK_SECRET_KEY:-}" ]; then + echo -e "${YELLOW}Generating Clerk tokens...${NC}" + TOKENS=$(node "$SCRIPT_DIR/generate-tokens.mjs") + + if [ -n "$TOKENS" ]; then + TOKEN_COUNT=$(echo "$TOKENS" | tr ',' '\n' | wc -l | tr -d ' ') + echo -e "${GREEN}Got $TOKEN_COUNT tokens${NC}" + K6_ENVS="$K6_ENVS -e CLERK_TOKENS=$TOKENS" + + # Also pass the secret key for token refresh during test + K6_ENVS="$K6_ENVS -e CLERK_SECRET_KEY=$CLERK_SECRET_KEY" + K6_ENVS="$K6_ENVS -e TEST_USER_IDS=${TEST_USER_IDS:-}" + else + echo -e "${RED}Warning: Failed to generate tokens. CRUD tests will be skipped.${NC}" + fi +else + echo -e "${YELLOW}No CLERK_SECRET_KEY set. Running scan-only mode.${NC}" + K6_ENVS="$K6_ENVS -e MODE=scan-only" +fi + +echo "" + +# Add result output if requested +if [ "${SAVE_RESULTS:-}" = "1" ]; then + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + RESULT_FILE="$SCRIPT_DIR/results/${PROFILE}_${TIMESTAMP}.json" + K6_EXTRA="--out json=$RESULT_FILE" + echo -e "${YELLOW}Results → $RESULT_FILE${NC}" + echo "" +else + K6_EXTRA="" +fi + +# Run k6 +echo -e "${YELLOW}Starting k6 with profile: $PROFILE${NC}" +echo "" +eval k6 run "$SCRIPT_DIR/main.js" $K6_ENVS $K6_EXTRA diff --git a/load-tests/scenarios/qr-codes.js b/load-tests/scenarios/qr-codes.js new file mode 100644 index 00000000..c5bd7c24 --- /dev/null +++ b/load-tests/scenarios/qr-codes.js @@ -0,0 +1,148 @@ +// ============================================================================= +// QR Code CRUD scenario +// Simulates: list → create → assign tags → create share link → get → update → delete +// ============================================================================= + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE_URL } from '../config.js'; +import { apiGet, apiPost, apiPatch, apiDelete, apiPut } from '../helpers.js'; +import { getValidToken } from '../auth.js'; +import { randomQrCodePayload, createTagPayload } from '../data/payloads.js'; + +export function qrCodeCrudFlow() { + const token = getValidToken(); + if (!token) return; + + // 1. List QR codes + const listRes = apiGet(`${BASE_URL}/qr-code?page=1&limit=10`, token, { type: 'api_list' }); + check(listRes, { + 'QR list: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 2. Create a QR code + const payload = randomQrCodePayload(); + const createRes = apiPost(`${BASE_URL}/qr-code`, payload, token, { type: 'api_create' }); + const created = check(createRes, { + 'QR create: status 201': (r) => r.status === 201, + }); + + if (!created || createRes.status !== 201) { + sleep(2); + return; + } + + let qrCode; + try { + qrCode = JSON.parse(createRes.body); + } catch { + sleep(2); + return; + } + + sleep(1); + + // 3. Create a tag and assign it to the QR code + const tagRes = apiPost(`${BASE_URL}/tag`, createTagPayload(), token, { type: 'api_create' }); + let tagId = null; + if (tagRes.status === 200 || tagRes.status === 201) { + try { + const tag = JSON.parse(tagRes.body); + tagId = tag.id; + + // Assign tag to QR code + const assignRes = apiPut(`${BASE_URL}/tag/qr-code/${qrCode.id}`, { tagIds: [tagId] }, token, { + type: 'api_update', + }); + check(assignRes, { + 'QR assign tags: status 200': (r) => r.status === 200, + }); + } catch { + // ignore + } + } + sleep(1); + + // 4. Create a share link for the QR code + const shareRes = apiPost( + `${BASE_URL}/qr-code/${qrCode.id}/share`, + { showName: true, showDownloadButton: true }, + token, + { type: 'api_create' }, + ); + check(shareRes, { + 'QR share create: status 201': (r) => r.status === 201, + }); + + // View the share link + if (shareRes.status === 201) { + const getShareRes = apiGet(`${BASE_URL}/qr-code/${qrCode.id}/share`, token, { + type: 'api_get', + }); + check(getShareRes, { + 'QR share get: status 200': (r) => r.status === 200, + }); + } + sleep(1); + + // 5. Get QR code by ID + const getRes = apiGet(`${BASE_URL}/qr-code/${qrCode.id}`, token, { type: 'api_get' }); + check(getRes, { + 'QR get: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 6. Update QR code + const updateRes = apiPatch(`${BASE_URL}/qr-code/${qrCode.id}`, { name: 'k6-updated' }, token, { + type: 'api_update', + }); + check(updateRes, { + 'QR update: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 7. Delete QR code (cleanup — also deletes share link) + const deleteRes = apiDelete(`${BASE_URL}/qr-code/${qrCode.id}`, token, { + type: 'api_delete', + }); + check(deleteRes, { + 'QR delete: status 200': (r) => r.status === 200 || r.status === 204, + }); + + // 8. Clean up the tag + if (tagId) { + apiDelete(`${BASE_URL}/tag/${tagId}`, token, { type: 'api_delete' }); + } + + sleep(2); +} + +/** + * Read-heavy flow: mostly listing and viewing QR codes + */ +export function qrCodeReadFlow() { + const token = getValidToken(); + if (!token) return; + + // List with different pages + for (let page = 1; page <= 3; page++) { + const res = apiGet(`${BASE_URL}/qr-code?page=${page}&limit=10`, token, { + type: 'api_list', + }); + check(res, { + 'QR list page: status 200': (r) => r.status === 200, + }); + sleep(0.5); + } + + // List with content type filter + const filteredRes = apiGet(`${BASE_URL}/qr-code?page=1&limit=10&contentType=url`, token, { + type: 'api_list', + }); + check(filteredRes, { + 'QR list filtered: status 200': (r) => r.status === 200, + }); + + sleep(2); +} diff --git a/load-tests/scenarios/scan-traffic.js b/load-tests/scenarios/scan-traffic.js new file mode 100644 index 00000000..27cc8378 --- /dev/null +++ b/load-tests/scenarios/scan-traffic.js @@ -0,0 +1,113 @@ +// ============================================================================= +// Scan Traffic Simulation +// +// Simulates real users scanning QR codes and clicking short URLs. +// Hits the frontend /u/{shortCode} endpoint which triggers the full flow: +// 1. Resolve short URL (backend GET /short-url/{shortCode}) +// 2. Umami analytics event +// 3. Clear views cache (backend POST) +// 4. Track scan with device/browser info (backend POST) +// 5. 302 Redirect to destination +// +// Short codes are provided via setupData (created dynamically in setup()) +// or via SHORT_CODES env var as fallback. +// ============================================================================= + +import http from 'k6/http'; +import encoding from 'k6/encoding'; +import { check, sleep } from 'k6'; +import { randomScanProfile, getScanHeaders } from '../data/user-agents.js'; +import { randomItem, randomInt } from '../helpers.js'; + +// Frontend URL for scan simulation (goes through Next.js middleware) +const FRONTEND_URL = __ENV.FRONTEND_URL || 'https://stage.qrcodly.de'; + +// HTAccess basic auth for staging frontend +const HTACCESS_USER = __ENV.HTACCESS_USER || ''; +const HTACCESS_PASS = __ENV.HTACCESS_PASS || ''; + +/** + * Simulate a single QR code scan / short URL click + * with a unique browser fingerprint + */ +function performScan(shortCode) { + const profile = randomScanProfile(); + const headers = getScanHeaders(profile); + + // Add HTAccess basic auth if configured + if (HTACCESS_USER && HTACCESS_PASS) { + headers['Authorization'] = `Basic ${encoding.b64encode(`${HTACCESS_USER}:${HTACCESS_PASS}`)}`; + } + + // Hit the frontend /u/{shortCode} — this triggers the full tracking pipeline + const res = http.get(`${FRONTEND_URL}/u/${shortCode}`, { + headers, + redirects: 0, // Don't follow the redirect — we just want to trigger tracking + tags: { type: 'scan' }, + }); + + check(res, { + 'Scan: redirect or success': (r) => + r.status === 200 || + r.status === 301 || + r.status === 302 || + r.status === 307 || + r.status === 308, + }); + + return res; +} + +/** + * Get available short codes from setupData or env var fallback + */ +function getShortCodes(setupData) { + if (setupData && setupData.shortCodes && setupData.shortCodes.length > 0) { + return setupData.shortCodes; + } + // Fallback to env var + if (__ENV.SHORT_CODES) { + return __ENV.SHORT_CODES.split(',').map((c) => c.trim()); + } + return []; +} + +/** + * High-volume scan flow + */ +export function scanTrafficFlow(setupData) { + const codes = getShortCodes(setupData); + if (codes.length === 0) { + sleep(5); + return; + } + + const shortCode = randomItem(codes); + performScan(shortCode); + + // Simulate human delay between scans (0.5s to 3s) + sleep(0.5 + Math.random() * 2.5); +} + +/** + * Burst scan flow — simulates a QR code going viral + */ +export function scanBurstFlow(setupData) { + const codes = getShortCodes(setupData); + if (codes.length === 0) { + sleep(5); + return; + } + + const shortCode = randomItem(codes); + + const burstSize = randomInt(3, 8); + for (let i = 0; i < burstSize; i++) { + performScan(shortCode); + sleep(0.1 + Math.random() * 0.5); + } + + sleep(1); +} + +export { performScan }; diff --git a/load-tests/scenarios/short-urls.js b/load-tests/scenarios/short-urls.js new file mode 100644 index 00000000..1a0e215c --- /dev/null +++ b/load-tests/scenarios/short-urls.js @@ -0,0 +1,94 @@ +// ============================================================================= +// Short URL CRUD scenario +// Simulates: list → create → get details → update → toggle active → delete +// ============================================================================= + +import { check, sleep } from 'k6'; +import { BASE_URL } from '../config.js'; +import { apiGet, apiPost, apiPatch, apiDelete } from '../helpers.js'; +import { getValidToken } from '../auth.js'; +import { createShortUrlPayload, updateShortUrlPayload } from '../data/payloads.js'; + +export function shortUrlCrudFlow() { + const token = getValidToken(); + if (!token) return; + + // 1. List short URLs + const listRes = apiGet(`${BASE_URL}/short-url?page=1&limit=10&standalone=true`, token, { + type: 'api_list', + }); + check(listRes, { + 'ShortURL list: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 2. Create short URL + const createRes = apiPost(`${BASE_URL}/short-url`, createShortUrlPayload(), token, { + type: 'api_create', + }); + const created = check(createRes, { + 'ShortURL create: status 200': (r) => r.status === 200 || r.status === 201, + }); + + if (!created || (createRes.status !== 200 && createRes.status !== 201)) { + sleep(2); + return; + } + + let shortUrl; + try { + shortUrl = JSON.parse(createRes.body); + } catch { + sleep(2); + return; + } + + const shortCode = shortUrl.shortCode; + sleep(1); + + // 3. Get short URL details + const detailRes = apiGet(`${BASE_URL}/short-url/${shortCode}/detail`, token, { + type: 'api_get', + }); + check(detailRes, { + 'ShortURL detail: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 4. Update short URL + const updateRes = apiPatch(`${BASE_URL}/short-url/${shortCode}`, updateShortUrlPayload(), token, { + type: 'api_update', + }); + check(updateRes, { + 'ShortURL update: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 5. Toggle active state + const toggleRes = apiPatch(`${BASE_URL}/short-url/${shortCode}/toggle-active-state`, {}, token, { + type: 'api_update', + }); + check(toggleRes, { + 'ShortURL toggle: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 6. Get views + const viewsRes = apiGet(`${BASE_URL}/short-url/${shortCode}/get-views`, token, { + type: 'api_get', + }); + check(viewsRes, { + 'ShortURL views: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 7. Delete short URL + const deleteRes = apiDelete(`${BASE_URL}/short-url/${shortCode}`, token, { + type: 'api_delete', + }); + check(deleteRes, { + 'ShortURL delete: status 200': (r) => r.status === 200 || r.status === 204, + }); + + sleep(2); +} diff --git a/load-tests/scenarios/templates-tags.js b/load-tests/scenarios/templates-tags.js new file mode 100644 index 00000000..e62fcbc2 --- /dev/null +++ b/load-tests/scenarios/templates-tags.js @@ -0,0 +1,147 @@ +// ============================================================================= +// Config Templates & Tags scenarios +// ============================================================================= + +import { check, sleep } from 'k6'; +import { BASE_URL } from '../config.js'; +import { apiGet, apiPost, apiPatch, apiDelete } from '../helpers.js'; +import { getValidToken } from '../auth.js'; +import { + createConfigTemplatePayload, + updateConfigTemplatePayload, + createTagPayload, + updateTagPayload, +} from '../data/payloads.js'; + +// --- Config Templates --- + +export function configTemplateCrudFlow() { + const token = getValidToken(); + if (!token) return; + + // 1. List templates + const listRes = apiGet(`${BASE_URL}/config-template?page=1&limit=10`, token, { + type: 'api_list', + }); + check(listRes, { + 'Template list: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 2. List predefined templates (public) + const predefinedRes = apiGet(`${BASE_URL}/config-template/predefined`, token, { + type: 'api_list', + }); + check(predefinedRes, { + 'Template predefined: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 3. Create template + const createRes = apiPost(`${BASE_URL}/config-template`, createConfigTemplatePayload(), token, { + type: 'api_create', + }); + const created = check(createRes, { + 'Template create: status 200': (r) => r.status === 200 || r.status === 201, + }); + + if (!created || (createRes.status !== 200 && createRes.status !== 201)) { + sleep(2); + return; + } + + let template; + try { + template = JSON.parse(createRes.body); + } catch { + sleep(2); + return; + } + + sleep(1); + + // 4. Get template by ID + const getRes = apiGet(`${BASE_URL}/config-template/${template.id}`, token, { + type: 'api_get', + }); + check(getRes, { + 'Template get: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 5. Update template + const updateRes = apiPatch( + `${BASE_URL}/config-template/${template.id}`, + updateConfigTemplatePayload(), + token, + { type: 'api_update' }, + ); + check(updateRes, { + 'Template update: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 6. Delete template + const deleteRes = apiDelete(`${BASE_URL}/config-template/${template.id}`, token, { + type: 'api_delete', + }); + check(deleteRes, { + 'Template delete: status 200': (r) => r.status === 200 || r.status === 204, + }); + + sleep(2); +} + +// --- Tags --- + +export function tagCrudFlow() { + const token = getValidToken(); + if (!token) return; + + // 1. List tags + const listRes = apiGet(`${BASE_URL}/tag?page=1&limit=10`, token, { type: 'api_list' }); + check(listRes, { + 'Tag list: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 2. Create tag + const createRes = apiPost(`${BASE_URL}/tag`, createTagPayload(), token, { + type: 'api_create', + }); + const created = check(createRes, { + 'Tag create: status 200': (r) => r.status === 200 || r.status === 201, + }); + + if (!created || (createRes.status !== 200 && createRes.status !== 201)) { + sleep(2); + return; + } + + let tag; + try { + tag = JSON.parse(createRes.body); + } catch { + sleep(2); + return; + } + + sleep(1); + + // 3. Update tag + const updateRes = apiPatch(`${BASE_URL}/tag/${tag.id}`, updateTagPayload(), token, { + type: 'api_update', + }); + check(updateRes, { + 'Tag update: status 200': (r) => r.status === 200, + }); + sleep(1); + + // 4. Delete tag + const deleteRes = apiDelete(`${BASE_URL}/tag/${tag.id}`, token, { type: 'api_delete' }); + check(deleteRes, { + 'Tag delete: status 200': (r) => r.status === 200 || r.status === 204, + }); + + sleep(2); +}