Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
405
apps/backend/tests/k6/lib/helpers.js
Normal file
405
apps/backend/tests/k6/lib/helpers.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* K6 Test Helpers
|
||||
* Reusable helper functions for load tests
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { urls, checks } from './config.js';
|
||||
|
||||
/**
|
||||
* Authentication helper
|
||||
*/
|
||||
export class AuthHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and store token
|
||||
*/
|
||||
login(email, password) {
|
||||
const res = http.post(`${this.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const success = check(res, {
|
||||
'login successful': checks.status200,
|
||||
'received token': (r) => r.json('token') !== undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.token = res.json('token');
|
||||
}
|
||||
|
||||
return { res, success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers
|
||||
*/
|
||||
getAuthHeaders() {
|
||||
if (!this.token) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear token
|
||||
*/
|
||||
logout() {
|
||||
const res = http.post(`${this.baseUrl}/api/users/logout`, null, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
this.token = null;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page load helper
|
||||
*/
|
||||
export class PageHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page and check response
|
||||
*/
|
||||
loadPage(path) {
|
||||
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
||||
const res = http.get(url);
|
||||
|
||||
check(res, {
|
||||
[`page loaded: ${path}`]: checks.status200,
|
||||
'response time < 500ms': checks.nfr4ResponseTime,
|
||||
'has content': checks.hasContent,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple pages randomly
|
||||
*/
|
||||
loadRandomPages(pageList, count = 5) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomPage = pageList[Math.floor(Math.random() * pageList.length)];
|
||||
pages.push(this.loadPage(randomPage));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API helper
|
||||
*/
|
||||
export class ApiHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth token
|
||||
*/
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers
|
||||
*/
|
||||
getHeaders(additionalHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
*/
|
||||
get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
|
||||
const res = http.get(url.toString(), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`GET ${endpoint} successful`]: checks.status200,
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
post(endpoint, data) {
|
||||
const res = http.post(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`POST ${endpoint} successful`]: (r) => [200, 201].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
put(endpoint, data) {
|
||||
const res = http.put(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`PUT ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
delete(endpoint) {
|
||||
const res = http.del(`${this.baseUrl}${endpoint}`, null, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`DELETE ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query helper
|
||||
*/
|
||||
graphql(query, variables = {}) {
|
||||
const res = http.post(`${this.baseUrl}/api/graphql`, JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
'GraphQL successful': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
const body = r.json();
|
||||
return !body.errors;
|
||||
},
|
||||
'GraphQL response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Think time helper
|
||||
* Simulates real user think time between actions
|
||||
*/
|
||||
export function thinkTime(min = 1, max = 3) {
|
||||
sleep(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random item picker
|
||||
*/
|
||||
export function pickRandom(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random picker
|
||||
*/
|
||||
export function pickWeighted(items) {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
|
||||
return items[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random test data
|
||||
*/
|
||||
export const testData = {
|
||||
// Random string
|
||||
string(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Random email
|
||||
email() {
|
||||
return `test_${this.string(8)}@example.com`;
|
||||
},
|
||||
|
||||
// Random number
|
||||
number(min = 0, max = 100) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
|
||||
// Random phone
|
||||
phone() {
|
||||
return `09${this.number(10000000, 99999999)}`;
|
||||
},
|
||||
|
||||
// Random title
|
||||
title() {
|
||||
const adjectives = ['Amazing', 'Awesome', 'Brilliant', 'Creative', 'Dynamic'];
|
||||
const nouns = ['Project', 'Solution', 'Product', 'Service', 'Innovation'];
|
||||
return `${pickRandom(adjectives)} ${pickRandom(nouns)}`;
|
||||
},
|
||||
|
||||
// Random content
|
||||
content(paragraphs = 3) {
|
||||
const sentences = [
|
||||
'This is a test sentence for load testing purposes.',
|
||||
'Load testing helps identify performance bottlenecks.',
|
||||
'We ensure the system can handle expected traffic.',
|
||||
'Performance is critical for user experience.',
|
||||
'Testing under load reveals system behavior.',
|
||||
];
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < paragraphs; i++) {
|
||||
content += '\n\n';
|
||||
for (let j = 0; j < 3; j++) {
|
||||
content += pickRandom(sentences) + ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Metrics helper
|
||||
*/
|
||||
export class MetricsHelper {
|
||||
constructor() {
|
||||
this.metrics = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric
|
||||
*/
|
||||
record(name, value) {
|
||||
if (!this.metrics[name]) {
|
||||
this.metrics[name] = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Infinity,
|
||||
max: -Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const metric = this.metrics[name];
|
||||
metric.count++;
|
||||
metric.sum += value;
|
||||
metric.min = Math.min(metric.min, value);
|
||||
metric.max = Math.max(metric.max, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric statistics
|
||||
*/
|
||||
getStats(name) {
|
||||
const metric = this.metrics[name];
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
count: metric.count,
|
||||
sum: metric.sum,
|
||||
avg: metric.sum / metric.count,
|
||||
min: metric.min,
|
||||
max: metric.max,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print all metrics
|
||||
*/
|
||||
printAll() {
|
||||
console.log('=== Custom Metrics ===');
|
||||
Object.keys(this.metrics).forEach(name => {
|
||||
const stats = this.getStats(name);
|
||||
console.log(`${name}:`, stats);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario helper
|
||||
* Define test scenarios with weights
|
||||
*/
|
||||
export class ScenarioHelper {
|
||||
constructor() {
|
||||
this.scenarios = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scenario
|
||||
*/
|
||||
register(name, fn, weight = 1) {
|
||||
this.scenarios[name] = { fn, weight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a random scenario based on weights
|
||||
*/
|
||||
execute() {
|
||||
const scenarios = Object.entries(this.scenarios).map(([name, { fn, weight }]) => ({
|
||||
value: fn,
|
||||
weight,
|
||||
}));
|
||||
|
||||
const scenario = pickWeighted(scenarios);
|
||||
scenario();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user