Files
website-enchun-mgr/apps/backend/tests/k6/lib/helpers.js
pkupuk 7fd73e0e3d 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
2026-01-31 17:20:35 +08:00

406 lines
8.0 KiB
JavaScript

/**
* 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();
}
}