feat: add CASL authorization and AI configuration
This commit is contained in:
178
apps/server/src/database/database-migrations.spec.ts
Normal file
178
apps/server/src/database/database-migrations.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { TestingModule, Test } from '@nestjs/testing';
|
||||
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
interface MockColumn {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MockTable {
|
||||
name: string;
|
||||
columns: MockColumn[];
|
||||
}
|
||||
|
||||
function mockRunner(overrides: {
|
||||
getTables?: MockTable[];
|
||||
getTable?: MockTable;
|
||||
queryError?: Error;
|
||||
} = {}) {
|
||||
const release = jest.fn();
|
||||
const connect = jest.fn();
|
||||
const query = jest.fn();
|
||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||
const getTable = jest.fn().mockResolvedValue(
|
||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
||||
);
|
||||
|
||||
if (overrides.queryError) {
|
||||
query.mockRejectedValue(overrides.queryError);
|
||||
}
|
||||
|
||||
return { release, connect, query, getTables, getTable };
|
||||
}
|
||||
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>) {
|
||||
return {
|
||||
options: { type: 'better-sqlite3' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Type to reach the private ensureAiConfigTable for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
||||
let service: MigrationsPrivate & DatabaseMigrationsService;
|
||||
|
||||
async function bootstrap(runner: ReturnType<typeof mockRunner>) {
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(
|
||||
DatabaseMigrationsService,
|
||||
);
|
||||
}
|
||||
|
||||
it('creates table + index when ai_config does not exist', async () => {
|
||||
const runner = mockRunner({ getTables: [] });
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips ALTER when table exists with all columns', async () => {
|
||||
const allColumns: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
{ name: 'last_test_latency_ms' },
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: allColumns }],
|
||||
getTable: { name: 'ai_config', columns: allColumns },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
// Should NOT issue any ALTER TABLE
|
||||
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
|
||||
);
|
||||
expect(alterCalls).toHaveLength(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds missing column via ALTER TABLE', async () => {
|
||||
// Table has most columns but is missing last_test_latency_ms
|
||||
const missingOne: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
// last_test_latency_ms missing
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: missingOne }],
|
||||
getTable: { name: 'ai_config', columns: missingOne },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases runner even when query throws', async () => {
|
||||
const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
|
||||
await bootstrap(runner);
|
||||
|
||||
await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — bootstrap failure handling', () => {
|
||||
it('fails application bootstrap when the required ai_config migration fails', async () => {
|
||||
const runner = mockRunner();
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
|
||||
jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
|
||||
const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
|
||||
const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();
|
||||
|
||||
await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
|
||||
expect(backfill).not.toHaveBeenCalled();
|
||||
expect(normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user