[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* End-to-end billing integration test.
|
||||
*
|
||||
* Exercises the FULL purchase → fulfillment → activation → Pro unlock flow:
|
||||
*
|
||||
* 1. POST /api/v1/billing/checkout → mock Stripe SDK → session { id, url }
|
||||
* 2. Simulate webhook delivery → bridge.handleWebhook() with a signed
|
||||
* checkout.session.completed payload
|
||||
* 3. GET /api/v1/billing/lookup/:sessionId → verify license code returned
|
||||
* 4. POST /api/v1/license/activate → verify code activates, Pro unlocks
|
||||
*
|
||||
* The bridge and the API billing routes communicate through a SHARED
|
||||
* fulfillment-store file (the production IPC channel — a bind-mounted JSON
|
||||
* file). This test wires both sides to the same tmp file so the lookup
|
||||
* endpoint sees the license the bridge persisted, exactly as in production.
|
||||
*
|
||||
* The REAL license-keygen + LicenseManager are used (no HMAC mock) so the
|
||||
* code generated by the bridge is cryptographically valid and activates
|
||||
* through the real LicenseManager.verifyCode() path. Only Stripe's network
|
||||
* surface and nodemailer are mocked.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// ── jest.mock must be hoisted before any require() ─────────────────────────
|
||||
// Mock nodemailer so the bridge never opens a real SMTP connection. SMTP is
|
||||
// left unconfigured (no SMTP_HOST/SMTP_FROM) so deliverCode() falls back to
|
||||
// dev-console mode — the documented dev/test path where the license is marked
|
||||
// `delivered` without actually sending email.
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(() => ({ sendMail: jest.fn() })),
|
||||
}));
|
||||
|
||||
// ── Isolated tmp state (set BEFORE requiring the bridge + routes) ──────────
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-e2e-billing-'));
|
||||
|
||||
// Shared fulfillment-store file — the IPC channel between bridge and API.
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_e2e_' + crypto.randomBytes(8).toString('hex');
|
||||
|
||||
// Configure Stripe products so the catalog + stripe-client can resolve price IDs.
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_e2e';
|
||||
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_e2e';
|
||||
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_e2e';
|
||||
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_e2e';
|
||||
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_e2e';
|
||||
process.env.STRIPE_PUBLIC_ORIGIN = 'https://status.test';
|
||||
|
||||
// No SMTP → bridge uses dev-console delivery (license marked delivered, no email).
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
// ── Real license-keygen with a known master secret ─────────────────────────
|
||||
// We write a real secret file so the bridge's loadSecret() + generateCodes()
|
||||
// produce HMAC-valid codes that the LicenseManager can verify with the SAME
|
||||
// secret. This makes the activation step exercise the real cryptographic path.
|
||||
const E2E_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
const SECRET_FILE = path.join(TMP, '.license-secret');
|
||||
fs.writeFileSync(SECRET_FILE, E2E_SECRET, { mode: 0o600 });
|
||||
process.env.LICENSE_SECRET_FILE = SECRET_FILE;
|
||||
|
||||
// Real keygen — no mock. The counter file is isolated to the tmp dir.
|
||||
process.env.LICENSE_COUNTER_FILE = path.join(TMP, '.license-counter');
|
||||
|
||||
// Now require modules (after env + mock setup).
|
||||
const keygen = require('../../license-keygen');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
const billingRoutesFactory = require('../../routes/billing');
|
||||
const licenseRoutesFactory = require('../../routes/license');
|
||||
const { LicenseManager } = require('../../src/managers/license-manager');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
// ── Test app: mounts billing + license routes the same way app.js does ─────
|
||||
function makeApp(licenseManager) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
app.use('/api/v1/billing', billingRoutesFactory({ asyncHandler }));
|
||||
app.use('/api/v1/license', licenseRoutesFactory({ licenseManager, asyncHandler }));
|
||||
|
||||
// Jest/express error handler — surfaces route errors as JSON so supertest
|
||||
// can assert on the body.
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a signed Stripe webhook payload for checkout.session.completed.
|
||||
*/
|
||||
function buildSignedWebhook(sessionId, productId, customerEmail, opts = {}) {
|
||||
const product = catalog.getProduct(productId);
|
||||
const event = {
|
||||
id: opts.eventId || `evt_e2e_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: opts.type || 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId,
|
||||
customer_email: customerEmail,
|
||||
customer_details: { email: customerEmail },
|
||||
payment_status: 'paid',
|
||||
amount_total: product ? product.amountCents : 0,
|
||||
currency: 'usd',
|
||||
metadata: { productId, product: 'dashcaddy-pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const rawBody = Buffer.from(JSON.stringify(event));
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET)
|
||||
.update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
return { rawBody, signatureHeader: `t=${ts},v1=${sig}`, event };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a mock Stripe SDK that returns a checkout session with a
|
||||
* caller-chosen id + url. Captures the params passed to sessions.create().
|
||||
*/
|
||||
function installMockStripe(sessionId, sessionUrl) {
|
||||
let capturedParams;
|
||||
const mockStripe = jest.fn().mockReturnValue({
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return { id: sessionId, url: sessionUrl };
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
stripeClient._setStripeSdk(mockStripe);
|
||||
return { capturedParams: () => capturedParams };
|
||||
}
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
||||
afterAll(() => {
|
||||
stripeClient._setStripeSdk(null);
|
||||
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// THE END-TO-END FLOW
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('end-to-end billing flow: checkout → webhook → lookup → activate → Pro', () => {
|
||||
const PRODUCT_ID = 'pro-90d';
|
||||
const CUSTOMER_EMAIL = 'alice@example.com';
|
||||
const SESSION_ID = `cs_e2e_${crypto.randomBytes(6).toString('hex')}`;
|
||||
const CHECKOUT_URL = `https://checkout.stripe.com/c/pay/${SESSION_ID}`;
|
||||
|
||||
let app;
|
||||
let licenseManager;
|
||||
let activationCode; // captured during the flow
|
||||
|
||||
beforeAll(() => {
|
||||
// Real LicenseManager, configured with the same secret the bridge uses.
|
||||
licenseManager = new LicenseManager(
|
||||
{
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
path.join(TMP, 'config.json'),
|
||||
{ info: () => {}, warn: () => {}, error: () => {} }
|
||||
);
|
||||
// loadSecret reads the file and stores it as masterSecretHash for verifyCode().
|
||||
licenseManager.loadSecret(SECRET_FILE);
|
||||
app = makeApp(licenseManager);
|
||||
});
|
||||
|
||||
// ── Step 1: POST /api/v1/billing/checkout ──────────────────────────────
|
||||
test('Step 1: checkout creates a Stripe session via the mock SDK', async () => {
|
||||
const stripe = installMockStripe(SESSION_ID, CHECKOUT_URL);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/billing/checkout')
|
||||
.send({ productId: PRODUCT_ID, customerEmail: CUSTOMER_EMAIL })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.id).toBe(SESSION_ID);
|
||||
expect(res.body.data.url).toBe(CHECKOUT_URL);
|
||||
|
||||
// The mock Stripe SDK was called with the correct product + metadata.
|
||||
const params = stripe.capturedParams();
|
||||
expect(params.mode).toBe('payment');
|
||||
expect(params.metadata.productId).toBe(PRODUCT_ID);
|
||||
expect(params.line_items[0].price).toBe('price_90d_e2e');
|
||||
expect(params.customer_email).toBe(CUSTOMER_EMAIL);
|
||||
});
|
||||
|
||||
// ── Step 2: Simulate Stripe webhook delivery ───────────────────────────
|
||||
test('Step 2: webhook generates + persists + delivers the license', async () => {
|
||||
const { rawBody, signatureHeader, event } = buildSignedWebhook(
|
||||
SESSION_ID, PRODUCT_ID, CUSTOMER_EMAIL
|
||||
);
|
||||
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe(PRODUCT_ID);
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
expect(result.body.codeId).toBeTruthy();
|
||||
expect(result.body.deliveredVia).toBe('dev-console');
|
||||
|
||||
// Capture the code for subsequent steps.
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(SESSION_ID);
|
||||
expect(record).toBeTruthy();
|
||||
expect(record.status).toBe('delivered');
|
||||
expect(record.code).toBeTruthy();
|
||||
activationCode = record.code;
|
||||
});
|
||||
|
||||
// ── Step 3: GET /api/v1/billing/lookup/:sessionId ──────────────────────
|
||||
test('Step 3: lookup returns the delivered license code', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/billing/lookup/${SESSION_ID}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.status).toBe('delivered');
|
||||
expect(res.body.data.code).toBe(activationCode);
|
||||
expect(res.body.data.codeId).toBeTruthy();
|
||||
expect(res.body.data.productId).toBe(PRODUCT_ID);
|
||||
expect(res.body.data.durationDays).toBe(90);
|
||||
expect(res.body.data.deliveredVia).toBe('dev-console');
|
||||
// Bearer-style secret — must never be cached.
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
// ── Step 4: POST /api/v1/license/activate → Pro unlock ─────────────────
|
||||
test('Step 4: activate the license → Pro tier unlocks', async () => {
|
||||
expect(activationCode).toBeTruthy();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/license/activate')
|
||||
.send({ code: activationCode })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.license).toBeDefined();
|
||||
expect(res.body.license.active).toBe(true);
|
||||
expect(res.body.license.tier).toBe('premium');
|
||||
expect(res.body.license.durationDays).toBe(90);
|
||||
expect(res.body.license.expired).toBe(false);
|
||||
|
||||
// The LicenseManager itself now reports Pro (this is what gates features
|
||||
// elsewhere in the app via licenseManager.isPro()).
|
||||
expect(licenseManager.isPro()).toBe(true);
|
||||
expect(licenseManager.hasFeature('sso')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Bonus: GET /api/v1/license/status reflects the active Pro license ──
|
||||
test('Step 5: license status confirms Pro is active', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/license/status')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.license.active).toBe(true);
|
||||
expect(res.body.license.tier).toBe('premium');
|
||||
expect(res.body.license.expired).toBe(false);
|
||||
expect(res.body.license.features).toEqual(
|
||||
expect.arrayContaining(['sso', 'recipes', 'swarm'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Additional e2e scenarios
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('e2e: lookup returns 404 before webhook delivers the license', () => {
|
||||
test('lookup before webhook → 404 not found', async () => {
|
||||
const app = makeApp(null);
|
||||
const sessionId = `cs_notyet_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/billing/lookup/${sessionId}`)
|
||||
.expect(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('e2e: each catalog product flows through to a valid activatable license', () => {
|
||||
// Use a fresh app + licenseManager per product to avoid activation conflicts.
|
||||
for (const product of catalog.PRODUCTS) {
|
||||
test(`product ${product.id} (${product.durationDays}d) activates and unlocks Pro`, async () => {
|
||||
const sessionId = `cs_e2e_${product.id}_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const email = `buyer_${product.id}@example.com`;
|
||||
|
||||
const lm = new LicenseManager(
|
||||
{
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
path.join(TMP, `config-${product.id}.json`),
|
||||
{ info: () => {}, warn: () => {}, error: () => {} }
|
||||
);
|
||||
lm.loadSecret(SECRET_FILE);
|
||||
const app = makeApp(lm);
|
||||
|
||||
// Checkout
|
||||
installMockStripe(sessionId, `https://checkout.stripe.com/c/pay/${sessionId}`);
|
||||
const checkoutRes = await request(app)
|
||||
.post('/api/v1/billing/checkout')
|
||||
.send({ productId: product.id, customerEmail: email })
|
||||
.expect(200);
|
||||
expect(checkoutRes.body.data.id).toBe(sessionId);
|
||||
|
||||
// Webhook
|
||||
const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, product.id, email);
|
||||
const whResult = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(whResult.status).toBe(200);
|
||||
expect(whResult.body.delivered).toBe(true);
|
||||
expect(whResult.body.durationDays).toBe(product.durationDays);
|
||||
|
||||
// Lookup
|
||||
const lookupRes = await request(app)
|
||||
.get(`/api/v1/billing/lookup/${sessionId}`)
|
||||
.expect(200);
|
||||
expect(lookupRes.body.data.status).toBe('delivered');
|
||||
expect(lookupRes.body.data.code).toBeTruthy();
|
||||
const code = lookupRes.body.data.code;
|
||||
|
||||
// Activate → Pro
|
||||
const activateRes = await request(app)
|
||||
.post('/api/v1/license/activate')
|
||||
.send({ code })
|
||||
.expect(200);
|
||||
expect(activateRes.body.license.tier).toBe('premium');
|
||||
expect(activateRes.body.license.durationDays).toBe(product.durationDays);
|
||||
expect(lm.isPro()).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('e2e: webhook idempotency — duplicate delivery reuses the same license', () => {
|
||||
test('a second webhook for the same session does not mint a new code', async () => {
|
||||
const sessionId = `cs_e2e_dedup_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const productId = 'pro-30d';
|
||||
const email = 'dedup@example.com';
|
||||
|
||||
// First delivery.
|
||||
const payload1 = buildSignedWebhook(sessionId, productId, email);
|
||||
const r1 = await bridge.handleWebhook({
|
||||
rawBody: payload1.rawBody,
|
||||
signatureHeader: payload1.signatureHeader,
|
||||
});
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.body.delivered).toBe(true);
|
||||
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const firstCode = store.readBySession(sessionId).code;
|
||||
expect(firstCode).toBeTruthy();
|
||||
|
||||
// Same eventId (Stripe retry) → layer-1 idempotency, no regeneration.
|
||||
const r2 = await bridge.handleWebhook({
|
||||
rawBody: payload1.rawBody,
|
||||
signatureHeader: payload1.signatureHeader,
|
||||
});
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.body.deduplicated).toBe(true);
|
||||
|
||||
const secondCode = store.readBySession(sessionId).code;
|
||||
expect(secondCode).toBe(firstCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('e2e: the license code generated by the bridge verifies via the real keygen', () => {
|
||||
test('bridge-generated code is cryptographically valid', async () => {
|
||||
const sessionId = `cs_e2e_crypto_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, 'pro-365d', 'crypto@example.com');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const code = store.readBySession(sessionId).code;
|
||||
|
||||
// verifyCode with the SAME secret the bridge used — this is exactly what
|
||||
// LicenseManager._validateOffline does during activation.
|
||||
const verification = keygen.verifyCode(E2E_SECRET, code);
|
||||
expect(verification.valid).toBe(true);
|
||||
expect(verification.durationDays).toBe(365);
|
||||
expect(verification.expired).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -151,7 +151,8 @@ describe('config/migrations', () => {
|
||||
const mtimeBefore = fs.statSync(configFile).mtimeMs;
|
||||
// Wait a tick
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 50) {} // 50ms busy-wait
|
||||
let spin = start;
|
||||
while (Date.now() - spin < 50) { spin = Date.now(); } // 50ms busy-wait
|
||||
|
||||
loadAndMigrate(configFile, null);
|
||||
|
||||
|
||||
@@ -156,18 +156,19 @@ describe('Error Handler', () => {
|
||||
});
|
||||
|
||||
it('logs non-operational errors as FATAL', () => {
|
||||
const origError = console.error;
|
||||
console.error = jest.fn();
|
||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
const err = new Error('programming bug');
|
||||
errorMiddleware(err, req, res, next);
|
||||
try {
|
||||
const err = new Error('programming bug');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'FATAL: Non-operational error detected',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
console.error = origError;
|
||||
const calls = stderrSpy.mock.calls.map(c => String(c[0]));
|
||||
const fatalLine = calls.find(l => l.includes('FATAL'));
|
||||
expect(fatalLine).toBeDefined();
|
||||
expect(fatalLine).toContain('programming bug');
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* DC-071: Error tracker tests
|
||||
*/
|
||||
const errorTracker = require('../src/utilities/error-tracker');
|
||||
|
||||
describe('DC-071: Error Tracker', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to clean state
|
||||
errorTracker.dsn = null;
|
||||
errorTracker.enabled = false;
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('is disabled without DSN', () => {
|
||||
const enabled = errorTracker.init({});
|
||||
expect(enabled).toBe(false);
|
||||
expect(errorTracker.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables with DSN', () => {
|
||||
const enabled = errorTracker.init({
|
||||
dsn: 'https://abc123@sentry.io/123',
|
||||
release: '1.15.0',
|
||||
});
|
||||
expect(enabled).toBe(true);
|
||||
expect(errorTracker.enabled).toBe(true);
|
||||
expect(errorTracker.release).toBe('1.15.0');
|
||||
});
|
||||
|
||||
it('reads DSN from env', () => {
|
||||
process.env.ERROR_TRACKING_DSN = 'https://key@sentry.io/456';
|
||||
const enabled = errorTracker.init({});
|
||||
expect(enabled).toBe(true);
|
||||
delete process.env.ERROR_TRACKING_DSN;
|
||||
});
|
||||
});
|
||||
|
||||
describe('capture()', () => {
|
||||
it('returns undefined when disabled', () => {
|
||||
const result = errorTracker.capture(new Error('test'));
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns event ID when enabled', () => {
|
||||
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||
const eventId = errorTracker.capture(new Error('test'));
|
||||
expect(eventId).toBeTruthy();
|
||||
expect(typeof eventId).toBe('string');
|
||||
});
|
||||
|
||||
it('handles null error gracefully', () => {
|
||||
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||
const result = errorTracker.capture(null);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureMessage()', () => {
|
||||
it('returns undefined when disabled', () => {
|
||||
const result = errorTracker.captureMessage('test');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns event ID when enabled', () => {
|
||||
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||
const eventId = errorTracker.captureMessage('test info', 'info');
|
||||
expect(eventId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware()', () => {
|
||||
it('calls next(err) after capturing', () => {
|
||||
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||
const middleware = errorTracker.middleware();
|
||||
const err = new Error('middleware test');
|
||||
const req = { url: '/test', method: 'GET', headers: {}, path: '/test' };
|
||||
const res = {};
|
||||
let nextCalled = false;
|
||||
let nextArg = null;
|
||||
middleware(err, req, res, (e) => { nextCalled = true; nextArg = e; });
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(nextArg).toBe(err);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush()', () => {
|
||||
it('resolves without error', async () => {
|
||||
await expect(errorTracker.flush(100)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* DC-077: Tests for the i18n system
|
||||
*/
|
||||
const i18n = require('../src/utilities/i18n');
|
||||
|
||||
describe('DC-077: i18n system', () => {
|
||||
describe('t() translation function', () => {
|
||||
it('translates keys in English by default', () => {
|
||||
expect(i18n.t('dashboard.title')).toBe('Dashboard');
|
||||
expect(i18n.t('action.start')).toBe('Start');
|
||||
});
|
||||
|
||||
it('translates keys in Spanish', () => {
|
||||
expect(i18n.t('dashboard.title', 'es')).toBe('Panel de control');
|
||||
expect(i18n.t('action.start', 'es')).toBe('Iniciar');
|
||||
});
|
||||
|
||||
it('translates keys in French', () => {
|
||||
expect(i18n.t('dashboard.title', 'fr')).toBe('Tableau de bord');
|
||||
expect(i18n.t('action.stop', 'fr')).toBe('Arrêter');
|
||||
});
|
||||
|
||||
it('translates keys in German', () => {
|
||||
expect(i18n.t('dashboard.title', 'de')).toBe('Dashboard');
|
||||
expect(i18n.t('action.delete', 'de')).toBe('Löschen');
|
||||
});
|
||||
|
||||
it('translates keys in Arabic', () => {
|
||||
expect(i18n.t('dashboard.title', 'ar')).toBe('لوحة التحكم');
|
||||
expect(i18n.t('action.start', 'ar')).toBe('تشغيل');
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported language', () => {
|
||||
expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('falls back to key if not found in any language', () => {
|
||||
expect(i18n.t('nonexistent.key.xyz')).toBe('nonexistent.key.xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSupportedLanguages()', () => {
|
||||
it('returns array of language codes', () => {
|
||||
const langs = i18n.getSupportedLanguages();
|
||||
expect(langs).toContain('en');
|
||||
expect(langs).toContain('es');
|
||||
expect(langs).toContain('fr');
|
||||
expect(langs).toContain('de');
|
||||
expect(langs).toContain('ar');
|
||||
expect(langs.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupported()', () => {
|
||||
it('returns true for supported languages', () => {
|
||||
expect(i18n.isSupported('en')).toBe(true);
|
||||
expect(i18n.isSupported('fr')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unsupported languages', () => {
|
||||
expect(i18n.isSupported('zh')).toBe(false);
|
||||
expect(i18n.isSupported('ja')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLanguage()', () => {
|
||||
it('detects from Accept-Language header', () => {
|
||||
expect(i18n.detectLanguage('es-ES,es;q=0.9,en;q=0.8')).toBe('es');
|
||||
expect(i18n.detectLanguage('fr-FR,fr;q=0.9')).toBe('fr');
|
||||
expect(i18n.detectLanguage('de-DE,de;q=0.9,en;q=0.8')).toBe('de');
|
||||
});
|
||||
|
||||
it('handles quality values correctly', () => {
|
||||
expect(i18n.detectLanguage('en;q=0.9,fr;q=1.0')).toBe('fr');
|
||||
});
|
||||
|
||||
it('defaults to English for no header', () => {
|
||||
expect(i18n.detectLanguage(null)).toBe('en');
|
||||
expect(i18n.detectLanguage(undefined)).toBe('en');
|
||||
expect(i18n.detectLanguage('')).toBe('en');
|
||||
});
|
||||
|
||||
it('defaults to English for unsupported languages', () => {
|
||||
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('strips region codes before matching', () => {
|
||||
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RTL support', () => {
|
||||
it('Arabic is in supported languages', () => {
|
||||
expect(i18n.isSupported('ar')).toBe(true);
|
||||
expect(i18n.t('dashboard.title', 'ar')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,7 +197,8 @@ describe('Metrics (singleton)', () => {
|
||||
const before = metrics.startTime;
|
||||
// Sleep a tick so Date.now() moves forward
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5) {} // ~5ms busy-wait
|
||||
let spin = start;
|
||||
while (Date.now() - spin < 5) { spin = Date.now(); } // ~5ms busy-wait
|
||||
metrics.reset();
|
||||
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
|
||||
const summary = metrics.getSummary();
|
||||
|
||||
@@ -88,6 +88,13 @@ describe('Platform Paths — cross-platform path resolution', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through non-drive-letter strings unchanged on any platform', () => {
|
||||
const paths = loadPaths();
|
||||
// Plain strings without drive letters should pass through unchanged
|
||||
expect(paths.toDockerMountPath('relative/path')).toBe('relative/path');
|
||||
expect(paths.toDockerMountPath('plainstring')).toBe('plainstring');
|
||||
});
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
it('converts Windows drive paths to Docker mount format', () => {
|
||||
const paths = loadPaths();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* DC-080: Plugin manager tests
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PluginManager } = require('../../src/plugins/plugin-manager');
|
||||
|
||||
describe('DC-080: Plugin Manager', () => {
|
||||
let tmpDir, manager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-plugins-'));
|
||||
manager = new PluginManager({
|
||||
dataDir: tmpDir,
|
||||
log: { info: jest.fn(), error: jest.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadAll()', () => {
|
||||
it('creates plugin directory if it does not exist', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins');
|
||||
expect(fs.existsSync(pluginDir)).toBe(false);
|
||||
await manager.loadAll();
|
||||
expect(fs.existsSync(pluginDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('loads successfully with empty plugin dir', async () => {
|
||||
await manager.loadAll();
|
||||
expect(manager.plugins.size).toBe(0);
|
||||
expect(manager.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('skips hidden directories', async () => {
|
||||
const hiddenDir = path.join(tmpDir, 'plugins', '.hidden');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
await manager.loadAll();
|
||||
expect(manager.plugins.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadOne()', () => {
|
||||
it('loads a plugin with valid manifest', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'test-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
})
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
expect(manager.plugins.has('test-plugin')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws if manifest.json is missing', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'no-manifest');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('manifest.json');
|
||||
});
|
||||
|
||||
it('throws if manifest lacks name or version', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'invalid');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ description: 'no name' })
|
||||
);
|
||||
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('name and version');
|
||||
});
|
||||
|
||||
it('throws on duplicate plugin name', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'dup');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'dup', version: '1.0.0' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
await expect(manager.loadOne(pluginDir)).rejects.toThrow('already loaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unload()', () => {
|
||||
it('unloads a loaded plugin', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'removable');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'removable', version: '1.0.0' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
expect(manager.plugins.has('removable')).toBe(true);
|
||||
|
||||
manager.unload('removable');
|
||||
expect(manager.plugins.has('removable')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown plugin', () => {
|
||||
expect(manager.unload('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()', () => {
|
||||
it('returns empty array when no plugins', () => {
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns plugin metadata', async () => {
|
||||
const pluginDir = path.join(tmpDir, 'plugins', 'listed');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'listed', version: '2.0.0', description: 'Test' })
|
||||
);
|
||||
|
||||
await manager.loadOne(pluginDir);
|
||||
const list = manager.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].name).toBe('listed');
|
||||
expect(list[0].version).toBe('2.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeHook()', () => {
|
||||
it('returns empty results when no plugins have the hook', async () => {
|
||||
await manager.loadAll();
|
||||
const results = await manager.executeHook('service:health-check');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWidgets()', () => {
|
||||
it('returns empty array by default', () => {
|
||||
expect(manager.getWidgets()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServiceTypes()', () => {
|
||||
it('returns empty array by default', () => {
|
||||
expect(manager.getServiceTypes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* DC-106 + DC-108: Caddycode + Fleet endpoint tests
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
function createCaddycodeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/caddycode');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||
return app;
|
||||
}
|
||||
|
||||
function createFleetApp(log) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/fleet');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', routes({ log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('DC-106: Caddyfile-as-Code', () => {
|
||||
it('POST /generate creates Caddyfile from config', async () => {
|
||||
const app = createCaddycodeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/caddycode/generate')
|
||||
.send({
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:8080',
|
||||
websocket: true,
|
||||
cors: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caddyfile).toContain('app.example.com');
|
||||
expect(res.body.caddyfile).toContain('reverse_proxy');
|
||||
expect(res.body.caddyfile).toContain('Access-Control-Allow-Origin');
|
||||
});
|
||||
|
||||
it('POST /generate returns 400 without domain', async () => {
|
||||
const app = createCaddycodeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/caddycode/generate')
|
||||
.send({ upstream: 'localhost:8080' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /validate finds unbalanced braces', async () => {
|
||||
const app = createCaddycodeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/caddycode/validate')
|
||||
.send({ caddyfile: 'app.com {\n reverse_proxy localhost:8080\n' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.issues[0]).toContain('Unbalanced');
|
||||
});
|
||||
|
||||
it('POST /validate passes for valid Caddyfile', async () => {
|
||||
const app = createCaddycodeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/caddycode/validate')
|
||||
.send({ caddyfile: 'app.com {\n reverse_proxy localhost:8080\n}' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /templates returns preset configs', async () => {
|
||||
const app = createCaddycodeApp();
|
||||
const res = await request(app).get('/api/v1/caddycode/templates');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Object.keys(res.body.templates).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-108: Fleet Management', () => {
|
||||
beforeEach(() => {
|
||||
process.env.FLEET_HOSTS_FILE = `/tmp/fleet-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { require('fs').unlinkSync(process.env.FLEET_HOSTS_FILE); } catch { /* ok */ }
|
||||
});
|
||||
|
||||
it('GET /hosts returns empty list initially', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app).get('/api/v1/fleet/hosts');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total).toBe(0);
|
||||
});
|
||||
|
||||
it('POST /hosts registers a new host', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Test Host', hostname: '192.168.1.100', apiKey: 'dk_test_12345', tags: ['prod'] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.name).toBe('Test Host');
|
||||
expect(res.body.host.apiKey).toBe('***'); // Key is masked
|
||||
expect(res.body.host.apiKeyHash).toBeTruthy();
|
||||
expect(res.body.host.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('POST /hosts returns 400 without name', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ hostname: '192.168.1.100' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /deploy generates deployment plan', async () => {
|
||||
const app = createFleetApp();
|
||||
// First register a host
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Host 1', hostname: '10.0.0.1' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex', config: { port: 32400 } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.plan[0].templateId).toBe('plex');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DC-100: Service discovery tests
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
function createApp(docker, servicesStateManager) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const discoverRoutes = require('../../routes/discover');
|
||||
|
||||
app.use('/api/v1', discoverRoutes({
|
||||
docker,
|
||||
servicesStateManager,
|
||||
asyncHandler,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('DC-100: Service Discovery', () => {
|
||||
it('returns 503 when Docker is not available', async () => {
|
||||
const app = createApp(null, null);
|
||||
const res = await request(app).get('/api/v1/discover');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.code).toBe('DC-CONT-011');
|
||||
});
|
||||
|
||||
it('discovers running containers with pattern matching', async () => {
|
||||
const mockDocker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
Id: 'abc123def456',
|
||||
Names: ['/plex-server'],
|
||||
Image: 'plexinc/pms-docker:latest',
|
||||
State: 'running',
|
||||
Ports: [
|
||||
{ IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' },
|
||||
],
|
||||
Labels: {},
|
||||
},
|
||||
{
|
||||
Id: 'def789abc012',
|
||||
Names: ['/redis-cache'],
|
||||
Image: 'redis:7-alpine',
|
||||
State: 'running',
|
||||
Ports: [
|
||||
{ IP: '0.0.0.0', PrivatePort: 6379, PublicPort: 6379, Type: 'tcp' },
|
||||
],
|
||||
Labels: {},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const mockStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const app = createApp(mockDocker, mockStateManager);
|
||||
const res = await request(app).get('/api/v1/discover');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.total).toBe(2);
|
||||
expect(res.body.discovered).toHaveLength(2);
|
||||
|
||||
const plex = res.body.discovered.find(d => d.name === 'plex-server');
|
||||
expect(plex.suggested.type).toBe('plex');
|
||||
expect(plex.suggested.name).toBe('Plex');
|
||||
expect(plex.suggested.port).toBe(32400);
|
||||
expect(plex.existing).toBe(false);
|
||||
|
||||
const redis = res.body.discovered.find(d => d.name === 'redis-cache');
|
||||
expect(redis.suggested.type).toBe('redis');
|
||||
});
|
||||
|
||||
it('marks already-added services as existing', async () => {
|
||||
const mockDocker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
Id: 'abc123def456',
|
||||
Names: ['/plex-server'],
|
||||
Image: 'plexinc/pms-docker:latest',
|
||||
State: 'running',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const mockStateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex-server' }]),
|
||||
};
|
||||
|
||||
const app = createApp(mockDocker, mockStateManager);
|
||||
const res = await request(app).get('/api/v1/discover');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.discovered[0].existing).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty container list', async () => {
|
||||
const mockDocker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const app = createApp(mockDocker, null);
|
||||
const res = await request(app).get('/api/v1/discover');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total).toBe(0);
|
||||
expect(res.body.discovered).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 500 on Docker error', async () => {
|
||||
const mockDocker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockRejectedValue(new Error('connection refused')),
|
||||
},
|
||||
};
|
||||
|
||||
const app = createApp(mockDocker, null);
|
||||
const res = await request(app).get('/api/v1/discover');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* DC-083: Branch coverage tests for the new /system/health endpoint in routes/health.js.
|
||||
*
|
||||
* The endpoint at GET /api/system/health aggregates four checks (services, memory,
|
||||
* diskSpace, incidents) into an overall status. It has many uncovered branches:
|
||||
* - status === 'ok' / 'degraded' / 'down' in the services check
|
||||
* - status === 'ok' / 'warning' in the memory check
|
||||
* - status === 'ok' / 'warning' / 'critical' in the diskSpace check
|
||||
* - status === 'ok' / 'degraded' in the incidents check
|
||||
* - each check has a try/catch → unknown fallback
|
||||
* - overall status computation (unhealthy / degraded / healthy)
|
||||
*
|
||||
* Also covers additional uncovered branches in the /health-checks/* endpoints:
|
||||
* - unhealthy filter in /health-checks/status
|
||||
* - incidents open/non-empty
|
||||
* - incidents/history with pagination params
|
||||
* - /health/probe with and without ?url
|
||||
* - /health/services with array vs object services data, error paths
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Minimal asyncHandler that catches errors
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
// ---- Mocks (mirrors health.routes.test.js) ----
|
||||
jest.mock('child_process', () => ({ execSync: jest.fn() }));
|
||||
jest.mock('../../platform-paths', () => ({
|
||||
caCertDir: '/mock/ca',
|
||||
pkiRootCert: '/mock/pki/root.crt',
|
||||
dataDir: '/mock/data',
|
||||
}));
|
||||
jest.mock('../../src/utilities/fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true) }));
|
||||
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||
}));
|
||||
jest.mock('../../src/utilities/pagination', () => ({
|
||||
paginate: jest.fn((data, params) => ({ data, pagination: params ? { page: 1, limit: 10, total: data.length } : null })),
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { resolveServiceUrl } = require('../../src/utilities/url-resolver');
|
||||
const { execSync } = require('child_process');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
const defaultDeps = {
|
||||
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }),
|
||||
SERVICES_FILE: '/tmp/services.json',
|
||||
servicesStateManager: {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
siteConfig: { tld: 'sami' },
|
||||
buildServiceUrl: jest.fn(id => `https://${id}.sami`),
|
||||
asyncHandler,
|
||||
logError: jest.fn(),
|
||||
healthChecker: {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn().mockReturnValue(null),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
};
|
||||
const deps = { ...defaultDeps, ...depsOverride };
|
||||
const healthRoutes = require('../../routes/health');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', healthRoutes(deps));
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, deps };
|
||||
}
|
||||
|
||||
describe('System health endpoint (DC-083)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exists.mockResolvedValue(true);
|
||||
execSync.mockReturnValue('notAfter=Dec 22 12:00:00 2034 GMT');
|
||||
});
|
||||
|
||||
describe('GET /api/system/health', () => {
|
||||
it('returns healthy overall when all checks pass', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { status: 'up' },
|
||||
svc2: { status: 'healthy' },
|
||||
svc3: { status: 'online' },
|
||||
}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
// disk: 40% used → ok. df output format: header line + data line.
|
||||
// parts[0]='40%', parseInt → 40
|
||||
execSync.mockReturnValue('Use% Size Avail\n 40% 100G 60G');
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body.checks.services.status).toBe('ok');
|
||||
expect(res.body.checks.services.healthy).toBe(3);
|
||||
expect(res.body.checks.memory.status).toBe('ok');
|
||||
expect(res.body.checks.diskSpace.status).toBe('ok');
|
||||
expect(res.body.checks.incidents.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns degraded when some services are unhealthy (mixed)', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { status: 'up' },
|
||||
svc2: { status: 'down' },
|
||||
}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.checks.services.status).toBe('degraded');
|
||||
expect(res.body.checks.services.unhealthy).toBe(1);
|
||||
expect(res.body.checks.services.unknown).toBe(0);
|
||||
// Overall degraded because services degraded
|
||||
expect(res.body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
it('returns down when ALL services are unhealthy', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { status: 'down' },
|
||||
svc2: { status: 'offline' },
|
||||
}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.services.status).toBe('down');
|
||||
// Overall unhealthy because services down
|
||||
expect(res.body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('counts unknown status values (not up/down/healthy/etc.)', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { state: 'starting' }, // unknown state value
|
||||
svc2: { status: 'paused' }, // unknown status value
|
||||
svc3: { }, // no status/state → unknown
|
||||
}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.services.total).toBe(3);
|
||||
expect(res.body.checks.services.healthy).toBe(0);
|
||||
expect(res.body.checks.services.unhealthy).toBe(0);
|
||||
expect(res.body.checks.services.unknown).toBe(3);
|
||||
});
|
||||
|
||||
it('returns degraded when incidents are open', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([{ id: 'inc1' }, { id: 'inc2' }]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.incidents.status).toBe('degraded');
|
||||
expect(res.body.checks.incidents.count).toBe(2);
|
||||
expect(res.body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
it('returns warning when disk usage between 90-95%', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
execSync.mockReturnValue('Use% Size Avail\n 92% 100G 8G');
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.diskSpace.status).toBe('warning');
|
||||
expect(res.body.checks.diskSpace.usedPercent).toBe(92);
|
||||
expect(res.body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
it('returns critical when disk usage >= 95%', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
execSync.mockReturnValue('Use% Size Avail\n 97% 100G 3G');
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.diskSpace.status).toBe('critical');
|
||||
expect(res.body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('falls back to unknown for services when getCurrentStatus throws', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockImplementation(() => { throw new Error('boom'); }),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.services.status).toBe('unknown');
|
||||
// unknown → degraded overall
|
||||
expect(res.body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
it('falls back to unknown for disk when execSync throws', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
execSync.mockImplementation(() => { throw new Error('df failed'); });
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.diskSpace.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('falls back to unknown for incidents when getOpenIncidents throws', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getOpenIncidents: jest.fn().mockImplementation(() => { throw new Error('inc fail'); }),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.incidents.status).toBe('unknown');
|
||||
expect(res.body.checks.incidents.count).toBe(0);
|
||||
});
|
||||
|
||||
it('sets Cache-Control: no-store header', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('includes uptime block with seconds and human-readable', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.body.checks.uptime).toHaveProperty('seconds');
|
||||
expect(res.body.checks.uptime).toHaveProperty('human');
|
||||
expect(typeof res.body.checks.uptime.seconds).toBe('number');
|
||||
});
|
||||
|
||||
it('handles empty df output (only header line) — no diskSpace block set to ok', async () => {
|
||||
// df returns just one line → lines.length < 2 → diskSpace not assigned in try
|
||||
// (stays undefined → overall status considers it). Actually the try block
|
||||
// does NOT set diskSpace when lines.length < 2, so diskSpace is undefined
|
||||
// and Object.values(checks) excludes it. Verify no crash.
|
||||
execSync.mockReturnValue('Use% Size Avail');
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/system/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Coverage for health-checks/status unhealthy filter ----
|
||||
describe('GET /api/health-checks/status — unhealthy filter coverage', () => {
|
||||
it('counts unhealthy services via various status/state tokens', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { status: 'down' },
|
||||
svc2: { state: 'unhealthy' },
|
||||
svc3: { status: 'offline' },
|
||||
svc4: { status: 'error' },
|
||||
svc5: { status: 'up' },
|
||||
}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.summary.unhealthy).toBe(4);
|
||||
expect(res.body.summary.healthy).toBe(1);
|
||||
expect(res.body.summary.unknown).toBe(0);
|
||||
expect(res.body.summary.total).toBe(5);
|
||||
});
|
||||
|
||||
it('handles null/undefined status entries', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: null,
|
||||
svc2: {},
|
||||
svc3: { status: 'up' },
|
||||
}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/status');
|
||||
expect(res.status).toBe(200);
|
||||
// null and {} are not healthy or unhealthy → unknown
|
||||
expect(res.body.summary.unknown).toBe(2);
|
||||
expect(res.body.summary.healthy).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Coverage for /health/probe ----
|
||||
describe('GET /api/health/probe', () => {
|
||||
it('returns 400 when url query param missing', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/probe');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns probe result when url provided and fetch succeeds', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) });
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=https://example.com');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns unhealthy when probe fetch fails completely', async () => {
|
||||
const fetchT = jest.fn().mockRejectedValue(new Error('timeout'));
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=https://down.example');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('unhealthy');
|
||||
expect(res.body.reason).toBe('fetch failed');
|
||||
});
|
||||
|
||||
it('marks status as unhealthy when statusCode >= 500', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=https://500.example');
|
||||
expect(res.body.status).toBe('unhealthy');
|
||||
expect(res.body.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it('marks status as healthy when statusCode is 401/403 (auth wall)', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=https://auth.example');
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Coverage for /health/services with various service shapes ----
|
||||
describe('GET /api/health/services — service shape branches', () => {
|
||||
it('handles services as object with .services array', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue({ services: [{ id: 'svc1', name: 'S1' }] }),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const { app } = createApp({ servicesStateManager: stateManager, fetchT });
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health).toHaveProperty('svc1');
|
||||
});
|
||||
|
||||
it('uses service.name (lowercased) as id when service.id absent', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ name: 'MyService' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const { app } = createApp({ servicesStateManager: stateManager, fetchT });
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health).toHaveProperty('myservice');
|
||||
});
|
||||
|
||||
it('skips services with no id and no name', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ port: 8080 }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health).toEqual({});
|
||||
});
|
||||
|
||||
it('marks service as unknown when URL resolves to null', async () => {
|
||||
resolveServiceUrl.mockReturnValue(null);
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'novurl', name: 'No URL' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.body.health.novurl.status).toBe('unknown');
|
||||
expect(res.body.health.novurl.reason).toMatch(/No URL/);
|
||||
resolveServiceUrl.mockReturnValue('https://fallback.test');
|
||||
});
|
||||
|
||||
it('uses pylon relay when direct check fails and pylon configured', async () => {
|
||||
// Direct HEAD and GET both throw → falls through to pylon
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD fail')) // HEAD
|
||||
.mockRejectedValueOnce(new Error('GET fail')) // GET (fallback in checkDirect)
|
||||
.mockResolvedValueOnce({ // pylon probe
|
||||
ok: true, status: 200,
|
||||
json: () => ({ status: 'healthy', statusCode: 200, responseTime: 42 }),
|
||||
});
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { tld: 'sami', pylon: { url: 'http://pylon.test', key: 'k' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.body.health.svc1.via).toBe('pylon');
|
||||
expect(res.body.health.svc1.status).toBe('healthy');
|
||||
});
|
||||
|
||||
it('marks unhealthy when both direct and pylon fail (pylon configured)', async () => {
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD fail'))
|
||||
.mockRejectedValueOnce(new Error('GET fail'))
|
||||
.mockRejectedValueOnce(new Error('pylon fail'));
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { tld: 'sami', pylon: { url: 'http://pylon.test' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.body.health.svc1.status).toBe('unhealthy');
|
||||
expect(res.body.health.svc1.reason).toMatch(/direct \+ pylon/);
|
||||
});
|
||||
|
||||
it('catches errors thrown by resolveServiceUrl and marks as error', async () => {
|
||||
resolveServiceUrl.mockImplementation(() => { throw new Error('resolver exploded'); });
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.body.health.svc1.status).toBe('error');
|
||||
expect(res.body.health.svc1.reason).toMatch(/resolver exploded/);
|
||||
resolveServiceUrl.mockReturnValue('https://fallback.test');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Coverage for /health-checks/incidents and history with pagination ----
|
||||
describe('GET /api/health-checks/incidents — non-empty', () => {
|
||||
it('returns incidents list', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([{ id: 'inc1', serviceId: 'svc1' }]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/incidents');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.incidents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* DC-105: Wizard endpoint tests
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
function createApp(templates) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const wizardRoutes = require('../../routes/wizard');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', wizardRoutes({ APP_TEMPLATES: templates || [], asyncHandler: wrap }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('DC-105: Smart Defaults Wizard', () => {
|
||||
it('GET /categories returns 6 categories', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/api/v1/wizard/categories');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.categories).toHaveLength(6);
|
||||
expect(res.body.categories[0]).toHaveProperty('id');
|
||||
expect(res.body.categories[0]).toHaveProperty('label');
|
||||
expect(res.body.categories[0]).toHaveProperty('icon');
|
||||
});
|
||||
|
||||
it('POST /recommend returns services for media-streaming', async () => {
|
||||
const app = createApp([
|
||||
{ id: 'plex', name: 'Plex', image: 'plexinc/pms-docker', ports: [32400] },
|
||||
{ id: 'sonarr', name: 'Sonarr', image: 'lscr.io/linuxserver/sonarr', ports: [8989] },
|
||||
]);
|
||||
const res = await request(app)
|
||||
.post('/api/v1/wizard/recommend')
|
||||
.send({ categories: ['media-streaming'], hardwareProfile: 'medium' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalRecommended).toBeGreaterThan(0);
|
||||
expect(res.body.services[0].template).toBe('plex');
|
||||
expect(res.body.services[0].available).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /recommend returns 400 without categories', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/wizard/recommend')
|
||||
.send({ categories: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /recommend limits services by hardware profile', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/wizard/recommend')
|
||||
.send({ categories: ['media-streaming', 'development', 'monitoring'], hardwareProfile: 'minimal' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalRecommended).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('POST /apply returns deployment plan', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/wizard/apply')
|
||||
.send({ services: ['plex', 'sonarr'], subdomainPrefix: 'sami-' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalSteps).toBe(2);
|
||||
expect(res.body.plan[0].subdomain).toBe('sami-plex');
|
||||
});
|
||||
|
||||
it('POST /apply returns 400 without services', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/wizard/apply')
|
||||
.send({ services: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -778,11 +778,11 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'data') handler(Buffer.from(JSON.stringify({
|
||||
if (event === 'data') {handler(Buffer.from(JSON.stringify({
|
||||
description: 'Plex Media Server',
|
||||
pull_count: 1000000,
|
||||
star_count: 500
|
||||
})));
|
||||
})));}
|
||||
if (event === 'end') handler();
|
||||
})
|
||||
}));
|
||||
@@ -830,12 +830,12 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'data') handler(Buffer.from(JSON.stringify({
|
||||
if (event === 'data') {handler(Buffer.from(JSON.stringify({
|
||||
results: [
|
||||
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
|
||||
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
|
||||
]
|
||||
})));
|
||||
})));}
|
||||
if (event === 'end') handler();
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* DC-076: Tests for the dashboard WebSocket server
|
||||
*/
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const EventEmitter = require('events');
|
||||
const createDashboardWS = require('../../src/websocket/dashboard-ws');
|
||||
|
||||
function createMockServer() {
|
||||
return http.createServer((req, res) => {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('DC-076: Dashboard WebSocket', () => {
|
||||
let server, wsServer, port;
|
||||
|
||||
beforeEach((done) => {
|
||||
server = createMockServer();
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
|
||||
const resourceMonitor = new EventEmitter();
|
||||
const healthChecker = new EventEmitter();
|
||||
const updateManager = new EventEmitter();
|
||||
|
||||
wsServer = createDashboardWS(server, {
|
||||
resourceMonitor,
|
||||
healthChecker,
|
||||
updateManager,
|
||||
log: { info: jest.fn(), error: jest.fn() },
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach((done) => {
|
||||
wsServer.close();
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('accepts connections at the upgrade path', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.close();
|
||||
});
|
||||
ws.on('close', () => {
|
||||
done();
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('sends a connected event on join', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'connected') {
|
||||
expect(msg.data).toHaveProperty('clients');
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('responds to ping with pong', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'pong') {
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('responds to subscribe with subscribed confirmation', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'subscribed') {
|
||||
expect(msg.events).toEqual(['resource-alert', 'incident']);
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('responds to client-count request', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'client-count' }));
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'client-count') {
|
||||
expect(msg.count).toBeGreaterThanOrEqual(1);
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('returns error for invalid JSON', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.send('not json');
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'error') {
|
||||
expect(msg.error).toContain('Invalid JSON');
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
});
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('tracks client count', () => {
|
||||
expect(wsServer.getClientCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('broadcast method does not throw with no clients', () => {
|
||||
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user