Integrate Oudelaa backend features, security, tests, and deployment updates
هذا الالتزام موجود في:
53
scripts/check-coverage.js
Normal file
53
scripts/check-coverage.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const summaryPath = path.resolve(process.cwd(), 'coverage', 'coverage-summary.json');
|
||||
const thresholds = {
|
||||
statements: 90,
|
||||
lines: 90,
|
||||
functions: 90,
|
||||
branches: 70,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(summaryPath)) {
|
||||
console.error(`Coverage summary was not found at ${summaryPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
||||
const serviceEntries = Object.entries(summary).filter(([file]) =>
|
||||
file.replace(/\\/g, '/').endsWith('.service.ts'),
|
||||
);
|
||||
|
||||
if (!serviceEntries.length) {
|
||||
console.error('Coverage summary did not contain any service files');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
console.log(`\nRuntime service coverage (${serviceEntries.length} files):`);
|
||||
|
||||
for (const [metric, minimum] of Object.entries(thresholds)) {
|
||||
const totals = serviceEntries.reduce(
|
||||
(result, [, coverage]) => ({
|
||||
covered: result.covered + coverage[metric].covered,
|
||||
total: result.total + coverage[metric].total,
|
||||
}),
|
||||
{ covered: 0, total: 0 },
|
||||
);
|
||||
const percentage = totals.total === 0 ? 100 : (totals.covered / totals.total) * 100;
|
||||
const formatted = percentage.toFixed(2);
|
||||
const status = percentage >= minimum ? 'PASS' : 'FAIL';
|
||||
console.log(
|
||||
` ${metric.padEnd(10)} ${formatted.padStart(6)}% ` +
|
||||
`(${totals.covered}/${totals.total}, required ${minimum}%) ${status}`,
|
||||
);
|
||||
failed ||= percentage < minimum;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error('\nRuntime service coverage gate failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nRuntime service coverage gate passed.');
|
||||
@@ -11,6 +11,12 @@ function parseArgs(argv) {
|
||||
warmup: 5,
|
||||
headers: {},
|
||||
body: undefined,
|
||||
gates: {
|
||||
minSuccessRate: undefined,
|
||||
minRequestsPerSecond: undefined,
|
||||
maxP95Ms: undefined,
|
||||
maxP99Ms: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -53,6 +59,34 @@ function parseArgs(argv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--min-success-rate') {
|
||||
assertOptionValue(arg, next);
|
||||
options.gates.minSuccessRate = Number(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--min-rps') {
|
||||
assertOptionValue(arg, next);
|
||||
options.gates.minRequestsPerSecond = Number(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--max-p95') {
|
||||
assertOptionValue(arg, next);
|
||||
options.gates.maxP95Ms = Number(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--max-p99') {
|
||||
assertOptionValue(arg, next);
|
||||
options.gates.maxP99Ms = Number(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--header' && next) {
|
||||
const separatorIndex = next.indexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
@@ -95,6 +129,14 @@ function parseArgs(argv) {
|
||||
throw new Error('warmup must be zero or a positive integer');
|
||||
}
|
||||
|
||||
validateGate(options.gates.minSuccessRate, 'min-success-rate', {
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
validateGate(options.gates.minRequestsPerSecond, 'min-rps', { min: 0 });
|
||||
validateGate(options.gates.maxP95Ms, 'max-p95', { min: 0 });
|
||||
validateGate(options.gates.maxP99Ms, 'max-p99', { min: 0 });
|
||||
|
||||
if (options.body && !options.headers['Content-Type']) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
@@ -102,6 +144,27 @@ function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function assertOptionValue(option, value) {
|
||||
if (value === undefined) {
|
||||
throw new Error(`${option} requires a value`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGate(value, name, limits) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aboveMaximum = limits.max !== undefined && value > limits.max;
|
||||
if (!Number.isFinite(value) || value < limits.min || aboveMaximum) {
|
||||
const range =
|
||||
limits.max === undefined
|
||||
? `at least ${limits.min}`
|
||||
: `between ${limits.min} and ${limits.max}`;
|
||||
throw new Error(`${name} must be a finite number ${range}`);
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(sortedValues, p) {
|
||||
if (!sortedValues.length) {
|
||||
return 0;
|
||||
@@ -124,6 +187,9 @@ function average(values) {
|
||||
function printUsage() {
|
||||
console.log('Usage: node scripts/load-test.js --url <url> [--duration 15] [--concurrency 20]');
|
||||
console.log('Optional: --method POST --header "Authorization: Bearer <token>" --body "{\"key\":\"value\"}"');
|
||||
console.log(
|
||||
'Performance gates: --min-success-rate 99.9 --min-rps 100 --max-p95 250 --max-p99 500',
|
||||
);
|
||||
}
|
||||
|
||||
async function warmup(options) {
|
||||
@@ -218,6 +284,86 @@ function buildSummary(options, results, totalDurationMs) {
|
||||
};
|
||||
}
|
||||
|
||||
function evaluatePerformanceGates(summary, gates) {
|
||||
const definitions = [
|
||||
{
|
||||
configuredValue: gates.minSuccessRate,
|
||||
metric: 'successRate',
|
||||
label: 'Success rate',
|
||||
actual: summary.successRate,
|
||||
operator: '>=',
|
||||
unit: '%',
|
||||
passes: (actual, threshold) => actual >= threshold,
|
||||
},
|
||||
{
|
||||
configuredValue: gates.minRequestsPerSecond,
|
||||
metric: 'requestsPerSecond',
|
||||
label: 'Requests/second',
|
||||
actual: summary.requestsPerSecond,
|
||||
operator: '>=',
|
||||
unit: ' req/s',
|
||||
passes: (actual, threshold) => actual >= threshold,
|
||||
},
|
||||
{
|
||||
configuredValue: gates.maxP95Ms,
|
||||
metric: 'latencyMs.p95',
|
||||
label: 'Latency p95',
|
||||
actual: summary.latencyMs.p95,
|
||||
operator: '<=',
|
||||
unit: ' ms',
|
||||
passes: (actual, threshold) => actual <= threshold,
|
||||
},
|
||||
{
|
||||
configuredValue: gates.maxP99Ms,
|
||||
metric: 'latencyMs.p99',
|
||||
label: 'Latency p99',
|
||||
actual: summary.latencyMs.p99,
|
||||
operator: '<=',
|
||||
unit: ' ms',
|
||||
passes: (actual, threshold) => actual <= threshold,
|
||||
},
|
||||
];
|
||||
|
||||
const checks = definitions
|
||||
.filter((definition) => definition.configuredValue !== undefined)
|
||||
.map((definition) => ({
|
||||
metric: definition.metric,
|
||||
label: definition.label,
|
||||
actual: definition.actual,
|
||||
operator: definition.operator,
|
||||
threshold: definition.configuredValue,
|
||||
unit: definition.unit,
|
||||
passed: definition.passes(definition.actual, definition.configuredValue),
|
||||
}));
|
||||
|
||||
return {
|
||||
configured: checks.length > 0,
|
||||
passed: checks.every((check) => check.passed),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
function printGateReport(gateResult) {
|
||||
console.log('');
|
||||
|
||||
if (!gateResult.configured) {
|
||||
console.log('Performance gates: not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const passedChecks = gateResult.checks.filter((check) => check.passed).length;
|
||||
console.log(
|
||||
`Performance gates: ${gateResult.passed ? 'PASSED' : 'FAILED'} (${passedChecks}/${gateResult.checks.length})`,
|
||||
);
|
||||
|
||||
for (const check of gateResult.checks) {
|
||||
const status = check.passed ? 'PASS' : 'FAIL';
|
||||
console.log(
|
||||
`[${status}] ${check.label}: ${check.actual}${check.unit} ${check.operator} ${check.threshold}${check.unit}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes('--help')) {
|
||||
printUsage();
|
||||
@@ -249,12 +395,27 @@ async function main() {
|
||||
|
||||
const totalDurationMs = performance.now() - startedAt;
|
||||
const summary = buildSummary(options, results, totalDurationMs);
|
||||
const performanceGates = evaluatePerformanceGates(summary, options.gates);
|
||||
summary.performanceGates = performanceGates;
|
||||
|
||||
console.log('');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
printGateReport(performanceGates);
|
||||
|
||||
if (!performanceGates.passed) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSummary,
|
||||
evaluatePerformanceGates,
|
||||
parseArgs,
|
||||
};
|
||||
|
||||
123
scripts/load-test.test.js
Normal file
123
scripts/load-test.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { createServer } = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { evaluatePerformanceGates, parseArgs } = require('./load-test');
|
||||
|
||||
const passingSummary = {
|
||||
requestsPerSecond: 250.25,
|
||||
successRate: 99.95,
|
||||
latencyMs: { p95: 120.5, p99: 180.75 },
|
||||
};
|
||||
|
||||
test('parseArgs reads all optional performance gates', () => {
|
||||
const options = parseArgs([
|
||||
'--min-success-rate',
|
||||
'99.9',
|
||||
'--min-rps',
|
||||
'200',
|
||||
'--max-p95',
|
||||
'150',
|
||||
'--max-p99',
|
||||
'250',
|
||||
]);
|
||||
|
||||
assert.deepEqual(options.gates, {
|
||||
minSuccessRate: 99.9,
|
||||
minRequestsPerSecond: 200,
|
||||
maxP95Ms: 150,
|
||||
maxP99Ms: 250,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseArgs rejects invalid gate values', () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['--min-success-rate', '100.1']),
|
||||
/min-success-rate must be a finite number between 0 and 100/,
|
||||
);
|
||||
assert.throws(() => parseArgs(['--min-rps', '-1']), /min-rps must be a finite number/);
|
||||
assert.throws(() => parseArgs(['--max-p95']), /--max-p95 requires a value/);
|
||||
});
|
||||
|
||||
test('evaluatePerformanceGates passes when every threshold is satisfied', () => {
|
||||
const result = evaluatePerformanceGates(passingSummary, {
|
||||
minSuccessRate: 99.9,
|
||||
minRequestsPerSecond: 200,
|
||||
maxP95Ms: 150,
|
||||
maxP99Ms: 200,
|
||||
});
|
||||
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.passed, true);
|
||||
assert.equal(result.checks.length, 4);
|
||||
assert.ok(result.checks.every((check) => check.passed));
|
||||
});
|
||||
|
||||
test('evaluatePerformanceGates reports every failed threshold', () => {
|
||||
const result = evaluatePerformanceGates(passingSummary, {
|
||||
minSuccessRate: 100,
|
||||
minRequestsPerSecond: 300,
|
||||
maxP95Ms: 100,
|
||||
maxP99Ms: 150,
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.deepEqual(
|
||||
result.checks.filter((check) => !check.passed).map((check) => check.metric),
|
||||
['successRate', 'requestsPerSecond', 'latencyMs.p95', 'latencyMs.p99'],
|
||||
);
|
||||
});
|
||||
|
||||
test('load-test process exits with code 2 and a clear report when a gate fails', async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.statusCode = 503;
|
||||
response.end('unavailable');
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
|
||||
const result = await runLoadTest([
|
||||
'--url',
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
'--duration',
|
||||
'0.05',
|
||||
'--concurrency',
|
||||
'1',
|
||||
'--warmup',
|
||||
'0',
|
||||
'--min-success-rate',
|
||||
'100',
|
||||
]);
|
||||
|
||||
assert.equal(result.exitCode, 2);
|
||||
assert.match(result.stdout, /Performance gates: FAILED \(0\/1\)/);
|
||||
assert.match(result.stdout, /\[FAIL\] Success rate:/);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function runLoadTest(args) {
|
||||
const scriptPath = path.join(__dirname, 'load-test.js');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(process.execPath, [scriptPath, ...args], (error, stdout, stderr) => {
|
||||
resolve({
|
||||
exitCode: error && typeof error.code === 'number' ? error.code : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,16 @@ async function main() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
[
|
||||
reason,
|
||||
`Child exit code: ${child.exitCode ?? 'still-running'}`,
|
||||
`Recent stdout: ${stdoutLines.join(' | ') || 'none'}`,
|
||||
`Recent stderr: ${stderrLines.join(' | ') || 'none'}`,
|
||||
].join('\n'),
|
||||
);
|
||||
} finally {
|
||||
await terminate(child);
|
||||
}
|
||||
|
||||
58
scripts/sync-atlas-search-indexes.js
Normal file
58
scripts/sync-atlas-search-indexes.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
require('dotenv').config({ path: path.join(projectRoot, '.env') });
|
||||
|
||||
const indexSpecs = [
|
||||
{
|
||||
collection: 'users',
|
||||
name: process.env.SEARCH_ATLAS_USER_INDEX || 'users_search',
|
||||
definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'users_search.json'),
|
||||
},
|
||||
{
|
||||
collection: 'posts',
|
||||
name: process.env.SEARCH_ATLAS_POST_INDEX || 'posts_search',
|
||||
definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'posts_search.json'),
|
||||
},
|
||||
];
|
||||
|
||||
async function syncIndex(database, spec) {
|
||||
const collection = database.collection(spec.collection);
|
||||
const definition = JSON.parse(fs.readFileSync(spec.definitionPath, 'utf8'));
|
||||
const existing = await collection.listSearchIndexes(spec.name).toArray();
|
||||
|
||||
if (existing.length) {
|
||||
await collection.updateSearchIndex(spec.name, definition);
|
||||
process.stdout.write(`Updated Atlas Search index ${spec.name} on ${spec.collection}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.createSearchIndex({ name: spec.name, definition });
|
||||
process.stdout.write(`Created Atlas Search index ${spec.name} on ${spec.collection}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) {
|
||||
throw new Error('MONGODB_URI is required');
|
||||
}
|
||||
|
||||
await mongoose.connect(uri, { serverSelectionTimeoutMS: 15_000 });
|
||||
try {
|
||||
if (!mongoose.connection.db) {
|
||||
throw new Error('MongoDB connection has no selected database');
|
||||
}
|
||||
for (const spec of indexSpecs) {
|
||||
await syncIndex(mongoose.connection.db, spec);
|
||||
}
|
||||
} finally {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
المرجع في مشكلة جديدة
حظر مستخدم