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
406 lines
8.0 KiB
JavaScript
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();
|
|
}
|
|
}
|