Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Server } from 'http';
import type { AddressInfo } from 'net';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';
import { createApp } from '../server/app';

// Exercises the real Express stack (param routes, JSON 404, store wiring) over HTTP,
// rather than poking router internals.
describe('Dynamic router integration (real HTTP)', () => {
const tempFile = path.resolve(__dirname, 'temp-integration-db.json');
let server: Server;
let baseUrl: string;

beforeAll(async () => {
fs.writeFileSync(
tempFile,
JSON.stringify({ posts: [{ id: 1, title: 'Hello' }] }),
);
await JsonStore.load(tempFile);

const app = createApp({ delayMs: 0 });
await new Promise<void>((resolve) => {
server = app.listen(0, resolve);
});
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
});

it('lists a collection', async () => {
const res = await fetch(`${baseUrl}/posts`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([{ id: 1, title: 'Hello' }]);
});

it('gets an item by id', async () => {
const res = await fetch(`${baseUrl}/posts/1`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: 1, title: 'Hello' });
});

it('returns JSON 404 for an unknown resource', async () => {
const res = await fetch(`${baseUrl}/widgets`);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as { error: string };
expect(body.error).toContain('Not found');
});

it('returns JSON 404 for a known resource with a missing id', async () => {
const res = await fetch(`${baseUrl}/posts/999`);
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toContain('999');
});

it('creates a record via POST and assigns the next id', async () => {
const res = await fetch(`${baseUrl}/posts`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'World' }),
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ id: 2, title: 'World' });
});

it('serves a collection added to the store after startup (no restart)', async () => {
// Simulate a watch reload introducing a new top-level key.
fs.writeFileSync(
tempFile,
JSON.stringify({
posts: [{ id: 1, title: 'Hello' }],
comments: [{ id: 1, body: 'Nice' }],
}),
);
await JsonStore.load(tempFile, true);

const res = await fetch(`${baseUrl}/comments`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([{ id: 1, body: 'Nice' }]);
});
});
29 changes: 29 additions & 0 deletions src/__tests__/selfWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';

// Isolated in its own file so the JsonStore singleton starts with a clean
// (unset) self-write window.
describe('Self-write suppression', () => {
const tempFile = path.resolve(__dirname, 'temp-selfwrite-db.json');

beforeAll(() => {
fs.writeFileSync(tempFile, JSON.stringify({ posts: [{ id: 1 }] }));
});

afterAll(() => {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
});

it('marks recent saves as self-caused so the watcher ignores them', async () => {
await JsonStore.load(tempFile);
expect(JsonStore.isSelfWrite()).toBe(false);

JsonStore.getData().posts.push({ id: 2 });
await JsonStore.save();

// Immediately after save(), a file event would be ours — must be suppressed.
expect(JsonStore.isSelfWrite()).toBe(true);
});
});
132 changes: 132 additions & 0 deletions src/__tests__/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';
import { buildDynamicRouter } from '../server/routes/dynamicRouter';

// The tests interrogate Express router internals, which aren't fully typed.
function getResourceGetHandler(router: any): (req: any, res: any, next: any) => Promise<void> {
const layer = router.stack.find(
(l: any) => l.route && l.route.path === '/:resource' && l.route.methods.get,
);
expect(layer).toBeDefined();
return layer.route.stack[0].handle;
}

describe('Watch Mode and Dynamic Routing', () => {
const tempFile = path.resolve(__dirname, 'temp-watch-db.json');

beforeAll(() => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Original Title' }]
}));
});

afterAll(() => {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
});

it('should load initial data and dynamically serve it', async () => {
await JsonStore.load(tempFile);
expect(JsonStore.getData().posts).toEqual([{ id: 1, title: 'Original Title' }]);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let jsonResponse: any = null;
const req = {
params: { resource: 'posts' },
query: {}
} as any;
const res = {
setHeader: () => {},
json: (data: any) => {
jsonResponse = data;
}
} as any;

await handler(req, res, () => {});

expect(jsonResponse).toEqual([{ id: 1, title: 'Original Title' }]);
});

