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
231 lines
5.5 KiB
JavaScript
231 lines
5.5 KiB
JavaScript
/**
|
|
* API Performance Load Test
|
|
*
|
|
* Tests specific API endpoints for performance
|
|
* Tests:
|
|
* - REST API endpoints (Pages, Posts, Portfolio, Categories)
|
|
* - GraphQL API queries
|
|
* - Global API endpoint
|
|
* - Authentication endpoints
|
|
*
|
|
* NFR4 Requirements:
|
|
* - p95 response time < 300ms (faster for API)
|
|
* - Error rate < 0.5% (stricter for API)
|
|
* - Throughput > 100 requests/second
|
|
*/
|
|
|
|
import http from 'k6/http';
|
|
import { check, group } from 'k6';
|
|
import { urls, thresholdGroups, config } from './lib/config.js';
|
|
import { ApiHelper, testData, thinkTime } from './lib/helpers.js';
|
|
|
|
// Test configuration
|
|
export const options = {
|
|
stages: config.stages.apiPerformance,
|
|
thresholds: thresholdGroups.api,
|
|
...config.requestOptions,
|
|
};
|
|
|
|
// API helper (no auth needed for public endpoints)
|
|
const apiHelper = new ApiHelper(config.baseUrl);
|
|
|
|
/**
|
|
* Setup function
|
|
*/
|
|
export function setup() {
|
|
console.log('=== API Performance Load Test ===');
|
|
console.log(`Target: ${config.baseUrl}`);
|
|
console.log('Starting test...');
|
|
}
|
|
|
|
/**
|
|
* Main test scenario
|
|
*/
|
|
export default function () {
|
|
// Scenario 1: Global API endpoint (metadata)
|
|
group('Global API', () => {
|
|
const res = apiHelper.get('/global');
|
|
|
|
check(res, {
|
|
'has global data': (r) => {
|
|
try {
|
|
const body = r.json();
|
|
return body !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
thinkTime(0.1, 0.3); // Minimal think time for API
|
|
});
|
|
|
|
// Scenario 2: Pages API
|
|
group('Pages API', () => {
|
|
// List pages
|
|
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
|
|
|
// Try to get a specific page
|
|
try {
|
|
const list = apiHelper.get('/pages', { limit: 1, depth: 0, page: 1 });
|
|
if (list.status === 200 && list.json('totalDocs') > 0) {
|
|
const firstId = list.json('docs')[0].id;
|
|
apiHelper.get(`/pages/${firstId}`, { depth: 1 });
|
|
}
|
|
} catch (e) {
|
|
// Page might not exist
|
|
}
|
|
|
|
thinkTime(0.1, 0.3);
|
|
});
|
|
|
|
// Scenario 3: Posts API
|
|
group('Posts API', () => {
|
|
// List posts
|
|
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
|
|
|
// List with pagination
|
|
apiHelper.get('/posts', { limit: 20, depth: 0, page: 1 });
|
|
|
|
thinkTime(0.1, 0.3);
|
|
});
|
|
|
|
// Scenario 4: Portfolio API
|
|
group('Portfolio API', () => {
|
|
// List portfolio items
|
|
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
|
|
|
// Filter by category (if applicable)
|
|
try {
|
|
const categories = apiHelper.get('/categories', { limit: 1, depth: 0 });
|
|
if (categories.status === 200 && categories.json('totalDocs') > 0) {
|
|
const categoryId = categories.json('docs')[0].id;
|
|
apiHelper.get('/portfolio', {
|
|
limit: 10,
|
|
depth: 1,
|
|
where: {
|
|
category: { equals: categoryId },
|
|
},
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Categories might not exist
|
|
}
|
|
|
|
thinkTime(0.1, 0.3);
|
|
});
|
|
|
|
// Scenario 5: Categories API
|
|
group('Categories API', () => {
|
|
// List all categories
|
|
apiHelper.get('/categories', { limit: 10, depth: 1 });
|
|
|
|
thinkTime(0.1, 0.3);
|
|
});
|
|
|
|
// Scenario 6: GraphQL API
|
|
group('GraphQL API', () => {
|
|
// Query 1: Simple list
|
|
const simpleQuery = `
|
|
query {
|
|
Posts(limit: 5) {
|
|
docs {
|
|
id
|
|
title
|
|
slug
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
apiHelper.graphql(simpleQuery);
|
|
|
|
thinkTime(0.1, 0.2);
|
|
|
|
// Query 2: With relationships
|
|
const complexQuery = `
|
|
query {
|
|
Posts(limit: 3, depth: 2) {
|
|
docs {
|
|
id
|
|
title
|
|
content
|
|
category {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
apiHelper.graphql(complexQuery);
|
|
|
|
thinkTime(0.1, 0.3);
|
|
});
|
|
|
|
// Scenario 7: Authentication endpoints
|
|
group('Auth API', () => {
|
|
// Note: These will fail with invalid credentials, but test the endpoint response
|
|
const loginRes = http.post(`${config.baseUrl}/api/users/login`, JSON.stringify({
|
|
email: 'test@example.com',
|
|
password: 'wrongpassword',
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
check(loginRes, {
|
|
'login responds': (r) => [200, 400, 401].includes(r.status),
|
|
'login responds quickly': (r) => r.timings.duration < 500,
|
|
});
|
|
|
|
thinkTime(0.1, 0.2);
|
|
});
|
|
|
|
// Scenario 8: Concurrent API requests
|
|
group('Concurrent Requests', () => {
|
|
// Simulate multiple API calls in parallel
|
|
const requests = [
|
|
apiHelper.get('/pages', { limit: 5, depth: 1 }),
|
|
apiHelper.get('/posts', { limit: 5, depth: 1 }),
|
|
apiHelper.get('/portfolio', { limit: 5, depth: 1 }),
|
|
];
|
|
|
|
// Check all succeeded
|
|
const allSuccessful = requests.every(r => r.status === 200);
|
|
|
|
check(null, {
|
|
'all concurrent requests successful': () => allSuccessful,
|
|
});
|
|
|
|
thinkTime(0.2, 0.5);
|
|
});
|
|
|
|
// Scenario 9: Filtered queries
|
|
group('Filtered Queries', () => {
|
|
// Various filter combinations
|
|
const filters = [
|
|
{ where: { status: { equals: 'published' } } },
|
|
{ limit: 5, sort: '-createdAt' },
|
|
{ limit: 10, depth: 2 },
|
|
];
|
|
|
|
filters.forEach((filter, i) => {
|
|
apiHelper.get('/posts', filter);
|
|
thinkTime(0.05, 0.15);
|
|
});
|
|
});
|
|
|
|
// Minimal think time for API-focused test
|
|
thinkTime(0.5, 1);
|
|
}
|
|
|
|
/**
|
|
* Teardown function
|
|
*/
|
|
export function teardown(data) {
|
|
console.log('=== API Performance Test Complete ===');
|
|
console.log('Check results above for:');
|
|
console.log('- p95 response time < 300ms');
|
|
console.log('- Error rate < 0.5%');
|
|
console.log('- Throughput > 100 req/s');
|
|
}
|