FASHIONISTAR CI/CD
🔄 Celery Queues Deploy: 4aae6106b1530c3cc7d7d64f5e3c3e1d5015691d [GitHub Actions]
27c799c
Raw
History Blame
11.9 kB
// k6/load_test.js
// =============================================================
// FASHIONISTAR — Phase 10: K6 Enterprise Load Test
// Target: p95 < 100ms at 10k RPS | 100k VU campaign
//
// Run:
// k6 run --env BASE_URL=http://localhost:8001 k6/load_test.js
// k6 run --env BASE_URL=https://api.fashionistar.com \
// --out json=results/load_test_$(date +%Y%m%d_%H%M%S).json \
// k6/load_test.js
//
// Environment variables:
// BASE_URL — API base URL (required)
// AUTH_TOKEN — Bearer token for authenticated scenarios
// SCENARIO — 'smoke' | 'load' | 'stress' | 'spike' | 'soak' (default: load)
// =============================================================
import http from "k6/http";
import { check, sleep, group } from "k6";
import { Counter, Rate, Trend } from "k6/metrics";
import { randomItem, randomIntBetween } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
// ── Custom Metrics ────────────────────────────────────────────────────────────
const errorRate = new Rate("error_rate");
const p95Latency = new Trend("p95_latency_ms", true);
const cartAddDuration = new Trend("cart_add_duration_ms", true);
const catalogPageDuration = new Trend("catalog_page_duration_ms", true);
const authLoginDuration = new Trend("auth_login_duration_ms", true);
// ── Configuration ─────────────────────────────────────────────────────────────
const BASE_URL = __ENV.BASE_URL || "http://localhost:8001";
const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
const SCENARIO = __ENV.SCENARIO || "load";
// ── Thresholds (FASHIONISTAR SLAs) ────────────────────────────────────────────
export let options = {
thresholds: {
// Core SLA: p95 under 100ms
"http_req_duration": ["p(95)<100", "p(99)<200"],
// Error budget: less than 0.1% failures
"http_req_failed": ["rate<0.001"],
"error_rate": ["rate<0.001"],
// Domain-specific latencies
"catalog_page_duration_ms": ["p(95)<80"],
"cart_add_duration_ms": ["p(95)<150"],
"auth_login_duration_ms": ["p(95)<200"],
},
scenarios: {
// ── 1. Smoke: verify baseline correctness (1 VU, 1 min) ─────────────────
smoke: {
executor: "constant-vus",
vus: 1,
duration: "1m",
gracefulStop: "10s",
tags: { scenario: "smoke" },
},
// ── 2. Load: realistic traffic (ramp to 10k RPS) ─────────────────────────
load: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "2m", target: 100 },
{ duration: "5m", target: 1000 },
{ duration: "10m", target: 5000 },
{ duration: "5m", target: 10000 },
{ duration: "2m", target: 0 },
],
gracefulRampDown: "30s",
tags: { scenario: "load" },
},
// ── 3. Stress: beyond normal capacity (up to 100k VUs) ───────────────────
stress: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "2m", target: 1000 },
{ duration: "5m", target: 10000 },
{ duration: "10m", target: 100000 },
{ duration: "2m", target: 0 },
],
gracefulRampDown: "60s",
tags: { scenario: "stress" },
},
// ── 4. Spike: sudden traffic surge ───────────────────────────────────────
spike: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "30s", target: 100 },
{ duration: "10s", target: 50000 }, // spike
{ duration: "3m", target: 50000 }, // hold spike
{ duration: "30s", target: 0 },
],
tags: { scenario: "spike" },
},
// ── 5. Soak: extended endurance (8 hours at 5k VUs) ──────────────────────
soak: {
executor: "constant-vus",
vus: 5000,
duration: "8h",
gracefulStop: "2m",
tags: { scenario: "soak" },
},
},
};
// Only run the selected scenario
if (SCENARIO !== "all") {
const selectedScenario = options.scenarios[SCENARIO];
if (selectedScenario) {
options.scenarios = { [SCENARIO]: selectedScenario };
}
}
// ── Headers ───────────────────────────────────────────────────────────────────
function getHeaders(authenticated = false) {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Idempotency-Key": `k6-${Date.now()}-${Math.random()}`,
};
if (authenticated && AUTH_TOKEN) {
headers["Authorization"] = `Bearer ${AUTH_TOKEN}`;
}
return headers;
}
// ── Test Data ─────────────────────────────────────────────────────────────────
const CATALOG_PAGES = [1, 2, 3, 4, 5];
const SEARCH_QUERIES = ["ankara", "aso ebi", "kaftan", "agbada", "buba", "suit"];
const CATEGORIES = ["women", "men", "children", "accessories", "fabric"];
// ── Scenario Functions ────────────────────────────────────────────────────────
function scenarioHealthCheck() {
const res = http.get(`${BASE_URL}/api/v1/health/`, { headers: getHeaders() });
check(res, {
"health: status 200": (r) => r.status === 200,
"health: response < 50ms": (r) => r.timings.duration < 50,
});
errorRate.add(res.status !== 200);
}
function scenarioCatalogBrowse() {
group("Catalog Browse", () => {
// Product list
const page = randomItem(CATALOG_PAGES);
const res = http.get(
`${BASE_URL}/api/v1/ninja/products/?page=${page}&page_size=20`,
{ headers: getHeaders() }
);
catalogPageDuration.add(res.timings.duration);
check(res, {
"catalog: status 200": (r) => r.status === 200,
"catalog: has results": (r) => {
try { return JSON.parse(r.body).data?.count > 0; } catch { return false; }
},
});
errorRate.add(res.status !== 200);
sleep(randomIntBetween(1, 3) * 0.1);
// Category filter
const cat = randomItem(CATEGORIES);
const catRes = http.get(
`${BASE_URL}/api/v1/ninja/products/?category=${cat}&page=1`,
{ headers: getHeaders() }
);
check(catRes, { "category filter: status 200": (r) => r.status === 200 });
errorRate.add(catRes.status !== 200);
});
}
function scenarioSearch() {
const query = randomItem(SEARCH_QUERIES);
const res = http.get(
`${BASE_URL}/api/v1/ninja/products/search/?q=${encodeURIComponent(query)}`,
{ headers: getHeaders() }
);
check(res, {
"search: status 200 or 404": (r) => [200, 404].includes(r.status),
});
errorRate.add(![200, 404].includes(res.status));
}
function scenarioCartOperation() {
if (!AUTH_TOKEN) return;
group("Cart Operations", () => {
// View cart
const cartRes = http.get(
`${BASE_URL}/api/v1/ninja/cart/`,
{ headers: getHeaders(true) }
);
check(cartRes, { "cart view: status 200": (r) => r.status === 200 });
sleep(0.05);
// Get a product to add
const productsRes = http.get(
`${BASE_URL}/api/v1/ninja/products/?page=1&page_size=1`,
{ headers: getHeaders() }
);
if (productsRes.status === 200) {
try {
const data = JSON.parse(productsRes.body);
const product = data?.data?.results?.[0];
if (product?.id) {
const addRes = http.post(
`${BASE_URL}/api/v1/cart/add/`,
JSON.stringify({ product_id: product.id, quantity: 1 }),
{ headers: getHeaders(true) }
);
cartAddDuration.add(addRes.timings.duration);
check(addRes, {
"cart add: status 2xx": (r) => r.status >= 200 && r.status < 300,
});
errorRate.add(addRes.status >= 400);
}
} catch {}
}
});
}
function scenarioNotificationFeed() {
if (!AUTH_TOKEN) return;
const res = http.get(
`${BASE_URL}/api/v1/ninja/notifications/?page=1&page_size=10`,
{ headers: getHeaders(true) }
);
check(res, { "notifications: status 200": (r) => r.status === 200 });
errorRate.add(res.status !== 200);
}
// ── Main VU Function ──────────────────────────────────────────────────────────
export default function () {
// Distribution: 60% catalog, 20% search, 10% cart, 5% notifications, 5% health
const rand = Math.random();
if (rand < 0.05) {
scenarioHealthCheck();
} else if (rand < 0.25) {
scenarioSearch();
} else if (rand < 0.35) {
scenarioCartOperation();
} else if (rand < 0.40) {
scenarioNotificationFeed();
} else {
scenarioCatalogBrowse();
}
sleep(randomIntBetween(1, 3) * 0.05);
}
// ── Setup & Teardown ──────────────────────────────────────────────────────────
export function setup() {
const res = http.get(`${BASE_URL}/api/v1/health/`);
if (res.status !== 200) {
throw new Error(`Backend health check failed: ${res.status}. Aborting load test.`);
}
console.log(`Load test starting against: ${BASE_URL}`);
return { started_at: new Date().toISOString() };
}
export function teardown(data) {
console.log(`Load test completed. Started: ${data.started_at}`);
}
// ── Summary Report ────────────────────────────────────────────────────────────
export function handleSummary(data) {
const p95 = data.metrics["http_req_duration"]?.values?.["p(95)"] || 0;
const errRate = data.metrics["http_req_failed"]?.values?.rate || 0;
const reqCount = data.metrics["http_reqs"]?.values?.count || 0;
const report = {
summary: {
total_requests: reqCount,
p95_latency_ms: p95.toFixed(2),
error_rate_pct: (errRate * 100).toFixed(3),
sla_p95_passed: p95 < 100,
sla_error_rate_passed: errRate < 0.001,
scenario: SCENARIO,
base_url: BASE_URL,
timestamp: new Date().toISOString(),
},
raw: data,
};
return {
"results/k6_summary.json": JSON.stringify(report, null, 2),
stdout: `
╔══════════════════════════════════════════════╗
║ FASHIONISTAR K6 Load Test Summary ║
╠══════════════════════════════════════════════╣
║ Scenario: ${SCENARIO.padEnd(27)}
║ Base URL: ${BASE_URL.substring(0, 27).padEnd(27)}
║ Total Requests: ${String(reqCount).padEnd(27)}
║ p95 Latency: ${(p95.toFixed(2) + "ms").padEnd(27)}
║ Error Rate: ${((errRate * 100).toFixed(3) + "%").padEnd(27)}
║ SLA p95 < 100ms: ${(p95 < 100 ? "✅ PASS" : "❌ FAIL").padEnd(26)}
║ SLA err < 0.1%: ${(errRate < 0.001 ? "✅ PASS" : "❌ FAIL").padEnd(26)}
╚══════════════════════════════════════════════╝
`,
};
}