Compare commits
2
Commits
223ab79d21
...
b04ef78e7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04ef78e7c | ||
|
|
af4f5d87ca |
No files matched your search
@@ -44,8 +44,8 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/dbmate
|
||||
|
||||
- name: Run Migrations against Test Database
|
||||
run: |
|
||||
npx dbmate up
|
||||
working-directory: ./api
|
||||
run: npx dbmate up
|
||||
env:
|
||||
DATABASE_URL: "postgres://test_user:test_password@saber.home.lan:5433/test_db?sslmode=disable"
|
||||
|
||||
|
||||
File renamed without changes.
+2
-2
@@ -1,7 +1,7 @@
|
||||
\restrict dbmate
|
||||
|
||||
-- Dumped from database version 18.4 (Ubuntu 18.4-1.pgdg24.04+1)
|
||||
-- Dumped by pg_dump version 18.4 (Ubuntu 18.4-1.pgdg24.04+1)
|
||||
-- Dumped from database version 18.6 (Ubuntu 18.6-1.pgdg24.04+2)
|
||||
-- Dumped by pg_dump version 18.6 (Ubuntu 18.6-1.pgdg24.04+2)
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
|
||||
@@ -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<Response> = {};
|
||||
const data: { statusCode: number; jsonPayload: any } = { statusCode: 200, jsonPayload: null };
|
||||
|
||||
res.status = function (code: number) {
|
||||
data.statusCode = code;
|
||||
return this as Response;
|
||||
};
|
||||
|
||||
res.json = function (payload: any) {
|
||||
data.jsonPayload = payload;
|
||||
return this as Response;
|
||||
};
|
||||
|
||||
return { mockRes: res as Response, data };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const tree = buildCategoryTree(flatNodes);
|
||||
// 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 root nodes count
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0].name, 'Root');
|
||||
describe('Category Router Endpoints', () => {
|
||||
|
||||
// Verify second level ("Science" and "Hobbies")
|
||||
const rootChildren = tree[0].children;
|
||||
assert.equal(rootChildren.length, 2);
|
||||
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 scienceNode = rootChildren.find(c => c.name === 'Science');
|
||||
const hobbiesNode = rootChildren.find(c => c.name === 'Hobbies');
|
||||
const mockReq = { params: { path: 'Top' } } as unknown as Request;
|
||||
const { mockRes, data } = createMockResponse();
|
||||
|
||||
assert.ok(scienceNode);
|
||||
assert.ok(hobbiesNode);
|
||||
const routeHandler = getRouteHandler(router);
|
||||
await routeHandler(mockReq, mockRes);
|
||||
|
||||
// Verify third level deeply nested child ("Astronomy")
|
||||
assert.equal(scienceNode.children.length, 1);
|
||||
assert.equal(scienceNode.children[0].name, 'Astronomy');
|
||||
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');
|
||||
});
|
||||
});
|
||||
+15
-14
@@ -2,32 +2,33 @@ 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();
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
router.get('/tree{/:path}', async (req: Request, res: Response): Promise<void> => {
|
||||
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);
|
||||
|
||||
// Format data into nested layout
|
||||
const nestedTree = buildCategoryTree(flatRows);
|
||||
|
||||
params.json({ success: true, data: nestedTree });
|
||||
res.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' });
|
||||
res.status(500).json({ success: false, error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user