Merge pull request #34267 from backstage/freben/describe-each-databases

tests: use describe.each for database test iteration
This commit is contained in:
Fredrik Adelöw
2026-05-19 10:24:00 +02:00
committed by GitHub
40 changed files with 4406 additions and 4992 deletions
@@ -24,13 +24,13 @@ import { ConfigReader } from '@backstage/config';
jest.setTimeout(60_000);
describe('createPluginKeySource', () => {
const databases = TestDatabases.create();
const mockDir = createMockDirectory();
const databases = TestDatabases.create();
const mockDir = createMockDirectory();
it.each(databases.eachSupportedId())(
'works for implicit database (no config), %p',
async databaseId => {
describe.each(databases.eachSupportedId())(
'createPluginKeySource, %p',
databaseId => {
it('works for implicit database (no config)', async () => {
const knex = await databases.init(databaseId);
const getClient = jest.fn(async () => knex);
@@ -61,12 +61,9 @@ describe('createPluginKeySource', () => {
}),
],
});
},
);
});
it.each(databases.eachSupportedId())(
'works for explicit database, %p',
async databaseId => {
it('works for explicit database', async () => {
const knex = await databases.init(databaseId);
const getClient = jest.fn(async () => knex);
@@ -99,11 +96,12 @@ describe('createPluginKeySource', () => {
}),
],
});
},
);
});
},
);
it('works for static', async () => {
const privateKey = `
it('works for static', async () => {
const privateKey = `
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY
Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm
@@ -111,60 +109,59 @@ describe('createPluginKeySource', () => {
-----END PRIVATE KEY-----
`.trim();
const publicKey = `
const publicKey = `
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92
ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw==
-----END PUBLIC KEY-----
`.trim();
mockDir.setContent({
'public.pem': publicKey,
'private.pem': privateKey,
});
const publicKeyPath = mockDir.resolve('public.pem');
const privateKeyPath = mockDir.resolve('private.pem');
mockDir.setContent({
'public.pem': publicKey,
'private.pem': privateKey,
});
const publicKeyPath = mockDir.resolve('public.pem');
const privateKeyPath = mockDir.resolve('private.pem');
const getClient = jest.fn();
const getClient = jest.fn();
const source = await createPluginKeySource({
config: new ConfigReader({
backend: {
auth: {
pluginKeyStore: {
type: 'static',
static: {
keys: [
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '1',
},
],
},
const source = await createPluginKeySource({
config: new ConfigReader({
backend: {
auth: {
pluginKeyStore: {
type: 'static',
static: {
keys: [
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '1',
},
],
},
},
},
}),
database: mockServices.database.mock({ getClient }),
logger: mockServices.logger.mock(),
keyDuration: { seconds: 10 },
});
},
}),
database: mockServices.database.mock({ getClient }),
logger: mockServices.logger.mock(),
keyDuration: { seconds: 10 },
});
expect(getClient).not.toHaveBeenCalled();
expect(getClient).not.toHaveBeenCalled();
const keys = await source.listKeys();
expect(keys.keys.length).toEqual(1);
expect(keys.keys[0].key).toMatchObject({
kid: '1',
alg: 'ES256',
});
const keys = await source.listKeys();
expect(keys.keys.length).toEqual(1);
expect(keys.keys[0].key).toMatchObject({
kid: '1',
alg: 'ES256',
});
const pk = await source.getPrivateSigningKey();
expect(pk).toMatchObject({
kid: '1',
alg: 'ES256',
d: expect.any(String),
});
const pk = await source.getPrivateSigningKey();
expect(pk).toMatchObject({
kid: '1',
alg: 'ES256',
d: expect.any(String),
});
});
@@ -54,14 +54,16 @@ jest.mock('@keyv/memcache', () => {
};
});
describe('CacheManager integration', () => {
const caches = TestCaches.create();
jest.setTimeout(60_000);
afterEach(jest.clearAllMocks);
const caches = TestCaches.create();
it.each(caches.eachSupportedId())(
'only creates one underlying connection per plugin, %p',
async cacheId => {
describe.each(caches.eachSupportedId())(
'CacheManager integration, %p',
cacheId => {
afterEach(jest.clearAllMocks);
it('only creates one underlying connection per plugin', async () => {
const { store, connection } = await caches.init(cacheId);
const manager = CacheManager.fromConfig(
@@ -85,12 +87,9 @@ describe('CacheManager integration', () => {
// eslint-disable-next-line jest/no-conditional-expect
expect(KeyvValkey).toHaveBeenCalledTimes(3);
}
},
);
});
it.each(caches.eachSupportedId())(
'interacts correctly with store, %p',
async cacheId => {
it('interacts correctly with store', async () => {
const { store, connection } = await caches.init(cacheId);
const manager = CacheManager.fromConfig(
@@ -114,12 +113,9 @@ describe('CacheManager integration', () => {
await expect(plugin1.get('a')).resolves.toBe('plugin1');
await expect(plugin2a.get('a')).resolves.toBe('plugin2b');
await expect(plugin2b.get('a')).resolves.toBe('plugin2b');
},
);
});
it.each(caches.eachSupportedId())(
'supports both milliseconds and human durations throughout, %p',
async cacheId => {
it('supports both milliseconds and human durations throughout', async () => {
const { store, connection } = await caches.init(cacheId);
for (const defaultTtl of [200, { milliseconds: 200 }]) {
@@ -175,39 +171,39 @@ describe('CacheManager integration', () => {
await expect(defaultClient.get('e')).resolves.toBeUndefined();
await expect(defaultClient.get('f')).resolves.toBeUndefined();
}
},
);
});
},
);
it('rejects invalid defaultTtl', () => {
expect(() =>
CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'memory',
},
it('rejects invalid defaultTtl', () => {
expect(() =>
CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'memory',
},
},
}),
),
).not.toThrow();
},
}),
),
).not.toThrow();
expect(() =>
CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'memory',
defaultTtl: 'hello',
},
expect(() =>
CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'memory',
defaultTtl: 'hello',
},
},
}),
),
).toThrow(/Invalid duration 'hello' in config/);
});
},
}),
),
).toThrow(/Invalid duration 'hello' in config/);
});
describe('CacheManager store options', () => {
@@ -41,68 +41,65 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
jest.setTimeout(60_000);
describe('migrations', () => {
const databases = TestDatabases.create();
const databases = TestDatabases.create();
it.each(databases.eachSupportedId())(
'20250411000000_last_run.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
it('20250411000000_last_run.js', async () => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20250411000000_last_run.js');
await migrateUntilBefore(knex, '20250411000000_last_run.js');
await knex
.insert({
id: 'i',
settings_json: '{}',
})
.into('backstage_backend_tasks__tasks');
await knex
.insert({
id: 'i',
settings_json: '{}',
})
.into('backstage_backend_tasks__tasks');
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await migrateUpOnce(knex);
await migrateUpOnce(knex);
await knex
.table('backstage_backend_tasks__tasks')
.update({ last_run_error_json: 'error' })
.where({ id: 'i' });
await knex
.table('backstage_backend_tasks__tasks')
.update({ last_run_error_json: 'error' })
.where({ id: 'i' });
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
last_run_ended_at: null,
last_run_error_json: 'error',
},
]);
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
last_run_ended_at: null,
last_run_error_json: 'error',
},
]);
await migrateDownOnce(knex);
await migrateDownOnce(knex);
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'i',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await knex.destroy();
},
);
await knex.destroy();
});
});
@@ -24,9 +24,10 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
describe('TaskScheduler', () => {
const databases = TestDatabases.create();
describe.each(databases.eachSupportedId())('TaskScheduler, %p', databaseId => {
const logger = mockServices.logger.mock();
const databases = TestDatabases.create();
const rootLifecycle = mockServices.rootLifecycle.mock();
const httpRouter = mockServices.httpRouter.mock();
const pluginMetadata = {
@@ -35,63 +36,57 @@ describe('TaskScheduler', () => {
const testScopedSignal = createTestScopedSignal();
const metrics = metricsServiceMock.mock();
it.each(databases.eachSupportedId())(
'can return a working v1 plugin impl, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const database = mockServices.database({ knex });
it('can return a working v1 plugin impl', async () => {
const knex = await databases.init(databaseId);
const database = mockServices.database({ knex });
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
});
const fn = jest.fn();
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
});
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromMillis(5000),
signal: testScopedSignal(),
fn,
});
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromMillis(5000),
signal: testScopedSignal(),
fn,
});
await waitForExpect(() => {
expect(fn).toHaveBeenCalled();
});
},
);
await waitForExpect(() => {
expect(fn).toHaveBeenCalled();
});
});
it.each(databases.eachSupportedId())(
'can return a working v2 plugin impl, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const database = mockServices.database({ knex });
it('can return a working v2 plugin impl', async () => {
const knex = await databases.init(databaseId);
const database = mockServices.database({ knex });
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
});
const fn = jest.fn();
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
});
const fn = jest.fn();
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
signal: testScopedSignal(),
fn,
});
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
signal: testScopedSignal(),
fn,
});
await waitForExpect(() => {
expect(fn).toHaveBeenCalled();
});
},
);
await waitForExpect(() => {
expect(fn).toHaveBeenCalled();
});
});
});
@@ -31,48 +31,50 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
describe('PluginTaskManagerImpl', () => {
const addShutdownHook = jest.fn();
const databases = TestDatabases.create({
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
});
const databases = TestDatabases.create({
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
});
beforeAll(async () => {
// Make sure all databases are running before mocking timers, in case of testcontainers
await Promise.all(
databases.eachSupportedId().map(([id]) => databases.init(id)),
);
describe.each(databases.eachSupportedId())(
'PluginTaskManagerImpl, %p',
databaseId => {
const addShutdownHook = jest.fn();
jest.useFakeTimers();
}, 60_000);
beforeAll(async () => {
// Make sure the database is running before mocking timers, in case of testcontainers
await databases.init(databaseId);
jest.useFakeTimers();
}, 60_000);
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
async function init(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
const manager = new PluginTaskSchedulerImpl(
'myplugin',
async () => knex,
mockServices.logger.mock(),
metricsServiceMock.mock(),
{
addShutdownHook,
addBeforeShutdownHook: jest.fn(),
addStartupHook: jest.fn(),
},
);
return { knex, manager };
}
beforeEach(() => {
jest.clearAllMocks();
});
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('scheduleTask with global scope', () => {
it.each(databases.eachSupportedId())(
'can run the v1 happy path, %p',
async databaseId => {
async function init(id: TestDatabaseId) {
const knex = await databases.init(id);
await migrateBackendTasks(knex);
const manager = new PluginTaskSchedulerImpl(
'myplugin',
async () => knex,
mockServices.logger.mock(),
metricsServiceMock.mock(),
{
addShutdownHook,
addBeforeShutdownHook: jest.fn(),
addStartupHook: jest.fn(),
},
);
return { knex, manager };
}
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('scheduleTask with global scope', () => {
it('can run the v1 happy path', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -87,12 +89,9 @@ describe('PluginTaskManagerImpl', () => {
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
},
);
});
it.each(databases.eachSupportedId())(
'can run the v2 happy path, %p',
async databaseId => {
it('can run the v2 happy path', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -107,12 +106,9 @@ describe('PluginTaskManagerImpl', () => {
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
},
);
});
it.each(databases.eachSupportedId())(
'aborts the task if shutdown hook is invoked, %p',
async databaseId => {
it('aborts the task if shutdown hook is invoked', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -134,14 +130,11 @@ describe('PluginTaskManagerImpl', () => {
// Should be aborted after the shutdown hook is invoked
await shutdownHook();
expect(abortSignal.aborted).toBe(true);
},
);
});
});
});
describe('triggerTask with global scope', () => {
it.each(databases.eachSupportedId())(
'can manually trigger a task, %p',
async databaseId => {
describe('triggerTask with global scope', () => {
it('can manually trigger a task', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -160,12 +153,9 @@ describe('PluginTaskManagerImpl', () => {
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
},
);
});
it.each(databases.eachSupportedId())(
'cant trigger a non-existent task, %p',
async databaseId => {
it('cant trigger a non-existent task', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -180,12 +170,9 @@ describe('PluginTaskManagerImpl', () => {
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
NotFoundError,
);
},
);
});
it.each(databases.eachSupportedId())(
'cant trigger a running task, %p',
async databaseId => {
it('cant trigger a running task', async () => {
const { manager } = await init(databaseId);
const promise = createDeferred();
@@ -205,140 +192,137 @@ describe('PluginTaskManagerImpl', () => {
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
ConflictError,
);
},
);
});
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('scheduleTask with local scope', () => {
it('can run the v1 happy path', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task1',
timeout: { milliseconds: 5000 },
frequency: { milliseconds: 5000 },
fn,
scope: 'local',
});
});
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('scheduleTask with local scope', () => {
it('can run the v1 happy path', async () => {
const { manager } = await init('SQLITE_3');
it('can run the v2 happy path', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task1',
timeout: { milliseconds: 5000 },
frequency: { milliseconds: 5000 },
fn,
scope: 'local',
});
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
scope: 'local',
});
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
it('can run the v2 happy path', async () => {
const { manager } = await init('SQLITE_3');
it('aborts the task if shutdown hook is invoked', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task2',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
scope: 'local',
});
const fn = jest.fn();
const promise = new Promise<AbortSignal>(resolve =>
fn.mockImplementation(resolve),
);
await manager.scheduleTask({
id: 'task3',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
scope: 'local',
});
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
const shutdownHook = addShutdownHook.mock.calls[0][0];
const abortSignal = await promise;
expect(abortSignal.aborted).toBe(false);
it('aborts the task if shutdown hook is invoked', async () => {
const { manager } = await init('SQLITE_3');
// Should be aborted after the shutdown hook is invoked
await shutdownHook();
expect(abortSignal.aborted).toBe(true);
}, 60_000);
});
const fn = jest.fn();
const promise = new Promise<AbortSignal>(resolve =>
fn.mockImplementation(resolve),
);
await manager.scheduleTask({
id: 'task3',
timeout: Duration.fromMillis(5000),
frequency: { cron: '* * * * * *' },
fn,
scope: 'local',
});
describe('triggerTask with local scope', () => {
it('can manually trigger a task', async () => {
const { manager } = await init('SQLITE_3');
const shutdownHook = addShutdownHook.mock.calls[0][0];
const abortSignal = await promise;
expect(abortSignal.aborted).toBe(false);
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
// Should be aborted after the shutdown hook is invoked
await shutdownHook();
expect(abortSignal.aborted).toBe(true);
}, 60_000);
});
await manager.triggerTask('task1');
jest.advanceTimersByTime(5000);
describe('triggerTask with local scope', () => {
it('can manually trigger a task', async () => {
const { manager } = await init('SQLITE_3');
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
const fn = jest.fn();
const promise = new Promise(resolve => fn.mockImplementation(resolve));
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
it('cant trigger a non-existent task', async () => {
const { manager } = await init('SQLITE_3');
await manager.triggerTask('task1');
jest.advanceTimersByTime(5000);
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
}, 60_000);
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
NotFoundError,
);
}, 60_000);
it('cant trigger a non-existent task', async () => {
const { manager } = await init('SQLITE_3');
it('cant trigger a running task', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
const promise = createDeferred();
await expect(() => manager.triggerTask('task2')).rejects.toThrow(
NotFoundError,
);
}, 60_000);
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
it('cant trigger a running task', async () => {
const { manager } = await init('SQLITE_3');
await promise;
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
ConflictError,
);
}, 60_000);
});
const promise = createDeferred();
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('createScheduledTaskRunner', () => {
it.each(databases.eachSupportedId())(
'can run the happy path, %p',
async databaseId => {
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
await promise;
await expect(() => manager.triggerTask('task1')).rejects.toThrow(
ConflictError,
);
}, 60_000);
});
// This is just to test the wrapper code; most of the actual tests are in
// TaskWorker.test.ts
describe('createScheduledTaskRunner', () => {
it('can run the happy path', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -356,14 +340,11 @@ describe('PluginTaskManagerImpl', () => {
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
},
);
});
});
});
describe('can fetch task ids', () => {
it.each(databases.eachSupportedId())(
'can fetch both global and local task ids, %p',
async databaseId => {
describe('can fetch task ids', () => {
it('can fetch both global and local task ids', async () => {
const { manager } = await init(databaseId);
const fn = jest.fn();
@@ -395,52 +376,51 @@ describe('PluginTaskManagerImpl', () => {
settings: expect.objectContaining({ cadence: 'PT5S' }),
},
]);
},
);
});
describe('cancelTask with local scope', () => {
it('can cancel a running task', async () => {
const { manager } = await init('SQLITE_3');
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
}, 60_000);
describe('cancelTask with local scope', () => {
it('can cancel a running task', async () => {
const { manager } = await init('SQLITE_3');
it('cannot cancel a task that is not running', async () => {
const { manager } = await init('SQLITE_3');
const promise = createDeferred();
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(ConflictError);
}, 60_000);
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
}, 60_000);
describe('cancelTask with global scope', () => {
it.each(databases.eachSupportedId())(
'can cancel a running task, %p',
async databaseId => {
it('cannot cancel a task that is not running', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(
ConflictError,
);
}, 60_000);
});
describe('cancelTask with global scope', () => {
it('can cancel a running task', async () => {
const { manager } = await init(databaseId);
const promise = createDeferred();
@@ -458,23 +438,17 @@ describe('PluginTaskManagerImpl', () => {
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
},
);
});
it.each(databases.eachSupportedId())(
'cannot cancel a non-existent task, %p',
async databaseId => {
it('cannot cancel a non-existent task', async () => {
const { manager } = await init(databaseId);
await expect(manager.cancelTask('nonexistent')).rejects.toThrow(
NotFoundError,
);
},
);
});
it.each(databases.eachSupportedId())(
'cannot cancel a task that is not running, %p',
async databaseId => {
it('cannot cancel a task that is not running', async () => {
const { manager } = await init(databaseId);
await manager.scheduleTask({
@@ -489,16 +463,16 @@ describe('PluginTaskManagerImpl', () => {
await expect(manager.cancelTask('task1')).rejects.toThrow(
ConflictError,
);
},
);
});
describe('parseDuration', () => {
it('should parse durations', () => {
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S');
expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *');
expect(parseDuration({ trigger: 'manual' })).toEqual('manual');
});
});
});
});
describe('parseDuration', () => {
it('should parse durations', () => {
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
expect(parseDuration(Duration.fromMillis(5000))).toEqual('PT5S');
expect(parseDuration({ cron: '1 * * * *' })).toEqual('1 * * * *');
expect(parseDuration({ trigger: 'manual' })).toEqual('manual');
});
});
},
);
@@ -34,28 +34,29 @@ const getTask = async (knex: Knex): Promise<DbTasksRow> => {
return (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
};
describe('PluginTaskSchedulerJanitor', () => {
const logger = mockServices.logger.mock();
const databases = TestDatabases.create({
ids: [
/* 'MYSQL_8' not supported yet */
'POSTGRES_18',
'POSTGRES_14',
'SQLITE_3',
'MYSQL_8',
],
});
const testScopedSignal = createTestScopedSignal();
jest.setTimeout(60_000);
jest.setTimeout(60_000);
const databases = TestDatabases.create({
ids: [
/* 'MYSQL_8' not supported yet */
'POSTGRES_18',
'POSTGRES_14',
'SQLITE_3',
'MYSQL_8',
],
});
beforeEach(() => {
jest.resetAllMocks();
});
describe.each(databases.eachSupportedId())(
'PluginTaskSchedulerJanitor, %p',
databaseId => {
const logger = mockServices.logger.mock();
const testScopedSignal = createTestScopedSignal();
it.each(databases.eachSupportedId())(
'Should update date if current_run_expires_at expires, %p',
async databaseId => {
beforeEach(() => {
jest.resetAllMocks();
});
it('Should update date if current_run_expires_at expires', async () => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
@@ -92,6 +93,6 @@ describe('PluginTaskSchedulerJanitor', () => {
}),
);
});
},
);
});
});
},
);
@@ -41,83 +41,77 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
jest.setTimeout(60_000);
describe('migrations', () => {
const databases = TestDatabases.create();
const databases = TestDatabases.create();
it.each(databases.eachSupportedId())(
'20210928160613_init.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
it('20210928160613_init.js', async () => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20210928160613_init.js');
await migrateUpOnce(knex);
await migrateUntilBefore(knex, '20210928160613_init.js');
await migrateUpOnce(knex);
await knex('backstage_backend_tasks__tasks').insert({
await knex('backstage_backend_tasks__tasks').insert({
id: 'test',
settings_json: '{}',
next_run_start_at: knex.fn.now(),
});
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'test',
settings_json: '{}',
next_run_start_at: knex.fn.now(),
});
next_run_start_at: expect.anything(),
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'test',
settings_json: '{}',
next_run_start_at: expect.anything(),
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await migrateDownOnce(knex);
await migrateDownOnce(knex);
// This looks odd - you might expect a .toThrow at the end but that
// actually is flaky for some reason specifically on sqlite when
// performing multiple runs in sequence
await expect(knex('backstage_backend_tasks__tasks')).rejects.toEqual(
expect.anything(),
);
// This looks odd - you might expect a .toThrow at the end but that
// actually is flaky for some reason specifically on sqlite when
// performing multiple runs in sequence
await expect(knex('backstage_backend_tasks__tasks')).rejects.toEqual(
expect.anything(),
);
await knex.destroy();
});
await knex.destroy();
},
);
it('20240712211735_nullable_next_run.js', async () => {
const knex = await databases.init(databaseId);
it.each(databases.eachSupportedId())(
'20240712211735_nullable_next_run.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20240712211735_nullable_next_run.js');
await migrateUpOnce(knex);
await migrateUntilBefore(knex, '20240712211735_nullable_next_run.js');
await migrateUpOnce(knex);
await knex('backstage_backend_tasks__tasks').insert({
id: 'test',
settings_json: '{}',
next_run_start_at: knex.raw('null'),
});
await knex('backstage_backend_tasks__tasks').insert({
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'test',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await migrateDownOnce(knex);
await expect(
knex('backstage_backend_tasks__tasks').insert({
id: 'test',
settings_json: '{}',
next_run_start_at: knex.raw('null'),
});
}),
).rejects.toEqual(expect.anything());
await expect(knex('backstage_backend_tasks__tasks')).resolves.toEqual([
{
id: 'test',
settings_json: '{}',
next_run_start_at: null,
current_run_ticket: null,
current_run_started_at: null,
current_run_expires_at: null,
},
]);
await migrateDownOnce(knex);
await expect(
knex('backstage_backend_tasks__tasks').insert({
id: 'test',
settings_json: '{}',
next_run_start_at: knex.raw('null'),
}),
).rejects.toEqual(expect.anything());
await knex.destroy();
},
);
await knex.destroy();
});
});
+3 -3
View File
@@ -21,10 +21,10 @@ const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
jest.setTimeout(60_000);
describe('TestCaches', () => {
const caches = TestCaches.create();
const caches = TestCaches.create();
it.each(caches.eachSupportedId())('fires up a cache, %p', async cacheId => {
describe.each(caches.eachSupportedId())('TestCaches, %p', cacheId => {
it('fires up a cache', async () => {
const { keyv } = await caches.init(cacheId);
await keyv.set('test', 'value');
await expect(keyv.get('test')).resolves.toBe('value');
@@ -18,24 +18,14 @@ import { TestDatabases } from './TestDatabases';
jest.setTimeout(60_000);
describe('TestDatabases', () => {
describe('each create', () => {
const dbs = TestDatabases.create();
const dbs = TestDatabases.create();
it.each(dbs.eachSupportedId())(
'creates distinct %p databases',
async databaseId => {
if (!dbs.supports(databaseId)) {
return;
}
const db1 = await dbs.init(databaseId);
const db2 = await dbs.init(databaseId);
await db1.schema.createTable('a', table => table.string('x').primary());
await db2.schema.createTable('a', table => table.string('y').primary());
await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([
{ a: 1 },
]);
},
);
describe.each(dbs.eachSupportedId())('TestDatabases, %p', databaseId => {
it('creates distinct databases', async () => {
const db1 = await dbs.init(databaseId);
const db2 = await dbs.init(databaseId);
await db1.schema.createTable('a', table => table.string('x').primary());
await db2.schema.createTable('a', table => table.string('y').primary());
await expect(db1.select({ a: db1.raw('1') })).resolves.toEqual([{ a: 1 }]);
});
});