fix: run migrations on boot and stop failures rendering as empty (#23)
The storefront showed no inventory after deploying the categories/tags release. No data was lost: the code queried categories/item_tags/ items.category_id against a database where the migration had not been run, and that failure was invisible at every layer. Three changes, each addressing one layer: Migrations now run at container start, so deployed code cannot be ahead of the schema and the easily-forgotten manual `docker exec migrate.js up` step disappears. migrate.js waits for Postgres to accept connections first, since the NAS brings the DB container up slower than the app, and still exits non-zero so a bad migration stops the container rather than serving a half-migrated schema. Express 4 does not forward a rejected async handler, and no error middleware was mounted, so a failing query never responded at all. Async routes are now wrapped and an error middleware guarantees a 500. A hung request is indistinguishable from an empty result in the UI, which is how a schema mismatch came to read as "the store has no items". The storefront now separates "request failed" from "no items" and offers a retry. fetchItems/fetchFilterOptions throw on a non-OK response rather than returning the parsed error body, which would have been set as the item list and crashed the grid on .map. Also restores the project-context update from 7fb5764, which was left out of PR #24 and ended up dangling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
describe('unexpected route failures', () => {
|
||||
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||
// A non-numeric id reaches Postgres as `WHERE i.id = 'not-a-number'`, which
|
||||
// raises invalid-input-syntax. Express 4 does not forward a rejected async
|
||||
// handler on its own, so without the asyncRoute wrapper plus the error
|
||||
// middleware this request never gets a response at all — and a hung request
|
||||
// renders as an empty storefront rather than a visible failure.
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('internal error');
|
||||
});
|
||||
|
||||
it('does not leak the underlying database error to the client', async () => {
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
|
||||
expect(JSON.stringify(res.body)).not.toContain('syntax');
|
||||
expect(JSON.stringify(res.body)).not.toContain('items');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { asyncRoute } from '../../src/asyncRoute';
|
||||
|
||||
function fakeArgs() {
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
return { req: {} as Request, res: {} as Response, next };
|
||||
}
|
||||
|
||||
describe('asyncRoute', () => {
|
||||
it('passes a rejected handler to next so Express can answer the request', async () => {
|
||||
const boom = new Error('relation "categories" does not exist');
|
||||
const { req, res, next } = fakeArgs();
|
||||
|
||||
await asyncRoute(async () => { throw boom; })(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(boom);
|
||||
});
|
||||
|
||||
it('passes a synchronous throw to next as well', async () => {
|
||||
const boom = new Error('sync failure');
|
||||
const { req, res, next } = fakeArgs();
|
||||
|
||||
await asyncRoute(() => { throw boom; })(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(boom);
|
||||
});
|
||||
|
||||
it('leaves a successful handler alone', async () => {
|
||||
const { req, res, next } = fakeArgs();
|
||||
|
||||
await asyncRoute(async () => 'fine')(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards the same arguments it was given', async () => {
|
||||
const handler = jest.fn(async () => undefined);
|
||||
const { req, res, next } = fakeArgs();
|
||||
|
||||
await asyncRoute(handler)(req, res, next);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(req, res, next);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user