From af4f5d87ca58d9af717ec2a461c135166deba66f Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 28 Aug 2026 20:36:24 -0400 Subject: [PATCH] Update router and tests to fix issues --- api/src/category.router.test.ts | 119 +++++++++++++++++++++++++------- api/src/category.router.ts | 51 +++++++------- 2 files changed, 120 insertions(+), 50 deletions(-) diff --git a/api/src/category.router.test.ts b/api/src/category.router.test.ts index 9aad1a1..87b418d 100644 --- a/api/src/category.router.test.ts +++ b/api/src/category.router.test.ts @@ -1,35 +1,104 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { buildCategoryTree, CategoryNode } from './tree.utility.ts'; +import { createCategoryRouter } from './category.router.ts'; +import { CategoryRepository } from './category.repository.ts'; +import { Request, Response } from 'express'; -describe('Tree Utility - buildCategoryTree()', () => { - it('should transform a flat list of ltree rows into a nested JSON structure', () => { - const flatNodes: CategoryNode[] = [ - { id: 1, name: 'Root', path: 'Top' }, - { id: 3, name: 'Astronomy', path: 'Top.Science.Astronomy' }, - { id: 2, name: 'Science', path: 'Top.Science' }, - { id: 4, name: 'Gardening', path: 'Top.Hobbies.Gardening' }, - { id: 5, name: 'Hobbies', path: 'Top.Hobbies' } - ]; +// 1. Generate a mock factory for the Express Response object +function createMockResponse() { + const res: Partial = {}; + const data: { statusCode: number; jsonPayload: any } = { statusCode: 200, jsonPayload: null }; - const tree = buildCategoryTree(flatNodes); + res.status = function (code: number) { + data.statusCode = code; + return this as Response; + }; - // Verify root nodes count - assert.equal(tree.length, 1); - assert.equal(tree[0].name, 'Root'); + res.json = function (payload: any) { + data.jsonPayload = payload; + return this as Response; + }; - // Verify second level ("Science" and "Hobbies") - const rootChildren = tree[0].children; - assert.equal(rootChildren.length, 2); + return { mockRes: res as Response, data }; +} - const scienceNode = rootChildren.find(c => c.name === 'Science'); - const hobbiesNode = rootChildren.find(c => c.name === 'Hobbies'); +// 2. Generate a mock factory for the repository layer +function createMockRepository(behavior: 'success' | 'failure') { + return { + findAllDescendants: async (path: string) => { + if (behavior === 'failure') throw new Error('Database connection failed'); + // Return a valid single-root array format mimicking real ltree database outputs + return [ + { id: 1, name: 'Top', path: 'Top' }, + { id: 2, name: 'Science', path: 'Top.Science' } + ]; + } + } as unknown as CategoryRepository; +} - assert.ok(scienceNode); - assert.ok(hobbiesNode); +// Helper utility to safely extract the Express handler bypassing strict undefined types +function getRouteHandler(router: any): Function { + const routeLayer = router.stack.find((layer: any) => layer.route); + if (!routeLayer || !routeLayer.route || !routeLayer.route.stack) { + throw new Error('Could not find route handler in mock router stack'); + } + return routeLayer.route.stack[0].handle; // Target the primary callback handler array element +} - // Verify third level deeply nested child ("Astronomy") - assert.equal(scienceNode.children.length, 1); - assert.equal(scienceNode.children[0].name, 'Astronomy'); - }); +describe('Category Router Endpoints', () => { + + it('GET /tree{/:path} - should return 200 and structural JSON array data on valid requests', async () => { + const mockRepo = createMockRepository('success'); + const router = createCategoryRouter(mockRepo); + + const mockReq = { params: { path: 'Top' } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + const routeHandler = getRouteHandler(router); + await routeHandler(mockReq, mockRes); + + assert.equal(data.statusCode, 200); + assert.equal(data.jsonPayload.success, true); + + // Correct alignment: buildCategoryTree returns an ARRAY of root nodes + assert.equal(Array.isArray(data.jsonPayload.data), true); + assert.equal(data.jsonPayload.data[0].name, 'Top'); + }); + + it('GET /tree{/:path} - should return 400 Bad Request if ltree path contains malformed formatting', async () => { + const mockRepo = createMockRepository('success'); + const router = createCategoryRouter(mockRepo); + + const mockReq = { params: { path: 'Top.Bad Path!' } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + const routeHandler = getRouteHandler(router); + await routeHandler(mockReq, mockRes); + + assert.equal(data.statusCode, 400); + assert.equal(data.jsonPayload.success, false); + assert.match(data.jsonPayload.error, /Invalid path format/); + }); + + it('GET /tree{/:path} - should handle runtime database crashes gracefully with a 500 error status', async () => { + const mockRepo = createMockRepository('failure'); + const router = createCategoryRouter(mockRepo); + + const mockReq = { params: { path: 'Top' } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + // Prevent console.error from cluttering the test runner feedback during intentional failures + const originalConsoleError = console.error; + console.error = () => {}; + + const routeHandler = getRouteHandler(router); + await routeHandler(mockReq, mockRes); + + // Restore original logger tracking + console.error = originalConsoleError; + + assert.equal(data.statusCode, 500); + assert.equal(data.jsonPayload.success, false); + assert.equal(data.jsonPayload.error, 'Internal Server Error'); + }); }); diff --git a/api/src/category.router.ts b/api/src/category.router.ts index 40ccdf1..d483426 100644 --- a/api/src/category.router.ts +++ b/api/src/category.router.ts @@ -2,34 +2,35 @@ import { Router, Request, Response } from 'express'; import { CategoryRepository } from './category.repository.ts'; import { buildCategoryTree } from './tree.utility.ts'; +// Clean regex pattern matching valid Postgres ltree structures +const LTREE_REGEX = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/; + export function createCategoryRouter(repository: CategoryRepository): Router { - const router = Router(); + const router = Router(); - /** - * GET /api/categories/tree/:path? - * Fetches all descendants of a path and returns them formatted as a tree. - * If no path parameter is provided, it defaults to the root level. - */ - router.get('/tree/:path?', async (req: Request, params: Response): Promise => { - try { - const rawPath = req.params.path; + router.get('/tree{/:path}', async (req: Request, res: Response): Promise => { + try { + const rawPath = req.params.path; + const parentPath = Array.isArray(rawPath) ? rawPath[0] : (rawPath || 'Top'); - const parentPath = Array.isArray(rawPath) - ? rawPath[0] - : (rawPath || 'Top'); // default fallback root value in the database + // 1. INPUT VALIDATION: Stop malicious or broken ltree strings early + if (!LTREE_REGEX.test(parentPath)) { + res.status(400).json({ + success: false, + error: 'Invalid path format. Path elements must be alphanumeric separated by dots.' + }); + return; // Break execution early + } - // Fetch flat database rows from our repository - const flatRows = await repository.findAllDescendants(parentPath); + const flatRows = await repository.findAllDescendants(parentPath); + const nestedTree = buildCategoryTree(flatRows); + + res.json({ success: true, data: nestedTree }); + } catch (error) { + console.error('Failed to resolve category tree:', error); + res.status(500).json({ success: false, error: 'Internal Server Error' }); + } + }); - // Format data into nested layout - const nestedTree = buildCategoryTree(flatRows); - - params.json({ success: true, data: nestedTree }); - } catch (error) { - console.error('Failed to resolve category tree:', error); - params.status(500).json({ success: false, error: 'Internal Server Error' }); - } - }); - - return router; + return router; }