diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index eb0d3dd2d..eded2e4b8 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -18,6 +18,12 @@ const config = { swagger: { enable: true, }, + docs: { + // Grouping primitive for guide sections. See `public.docs.tree.js` JSDoc for full schema details. + guideSections: [ + { title: 'Get Started', prefixMin: 0, prefixMax: 9 }, + ], + }, api: { protocol: 'http', port: 3000, diff --git a/lib/helpers/guides.js b/lib/helpers/guides.js index 04060c9f1..273d4f955 100644 --- a/lib/helpers/guides.js +++ b/lib/helpers/guides.js @@ -1,18 +1,17 @@ /** - * Markdown guide loader for the Redoc API reference. + * Markdown guide loader for the OpenAPI reference. * * Per-module markdown guides live under `modules/{name}/doc/guides/*.md` * and are discovered by the same globbing mechanism as OpenAPI YAML files * (see `config/assets.js` → `allGuides`). * - * Guides are merged into the OpenAPI spec via `info.description`, which - * Redoc renders as a top-level "Introduction" section in the sidebar and - * splits on markdown H1/H2 headings. + * Guides are merged into the OpenAPI spec via `info.description`, which an + * OpenAPI viewer renders as a top-level "Introduction" section, split on + * markdown H1/H2 headings. * * When `mergeGuidesIntoSpec` is called with a `{ sections }` option, guides - * are grouped under H1 section dividers (with each guide rendered as H2). - * Redoc auto-nests H2 entries under their parent H1 in the sidebar, giving - * the 5-section IA structure instead of a flat list. + * are grouped under H1 section dividers (with each guide rendered as H2), + * giving a sectioned IA structure instead of a flat list. */ import fs from 'fs'; import path from 'path'; @@ -105,17 +104,16 @@ const prefixFromPath = (filePath) => { * Merge loaded guides into an OpenAPI spec's `info.description`. * * **Flat mode (default)** — each guide becomes a top-level H1 section. - * Redoc renders each H1 as a sidebar entry. + * An OpenAPI viewer renders each H1 as a sidebar entry. * * **Sectioned mode** — when `options.sections` is provided, guides are * grouped under H1 dividers (one per section) with each guide rendered as - * H2. Redoc auto-nests H2 entries under their parent H1 in the sidebar, - * giving a compact 5-section IA instead of a flat list of 18 guides. + * H2, giving a compact sectioned IA instead of a flat list. * Guides whose filename prefix does not fall in any section range are * appended at the end as H2 (never silently dropped). * * The original spec is mutated (and returned) to match the merge style used - * by `initSwagger` in `lib/services/express.js`. + * by `initApiSpec` in `lib/services/express.js`. * * @param {object} spec - OpenAPI spec object (will be mutated). * @param {{ title: string, body: string, path?: string }[]} guides - Loaded guide entries. diff --git a/lib/services/express.js b/lib/services/express.js index 94a2d031f..98e730368 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -15,7 +15,6 @@ import cors from 'cors'; import morgan from 'morgan'; import fs from 'fs'; import YAML from 'js-yaml'; -import redoc from 'redoc-express'; import config from '../../config/index.js'; import configHelper from '../helpers/config.js'; @@ -41,44 +40,14 @@ export const computeOpenApiServerUrl = (domain) => { }; /** - * Default Redoc theme — Inter + JetBrains Mono, tighter sidebar, refined right panel. - * No hardcoded brand color. Downstream projects override via config.docs.redocTheme - * (deep-merged, zero devkit edits required). - */ -const defaultRedocTheme = { - typography: { - fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - headings: { fontFamily: '"Inter", -apple-system, sans-serif', fontWeight: '600' }, - code: { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace' }, - }, - sidebar: { - width: '260px', - textTransform: 'none', - }, - rightPanel: { backgroundColor: '#1a1a1a' }, - spacing: { unit: 4 }, -}; - -/** - * Custom CSS injected into the Redoc UI to cover what the theme schema cannot. - * ≤ 30 lines. - */ -const redocCustomCss = ` - .menu-content { letter-spacing: 0; } - .menu-content label, .menu-content .operation-type { text-transform: none !important; } - pre, code { font-feature-settings: "calt" 0, "liga" 0; } - .api-content blockquote { border-left: 3px solid #888; padding-left: 12px; color: #555; } -`.trim(); - -/** - * Initialize API documentation (Redoc UI + JSON spec endpoint) + * Initialize API documentation (OpenAPI JSON spec endpoint) * @param {object} app - express application instance * @returns {void} */ -const initSwagger = (app) => { - // Secure-by-default: the API docs surface (/api/spec.json + /api/docs) is - // UNAUTHENTICATED, so it is only mounted in dev-grade envs. Any production-grade - // env (the literal `production` OR a deployment env label) skips it even when +const initApiSpec = (app) => { + // Secure-by-default: the API spec surface (/api/spec.json) is UNAUTHENTICATED, + // so it is only mounted in dev-grade envs. Any production-grade env (the + // literal `production` OR a deployment env label) skips it even when // config.swagger.enable is still truthy — opt-OUT by default in non-dev. if (config.swagger.enable && !configHelper.isProd()) { if (!config.files.swagger || config.files.swagger.length === 0) { @@ -136,8 +105,8 @@ const initSwagger = (app) => { }; spec.servers = [{ url: computeOpenApiServerUrl(config.domain) }]; - // Merge per-module markdown guides into info.description so Redoc - // renders them in its sidebar alongside the OpenAPI reference. + // Merge per-module markdown guides into info.description so an OpenAPI + // viewer renders them alongside the reference. const guides = guidesHelper.loadGuides(config.files.guides || []); guidesHelper.mergeGuidesIntoSpec(spec, guides); if (guides.length > 0) { @@ -156,29 +125,6 @@ const initSwagger = (app) => { // Serve the merged spec as JSON app.get('/api/spec.json', serveSpec); - - // Deep-merge devkit default theme with optional per-project override from - // config.docs.redocTheme — downstream projects need zero devkit edits. - const theme = _.merge({}, defaultRedocTheme, (config.docs && config.docs.redocTheme) || {}); - - // Mount Redoc API reference UI — consumes the spec via URL (not inline). - // Equivalents for the previous Scalar `hideModels` behavior: hide the - // download button and schema titles, and expand common success responses - // so the reference feels compact and consumer-focused. - app.get( - '/api/docs', - redoc({ - title: config.app.title, - specUrl: '/api/spec.json', - redocOptions: { - hideDownloadButton: true, - hideSchemaTitles: true, - expandResponses: '200,201', - theme, - customCss: redocCustomCss, - }, - }), - ); } }; @@ -371,8 +317,8 @@ const initErrorRoutes = (app) => { const init = async () => { // Initialize express app const app = express(); - // Initialize modules swagger doc - initSwagger(app); + // Initialize the OpenAPI JSON spec endpoint + initApiSpec(app); // Initialize local variables initLocalVariables(app); // Assign a unique request ID before any route registration @@ -415,7 +361,7 @@ const init = async () => { }; export default { - initSwagger, + initApiSpec, initLocalVariables, initPreParserRoutes, initMiddleware, diff --git a/lib/services/tests/express.docs.unit.tests.js b/lib/services/tests/express.docs.unit.tests.js index 76a4c0b46..9afe60245 100644 --- a/lib/services/tests/express.docs.unit.tests.js +++ b/lib/services/tests/express.docs.unit.tests.js @@ -8,7 +8,7 @@ import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globa * apiKeyAuth alias, SuccessResponse + ErrorResponse schema descriptions. * (T13 Fix B — infra#38) */ -describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B):', () => { +describe('express initApiSpec — core/doc/index.yml OpenAPI baseline (T13 Fix B):', () => { const mockCoreYamlDoc = { openapi: '3.0.0', info: { version: '1.0.0' }, @@ -80,8 +80,8 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B default: { readFileSync: jest.fn().mockReturnValue('mocked') }, readFileSync: jest.fn().mockReturnValue('mocked'), })); - // express.js gates initSwagger behind configHelper.isProd(). Mock the helper so - // the env gate is deterministic (dev-grade → docs mount) without loading the + // express.js gates initApiSpec behind configHelper.isProd(). Mock the helper so + // the env gate is deterministic (dev-grade → spec mount) without loading the // real config helper (which pulls in glob and would trip the partial fs mock). jest.unstable_mockModule('../../helpers/config.js', () => ({ default: { isProd: jest.fn().mockReturnValue(false), isDevEnv: jest.fn().mockReturnValue(true) }, @@ -104,7 +104,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(spec.components.securitySchemes.bearerAuth).toBeDefined(); }); @@ -113,7 +113,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(typeof spec.components.securitySchemes.bearerAuth.description).toBe('string'); expect(spec.components.securitySchemes.bearerAuth.description.length).toBeGreaterThan(0); @@ -123,7 +123,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(spec.components.securitySchemes.apiKeyAuth).toBeDefined(); }); @@ -132,7 +132,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(spec.components.securitySchemes.apiKeyAuth.description).toMatch(/bearerAuth/i); }); @@ -141,7 +141,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(typeof spec.components.schemas.SuccessResponse.description).toBe('string'); expect(spec.components.schemas.SuccessResponse.description.length).toBeGreaterThan(0); @@ -151,7 +151,7 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); const spec = callSpecRoute(app); expect(typeof spec.components.schemas.ErrorResponse.description).toBe('string'); expect(spec.components.schemas.ErrorResponse.description.length).toBeGreaterThan(0); @@ -159,30 +159,37 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B }); /** - * Unit tests for express.js initSwagger — Redoc theme polish (issue #3686). + * Unit tests for express.js initApiSpec — the OpenAPI JSON spec endpoint + * (Redoc UI decommissioned; the spec endpoint is the documentation surface). * - * Two scenarios: - * 1. Default theme markers (Inter + JetBrains Mono) appear in served HTML. - * 2. config.docs.redocTheme deep-merge override (e.g. primary color) reaches served HTML. + * Asserts: + * 1. /api/spec.json serves the merged spec. + * 2. Markdown guides are merged into info.description. + * 3. x-logo injection wiring from config.app.url / config.app.logo. + * 4. /api/docs (the old Redoc UI) is NOT mounted. + * + * NOTE: there is no module-level guides mock in this describe block. Every test + * that relies on guide loading MUST stub `loadGuides` (via + * `jest.unstable_mockModule('../../helpers/guides.js', ...)`) inline, before + * importing the module under test. A test that omits the stub will silently hit + * the real loader against a non-existent path. */ -describe('express initSwagger — Redoc theme (issue #3686):', () => { - let capturedHtml; - +describe('express initApiSpec — OpenAPI JSON spec endpoint:', () => { const mockYamlDoc = { openapi: '3.0.0', - info: { title: 'Test API', version: '1.0.0', description: 'Test' }, + info: { title: 'Test API', version: '1.0.0', description: 'Existing description.' }, paths: {}, }; const baseConfig = { swagger: { enable: true }, - files: { swagger: ['/fake/swagger.yaml'], guides: [] }, + files: { swagger: ['/fake/swagger.yaml'], guides: ['/fake/modules/home/doc/guides/00-welcome.md'] }, app: { title: 'Test API', description: 'Test', url: 'https://example.com' }, domain: 'https://example.com', }; /** - * Build a mock Express app that captures responses for /api/docs and /api/spec.json + * Build a mock Express app that captures registered route handlers. */ const buildMockApp = () => { const routes = {}; @@ -196,24 +203,7 @@ describe('express initSwagger — Redoc theme (issue #3686):', () => { }; /** - * Trigger the /api/docs route and capture the HTML sent - */ - const callDocsRoute = (app) => { - const handler = app._routes['/api/docs']; - if (!handler) throw new Error('/api/docs route not registered'); - let html = null; - const res = { - type: jest.fn(), - send: (body) => { - html = body; - }, - }; - handler({}, res); - return html; - }; - - /** - * Trigger the /api/spec.json route and capture the JSON spec served + * Trigger the /api/spec.json route and capture the JSON spec served. */ const callSpecRoute = (app) => { const handler = app._routes['/api/spec.json']; @@ -226,15 +216,14 @@ describe('express initSwagger — Redoc theme (issue #3686):', () => { beforeEach(() => { jest.resetModules(); - capturedHtml = null; // Mock fs + js-yaml so we don't need a real YAML file jest.unstable_mockModule('fs', () => ({ default: { readFileSync: jest.fn().mockReturnValue('mocked') }, readFileSync: jest.fn().mockReturnValue('mocked'), })); - // express.js gates initSwagger behind configHelper.isProd(). Mock the helper so - // the env gate is deterministic (dev-grade → docs mount) without loading the + // express.js gates initApiSpec behind configHelper.isProd(). Mock the helper so + // the env gate is deterministic (dev-grade → spec mount) without loading the // real config helper (which pulls in glob and would trip the partial fs mock). jest.unstable_mockModule('../../helpers/config.js', () => ({ default: { isProd: jest.fn().mockReturnValue(false), isDevEnv: jest.fn().mockReturnValue(true) }, @@ -244,14 +233,6 @@ describe('express initSwagger — Redoc theme (issue #3686):', () => { load: jest.fn().mockReturnValue(mockYamlDoc), })); - // Mock guides helper — no guides - jest.unstable_mockModule('../../helpers/guides.js', () => ({ - default: { - loadGuides: jest.fn().mockReturnValue([]), - mergeGuidesIntoSpec: jest.fn(), - }, - })); - // Mock logger jest.unstable_mockModule('../logger.js', () => ({ default: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, @@ -262,187 +243,106 @@ describe('express initSwagger — Redoc theme (issue #3686):', () => { jest.restoreAllMocks(); }); - describe('default theme (no config.docs override):', () => { - test('should include Inter font family in serialised Redoc options', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: baseConfig, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - expect(capturedHtml).toContain('Inter'); - }); - - test('should include JetBrains Mono in serialised Redoc options', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: baseConfig, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - expect(capturedHtml).toContain('JetBrains Mono'); - }); - - test('should include tighter sidebar width (260px) in serialised Redoc options', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: baseConfig, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - expect(capturedHtml).toContain('260px'); - }); - - test('should include dark right panel background (#1a1a1a) in serialised Redoc options', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: baseConfig, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - expect(capturedHtml).toContain('#1a1a1a'); - }); - - test('should inject x-logo href in spec when config.app.url is set', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { ...baseConfig }, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - const spec = callSpecRoute(app); - - // x-logo is added to spec.info when config.app.url is present - expect(spec.info['x-logo']).toBeDefined(); - expect(spec.info['x-logo'].href).toBe('https://example.com'); - }); - - test('should inject x-logo url in spec when config.app.logo is provided', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - app: { ...baseConfig.app, logo: 'https://example.com/logo.png' }, - }, - })); + test('serves the merged spec on /api/spec.json', async () => { + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - const spec = callSpecRoute(app); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initApiSpec(app); + const spec = callSpecRoute(app); - expect(spec.info['x-logo'].url).toBe('https://example.com/logo.png'); - }); + expect(spec).toBeDefined(); + expect(spec.openapi).toBe('3.0.0'); + expect(spec.info.title).toBe('Test API'); + expect(spec.servers[0].url).toBe('https://example.com'); + }); - test('should not inject x-logo when config.app.url is absent', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - app: { title: 'Test API', description: 'Test' }, // no url + test('merges markdown guides into info.description', async () => { + // The real guides helper runs so guide markdown lands in info.description — + // this is the documentation surface now that the Redoc UI is gone. + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { + loadGuides: jest.fn().mockReturnValue([{ title: 'Welcome', body: 'Get started here.', path: '/fake/00-welcome.md' }]), + mergeGuidesIntoSpec: (spec, guides) => { + const block = guides.map((g) => `# ${g.title}\n\n${g.body}`).join('\n\n'); + const existing = typeof spec.info?.description === 'string' ? spec.info.description.trim() : ''; + spec.info = { ...(spec.info || {}), description: [existing, block].filter(Boolean).join('\n\n') }; + return spec; }, - })); + }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initApiSpec(app); + const spec = callSpecRoute(app); - // Must not throw and no x-logo in spec - expect(() => expressService.initSwagger(app)).not.toThrow(); - const spec = callSpecRoute(app); - expect(spec.info['x-logo']).toBeUndefined(); - }); + expect(spec.info.description).toContain('Welcome'); + expect(spec.info.description).toContain('Get started here.'); }); - describe('config.docs.redocTheme deep-merge override:', () => { - test('should apply custom primary color from config.docs.redocTheme override', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - docs: { - redocTheme: { - colors: { primary: { main: '#ff0000' } }, - }, - }, - }, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - // Override color reaches the HTML - expect(capturedHtml).toContain('#ff0000'); - }); - - test('should preserve default font family when override adds a color', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - docs: { - redocTheme: { - colors: { primary: { main: '#00ff00' } }, - }, - }, - }, - })); - - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); - - // Default font must still be present even when an override is applied - expect(capturedHtml).toContain('Inter'); - expect(capturedHtml).toContain('#00ff00'); - }); - - test('should allow override to replace sidebar width', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - docs: { - redocTheme: { - sidebar: { width: '300px' }, - }, - }, - }, - })); + test('does NOT mount the /api/docs Redoc UI route', async () => { + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initApiSpec(app); - expect(capturedHtml).toContain('300px'); - }); + expect(app._routes['/api/spec.json']).toBeDefined(); + expect(app._routes['/api/docs']).toBeUndefined(); + }); - test('should apply devkit defaults when config.docs is absent', async () => { - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { - ...baseConfig, - // no docs key at all - }, - })); + test('injects x-logo href in spec when config.app.url is set', async () => { + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: { ...baseConfig } })); + + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initApiSpec(app); + const spec = callSpecRoute(app); + + expect(spec.info['x-logo']).toBeDefined(); + expect(spec.info['x-logo'].href).toBe('https://example.com'); + }); + + test('injects x-logo url in spec when config.app.logo is provided', async () => { + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { ...baseConfig, app: { ...baseConfig.app, logo: 'https://example.com/logo.png' } }, + })); + + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initApiSpec(app); + const spec = callSpecRoute(app); + + expect(spec.info['x-logo'].url).toBe('https://example.com/logo.png'); + }); - const { default: expressService } = await import('../express.js'); - const app = buildMockApp(); - expressService.initSwagger(app); - capturedHtml = callDocsRoute(app); + test('does not inject x-logo when config.app.url is absent', async () => { + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { ...baseConfig, app: { title: 'Test API', description: 'Test' } }, // no url + })); - expect(capturedHtml).toContain('Inter'); - expect(capturedHtml).toContain('JetBrains Mono'); - }); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + + expect(() => expressService.initApiSpec(app)).not.toThrow(); + const spec = callSpecRoute(app); + expect(spec.info['x-logo']).toBeUndefined(); }); }); diff --git a/lib/services/tests/express.docsEnvGate.unit.tests.js b/lib/services/tests/express.docsEnvGate.unit.tests.js index d6a5d2fbf..6ea129324 100644 --- a/lib/services/tests/express.docsEnvGate.unit.tests.js +++ b/lib/services/tests/express.docsEnvGate.unit.tests.js @@ -4,14 +4,16 @@ import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; /** - * Unit tests — initSwagger must be secure-by-default: API docs (/api/spec.json + - * /api/docs) are mounted ONLY in dev-grade envs (development/test/local). Under any - * production-grade env (the literal `production` OR a deployment env label), the - * unauthenticated docs surface must NOT be mounted, even if config.swagger.enable - * is still truthy. This closes the env-gate defect where docs were exposed - * downstream because the gate keyed off the literal `production`. + * Unit tests — initApiSpec must be secure-by-default: the OpenAPI spec endpoint + * (/api/spec.json) is mounted ONLY in dev-grade envs (development/test/local). + * Under any production-grade env (the literal `production` OR a deployment env + * label), the unauthenticated spec surface must NOT be mounted, even if + * config.swagger.enable is still truthy. This closes the env-gate defect where + * docs were exposed downstream because the gate keyed off the literal + * `production`. The /api/docs Redoc UI was decommissioned, so it must never be + * mounted in any env. */ -describe('express initSwagger — env gate (docs off in non-dev envs):', () => { +describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { let originalNodeEnv; const mockYamlDoc = { @@ -72,7 +74,7 @@ describe('express initSwagger — env gate (docs off in non-dev envs):', () => { }); /** - * Run initSwagger under a given NODE_ENV with config.swagger.enable left truthy. + * Run initApiSpec under a given NODE_ENV with config.swagger.enable left truthy. * @param {string} env - NODE_ENV value to set before init * @returns {Promise} the registered routes map */ @@ -81,48 +83,50 @@ describe('express initSwagger — env gate (docs off in non-dev envs):', () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); return app._routes; }; - test('does NOT mount /api/spec.json or /api/docs under a project (non-dev) env', async () => { + test('does NOT mount /api/spec.json under a project (non-dev) env', async () => { const routes = await initUnderEnv('someproject'); expect(routes['/api/spec.json']).toBeUndefined(); + // Redoc UI is decommissioned — /api/docs must never be mounted. expect(routes['/api/docs']).toBeUndefined(); }); - test('does NOT mount docs under the literal production env', async () => { + test('does NOT mount the spec under the literal production env', async () => { const routes = await initUnderEnv('production'); expect(routes['/api/spec.json']).toBeUndefined(); expect(routes['/api/docs']).toBeUndefined(); }); - test('DOES mount docs under development (config.swagger.enable === true)', async () => { + test('DOES mount the spec under development (config.swagger.enable === true)', async () => { const routes = await initUnderEnv('development'); expect(typeof routes['/api/spec.json']).toBe('function'); - expect(typeof routes['/api/docs']).toBe('function'); + // The Redoc UI is gone — the spec endpoint is the only docs surface. + expect(routes['/api/docs']).toBeUndefined(); }); - test('DOES mount docs under test (dev-grade env)', async () => { + test('DOES mount the spec under test (dev-grade env)', async () => { const routes = await initUnderEnv('test'); expect(typeof routes['/api/spec.json']).toBe('function'); - expect(typeof routes['/api/docs']).toBe('function'); + expect(routes['/api/docs']).toBeUndefined(); }); - test('DOES mount docs under local (dev-grade env)', async () => { + test('DOES mount the spec under local (dev-grade env)', async () => { const routes = await initUnderEnv('local'); expect(typeof routes['/api/spec.json']).toBe('function'); - expect(typeof routes['/api/docs']).toBe('function'); + expect(routes['/api/docs']).toBeUndefined(); }); - test('does NOT mount docs in dev when config.swagger.enable is false', async () => { + test('does NOT mount the spec in dev when config.swagger.enable is false', async () => { process.env.NODE_ENV = 'development'; jest.unstable_mockModule('../../../config/index.js', () => ({ default: { ...baseConfig, swagger: { enable: false } }, })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); - expressService.initSwagger(app); + expressService.initApiSpec(app); expect(app._routes['/api/spec.json']).toBeUndefined(); expect(app._routes['/api/docs']).toBeUndefined(); }); diff --git a/modules/audit/middlewares/audit.middleware.js b/modules/audit/middlewares/audit.middleware.js index 122a85bfd..f1cc6d145 100644 --- a/modules/audit/middlewares/audit.middleware.js +++ b/modules/audit/middlewares/audit.middleware.js @@ -7,10 +7,10 @@ import config from '../../../config/index.js'; /** * Default route prefixes to skip when auto-capturing audit events. - * Health checks, docs, and static assets generate noise. + * Health checks and static assets generate noise. * @type {string[]} */ -const SKIP_PREFIXES = ['/public', '/favicon', '/api/docs', '/api/health']; +const SKIP_PREFIXES = ['/public', '/favicon', '/api/health']; /** * Regex to match a 24-hex-char MongoDB ObjectId. diff --git a/modules/audit/tests/audit.middleware.unit.tests.js b/modules/audit/tests/audit.middleware.unit.tests.js index ccd8a2644..b477987a3 100644 --- a/modules/audit/tests/audit.middleware.unit.tests.js +++ b/modules/audit/tests/audit.middleware.unit.tests.js @@ -101,7 +101,7 @@ describe('Audit middleware unit tests:', () => { const middleware = createAuditMiddleware(); const next = jest.fn(); - const prefixes = ['/public/file.js', '/favicon.ico', '/api/docs', '/api/health']; + const prefixes = ['/public/file.js', '/favicon.ico', '/api/health']; for (const url of prefixes) { const req = createReq({ method: 'POST', originalUrl: url }); const res = createRes(); diff --git a/modules/core/tests/core.integration.tests.js b/modules/core/tests/core.integration.tests.js index 9aafe0048..cf6fe0c77 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -293,7 +293,7 @@ describe('Core integration tests:', () => { }); }); - describe('Redoc API reference', () => { + describe('OpenAPI spec endpoint', () => { it('should expose /api/spec.json with a valid OpenAPI info block', async () => { const res = await request(app).get('/api/spec.json').expect(200); expect(res.body).toBeDefined(); @@ -313,10 +313,16 @@ describe('Core integration tests:', () => { expect(res.body.servers[0].url.length).toBeGreaterThan(0); }); - it('should serve the Redoc API reference page on /api/docs', async () => { + it('should NOT serve a dedicated Redoc UI on /api/docs (decommissioned)', async () => { + // The Redoc UI handler was removed. /api/docs is no longer a dedicated + // route, so it falls through to the generic catch-all landing (200) — + // NOT a Redoc reference page that loads /api/spec.json. We assert it IS + // the catch-all (positive) so the 200 is unambiguously the fall-through, + // not some other docs surface. const res = await request(app).get('/api/docs').expect(200); - // Redoc returns HTML referencing the spec URL - expect(res.headers['content-type']).toMatch(/html/); + expect(res.text).toContain('Devkit Node Api'); // the generic catch-all landing + expect(res.text).not.toMatch(/redoc/i); + expect(res.text).not.toContain('/api/spec.json'); }); }); diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index 658f2ba0b..c346528c0 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -491,7 +491,7 @@ describe('Core unit tests:', () => { }); describe('Express service', () => { - describe('initSwagger', () => { + describe('initApiSpec', () => { let originalSwagger; let originalFiles; @@ -505,16 +505,17 @@ describe('Core unit tests:', () => { config.files = originalFiles; }); - it('should register /api/spec.json and /api/docs when swagger is enabled', () => { + it('should register /api/spec.json but NOT the decommissioned /api/docs Redoc UI when swagger is enabled', () => { config.swagger = { enable: true }; config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); expect(mockGet).toHaveBeenCalledWith('/api/spec.json', expect.any(Function)); - // Redoc middleware is a plain request handler mounted via app.get - expect(mockGet).toHaveBeenCalledWith('/api/docs', expect.any(Function)); + // The Redoc UI was decommissioned — /api/docs must never be mounted. + const docsCall = mockGet.mock.calls.find((c) => c[0] === '/api/docs'); + expect(docsCall).toBeUndefined(); }); it('should serve merged spec as JSON from /api/spec.json handler', () => { @@ -523,7 +524,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); // Extract the handler registered for /api/spec.json const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; @@ -545,7 +546,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; handler({}, mockRes); @@ -569,7 +570,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); expect(mockGet).not.toHaveBeenCalled(); expect(mockUse).not.toHaveBeenCalled(); }); @@ -580,7 +581,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); expect(mockGet).not.toHaveBeenCalled(); expect(mockUse).not.toHaveBeenCalled(); }); @@ -589,7 +590,7 @@ describe('Core unit tests:', () => { config.swagger = { enable: true }; config.files = { ...config.files, swagger: ['/nonexistent/path/bad.yml'] }; const mockApp = { get: jest.fn(), use: jest.fn() }; - expect(() => expressService.initSwagger(mockApp)).toThrow('[swagger] failed to load /nonexistent/path/bad.yml'); + expect(() => expressService.initApiSpec(mockApp)).toThrow('[swagger] failed to load /nonexistent/path/bad.yml'); }); it('should skip YAML files that do not parse to a plain object and still register routes from valid ones', async () => { @@ -604,7 +605,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); expect(mockGet).toHaveBeenCalledWith('/api/spec.json', expect.any(Function)); } finally { fsMod.unlinkSync(tmpFile); @@ -627,7 +628,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; handler({}, mockRes); @@ -652,7 +653,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; handler({}, mockRes); @@ -674,7 +675,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; handler({}, mockRes); @@ -701,7 +702,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; const mockRes = { json: jest.fn() }; handler({}, mockRes); @@ -726,7 +727,7 @@ describe('Core unit tests:', () => { const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; - expressService.initSwagger(mockApp); + expressService.initApiSpec(mockApp); expect(mockGet).not.toHaveBeenCalled(); expect(mockUse).not.toHaveBeenCalled(); } finally { diff --git a/modules/home/doc/guides/00-welcome.md b/modules/home/doc/guides/00-welcome.md new file mode 100644 index 000000000..a54e1b452 --- /dev/null +++ b/modules/home/doc/guides/00-welcome.md @@ -0,0 +1,13 @@ +# Welcome + +Welcome to the API documentation. These guides walk you through what the service does, how to authenticate, and how to make your first request. + +The guides are public reference content: no account is required to read them. Each guide is plain markdown — the same source powers the in-app docs, the OpenAPI reference sidebar, and the machine-readable docs feed. + +## How the docs are organised + +Guides are grouped into categories (for example, **Get Started**). Within a category, guides follow a recommended reading order. Start at the top and work down. + +## Where to go next + +When you are ready to make your first call, head to the **Quickstart** guide. It shows you how to send an authenticated request and read the response envelope. diff --git a/modules/home/doc/guides/01-quickstart.md b/modules/home/doc/guides/01-quickstart.md new file mode 100644 index 000000000..2aa0a809e --- /dev/null +++ b/modules/home/doc/guides/01-quickstart.md @@ -0,0 +1,38 @@ +# Quickstart + +This guide gets you from zero to your first authenticated API call in a few minutes. + +## 1. Get an API key + +Sign in to your account and create an API key from the developer settings. Treat the key like a password — store it in an environment variable, never commit it to source control. + +```bash +export API_KEY="" +``` + +## 2. Make your first request + +Send the key as a Bearer token in the `Authorization` header. Replace the host with your deployment's API base URL. + +```bash +curl https://api.example.com/api/tasks \ + -H "Authorization: Bearer $API_KEY" +``` + +## 3. Read the response + +Every endpoint returns a standard JSON envelope. A successful response wraps the payload in a `data` field: + +```json +{ + "type": "success", + "message": "task list", + "data": [] +} +``` + +An error response uses the same shape with `"type": "error"` and an HTTP status code in `status`. Check `type` first, then read `data` (on success) or `description` (on error). + +## Next steps + +You now have a working request. Explore the rest of the reference to discover the available endpoints and their parameters. diff --git a/modules/public/controllers/public.docs.controller.js b/modules/public/controllers/public.docs.controller.js new file mode 100644 index 000000000..e31992c91 --- /dev/null +++ b/modules/public/controllers/public.docs.controller.js @@ -0,0 +1,52 @@ +/** + * Module dependencies + */ +import errors from '../../../lib/helpers/errors.js'; +import responses from '../../../lib/helpers/responses.js'; +import PublicDocsService from '../services/public.docs.service.js'; + +/** + * @desc GET /api/public/docs — public, unauthenticated docs content tree. + * Returns the guide catalogue grouped into categories: + * `{ categories: [ { id, label, order, guides: [ { slug, title, persona, order, summary } ] } ] }`. + * Built from on-disk markdown and cached in-process (~5min). + * @param {Object} req - Express request + * @param {Object} res - Express response + * @returns {Promise} + */ +const tree = async (req, res) => { + try { + const payload = PublicDocsService.getTree(); + res.set('Cache-Control', 'public, max-age=300'); + return responses.success(res, 'public docs')(payload); + } catch (err) { + return responses.error(res, 503, 'Service Unavailable', errors.getMessage(err))(err); + } +}; + +/** + * @desc GET /api/public/docs/:slug.md — raw markdown body for a single guide. + * Responds with `Content-Type: text/markdown` and the prose body (the leading + * H1 stripped; guides carry no front-matter). Unknown slug → 404. + * @param {Object} req - Express request (`req.params.slug`) + * @param {Object} res - Express response + * @returns {Promise} + */ +const raw = async (req, res) => { + try { + const markdown = PublicDocsService.getMarkdown(req.params.slug); + if (markdown === null) { + return responses.error(res, 404, 'Not Found', 'Unknown guide')(); + } + res.set('Cache-Control', 'public, max-age=300'); + res.set('Content-Type', 'text/markdown; charset=utf-8'); + return res.status(200).send(markdown); + } catch (err) { + return responses.error(res, 503, 'Service Unavailable', errors.getMessage(err))(err); + } +}; + +export default { + tree, + raw, +}; diff --git a/modules/public/helpers/public.docs.tree.js b/modules/public/helpers/public.docs.tree.js new file mode 100644 index 000000000..8a80afb56 --- /dev/null +++ b/modules/public/helpers/public.docs.tree.js @@ -0,0 +1,286 @@ +/** + * Docs content-contract parser for the public docs API (GET /api/public/docs). + * + * Lives in the `public` module (NOT the stack `lib/helpers/guides.js`) on + * purpose: `guides.js` owns the flat/sectioned markdown merge into the OpenAPI + * `info.description` (for `/api/spec.json`), while this module owns the richer + * `{ categories: [{ id, label, order, guides }] }` contract that backs the + * structured public docs endpoint. + * + * Guides are discovered from the same on-disk source as the OpenAPI reference + * (`config.files.guides`, globbed from `modules/*/doc/guides/*.md` via + * `config/assets.js`). They carry no YAML front-matter, so each renders as + * plain prose starting with its H1. + * + * Category + persona come from the config grouping primitive + * `config.docs.guideSections` (`{ title, prefixMin, prefixMax }`), which groups the + * public docs contract. A section may additionally + * declare a `persona` array to narrow the audience; absent that, guides target + * every audience ({@link DEFAULT_PERSONA}). A guide whose prefix falls outside + * every configured range (or when no sections are configured) is grouped under + * its capitalised module name so it is never silently dropped. + */ +import fs from 'fs'; +import path from 'path'; + +import logger from '../../../lib/services/logger.js'; + +/** + * Default persona audience applied when a section declares none. + * Guides target every audience unless a section narrows them. + * @type {readonly string[]} + */ +const DEFAULT_PERSONA = Object.freeze(['all']); + +/** + * Derive the public slug for a guide from its file path: filename minus the + * numeric ordering prefix and the `.md` extension. + * E.g. `01-quickstart.md` → `quickstart`, `12-api-keys.md` → `api-keys`. + * Falls back to the full basename when stripping the prefix would empty it. + * @param {string} filePath - Absolute or relative path to the guide file. + * @returns {string} URL-safe guide slug. + */ +// Guide filenames are expected to be `NN-kebab-name.md` (numeric prefix + kebab); the prefix is stripped for the slug. +const slugFromPath = (filePath) => { + const base = path.basename(String(filePath), path.extname(String(filePath))); + const stripped = base.replace(/^\d+[-_]/, ''); + return stripped || base; +}; + +/** + * Extract the leading numeric prefix from a guide file path. + * E.g. `/foo/07-scheduling.md` → 7, `/foo/14-cli.md` → 14. + * Returns null when the basename has no numeric prefix. + * @param {string} filePath - Absolute or relative path to the guide file. + * @returns {number|null} Numeric prefix, or null if not present. + */ +const prefixFromPath = (filePath) => { + const base = path.basename(String(filePath), path.extname(String(filePath))); + const m = base.match(/^(\d+)[-_]/); + return m ? parseInt(m[1], 10) : null; +}; + +/** + * Derive the module name from a guide file path. + * E.g. `modules/home/doc/guides/01-quickstart.md` → `home`. + * Used as the fallback category when a guide matches no configured section. + * @param {string} filePath - Absolute or relative path to the guide file. + * @returns {string|null} Module name, or null when not under `modules/`. + */ +const moduleFromPath = (filePath) => { + const norm = String(filePath).replace(/\\/g, '/'); + const m = norm.match(/modules\/([^/]+)\//); + return m ? m[1] : null; +}; + +/** + * Derive the guide title from the first markdown H1 (`# Title`). + * Falls back to a title-cased slug when the body has no leading H1, so a guide + * is never emitted with an empty title. + * @param {string} markdown - Raw markdown content (front-matter-free). + * @param {string} slug - Guide slug, used for the fallback title. + * @returns {string} Guide title. + */ +const titleFromMarkdown = (markdown, slug) => { + const m = String(markdown).match(/^\s*#\s+([^\n]+?)\s*$/m); + if (m && m[1].trim()) return m[1].trim(); + return String(slug) + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +/** + * Strip the first H1 heading from a markdown body (if present), mirroring the + * stack loader so the raw-markdown endpoint returns prose only. + * @param {string} markdown - Raw markdown content. + * @returns {string} Markdown without the leading H1. + */ +const stripLeadingH1 = (markdown) => String(markdown).replace(/^\s*#\s+[^\n]*\n+/, ''); + +/** + * Extract the first prose paragraph from a markdown body to use as a summary. + * Skips blank lines, ATX headings (`#`), HTML anchor lines (``), + * blockquotes, and code fences, then collects consecutive non-blank lines until + * the next blank line. Returns an empty string when no prose paragraph is found. + * @param {string} markdown - Markdown body (H1 already stripped). + * @returns {string} First paragraph as a single trimmed line. + */ +const firstParagraph = (markdown) => { + const lines = String(markdown).split(/\r?\n/); + const buffer = []; + for (const line of lines) { + const trimmed = line.trim(); + if (buffer.length === 0) { + // Skip leading non-prose: blanks, headings, HTML, blockquotes, fences. + if (!trimmed) continue; + if (/^(#{1,6}\s|<|>|```|---)/.test(trimmed)) continue; + buffer.push(trimmed); + continue; + } + if (!trimmed) break; + buffer.push(trimmed); + } + return buffer.join(' ').trim(); +}; + +/** + * Load markdown guides as structured per-guide entries for the public docs API. + * + * Each entry: `{ slug, title, order, summary, body, path }`. + * - `slug` filename minus numeric prefix + `.md` + * - `title` first markdown H1 (fallback: title-cased slug) + * - `order` filename numeric prefix (fallback: discovery index) + * - `summary` first prose paragraph + * - `body` prose with the leading H1 stripped + * - `path` source file path (used for module-name grouping fallback) + * + * Persona + category are NOT decided here — they are derived per-section at + * tree-assembly time (see {@link buildDocsTree}). + * + * Invalid/unreadable/empty files are skipped with a warning so one broken guide + * cannot take down the docs endpoint. + * + * @param {string[]} filePaths - Absolute paths to `.md` guide files. + * @returns {{ slug: string, title: string, order: number, summary: string, + * body: string, path: string }[]} Structured guides, sorted by numeric + * filename prefix (stable, deterministic tiebreak by slug). + */ +const loadGuideEntries = (filePaths) => { + if (!Array.isArray(filePaths) || filePaths.length === 0) return []; + // Sort deterministically before mapping so the fallback `index` for + // unprefixed guides is stable across platforms (glob order is not guaranteed). + return [...filePaths].sort() + .map((filePath, index) => { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const slug = slugFromPath(filePath); + const title = titleFromMarkdown(raw, slug); + const body = stripLeadingH1(raw).trim(); + if (!body) { + logger.warn(`[public.docs] skipping ${filePath}: empty markdown content`); + return null; + } + const prefix = prefixFromPath(filePath); + return { + slug, + title, + // Order from the filename numeric prefix; fall back to discovery + // index so unprefixed guides still sort deterministically. + order: prefix !== null ? prefix : index, + summary: firstParagraph(body), + body, + path: filePath, + }; + } catch (err) { + logger.warn(`[public.docs] failed to load ${filePath}: ${err.message}`); + return null; + } + }) + .filter(Boolean) + .sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug)); +}; + +/** + * Slugify a label into a URL-safe category id. + * @param {string} label - Human category label. + * @returns {string} Lower-kebab id. + */ +const slugify = (label) => String(label) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +/** + * Resolve the section a guide belongs to from the config grouping primitive. + * A section matches when the guide's numeric prefix is within + * `[prefixMin, prefixMax]`. + * @param {{ slug: string, path: string, order: number }} entry - Guide entry. + * @param {{ title: string, prefixMin: number, prefixMax: number, persona?: string[] }[]} sections + * @returns {{ title: string, persona: string[] }|null} Matching section meta, or null. + */ +const sectionForEntry = (entry, sections) => { + if (!Array.isArray(sections) || sections.length === 0) return null; + const prefix = prefixFromPath(entry.path); + if (prefix === null) return null; + const match = sections.find((s) => prefix >= s.prefixMin && prefix <= s.prefixMax); + if (!match) return null; + return { + title: match.title, + persona: Array.isArray(match.persona) && match.persona.length > 0 + ? [...match.persona] + : [...DEFAULT_PERSONA], + }; +}; + +/** + * Build the public docs category tree from structured guide entries, grouped by + * the config `guideSections` primitive. + * + * Grouping precedence per guide: + * 1. the matching section from `config.docs.guideSections` (by prefix range); + * 2. the capitalised module name fallback — guarantees every guide lands + * somewhere (never silently dropped). + * + * Category objects keep first-seen order; within a category guides preserve the + * incoming (numeric-prefix) order. `id` is a slugified category key, `label` is + * the human title (the section title, or the capitalised module name), `order` + * is the lowest guide order within the category (stable category sort). + * + * @param {ReturnType} entries - Structured guides. + * @param {{ title: string, prefixMin: number, prefixMax: number, persona?: string[] }[]} [sections] + * The `config.docs.guideSections` grouping primitive. + * @returns {{ categories: { id: string, label: string, order: number, + * guides: { slug: string, title: string, persona: string[], order: number, summary: string }[] }[] }} + */ +const buildDocsTree = (entries, sections = []) => { + const list = Array.isArray(entries) ? entries : []; + + const categories = []; + const byId = new Map(); + const pushTo = (label, guide) => { + const id = slugify(label) || 'guides'; + let cat = byId.get(id); + if (!cat) { + cat = { id, label, order: guide.order, guides: [] }; + byId.set(id, cat); + categories.push(cat); + } + cat.guides.push(guide); + if (guide.order < cat.order) cat.order = guide.order; + }; + + for (const entry of list) { + const section = sectionForEntry(entry, sections); + // Precedence: config section → capitalised module name fallback. + const label = section + ? section.title + : (() => { + const mod = moduleFromPath(entry.path); + return mod ? mod.charAt(0).toUpperCase() + mod.slice(1) : 'Guides'; + })(); + const persona = section ? section.persona : [...DEFAULT_PERSONA]; + pushTo(label, { + slug: entry.slug, + title: entry.title, + persona, + order: entry.order, + summary: entry.summary, + }); + } + return { categories }; +}; + +export default { + DEFAULT_PERSONA, + slugFromPath, + prefixFromPath, + moduleFromPath, + titleFromMarkdown, + stripLeadingH1, + firstParagraph, + loadGuideEntries, + buildDocsTree, +}; diff --git a/modules/public/routes/public.docs.routes.js b/modules/public/routes/public.docs.routes.js new file mode 100644 index 000000000..03976634b --- /dev/null +++ b/modules/public/routes/public.docs.routes.js @@ -0,0 +1,27 @@ +/** + * Module dependencies + */ +import limiters from '../../../lib/middlewares/rateLimiter.js'; +import publicDocs from '../controllers/public.docs.controller.js'; + +/** + * Register public, unauthenticated documentation routes. + * + * Both routes reuse the `api` rate-limit profile (per-IP/user cap). The payload + * is cached in-process so occasional bursts are cheap, but the limiter is still + * a safety net against scrapers. In environments where the profile is not + * configured (e.g. dev) the limiter falls through to a passthrough. + * + * @param {import('express').Application} app - Express app instance + * @returns {void} + */ +export default (app) => { + // Docs content tree — { categories: [ { id, label, order, guides: [...] } ] }. + // No auth, no org scoping: the guides are public reference content. + app.route('/api/public/docs').get(limiters.api, publicDocs.tree); + + // Raw markdown body for a single guide (text/markdown). Unknown slug → 404. + // The literal `.md` suffix is matched by path-to-regexp (Express 5), leaving + // req.params.slug as the bare slug (e.g. `quickstart`). + app.route('/api/public/docs/:slug.md').get(limiters.api, publicDocs.raw); +}; diff --git a/modules/public/services/public.docs.service.js b/modules/public/services/public.docs.service.js new file mode 100644 index 000000000..901ebb1f4 --- /dev/null +++ b/modules/public/services/public.docs.service.js @@ -0,0 +1,116 @@ +/** + * Module dependencies + */ +import config from '../../../config/index.js'; +import docsTree from '../helpers/public.docs.tree.js'; +import logger from '../../../lib/services/logger.js'; + +/** + * @desc Cache TTL in ms. The docs tree is built from on-disk markdown that only + * changes on deploy, so a short staleness window is plenty — it keeps the + * unauthenticated endpoint cheap under burst while still picking up a redeploy + * within a few minutes. + */ +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * @type {{ tree: Object, bySlug: Map, expiresAt: number }|null} + * Single-slot TTL cache holding both the assembled tree (for `/docs`) and a + * slug→entry index (for `/docs/:slug.md`). Process-local; multi-replica + * deployments each maintain their own copy, which is fine because the payload + * is read-only and idempotent. + */ +let cacheEntry = null; + +/** + * @desc Resolve the configured guide file paths. Returns a defensive copy so a + * caller can never mutate config state. + * @returns {string[]} Absolute guide markdown paths (empty array when unset). + */ +const guideFiles = () => (Array.isArray(config.files?.guides) ? [...config.files.guides] : []); + +/** + * @desc Resolve the configured guide sections grouping primitive + * (`config.docs.guideSections`). Empty array when unset → guides fall back to + * module-name grouping in the tree builder. + * @returns {{ title: string, prefixMin: number, prefixMax: number, persona?: string[] }[]} + */ +const guideSections = () => (Array.isArray(config.docs?.guideSections) ? config.docs.guideSections.map((s) => ({ ...s })) : []); + +/** + * @desc Build the docs tree + slug index from disk. + * @returns {{ tree: { categories: Object[] }, bySlug: Map }} + */ +const compute = () => { + const entries = docsTree.loadGuideEntries(guideFiles()); + const tree = docsTree.buildDocsTree(entries, guideSections()); + const bySlug = new Map(); + for (const entry of entries) { + if (bySlug.has(entry.slug)) { + logger.warn(`[public/docs] duplicate guide slug "${entry.slug}" — later guide wins; rename one to avoid the collision`); + } + bySlug.set(entry.slug, entry); + } + return { tree, bySlug }; +}; + +/** + * @desc Return the cached tree+index or recompute when the TTL has elapsed. + * @param {Object} [options] + * @param {boolean} [options.bypassCache] - Skip the cache lookup (for tests). + * @returns {{ tree: { categories: Object[] }, bySlug: Map }} + */ +const load = ({ bypassCache = false } = {}) => { + if (!bypassCache && cacheEntry && cacheEntry.expiresAt > Date.now()) { + logger.debug('public.docs - cache hit'); + return cacheEntry; + } + // No inflight guard needed: compute() is fully synchronous (fs.readFileSync, + // no await), so there is no gap between the staleness check and the cache + // assignment on Node's event loop — stampede is impossible. If compute() is + // ever made async, an inflight guard must be added. + const { tree, bySlug } = compute(); + cacheEntry = { tree, bySlug, expiresAt: Date.now() + CACHE_TTL_MS }; + logger.info('public.docs - recomputed', { + event: 'public.docs.refresh', + categories: tree.categories.length, + guides: bySlug.size, + }); + return cacheEntry; +}; + +/** + * @desc Return the public docs tree: `{ categories: [{ id, label, order, guides }] }`. + * Each guide is `{ slug, title, persona, order, summary }`. + * @returns {{ categories: Object[] }} Docs tree payload. + */ +const getTree = () => load().tree; + +/** + * @desc Return the raw markdown body for a guide slug, or null when unknown. + * The body has the leading H1 already stripped — it is the prose a consumer + * renders. Guides carry no YAML front-matter, so the body starts at the prose. + * @param {string} slug - Guide slug (e.g. `quickstart`). + * @returns {string|null} Markdown body, or null when the slug is unknown. + */ +const getMarkdown = (slug) => { + if (typeof slug !== 'string' || !slug) return null; + const entry = load().bySlug.get(slug); + return entry ? entry.body : null; +}; + +/** + * @desc Clear the in-memory cache. Exposed for tests and admin tooling. + * @returns {void} + */ +const clearCache = () => { + cacheEntry = null; +}; + +export default { + getTree, + getMarkdown, + clearCache, + // Exposed for unit tests — not part of the public API. + _internals: { CACHE_TTL_MS, compute, guideFiles, guideSections }, +}; diff --git a/modules/public/tests/public.docs.controller.unit.tests.js b/modules/public/tests/public.docs.controller.unit.tests.js new file mode 100644 index 000000000..4ad6e8ca0 --- /dev/null +++ b/modules/public/tests/public.docs.controller.unit.tests.js @@ -0,0 +1,127 @@ +/** + * Unit tests for the public docs controller. + * Focuses on HTTP response shaping — delegates loading to the service. + */ +import { + jest, describe, test, expect, beforeEach, +} from '@jest/globals'; + +const getTree = jest.fn(); +const getMarkdown = jest.fn(); + +jest.unstable_mockModule('../services/public.docs.service.js', () => ({ + default: { + getTree, getMarkdown, clearCache: jest.fn(), _internals: {}, + }, +})); + +const controller = (await import('../controllers/public.docs.controller.js')).default; + +/** + * Build a chainable Express response double (status/json/send/set all return `res`). + * @returns {{ status: jest.Mock, json: jest.Mock, send: jest.Mock, set: jest.Mock }} + */ +const mockResponse = () => { + const res = {}; + res.status = jest.fn(() => res); + res.json = jest.fn(() => res); + res.send = jest.fn(() => res); + res.set = jest.fn(() => res); + return res; +}; + +describe('PublicDocsController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('tree: returns 200 with the service payload wrapped in a success envelope', async () => { + const payload = { categories: [{ id: 'get-started', label: 'Get Started', order: 0, guides: [] }] }; + getTree.mockReturnValueOnce(payload); + const res = mockResponse(); + + await controller.tree({ query: {} }, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + type: 'success', + message: 'public docs', + data: payload, + }); + }); + + test('tree: sets Cache-Control: public, max-age=300 on a 200 response', async () => { + getTree.mockReturnValueOnce({ categories: [] }); + const res = mockResponse(); + + await controller.tree({ query: {} }, res); + + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=300'); + }); + + test('tree: returns 503 when the service throws', async () => { + getTree.mockImplementationOnce(() => { throw new Error('disk gone'); }); + const res = mockResponse(); + + await controller.tree({ query: {} }, res); + + expect(res.status).toHaveBeenCalledWith(503); + const [body] = res.json.mock.calls[0]; + expect(body.type).toBe('error'); + expect(body.status).toBe(503); + }); + + test('raw: returns markdown with text/markdown content type for a known slug', async () => { + getMarkdown.mockReturnValueOnce('# Body\n\nText.'); + const res = mockResponse(); + + await controller.raw({ params: { slug: 'quickstart' } }, res); + + expect(res.set).toHaveBeenCalledWith('Content-Type', 'text/markdown; charset=utf-8'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith('# Body\n\nText.'); + }); + + test('raw: sets Cache-Control: public, max-age=300 on a 200 response', async () => { + getMarkdown.mockReturnValueOnce('# Guide\n\nProse.'); + const res = mockResponse(); + + await controller.raw({ params: { slug: 'quickstart' } }, res); + + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=300'); + }); + + test('raw: does NOT set Cache-Control on a 404 response', async () => { + getMarkdown.mockReturnValueOnce(null); + const res = mockResponse(); + + await controller.raw({ params: { slug: 'nope' } }, res); + + const cacheControlCalls = res.set.mock.calls.filter(([header]) => header === 'Cache-Control'); + expect(cacheControlCalls).toHaveLength(0); + }); + + test('raw: returns 404 for an unknown slug', async () => { + getMarkdown.mockReturnValueOnce(null); + const res = mockResponse(); + + await controller.raw({ params: { slug: 'nope' } }, res); + + expect(res.status).toHaveBeenCalledWith(404); + const [body] = res.json.mock.calls[0]; + expect(body.type).toBe('error'); + expect(body.status).toBe(404); + }); + + test('raw: returns 503 when the service throws', async () => { + getMarkdown.mockImplementationOnce(() => { throw new Error('disk gone'); }); + const res = mockResponse(); + + await controller.raw({ params: { slug: 'quickstart' } }, res); + + expect(res.status).toHaveBeenCalledWith(503); + const [body] = res.json.mock.calls[0]; + expect(body.type).toBe('error'); + expect(body.status).toBe(503); + }); +}); diff --git a/modules/public/tests/public.docs.integration.tests.js b/modules/public/tests/public.docs.integration.tests.js new file mode 100644 index 000000000..f55fcf12e --- /dev/null +++ b/modules/public/tests/public.docs.integration.tests.js @@ -0,0 +1,114 @@ +/** + * Integration tests — GET /api/public/docs and GET /api/public/docs/:slug.md. + * Drives the real Express app against the on-disk markdown guides (the two + * shipped sample guides: welcome + quickstart) so the whole content contract + * (tree shape, real grouping, raw markdown, 404) is exercised end-to-end, + * unauthenticated. The expectations assert the REAL wire shape against the REAL + * sample guides — not a self-authored mock. + */ +import request from 'supertest'; +import path from 'path'; + +import { + afterAll, beforeAll, beforeEach, describe, test, expect, +} from '@jest/globals'; +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; +import config from '../../../config/index.js'; + +describe('Public docs integration tests:', () => { + let app; + let PublicDocsService; + const originalOrgEnabled = config.organizations?.enabled; + + beforeAll(async () => { + if (config.organizations) config.organizations.enabled = false; + const init = await bootstrap(); + app = init.app; + PublicDocsService = (await import(path.resolve('./modules/public/services/public.docs.service.js'))).default; + }); + + afterAll(async () => { + if (config.organizations) config.organizations.enabled = originalOrgEnabled; + await mongooseService.disconnect(); + }); + + beforeEach(() => { + if (PublicDocsService) PublicDocsService.clearCache(); + }); + + test('GET /api/public/docs is publicly accessible without authentication', async () => { + const result = await request(app).get('/api/public/docs').expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('public docs'); + }); + + test('the tree contains the shipped sample guides grouped into categories', async () => { + const result = await request(app).get('/api/public/docs').expect(200); + const { categories } = result.body.data; + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + + const guides = categories.flatMap((c) => c.guides); + // Devkit ships exactly two sample guides out of the box. + expect(guides.length).toBeGreaterThanOrEqual(2); + + // Every guide carries the structured contract fields. + for (const guide of guides) { + expect(typeof guide.slug).toBe('string'); + expect(guide.slug.length).toBeGreaterThan(0); + expect(typeof guide.title).toBe('string'); + expect(Array.isArray(guide.persona)).toBe(true); + expect(typeof guide.order).toBe('number'); + expect(typeof guide.summary).toBe('string'); + } + + // Every category exposes id + label + order. + for (const cat of categories) { + expect(typeof cat.id).toBe('string'); + expect(typeof cat.label).toBe('string'); + expect(typeof cat.order).toBe('number'); + } + + // The two shipped sample guides are present. + const slugs = guides.map((g) => g.slug); + expect(slugs).toContain('welcome'); + expect(slugs).toContain('quickstart'); + // No duplicate slugs. + expect(new Set(slugs).size).toBe(slugs.length); + + // The default config groups both samples under the "Get Started" section + // (neutral persona ['all']) — assert the real grouping, not a mock. + const welcome = guides.find((g) => g.slug === 'welcome'); + expect(welcome.title).toBe('Welcome'); + expect(welcome.persona).toEqual(['all']); + const getStarted = categories.find((c) => c.id === 'get-started'); + expect(getStarted).toBeDefined(); + expect(getStarted.label).toBe('Get Started'); + expect(getStarted.guides.map((g) => g.slug)).toEqual( + expect.arrayContaining(['welcome', 'quickstart']), + ); + }); + + test('GET /api/public/docs/:slug.md returns raw markdown for a known slug', async () => { + const result = await request(app).get('/api/public/docs/quickstart.md').expect(200); + expect(result.headers['content-type']).toMatch(/text\/markdown/); + expect(typeof result.text).toBe('string'); + expect(result.text.length).toBeGreaterThan(0); + // The H1 was stripped by the loader. + expect(result.text).not.toMatch(/^#\s/); + // The real quickstart body uses a generic placeholder API key. + expect(result.text).toContain(''); + }); + + test('GET /api/public/docs/:slug.md returns 404 for an unknown slug', async () => { + const result = await request(app).get('/api/public/docs/does-not-exist.md').expect(404); + expect(result.body.type).toBe('error'); + expect(result.body.status).toBe(404); + }); + + test('the docs tree never leaks raw YAML front-matter into a guide body', async () => { + const result = await request(app).get('/api/public/docs/welcome.md').expect(200); + expect(result.text.trimStart().startsWith('---')).toBe(false); + }); +}); diff --git a/modules/public/tests/public.docs.service.fallback.unit.tests.js b/modules/public/tests/public.docs.service.fallback.unit.tests.js new file mode 100644 index 000000000..64364aa94 --- /dev/null +++ b/modules/public/tests/public.docs.service.fallback.unit.tests.js @@ -0,0 +1,43 @@ +/** + * Unit tests for the public docs service defensive config fallbacks. + * With `config.files.guides` and `config.docs` absent, the service must resolve + * to empty arrays (never throw) so the endpoint degrades to an empty tree + * instead of crashing on a minimally-configured deployment. + */ +import { + jest, describe, test, expect, +} from '@jest/globals'; + +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: {}, // no `files`, no `docs` +})); + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { + debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), + }, +})); + +jest.unstable_mockModule('../helpers/public.docs.tree.js', () => ({ + default: { + loadGuideEntries: jest.fn().mockReturnValue([]), + buildDocsTree: jest.fn().mockReturnValue({ categories: [] }), + }, +})); + +const PublicDocsService = (await import('../services/public.docs.service.js')).default; + +describe('PublicDocsService — config fallbacks (no files/docs):', () => { + test('guideFiles returns [] when config.files.guides is absent', () => { + expect(PublicDocsService._internals.guideFiles()).toEqual([]); + }); + + test('guideSections returns [] when config.docs.guideSections is absent', () => { + expect(PublicDocsService._internals.guideSections()).toEqual([]); + }); + + test('getTree degrades to an empty tree instead of throwing', () => { + PublicDocsService.clearCache(); + expect(PublicDocsService.getTree()).toEqual({ categories: [] }); + }); +}); diff --git a/modules/public/tests/public.docs.service.unit.tests.js b/modules/public/tests/public.docs.service.unit.tests.js new file mode 100644 index 000000000..f07deb81d --- /dev/null +++ b/modules/public/tests/public.docs.service.unit.tests.js @@ -0,0 +1,114 @@ +/** + * Unit tests for the public docs service. + * Verifies tree assembly (driven by config.docs.guideSections), slug lookup, + * the 404 (null) path, and TTL caching — with config + the docs-tree helper + * mocked so the test never touches disk. (The tree parsing itself is exercised + * in public.docs.tree.unit.tests.js.) + */ +import { + jest, describe, test, expect, beforeEach, +} from '@jest/globals'; + +const guideFilesPaths = [ + 'modules/home/doc/guides/00-welcome.md', + 'modules/home/doc/guides/01-quickstart.md', +]; + +const guideSections = [ + { title: 'Get Started', prefixMin: 0, prefixMax: 1 }, +]; + +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + files: { guides: guideFilesPaths }, + docs: { guideSections }, + }, +})); + +// Stub the logger so the mocked config (no `log.fileLogger`) doesn't trip the +// real logger's file-logging bootstrap when the service is imported. +const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: mockLogger, +})); + +const loadGuideEntries = jest.fn(); +const buildDocsTree = jest.fn(); + +jest.unstable_mockModule('../helpers/public.docs.tree.js', () => ({ + default: { loadGuideEntries, buildDocsTree }, +})); + +const PublicDocsService = (await import('../services/public.docs.service.js')).default; + +const sampleEntries = [ + { + slug: 'welcome', title: 'Welcome', order: 0, summary: 'w', body: 'Welcome body', + }, + { + slug: 'quickstart', title: 'Quickstart', order: 1, summary: 'q', body: 'Quickstart body', + }, +]; +const sampleTree = { categories: [{ id: 'get-started', label: 'Get Started', order: 0, guides: [] }] }; + +describe('PublicDocsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + PublicDocsService.clearCache(); + loadGuideEntries.mockReturnValue(sampleEntries); + buildDocsTree.mockReturnValue(sampleTree); + }); + + test('getTree builds the tree from the configured guide files + sections', () => { + const tree = PublicDocsService.getTree(); + expect(loadGuideEntries).toHaveBeenCalledWith(guideFilesPaths); + expect(buildDocsTree).toHaveBeenCalledWith(sampleEntries, guideSections); + expect(tree).toBe(sampleTree); + }); + + test('getMarkdown returns the body for a known slug', () => { + expect(PublicDocsService.getMarkdown('quickstart')).toBe('Quickstart body'); + }); + + test('getMarkdown returns null for an unknown slug', () => { + expect(PublicDocsService.getMarkdown('does-not-exist')).toBeNull(); + }); + + test('getMarkdown returns null for a non-string slug', () => { + expect(PublicDocsService.getMarkdown(undefined)).toBeNull(); + expect(PublicDocsService.getMarkdown('')).toBeNull(); + }); + + test('caches: a second call within the TTL does not recompute', () => { + PublicDocsService.getTree(); + PublicDocsService.getTree(); + expect(loadGuideEntries).toHaveBeenCalledTimes(1); + }); + + test('clearCache forces a recompute on the next call', () => { + PublicDocsService.getTree(); + PublicDocsService.clearCache(); + PublicDocsService.getTree(); + expect(loadGuideEntries).toHaveBeenCalledTimes(2); + }); + + test('warns and last-wins when two entries share the same slug', () => { + const dupeEntries = [ + { + slug: 'quickstart', title: 'Quickstart A', order: 0, summary: 'a', body: 'Body A', + }, + { + slug: 'quickstart', title: 'Quickstart B', order: 1, summary: 'b', body: 'Body B', + }, + ]; + loadGuideEntries.mockReturnValueOnce(dupeEntries); + + PublicDocsService.getTree(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('duplicate guide slug "quickstart"'), + ); + // Last entry wins + expect(PublicDocsService.getMarkdown('quickstart')).toBe('Body B'); + }); +}); diff --git a/modules/public/tests/public.docs.tree.unit.tests.js b/modules/public/tests/public.docs.tree.unit.tests.js new file mode 100644 index 000000000..c74e4e1af --- /dev/null +++ b/modules/public/tests/public.docs.tree.unit.tests.js @@ -0,0 +1,224 @@ +/** + * Unit tests for the docs-tree helpers in + * modules/public/helpers/public.docs.tree.js: + * slugFromPath, prefixFromPath, moduleFromPath, titleFromMarkdown, + * firstParagraph, loadGuideEntries, and buildDocsTree. + * + * These power the public docs content contract (GET /api/public/docs). + * Category + persona come from the config grouping primitive + * `config.docs.guideSections` (the same prefix-range grouping used to nest the + * OpenAPI reference sidebar), NOT YAML front-matter: guide .md files carry no + * front-matter, so each renders as plain prose starting with its H1. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import docsTree from '../helpers/public.docs.tree.js'; + +const { + slugFromPath, prefixFromPath, moduleFromPath, titleFromMarkdown, + firstParagraph, loadGuideEntries, buildDocsTree, DEFAULT_PERSONA, +} = docsTree; + +const sections = [ + { title: 'Get Started', prefixMin: 0, prefixMax: 1 }, + { title: 'Guides', prefixMin: 2, prefixMax: 9, persona: ['agent'] }, +]; + +describe('slugFromPath:', () => { + it('strips the numeric prefix and .md extension', () => { + expect(slugFromPath('/x/01-quickstart.md')).toBe('quickstart'); + expect(slugFromPath('/x/12-api-keys.md')).toBe('api-keys'); + }); + + it('keeps the basename when there is no numeric prefix', () => { + expect(slugFromPath('/x/welcome.md')).toBe('welcome'); + }); +}); + +describe('prefixFromPath:', () => { + it('extracts the leading numeric prefix', () => { + expect(prefixFromPath('/x/07-scheduling.md')).toBe(7); + expect(prefixFromPath('/x/14-cli.md')).toBe(14); + }); + + it('returns null when there is no numeric prefix', () => { + expect(prefixFromPath('/x/welcome.md')).toBeNull(); + }); +}); + +describe('moduleFromPath:', () => { + it('extracts the module name from a guide path', () => { + expect(moduleFromPath('modules/home/doc/guides/01-quickstart.md')).toBe('home'); + expect(moduleFromPath('/abs/modules/users/doc/guides/12-api-keys.md')).toBe('users'); + }); + + it('returns null when the path is not under modules/', () => { + expect(moduleFromPath('/tmp/foo.md')).toBeNull(); + }); +}); + +describe('titleFromMarkdown:', () => { + it('derives the title from the first H1', () => { + expect(titleFromMarkdown('# Getting Started\n\nBody.', 'welcome')).toBe('Getting Started'); + }); + + it('falls back to a title-cased slug when there is no H1', () => { + expect(titleFromMarkdown('Just prose, no heading.', 'api-keys')).toBe('Api Keys'); + }); +}); + +describe('firstParagraph:', () => { + it('returns the first prose paragraph, skipping headings and HTML anchors', () => { + const md = '\n\nReal first para.\nSecond line.\n\nNext para.'; + expect(firstParagraph(md)).toBe('Real first para. Second line.'); + }); + + it('returns an empty string when there is no prose', () => { + expect(firstParagraph('# Only a heading\n')).toBe(''); + }); +}); + +describe('loadGuideEntries:', () => { + let dir; + /** + * Write a fixture guide file into the per-suite temp dir. + * @param {string} name - File name (e.g. `01-quickstart.md`). + * @param {string} content - Raw markdown body. + * @returns {void} + */ + const write = (name, content) => fs.writeFileSync(path.join(dir, name), content); + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docs-tree-')); + // No front-matter — files start with their H1 (matches the real guides). + write('01-quickstart.md', '# Quickstart\n\nGet going fast.\n'); + write('99-unmapped.md', '# Unmapped\n\nDeep dive.\n'); + write('empty.md', ' \n'); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns structured entries with slug/title/persona/order/summary/body/path', () => { + const entries = loadGuideEntries([path.join(dir, '01-quickstart.md')]); + expect(entries).toHaveLength(1); + const qs = entries[0]; + expect(qs.slug).toBe('quickstart'); + expect(qs.title).toBe('Quickstart'); // H1-derived + expect(qs.order).toBe(1); + expect(qs.summary).toBe('Get going fast.'); + expect(qs.body).toBe('Get going fast.'); // H1 stripped + expect(qs.path).toContain('01-quickstart.md'); + }); + + it('skips empty guides without throwing', () => { + expect(loadGuideEntries([path.join(dir, 'empty.md')])).toEqual([]); + }); + + it('skips unreadable files without throwing', () => { + expect(loadGuideEntries([path.join(dir, 'does-not-exist.md')])).toEqual([]); + }); + + it('returns [] for empty input', () => { + expect(loadGuideEntries([])).toEqual([]); + expect(loadGuideEntries(null)).toEqual([]); + }); + + it('sorts entries by numeric filename prefix', () => { + const entries = loadGuideEntries([ + path.join(dir, '99-unmapped.md'), + path.join(dir, '01-quickstart.md'), + ]); + expect(entries.map((e) => e.slug)).toEqual(['quickstart', 'unmapped']); + }); +}); + +describe('buildDocsTree:', () => { + const entries = [ + { + slug: 'welcome', title: 'Welcome', order: 0, summary: 's', path: 'modules/home/doc/guides/00-welcome.md', + }, + { + slug: 'quickstart', title: 'Quickstart', order: 1, summary: 's', path: 'modules/home/doc/guides/01-quickstart.md', + }, + { + slug: 'advanced', title: 'Advanced', order: 3, summary: 's', path: 'modules/home/doc/guides/03-advanced.md', + }, + ]; + + it('groups guides under their section category with id/label/order/guides', () => { + const { categories } = buildDocsTree(entries, sections); + const ids = categories.map((c) => c.id); + expect(ids).toEqual(['get-started', 'guides']); + expect(categories[0].label).toBe('Get Started'); + expect(typeof categories[0].order).toBe('number'); + expect(categories[0].guides).toHaveLength(2); + // Guide projection drops body/path — keeps the public contract. + expect(categories[0].guides[0]).toEqual({ + slug: 'welcome', title: 'Welcome', persona: DEFAULT_PERSONA, order: 0, summary: 's', + }); + }); + + it('applies the per-section persona override when present', () => { + const { categories } = buildDocsTree(entries, sections); + const guidesCat = categories.find((c) => c.id === 'guides'); + expect(guidesCat.guides[0].persona).toEqual(['agent']); + }); + + it('defaults persona to the neutral DEFAULT_PERSONA when the section sets none', () => { + const { categories } = buildDocsTree(entries, sections); + expect(categories[0].guides[0].persona).toEqual(DEFAULT_PERSONA); + }); + + it('preserves section order (config order) and within-category order', () => { + const { categories } = buildDocsTree(entries, sections); + expect(categories[0].guides.map((g) => g.slug)).toEqual(['welcome', 'quickstart']); + expect(categories[1].guides.map((g) => g.slug)).toEqual(['advanced']); + }); + + it('falls back to the capitalised module name when no section matches', () => { + const orphan = [{ + slug: 'lonely', title: 'Lonely', order: 99, summary: 's', path: 'modules/users/doc/guides/99-lonely.md', + }]; + const { categories } = buildDocsTree(orphan, sections); + expect(categories).toHaveLength(1); + expect(categories[0].id).toBe('users'); + expect(categories[0].label).toBe('Users'); + expect(categories[0].guides[0].persona).toEqual(DEFAULT_PERSONA); + }); + + it('falls back to the module name when a guide has no numeric prefix (no section match)', () => { + const unprefixed = [{ + slug: 'overview', title: 'Overview', order: 0, summary: 's', path: 'modules/home/doc/guides/overview.md', + }]; + const { categories } = buildDocsTree(unprefixed, sections); + expect(categories).toHaveLength(1); + expect(categories[0].id).toBe('home'); + expect(categories[0].guides[0].persona).toEqual(DEFAULT_PERSONA); + }); + + it('falls back to a generic Guides category when a guide path is not under modules/', () => { + const detached = [{ + slug: 'loose', title: 'Loose', order: 5, summary: 's', path: '/tmp/loose.md', + }]; + const { categories } = buildDocsTree(detached, sections); + expect(categories).toHaveLength(1); + expect(categories[0].id).toBe('guides'); + expect(categories[0].label).toBe('Guides'); + }); + + it('falls back to a single generic Guides category when no sections are configured', () => { + const { categories } = buildDocsTree(entries, []); + expect(categories).toHaveLength(1); + expect(categories[0].id).toBe('home'); + expect(categories[0].guides).toHaveLength(3); + }); + + it('returns an empty tree for empty/invalid input', () => { + expect(buildDocsTree([], sections)).toEqual({ categories: [] }); + expect(buildDocsTree(null, sections)).toEqual({ categories: [] }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9be0c336f..336b8f306 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "posthog-node": "^5.37.0", - "redoc-express": "^2.1.3", "resend": "^6.12.4", "sharp": "^0.35.1", "snyk": "^1.1305.1", @@ -15446,15 +15445,6 @@ "node": ">=0.3.1" } }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/npm/node_modules/env-paths": { "version": "2.2.1", "dev": true, @@ -15464,11 +15454,6 @@ "node": ">=6" } }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.3", "dev": true, @@ -15592,14 +15577,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/npm/node_modules/ini": { "version": "6.0.0", "dev": true, @@ -16312,18 +16289,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/npm/node_modules/promzard": { "version": "3.0.1", "dev": true, @@ -16365,14 +16330,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", "dev": true, @@ -16610,28 +16567,6 @@ "node": ">=18.17" } }, - "node_modules/npm/node_modules/unique-filename": { - "version": "5.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "6.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -17875,12 +17810,6 @@ "node": ">=8.10.0" } }, - "node_modules/redoc-express": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/redoc-express/-/redoc-express-2.1.3.tgz", - "integrity": "sha512-m4bS7FeGeAqPHjdyDkT/0cx9yGQ2Wt8eDwN9thqSva0ewv0ZPoemwhwu8O/ZiBAbD8IIM5EBDzCsjd5cKEZuxA==", - "license": "MIT" - }, "node_modules/registry-auth-token": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", diff --git a/package.json b/package.json index 159ca75f6..5d2823bf0 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "posthog-node": "^5.37.0", - "redoc-express": "^2.1.3", "resend": "^6.12.4", "sharp": "^0.35.1", "snyk": "^1.1305.1",