it('should reflect updates to existing resources dynamically without recreating router', async () => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Updated Title' }]
}));

await JsonStore.load(tempFile, true);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let jsonResponse: any = null;
const req = {
params: { resource: 'posts' },
query: {}
} as any;
const res = {
setHeader: () => {},
json: (data: any) => {
jsonResponse = data;
}
} as any;

await handler(req, res, () => {});
expect(jsonResponse).toEqual([{ id: 1, title: 'Updated Title' }]);
});

it('should dynamically serve newly added resources without recreating router', async () => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Updated Title' }],
comments: [{ id: 1, body: 'New Comment' }]
}));

await JsonStore.load(tempFile, true);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let postsResponse: any = null;
const reqPosts = { params: { resource: 'posts' }, query: {} } as any;
const resPosts = {
setHeader: () => {},
json: (data: any) => { postsResponse = data; }
} as any;
await handler(reqPosts, resPosts, () => {});
expect(postsResponse).toEqual([{ id: 1, title: 'Updated Title' }]);

let commentsResponse: any = null;
const reqComments = { params: { resource: 'comments' }, query: {} } as any;
const resComments = {
setHeader: () => {},
json: (data: any) => { commentsResponse = data; }
} as any;

let nextCalled = false;
await handler(reqComments, resComments, () => { nextCalled = true; });

expect(nextCalled).toBe(false);
expect(commentsResponse).toEqual([{ id: 1, body: 'New Comment' }]);
});

it('should fall through to next() for non-existent resources', async () => {
const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

const req = { params: { resource: 'nonexistent' }, query: {} } as any;
const res = {
setHeader: () => {},
json: () => { throw new Error('Should not call json()'); }
} as any;

let nextCalled = false;
await handler(req, res, () => {
nextCalled = true;
});

expect(nextCalled).toBe(true);
});
});
27 changes: 21 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import * as chokidar from "chokidar";
import path from "path";
import pc from "picocolors";
import { Command } from "commander";
import { JsonStore } from "./store/jsonStore";
Expand Down Expand Up @@ -33,6 +34,7 @@ const program = new Command();
let watcher: chokidar.FSWatcher | null = null;

function startFileWatcher(filePath: string): void {
const absolutePath = path.resolve(filePath);
let reloadTimeout: ReturnType<typeof setTimeout> | null = null;

if (watcher) {
Expand All @@ -41,20 +43,33 @@ function startFileWatcher(filePath: string): void {
}

const reload = async (): Promise<void> => {
// Skip events caused by our own save().
if (JsonStore.isSelfWrite()) {
return;
}
try {
await JsonStore.load(filePath, true);
console.log(`\n ${pc.dim('│')} ${pc.green('✓')} ${pc.dim('Hot reloaded data from')} ${pc.white(filePath)}`);
await JsonStore.load(absolutePath, true);
console.log(`\n ${pc.dim('│')} ${pc.green('✓')} ${pc.dim('Hot reloaded data from')} ${pc.white(absolutePath)}`);
} catch (err: any) {
printError('WATCH_RELOAD_FAILED', err.message);
}
};

try {
watcher = chokidar.watch(filePath, { persistent: true, ignoreInitial: true });
watcher = chokidar.watch(absolutePath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50
}
});
watcher
.on("change", () => {
if (reloadTimeout) clearTimeout(reloadTimeout);
reloadTimeout = setTimeout(() => void reload(), 100);
.on("all", (event) => {
if (event === "change" || event === "add") {
if (reloadTimeout) clearTimeout(reloadTimeout);
reloadTimeout = setTimeout(() => void reload(), 100);
}
})
.on("error", (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
Expand Down
4 changes: 4 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export function createApp(options: CreateAppOptions): Application {
app.use(express.json());
app.use(requestLoggingMiddleware);
app.use(delayMiddleware(options.delayMs));
// Catch-all `/:resource` routes — mount any reserved endpoint above this line.
app.use("/", buildDynamicRouter());
app.use((req: Request, res: Response) => {
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` });
});
app.use(errorHandler);
return app;
}
Loading
Loading