tests/load/autocannon.config.ts is a thin shared wrapper over autocannon's programmatic API producing this feature's own report shape (throughput, latency p50/p90/p99, non-2xx, rate-limited count) — printed and written to tests/load/reports/ (gitignored) for every run. No pass/fail threshold is applied (FR-009): throughput/latency targets are an OPEN BUSINESS DECISION per the roadmap's own convention, never invented. Three scripts cover the named critical endpoint groups: ticket-creation (pure DB path), admin-reporting (015-reporting-dashboards, pure DB path), and ai-support-flow (005-ai-support's real Anthropic API calls — clearly flagged as real, billed cost, run only at a small bounded amount rather than an open-ended duration). All three were run once at a small scale against the real dev server to confirm the tooling works end-to-end and cleans up fully after itself (verified via direct DB checks, not just script exit codes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
/**
|
|
* specs/016-load-concurrency-testing User Story 5 / FR-007: load/throughput baseline for new
|
|
* support-request submission — the entry point of the entire support flow. Run against a real
|
|
* running dev server: `npx tsx tests/load/ticket-creation.load.ts`.
|
|
*
|
|
* IMPORTANT — real cost: a successful ticket creation asynchronously triggers 005-ai-support's
|
|
* AI_SESSION worker, which makes a real, billed Anthropic API call for that ticket's first
|
|
* diagnosis turn. This endpoint's own HTTP response is fast and free, but the request still has
|
|
* a real downstream cost — set `LOAD_TEST_REQUESTS` to bound the total ticket count explicitly
|
|
* rather than relying on an open-ended `LOAD_TEST_DURATION_SEC` run whose total is
|
|
* latency-dependent and less predictable.
|
|
*
|
|
* Creates its own throwaway product + integration credential, generates a fresh single-use
|
|
* integration token per request (002-saas-integration's own jti replay protection means a
|
|
* single static token can't be reused across requests), and cleans up everything it created
|
|
* once the run finishes.
|
|
*/
|
|
import { prismaClient } from '@/infrastructure/database';
|
|
import {
|
|
encryptCredential,
|
|
generateCredentialSecret,
|
|
issueIntegrationToken,
|
|
} from '@/modules/catalog/products';
|
|
import { runLoadTest } from './autocannon.config';
|
|
|
|
const API_URL = process.env.LOAD_TEST_API_URL ?? 'http://localhost:4501';
|
|
const CONNECTIONS = Number(process.env.LOAD_TEST_CONNECTIONS ?? 10);
|
|
const DURATION_SEC = Number(process.env.LOAD_TEST_DURATION_SEC ?? 10);
|
|
// When set, caps the total number of tickets created (and thus the total downstream AI cost)
|
|
// instead of running for an open-ended duration.
|
|
const REQUESTS = process.env.LOAD_TEST_REQUESTS ? Number(process.env.LOAD_TEST_REQUESTS) : undefined;
|
|
|
|
async function main(): Promise<void> {
|
|
const externalProductId = `LOAD_TEST_TICKET_CREATION_${Date.now()}`;
|
|
const product = await prismaClient.product.create({
|
|
data: { externalProductId, name: 'Load Test — Ticket Creation', status: 'active' },
|
|
});
|
|
const secret = generateCredentialSecret();
|
|
await prismaClient.productIntegration.create({
|
|
data: {
|
|
productId: product.id,
|
|
credentialRef: encryptCredential(secret),
|
|
authMechanism: 'signed_token',
|
|
allowedScope: { tenantIds: ['load-test-tenant'] },
|
|
status: 'active',
|
|
rateLimitPerMinute: 100000,
|
|
rateLimitPerUserPerMinute: 100000,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await runLoadTest('ticket-creation', {
|
|
url: `${API_URL}/v1/support/requests`,
|
|
connections: CONNECTIONS,
|
|
...(REQUESTS !== undefined ? { amount: REQUESTS } : { duration: DURATION_SEC }),
|
|
requests: [
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
setupRequest: (request) => {
|
|
const token = issueIntegrationToken(secret, {
|
|
externalProductId,
|
|
tenantId: 'load-test-tenant',
|
|
userId: 'load-test-user',
|
|
});
|
|
request.headers = { ...request.headers, authorization: `Bearer ${token}` };
|
|
request.body = JSON.stringify({
|
|
productId: externalProductId,
|
|
tenantId: 'load-test-tenant',
|
|
userId: 'load-test-user',
|
|
source: 'load-test',
|
|
problem: `Load test problem ${Date.now()}-${Math.random()}`,
|
|
});
|
|
return request;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
} finally {
|
|
await prismaClient.ticketMessage.deleteMany({ where: { ticket: { productId: product.id } } });
|
|
await prismaClient.ticket.deleteMany({ where: { productId: product.id } });
|
|
await prismaClient.problem.deleteMany({ where: { productId: product.id } });
|
|
await prismaClient.productIntegration.deleteMany({ where: { productId: product.id } });
|
|
await prismaClient.product.deleteMany({ where: { id: product.id } });
|
|
}
|
|
}
|
|
|
|
main()
|
|
.then(() => process.exit(0))
|
|
.catch((error) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|