189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
|
|
||
|
|
// Mock mongoose to prevent real DB connections; provide class-based models
|
||
|
|
vi.mock('mongoose', () => {
|
||
|
|
class FakeSchema {
|
||
|
|
paths: Record<string, unknown> = {};
|
||
|
|
constructor(def: Record<string, unknown>, _opts?: unknown) {
|
||
|
|
for (const key of Object.keys(def)) {
|
||
|
|
this.paths[key] = { path: key };
|
||
|
|
}
|
||
|
|
this.paths['createdAt'] = { path: 'createdAt' };
|
||
|
|
this.paths['updatedAt'] = { path: 'updatedAt' };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const models: Record<string, unknown> = {};
|
||
|
|
|
||
|
|
function createFakeModel(name: string) {
|
||
|
|
const mockExec = vi.fn().mockResolvedValue(null);
|
||
|
|
const mockLean = vi.fn(() => ({ exec: mockExec }));
|
||
|
|
|
||
|
|
class Model {
|
||
|
|
_data: Record<string, unknown>;
|
||
|
|
constructor(data: Record<string, unknown>) {
|
||
|
|
this._data = data;
|
||
|
|
Object.assign(this, data);
|
||
|
|
}
|
||
|
|
save() {
|
||
|
|
return Promise.resolve(this);
|
||
|
|
}
|
||
|
|
toObject() {
|
||
|
|
return { _id: `${name}-id`, ...this._data };
|
||
|
|
}
|
||
|
|
static modelName = name;
|
||
|
|
static schema = new FakeSchema({});
|
||
|
|
static findOne = vi.fn(() => ({ lean: mockLean }));
|
||
|
|
static findById = vi.fn(() => ({ lean: mockLean }));
|
||
|
|
static findOneAndUpdate = vi.fn(() => ({ exec: mockExec }));
|
||
|
|
static findByIdAndUpdate = vi.fn(() => ({ exec: mockExec }));
|
||
|
|
}
|
||
|
|
|
||
|
|
return Model;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
default: {
|
||
|
|
Schema: FakeSchema,
|
||
|
|
model: vi.fn((name: string, _schema?: unknown) => {
|
||
|
|
if (!models[name]) models[name] = createFakeModel(name);
|
||
|
|
return models[name];
|
||
|
|
}),
|
||
|
|
connect: vi.fn().mockResolvedValue(undefined),
|
||
|
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
vi.mock('jose', () => ({
|
||
|
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||
|
|
jwtVerify: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { buildApp } from './main.js';
|
||
|
|
import * as jose from 'jose';
|
||
|
|
import { NotFoundError } from './common/errors.js';
|
||
|
|
|
||
|
|
describe('buildApp', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('creates a Fastify app that is ready', async () => {
|
||
|
|
const app = await buildApp({ logger: false });
|
||
|
|
await app.ready();
|
||
|
|
expect(app).toBeDefined();
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('has the health route available', async () => {
|
||
|
|
const app = await buildApp({ logger: false });
|
||
|
|
await app.ready();
|
||
|
|
|
||
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/health' });
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
expect(res.json().status).toBe('ok');
|
||
|
|
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('error handler', () => {
|
||
|
|
async function getApp() {
|
||
|
|
// Set up a valid JWT mock for authenticated routes
|
||
|
|
vi.mocked(jose.jwtVerify).mockResolvedValue({
|
||
|
|
payload: {
|
||
|
|
sub: 'kc-1',
|
||
|
|
email: 'test@example.com',
|
||
|
|
preferred_username: 'testuser',
|
||
|
|
realm_access: { roles: ['member'] },
|
||
|
|
householdIds: ['hh1'],
|
||
|
|
},
|
||
|
|
protectedHeader: { alg: 'RS256' },
|
||
|
|
key: {} as never,
|
||
|
|
} as never);
|
||
|
|
|
||
|
|
const app = await buildApp({ logger: false });
|
||
|
|
|
||
|
|
// Register test routes that throw various errors
|
||
|
|
app.get('/test/app-error', { config: { public: true } as never }, async () => {
|
||
|
|
throw new NotFoundError('Test not found');
|
||
|
|
});
|
||
|
|
|
||
|
|
app.get('/test/generic-error', { config: { public: true } as never }, async () => {
|
||
|
|
const err = new Error('Something broke');
|
||
|
|
(err as unknown as Record<string, unknown>).statusCode = 422;
|
||
|
|
throw err;
|
||
|
|
});
|
||
|
|
|
||
|
|
app.get('/test/unknown-error', { config: { public: true } as never }, async () => {
|
||
|
|
throw new Error('Unexpected');
|
||
|
|
});
|
||
|
|
|
||
|
|
await app.ready();
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles AppError with correct status and body', async () => {
|
||
|
|
const app = await getApp();
|
||
|
|
|
||
|
|
const res = await app.inject({ method: 'GET', url: '/test/app-error' });
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(404);
|
||
|
|
const body = res.json();
|
||
|
|
expect(body.error).toBe('Not Found');
|
||
|
|
expect(body.message).toBe('Test not found');
|
||
|
|
expect(body.timestamp).toBeDefined();
|
||
|
|
expect(body.path).toBe('/test/app-error');
|
||
|
|
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles generic errors with statusCode', async () => {
|
||
|
|
const app = await getApp();
|
||
|
|
|
||
|
|
const res = await app.inject({ method: 'GET', url: '/test/generic-error' });
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(422);
|
||
|
|
const body = res.json();
|
||
|
|
expect(body.error).toBe('Error');
|
||
|
|
expect(body.message).toBe('Something broke');
|
||
|
|
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles unknown 500 errors without leaking messages', async () => {
|
||
|
|
const app = await getApp();
|
||
|
|
|
||
|
|
const res = await app.inject({ method: 'GET', url: '/test/unknown-error' });
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(500);
|
||
|
|
const body = res.json();
|
||
|
|
expect(body.error).toBe('Internal Server Error');
|
||
|
|
expect(body.message).toBe('An unexpected error occurred');
|
||
|
|
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 404 for unknown routes', async () => {
|
||
|
|
const app = await getApp();
|
||
|
|
|
||
|
|
const res = await app.inject({
|
||
|
|
method: 'GET',
|
||
|
|
url: '/nonexistent',
|
||
|
|
headers: {
|
||
|
|
authorization: 'Bearer test-token',
|
||
|
|
'x-household-id': 'hh1',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(404);
|
||
|
|
|
||
|
|
await app.close();
|
||
|
|
});
|
||
|
|
});
|