Merge pull request #34267 from backstage/freben/describe-each-databases
tests: use describe.each for database test iteration
This commit is contained in:
+53
-56
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+49
-54
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+220
-246
@@ -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');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+23
-22
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,12 @@ import { StaticAssetsStore } from './StaticAssetsStore';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('StaticAssetsStore', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should store and retrieve assets, %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'StaticAssetsStore, %p',
|
||||
databaseId => {
|
||||
it('should store and retrieve assets', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const store = await StaticAssetsStore.create({
|
||||
@@ -61,12 +61,9 @@ describe('StaticAssetsStore', () => {
|
||||
await expect(
|
||||
store.getAsset('does-not-exist.txt'),
|
||||
).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should update assets timestamps, but not contents, %p',
|
||||
async databaseId => {
|
||||
it('should update assets timestamps, but not contents', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const store = await StaticAssetsStore.create({
|
||||
@@ -112,12 +109,9 @@ describe('StaticAssetsStore', () => {
|
||||
|
||||
const sameBar = await store.getAsset('bar');
|
||||
expect(oldBar!.lastModifiedAt).toEqual(sameBar!.lastModifiedAt);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should trim old assets, %p',
|
||||
async databaseId => {
|
||||
it('should trim old assets', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const store = await StaticAssetsStore.create({
|
||||
@@ -157,12 +151,9 @@ describe('StaticAssetsStore', () => {
|
||||
|
||||
await expect(store.getAsset('new')).resolves.toBeDefined();
|
||||
await expect(store.getAsset('old')).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should isolate assets in namespace, %p',
|
||||
async databaseId => {
|
||||
it('should isolate assets in namespace', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const store = await StaticAssetsStore.create({
|
||||
@@ -197,6 +188,6 @@ describe('StaticAssetsStore', () => {
|
||||
await otherStore.trimAssets({ maxAgeSeconds: 0 });
|
||||
|
||||
await expect(otherStore.getAsset('bar')).resolves.not.toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,101 +41,95 @@ 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())(
|
||||
'20211229105307_init.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20211229105307_init.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20211229105307_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
await migrateUntilBefore(knex, '20211229105307_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex('static_assets_cache').insert({
|
||||
await knex('static_assets_cache').insert({
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
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('static_assets_cache')).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('static_assets_cache')).rejects.toEqual(
|
||||
expect.anything(),
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
it('20240113144027_assets-namespace.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20240113144027_assets-namespace.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUntilBefore(knex, '20240113144027_assets-namespace.js');
|
||||
|
||||
await migrateUntilBefore(knex, '20240113144027_assets-namespace.js');
|
||||
await knex('static_assets_cache').insert({
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
await knex('static_assets_cache').insert({
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
namespace: 'default',
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
await knex('static_assets_cache').insert({
|
||||
path: 'main.js',
|
||||
content: Buffer.from('other-script'),
|
||||
namespace: 'other',
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
namespace: 'default',
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await knex('static_assets_cache').insert({
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
namespace: 'default',
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('other-script'),
|
||||
namespace: 'other',
|
||||
last_modified_at: knex.fn.now(),
|
||||
});
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
namespace: 'default',
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('other-script'),
|
||||
namespace: 'other',
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(knex('static_assets_cache')).resolves.toEqual([
|
||||
{
|
||||
path: 'main.js',
|
||||
content: Buffer.from('some-script'),
|
||||
last_modified_at: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,12 +27,12 @@ const keyBase = {
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DatabaseKeyStore', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should store a key, %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DatabaseKeyStore, %p',
|
||||
databaseId => {
|
||||
it('should store a key', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await AuthDatabase.runMigrations(knex);
|
||||
|
||||
@@ -53,12 +53,9 @@ describe('DatabaseKeyStore', () => {
|
||||
DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds,
|
||||
),
|
||||
).toBeLessThan(10);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should remove stored keys, %p',
|
||||
async databaseId => {
|
||||
it('should remove stored keys', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await AuthDatabase.runMigrations(knex);
|
||||
|
||||
@@ -124,6 +121,6 @@ describe('DatabaseKeyStore', () => {
|
||||
await expect(store.listKeys()).resolves.toEqual({
|
||||
items: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,89 +24,80 @@ import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('KeyStores', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
const defaultConfigOptions = {
|
||||
auth: {
|
||||
keyStore: {
|
||||
provider: 'memory',
|
||||
},
|
||||
const defaultConfigOptions = {
|
||||
auth: {
|
||||
keyStore: {
|
||||
provider: 'memory',
|
||||
},
|
||||
};
|
||||
const defaultConfig = new ConfigReader(defaultConfigOptions);
|
||||
},
|
||||
};
|
||||
const defaultConfig = new ConfigReader(defaultConfigOptions);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'reads auth section from config, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const configSpy = jest.spyOn(defaultConfig, 'getOptionalConfig');
|
||||
const keyStore = await KeyStores.fromConfig(defaultConfig, {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
describe.each(databases.eachSupportedId())('KeyStores, %p', databaseId => {
|
||||
it('reads auth section from config', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const configSpy = jest.spyOn(defaultConfig, 'getOptionalConfig');
|
||||
const keyStore = await KeyStores.fromConfig(defaultConfig, {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
|
||||
expect(keyStore).toBeInstanceOf(MemoryKeyStore);
|
||||
expect(configSpy).toHaveBeenCalledWith('auth.keyStore');
|
||||
expect(
|
||||
defaultConfig
|
||||
.getOptionalConfig('auth.keyStore')
|
||||
?.getOptionalString('provider'),
|
||||
).toBe(defaultConfigOptions.auth.keyStore.provider);
|
||||
},
|
||||
);
|
||||
expect(keyStore).toBeInstanceOf(MemoryKeyStore);
|
||||
expect(configSpy).toHaveBeenCalledWith('auth.keyStore');
|
||||
expect(
|
||||
defaultConfig
|
||||
.getOptionalConfig('auth.keyStore')
|
||||
?.getOptionalString('provider'),
|
||||
).toBe(defaultConfigOptions.auth.keyStore.provider);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can handle without auth config, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const keyStore = await KeyStores.fromConfig(new ConfigReader({}), {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
|
||||
},
|
||||
);
|
||||
it('can handle without auth config', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const keyStore = await KeyStores.fromConfig(new ConfigReader({}), {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can handle additional provider config, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
jest.spyOn(FirestoreKeyStore, 'verifyConnection').mockResolvedValue();
|
||||
const createSpy = jest.spyOn(FirestoreKeyStore, 'create');
|
||||
it('can handle additional provider config', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
jest.spyOn(FirestoreKeyStore, 'verifyConnection').mockResolvedValue();
|
||||
const createSpy = jest.spyOn(FirestoreKeyStore, 'create');
|
||||
|
||||
const configOptions = {
|
||||
auth: {
|
||||
keyStore: {
|
||||
provider: 'firestore',
|
||||
firestore: {
|
||||
projectId: 'my-project',
|
||||
keyFilename: 'cred.json',
|
||||
path: 'my-path',
|
||||
timeout: 100,
|
||||
host: 'localhost',
|
||||
port: 8088,
|
||||
ssl: false,
|
||||
},
|
||||
const configOptions = {
|
||||
auth: {
|
||||
keyStore: {
|
||||
provider: 'firestore',
|
||||
firestore: {
|
||||
projectId: 'my-project',
|
||||
keyFilename: 'cred.json',
|
||||
path: 'my-path',
|
||||
timeout: 100,
|
||||
host: 'localhost',
|
||||
port: 8088,
|
||||
ssl: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = new ConfigReader(configOptions);
|
||||
const keyStore = await KeyStores.fromConfig(config, {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
},
|
||||
};
|
||||
const config = new ConfigReader(configOptions);
|
||||
const keyStore = await KeyStores.fromConfig(config, {
|
||||
logger: mockServices.logger.mock(),
|
||||
database: AuthDatabase.create(mockServices.database({ knex })),
|
||||
});
|
||||
|
||||
expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
configOptions.auth.keyStore.firestore,
|
||||
);
|
||||
expect(
|
||||
config
|
||||
.getOptionalConfig('auth.keyStore')
|
||||
?.getOptionalConfig('firestore')
|
||||
?.getOptionalString('projectId'),
|
||||
).toBe(configOptions.auth.keyStore.firestore.projectId);
|
||||
},
|
||||
);
|
||||
expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
configOptions.auth.keyStore.firestore,
|
||||
);
|
||||
expect(
|
||||
config
|
||||
.getOptionalConfig('auth.keyStore')
|
||||
?.getOptionalConfig('firestore')
|
||||
?.getOptionalString('projectId'),
|
||||
).toBe(configOptions.auth.keyStore.firestore.projectId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,179 +41,154 @@ 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())(
|
||||
'20230428155633_sessions.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20230428155633_sessions.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20230428155633_sessions.js');
|
||||
await migrateUntilBefore(knex, '20230428155633_sessions.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
// Ensure that large cookies are supported
|
||||
const data = `{"cookie":"${'a'.repeat(100_000)}"}`;
|
||||
await knex
|
||||
.insert({ sid: 'abc', expired: knex.fn.now(), sess: data })
|
||||
.into('sessions');
|
||||
await knex
|
||||
.insert({ sid: 'def', expired: knex.fn.now(), sess: data })
|
||||
.into('sessions');
|
||||
|
||||
await expect(knex('sessions').orderBy('sid', 'asc')).resolves.toEqual([
|
||||
{ sid: 'abc', expired: expect.anything(), sess: data },
|
||||
{ sid: 'def', expired: expect.anything(), sess: data },
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
it('20240510120825_user_info.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20240510120825_user_info.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
const user_info = JSON.stringify({
|
||||
claims: {
|
||||
ent: ['group:default/group1', 'group:default/group2'],
|
||||
},
|
||||
});
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
|
||||
await expect(knex('user_info')).resolves.toEqual([
|
||||
{
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
it('20250707164600_user_created_at.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUntilBefore(knex, '20250707164600_user_created_at.js');
|
||||
|
||||
if (knex.client.config.client.includes('sqlite')) {
|
||||
// Sqlite doesn't support adding a column with non-constant default when table has data
|
||||
// so we just test that the migration runs without errors
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
// Ensure that large cookies are supported
|
||||
const data = `{"cookie":"${'a'.repeat(100_000)}"}`;
|
||||
await knex
|
||||
.insert({ sid: 'abc', expired: knex.fn.now(), sess: data })
|
||||
.into('sessions');
|
||||
await knex
|
||||
.insert({ sid: 'def', expired: knex.fn.now(), sess: data })
|
||||
.into('sessions');
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(knex('sessions').orderBy('sid', 'asc')).resolves.toEqual([
|
||||
{ sid: 'abc', expired: expect.anything(), sess: data },
|
||||
{ sid: 'def', expired: expect.anything(), sess: data },
|
||||
]);
|
||||
const user_info = JSON.stringify({
|
||||
claims: {
|
||||
ent: ['group:default/group1', 'group:default/group2'],
|
||||
},
|
||||
});
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
const { exp } = await knex('user_info').first();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20240510120825_user_info.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await migrateUntilBefore(knex, '20240510120825_user_info.js');
|
||||
await migrateUpOnce(knex);
|
||||
const { created_at, updated_at } = await knex('user_info').first();
|
||||
|
||||
const user_info = JSON.stringify({
|
||||
claims: {
|
||||
ent: ['group:default/group1', 'group:default/group2'],
|
||||
},
|
||||
});
|
||||
expect(updated_at).toEqual(exp);
|
||||
expect(created_at).toBeDefined();
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
updated_at: knex.fn.now(),
|
||||
})
|
||||
.into('user_info')
|
||||
.onConflict(['user_entity_ref'])
|
||||
.merge();
|
||||
|
||||
await expect(knex('user_info')).resolves.toEqual([
|
||||
{
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: expect.anything(),
|
||||
},
|
||||
]);
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user-2',
|
||||
user_info,
|
||||
updated_at: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
await expect(
|
||||
knex('user_info').select('created_at', 'updated_at'),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
},
|
||||
{
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20250707164600_user_created_at.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUntilBefore(knex, '20250707164600_user_created_at.js');
|
||||
await expect(knex('user_info').select('exp')).resolves.toEqual([
|
||||
{ exp: expect.any(Date) },
|
||||
{ exp: expect.any(Date) },
|
||||
]);
|
||||
|
||||
if (knex.client.config.client.includes('sqlite')) {
|
||||
// Sqlite doesn't support adding a column with non-constant default when table has data
|
||||
// so we just test that the migration runs without errors
|
||||
await migrateUpOnce(knex);
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
it('20250909120000_oidc_client_registration.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
const user_info = JSON.stringify({
|
||||
claims: {
|
||||
ent: ['group:default/group1', 'group:default/group2'],
|
||||
},
|
||||
});
|
||||
await migrateUntilBefore(
|
||||
knex,
|
||||
'20250909120000_oidc_client_registration.js',
|
||||
);
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
exp: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
|
||||
const { exp } = await knex('user_info').first();
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
const { created_at, updated_at } = await knex('user_info').first();
|
||||
|
||||
expect(updated_at).toEqual(exp);
|
||||
expect(created_at).toBeDefined();
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user',
|
||||
user_info,
|
||||
updated_at: knex.fn.now(),
|
||||
})
|
||||
.into('user_info')
|
||||
.onConflict(['user_entity_ref'])
|
||||
.merge();
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user_entity_ref: 'user:default/backstage-user-2',
|
||||
user_info,
|
||||
updated_at: knex.fn.now(),
|
||||
})
|
||||
.into('user_info');
|
||||
|
||||
await expect(
|
||||
knex('user_info').select('created_at', 'updated_at'),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
},
|
||||
{
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
await expect(knex('user_info').select('exp')).resolves.toEqual([
|
||||
{ exp: expect.any(Date) },
|
||||
{ exp: expect.any(Date) },
|
||||
]);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20250909120000_oidc_client_registration.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(
|
||||
knex,
|
||||
'20250909120000_oidc_client_registration.js',
|
||||
);
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
scope: 'openid profile',
|
||||
metadata: JSON.stringify({ description: 'Test client' }),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
|
||||
await expect(
|
||||
knex('oidc_clients').where('client_id', 'test-client-id').first(),
|
||||
).resolves.toEqual({
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
@@ -222,230 +197,235 @@ describe('migrations', () => {
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
scope: 'openid profile',
|
||||
metadata: JSON.stringify({ description: 'Test client' }),
|
||||
});
|
||||
})
|
||||
.into('oidc_clients');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
await expect(
|
||||
knex('oidc_clients').where('client_id', 'test-client-id').first(),
|
||||
).resolves.toEqual({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
scope: 'openid profile',
|
||||
metadata: JSON.stringify({ description: 'Test client' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-session-id')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
expires_at: new Date(Date.now() + 600000),
|
||||
used: false,
|
||||
})
|
||||
.into('oidc_authorization_codes');
|
||||
|
||||
await expect(
|
||||
knex('oidc_authorization_codes')
|
||||
.where('code', 'test-auth-code')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
|
||||
await knex('oauth_authorization_sessions')
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-session-id')
|
||||
.del();
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-session-id',
|
||||
client_id: 'test-client-id',
|
||||
user_entity_ref: 'user:default/test-user',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid',
|
||||
state: 'test-state',
|
||||
response_type: 'code',
|
||||
code_challenge: 'test-challenge',
|
||||
code_challenge_method: 'S256',
|
||||
nonce: 'test-nonce',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
knex('oidc_authorization_codes').where('session_id', 'test-session-id'),
|
||||
).resolves.toHaveLength(0);
|
||||
await knex
|
||||
.insert({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
expires_at: new Date(Date.now() + 600000),
|
||||
used: false,
|
||||
})
|
||||
.into('oidc_authorization_codes');
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
await expect(
|
||||
knex('oidc_authorization_codes').where('code', 'test-auth-code').first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'test-auth-code',
|
||||
session_id: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
|
||||
const tables = [
|
||||
'oidc_clients',
|
||||
'oauth_authorization_sessions',
|
||||
'oidc_authorization_codes',
|
||||
];
|
||||
await knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-session-id')
|
||||
.del();
|
||||
|
||||
for (const table of tables) {
|
||||
await expect(knex.schema.hasTable(table)).resolves.toBe(false);
|
||||
}
|
||||
await expect(
|
||||
knex('oidc_authorization_codes').where('session_id', 'test-session-id'),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20251118120000_oauth_state_text.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const tables = [
|
||||
'oidc_clients',
|
||||
'oauth_authorization_sessions',
|
||||
'oidc_authorization_codes',
|
||||
];
|
||||
|
||||
await migrateUntilBefore(knex, '20251118120000_oauth_state_text.js');
|
||||
for (const table of tables) {
|
||||
await expect(knex.schema.hasTable(table)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
// First create a client for the foreign key constraint
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
// Insert a session with state before migration
|
||||
const existingState = 'existing-short-state';
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-existing-session',
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
state: existingState,
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
it('20251118120000_oauth_state_text.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
// Apply the migration that changes state to TEXT
|
||||
await migrateUpOnce(knex);
|
||||
await migrateUntilBefore(knex, '20251118120000_oauth_state_text.js');
|
||||
|
||||
// Verify existing state persists after migration
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-existing-session')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-existing-session',
|
||||
state: existingState,
|
||||
}),
|
||||
);
|
||||
// First create a client for the foreign key constraint
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'Test Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
|
||||
// Test inserting a state parameter longer than 255 characters
|
||||
// This is based on the real-world example from the issue
|
||||
const longState = 'a'.repeat(280);
|
||||
// Insert a session with state before migration
|
||||
const existingState = 'existing-short-state';
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-existing-session',
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
state: existingState,
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-long-state-session',
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
state: longState,
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
// Apply the migration that changes state to TEXT
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-long-state-session')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-long-state-session',
|
||||
state: longState,
|
||||
}),
|
||||
);
|
||||
// Verify existing state persists after migration
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-existing-session')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-existing-session',
|
||||
state: existingState,
|
||||
}),
|
||||
);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
// Test inserting a state parameter longer than 255 characters
|
||||
// This is based on the real-world example from the issue
|
||||
const longState = 'a'.repeat(280);
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await knex
|
||||
.insert({
|
||||
id: 'test-long-state-session',
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
state: longState,
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20251217120000_drop_oidc_clients_fk.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions')
|
||||
.where('id', 'test-long-state-session')
|
||||
.first(),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'test-long-state-session',
|
||||
state: longState,
|
||||
}),
|
||||
);
|
||||
|
||||
await migrateUntilBefore(knex, '20251217120000_drop_oidc_clients_fk.js');
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
// Create a client for DCR sessions
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'dcr-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'DCR Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
// Create a DCR session (has matching client in oidc_clients)
|
||||
await knex
|
||||
.insert({
|
||||
id: 'dcr-session',
|
||||
client_id: 'dcr-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
it('20251217120000_drop_oidc_clients_fk.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
// Apply migration - drops FK constraint
|
||||
await migrateUpOnce(knex);
|
||||
await migrateUntilBefore(knex, '20251217120000_drop_oidc_clients_fk.js');
|
||||
|
||||
// Now we can insert a CIMD session (URL-based client_id not in oidc_clients)
|
||||
await knex
|
||||
.insert({
|
||||
id: 'cimd-session',
|
||||
client_id: 'https://example.com/.well-known/oauth-client/cli',
|
||||
redirect_uri: 'http://localhost:8080/callback',
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
// Create a client for DCR sessions
|
||||
await knex
|
||||
.insert({
|
||||
client_id: 'dcr-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
client_name: 'DCR Client',
|
||||
response_types: JSON.stringify(['code']),
|
||||
grant_types: JSON.stringify(['authorization_code']),
|
||||
redirect_uris: JSON.stringify(['https://example.com/callback']),
|
||||
})
|
||||
.into('oidc_clients');
|
||||
|
||||
// Verify both sessions exist
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions').select('id').orderBy('id'),
|
||||
).resolves.toEqual([{ id: 'cimd-session' }, { id: 'dcr-session' }]);
|
||||
// Create a DCR session (has matching client in oidc_clients)
|
||||
await knex
|
||||
.insert({
|
||||
id: 'dcr-session',
|
||||
client_id: 'dcr-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
// Rollback - should delete CIMD sessions and re-add FK
|
||||
await migrateDownOnce(knex);
|
||||
// Apply migration - drops FK constraint
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
// CIMD session should be deleted, DCR session should remain
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions').select('id'),
|
||||
).resolves.toEqual([{ id: 'dcr-session' }]);
|
||||
// Now we can insert a CIMD session (URL-based client_id not in oidc_clients)
|
||||
await knex
|
||||
.insert({
|
||||
id: 'cimd-session',
|
||||
client_id: 'https://example.com/.well-known/oauth-client/cli',
|
||||
redirect_uri: 'http://localhost:8080/callback',
|
||||
response_type: 'code',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 3600000),
|
||||
})
|
||||
.into('oauth_authorization_sessions');
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
// Verify both sessions exist
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions').select('id').orderBy('id'),
|
||||
).resolves.toEqual([{ id: 'cimd-session' }, { id: 'dcr-session' }]);
|
||||
|
||||
// Rollback - should delete CIMD sessions and re-add FK
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
// CIMD session should be deleted, DCR session should remain
|
||||
await expect(
|
||||
knex('oauth_authorization_sessions').select('id'),
|
||||
).resolves.toEqual([{ id: 'dcr-session' }]);
|
||||
|
||||
await knex.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-17
@@ -23,18 +23,20 @@ const migrationsDir = `${__dirname}/../../migrations`;
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('IncrementalIngestionDatabaseManager', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'stores and retrieves marks, %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'IncrementalIngestionDatabaseManager, %p',
|
||||
databaseId => {
|
||||
it('stores and retrieves marks', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await knex.migrate.latest({ directory: migrationsDir });
|
||||
|
||||
const manager = new IncrementalIngestionDatabaseManager({ client: knex });
|
||||
const manager = new IncrementalIngestionDatabaseManager({
|
||||
client: knex,
|
||||
});
|
||||
const { ingestionId } = (await manager.createProviderIngestionRecord(
|
||||
'myProvider',
|
||||
))!;
|
||||
@@ -75,16 +77,15 @@ describe('IncrementalIngestionDatabaseManager', () => {
|
||||
sequence: 1,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'computeRemoved correctly sums total count from count query, %p',
|
||||
async databaseId => {
|
||||
it('computeRemoved correctly sums total count from count query', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await knex.migrate.latest({ directory: migrationsDir });
|
||||
|
||||
const manager = new IncrementalIngestionDatabaseManager({ client: knex });
|
||||
const manager = new IncrementalIngestionDatabaseManager({
|
||||
client: knex,
|
||||
});
|
||||
const { ingestionId } = (await manager.createProviderIngestionRecord(
|
||||
'testProvider',
|
||||
))!;
|
||||
@@ -119,6 +120,6 @@ describe('IncrementalIngestionDatabaseManager', () => {
|
||||
// On PostgreSQL, count queries return strings, so total should be 3 not NaN or string concatenation
|
||||
expect(result.total).toBe(3);
|
||||
expect(typeof result.total).toBe('number');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+20
-19
@@ -23,24 +23,25 @@ import { WrapperProviders } from './WrapperProviders';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('WrapperProviders', () => {
|
||||
const applyDatabaseMigrations = jest.fn();
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3', 'MYSQL_8'],
|
||||
});
|
||||
const config = new ConfigReader({});
|
||||
const logger = mockServices.logger.mock();
|
||||
const scheduler = {
|
||||
scheduleTask: jest.fn(),
|
||||
};
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14', 'SQLITE_3', 'MYSQL_8'],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'WrapperProviders, %p',
|
||||
databaseId => {
|
||||
const applyDatabaseMigrations = jest.fn();
|
||||
const config = new ConfigReader({});
|
||||
const logger = mockServices.logger.mock();
|
||||
const scheduler = {
|
||||
scheduleTask: jest.fn(),
|
||||
};
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should initialize the providers in order, %p',
|
||||
async databaseId => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize the providers in order', async () => {
|
||||
const client = await databases.init(databaseId);
|
||||
|
||||
const provider1: IncrementalEntityProvider<number, {}> = {
|
||||
@@ -111,6 +112,6 @@ describe('WrapperProviders', () => {
|
||||
id: 'provider2',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { DefaultCatalogDatabase } from './DefaultCatalogDatabase';
|
||||
import { applyDatabaseMigrations } from './migrations';
|
||||
import { DbRefreshStateReferencesRow, DbRefreshStateRow } from './tables';
|
||||
@@ -26,48 +22,46 @@ import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultCatalogDatabase', () => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
logger: LoggerService = defaultLogger,
|
||||
) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
db: new DefaultCatalogDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultCatalogDatabase, %p',
|
||||
databaseId => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
|
||||
describe('listAncestors', () => {
|
||||
let nextId = 1;
|
||||
function makeEntity(ref: string) {
|
||||
async function createDatabase(logger: LoggerService = defaultLogger) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
entity_id: String(nextId++),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
metadata: {
|
||||
name: 'xyz',
|
||||
},
|
||||
knex,
|
||||
db: new DefaultCatalogDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: '2019-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
};
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return ancestors, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
describe('listAncestors', () => {
|
||||
let nextId = 1;
|
||||
function makeEntity(ref: string) {
|
||||
return {
|
||||
entity_id: String(nextId++),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
metadata: {
|
||||
name: 'xyz',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: '2019-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
};
|
||||
}
|
||||
|
||||
it('should return ancestors', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert(
|
||||
makeEntity('location:default/root-1'),
|
||||
@@ -107,7 +101,7 @@ describe('DefaultCatalogDatabase', () => {
|
||||
'location:default/root-1',
|
||||
'location:default/root-2',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Knex } from 'knex';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
@@ -40,64 +36,62 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultProcessingDatabase', () => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
logger: LoggerService = defaultLogger,
|
||||
) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
db: new DefaultProcessingDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
refreshInterval: createRandomProcessingInterval({
|
||||
minSeconds: 100,
|
||||
maxSeconds: 150,
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultProcessingDatabase, %p',
|
||||
databaseId => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
|
||||
async function createDatabase(logger: LoggerService = defaultLogger) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
db: new DefaultProcessingDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
refreshInterval: createRandomProcessingInterval({
|
||||
minSeconds: 100,
|
||||
maxSeconds: 150,
|
||||
}),
|
||||
events: mockServices.events.mock(),
|
||||
metrics: metricsServiceMock.mock(),
|
||||
}),
|
||||
events: mockServices.events.mock(),
|
||||
metrics: metricsServiceMock.mock(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
|
||||
return db<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
ref,
|
||||
);
|
||||
};
|
||||
|
||||
const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
|
||||
await db<DbRefreshStateRow>('refresh_state').insert(ref);
|
||||
};
|
||||
|
||||
describe('updateProcessedEntity', () => {
|
||||
let id: string;
|
||||
let processedEntity: Entity;
|
||||
|
||||
beforeEach(() => {
|
||||
id = uuid();
|
||||
processedEntity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Location',
|
||||
metadata: {
|
||||
name: 'fakelocation',
|
||||
},
|
||||
spec: {
|
||||
type: 'url',
|
||||
target: 'somethingelse',
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'fails when an entity is processed with a different locationKey, %p',
|
||||
async databaseId => {
|
||||
const { db } = await createDatabase(databaseId);
|
||||
const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
|
||||
return db<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
ref,
|
||||
);
|
||||
};
|
||||
|
||||
const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
|
||||
await db<DbRefreshStateRow>('refresh_state').insert(ref);
|
||||
};
|
||||
|
||||
describe('updateProcessedEntity', () => {
|
||||
let id: string;
|
||||
let processedEntity: Entity;
|
||||
|
||||
beforeEach(() => {
|
||||
id = uuid();
|
||||
processedEntity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Location',
|
||||
metadata: {
|
||||
name: 'fakelocation',
|
||||
},
|
||||
spec: {
|
||||
type: 'url',
|
||||
target: 'somethingelse',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('fails when an entity is processed with a different locationKey', async () => {
|
||||
const { db } = await createDatabase();
|
||||
await db.transaction(async tx => {
|
||||
await expect(() =>
|
||||
db.updateProcessedEntity(tx, {
|
||||
@@ -112,12 +106,9 @@ describe('DefaultProcessingDatabase', () => {
|
||||
`Conflicting write of processing result for ${id} with location key 'undefined'`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'fails when the locationKey is different, %p',
|
||||
async databaseId => {
|
||||
it('fails when the locationKey is different', async () => {
|
||||
const options = {
|
||||
id,
|
||||
processedEntity,
|
||||
@@ -128,7 +119,7 @@ describe('DefaultProcessingDatabase', () => {
|
||||
refreshKeys: [],
|
||||
errors: "['something broke']",
|
||||
};
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
const { knex, db } = await createDatabase();
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: id,
|
||||
entity_ref: 'location:default/fakelocation',
|
||||
@@ -158,13 +149,10 @@ describe('DefaultProcessingDatabase', () => {
|
||||
`Conflicting write of processing result for ${id} with location key 'fail'`,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'updates the refresh state entry with the cache, processed entity and errors, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('updates the refresh state entry with the cache, processed entity and errors', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: id,
|
||||
entity_ref: 'location:default/fakelocation',
|
||||
@@ -197,13 +185,10 @@ describe('DefaultProcessingDatabase', () => {
|
||||
);
|
||||
expect(entities[0].errors).toEqual("['something broke']");
|
||||
expect(entities[0].location_key).toEqual('key');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'removes old relations and stores the new relationships, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('removes old relations and stores the new relationships', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: id,
|
||||
entity_ref: 'location:default/fakelocation',
|
||||
@@ -282,13 +267,10 @@ describe('DefaultProcessingDatabase', () => {
|
||||
target_entity_ref: 'component:default/foo',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'adds deferred entities to the refresh_state table to be picked up later, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('adds deferred entities to the refresh_state table to be picked up later', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: id,
|
||||
entity_ref: 'location:default/fakelocation',
|
||||
@@ -330,19 +312,15 @@ describe('DefaultProcessingDatabase', () => {
|
||||
.select();
|
||||
|
||||
expect(refreshStateEntries).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'updates unprocessed entities with varying location keys, %p',
|
||||
async databaseId => {
|
||||
it('updates unprocessed entities with varying location keys', async () => {
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const { knex, db } = await createDatabase(
|
||||
databaseId,
|
||||
mockLogger as unknown as Logger,
|
||||
);
|
||||
|
||||
@@ -479,19 +457,15 @@ describe('DefaultProcessingDatabase', () => {
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'stores the refresh keys for the entity where key length is 255 chars or less',
|
||||
async databaseId => {
|
||||
it('stores the refresh keys for the entity where key length is 255 chars or less', async () => {
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const { knex, db } = await createDatabase(
|
||||
databaseId,
|
||||
mockLogger as unknown as Logger,
|
||||
);
|
||||
await insertRefreshStateRow(knex, {
|
||||
@@ -536,19 +510,15 @@ describe('DefaultProcessingDatabase', () => {
|
||||
entity_id: id,
|
||||
key: 'protocol:foo-bar.com',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'stores the refresh keys for the entity where key length is greater than 255 chars',
|
||||
async databaseId => {
|
||||
it('stores the refresh keys for the entity where key length is greater than 255 chars', async () => {
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const { knex, db } = await createDatabase(
|
||||
databaseId,
|
||||
mockLogger as unknown as Logger,
|
||||
);
|
||||
await insertRefreshStateRow(knex, {
|
||||
@@ -597,15 +567,12 @@ describe('DefaultProcessingDatabase', () => {
|
||||
entity_id: id,
|
||||
key: `url:https://example.com/foo-bar-test-group/very-long-group-name-that-exceeds-255-characters-just-to-test-the-limits-of-url-length-in-the-catalog-info-yaml-file-and-see-how-the-back#sha256:edfb606500d184900e63891e5279d35bf0069ea251e90d15c0a430de6023d905`,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntityCache', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'updates the entityCache, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
describe('updateEntityCache', () => {
|
||||
it('updates the entityCache', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
const id = '123';
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: id,
|
||||
@@ -644,15 +611,12 @@ describe('DefaultProcessingDatabase', () => {
|
||||
).select();
|
||||
expect(entities2.length).toBe(1);
|
||||
expect(entities2[0].cache).toEqual('{}');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessableEntities', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return entities to process, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
describe('getProcessableEntities', () => {
|
||||
it('should return entities to process', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
const entity = JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
@@ -696,13 +660,10 @@ describe('DefaultProcessingDatabase', () => {
|
||||
}),
|
||||
).resolves.toEqual({ items: [] });
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should update the next_refresh interval with a timestamp that includes refresh spread, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should update the next_refresh interval with a timestamp that includes refresh spread', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
const entity = JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
@@ -731,33 +692,30 @@ describe('DefaultProcessingDatabase', () => {
|
||||
const nextUpdate = timestampToDateTime(result[0].next_update_at);
|
||||
const nextUpdateDiff = nextUpdate.diff(now, 'seconds');
|
||||
expect(nextUpdateDiff.seconds).toBeGreaterThanOrEqual(90);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listParents', () => {
|
||||
let nextId = 1;
|
||||
function makeEntity(ref: string) {
|
||||
return {
|
||||
entity_id: String(nextId++),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
metadata: {
|
||||
name: 'xyz',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: '2019-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
};
|
||||
}
|
||||
describe('listParents', () => {
|
||||
let nextId = 1;
|
||||
function makeEntity(ref: string) {
|
||||
return {
|
||||
entity_id: String(nextId++),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: JSON.stringify({
|
||||
kind: 'Location',
|
||||
apiVersion: '1.0.0',
|
||||
metadata: {
|
||||
name: 'xyz',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: '2019-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
};
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return parents, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should return parents', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert(
|
||||
makeEntity('location:default/root-1'),
|
||||
@@ -803,7 +761,7 @@ describe('DefaultProcessingDatabase', () => {
|
||||
db.listParents(tx, { entityRefs: ['location:default/root-2'] }),
|
||||
);
|
||||
expect(result3.entityRefs).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { mockServices, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Knex } from 'knex';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
@@ -30,54 +26,52 @@ import { generateStableHash } from './util';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultProviderDatabase', () => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
logger: LoggerService = defaultLogger,
|
||||
) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
db: new DefaultProviderDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultProviderDatabase, %p',
|
||||
databaseId => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
|
||||
const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
|
||||
return db<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
ref,
|
||||
);
|
||||
};
|
||||
|
||||
const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
|
||||
await db<DbRefreshStateRow>('refresh_state').insert(ref);
|
||||
};
|
||||
|
||||
const createLocations = async (db: Knex, entityRefs: string[]) => {
|
||||
for (const ref of entityRefs) {
|
||||
await insertRefreshStateRow(db, {
|
||||
entity_id: uuid(),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
async function createDatabase(logger: LoggerService = defaultLogger) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
db: new DefaultProviderDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
describe('replaceUnprocessedEntities', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'replaces all existing state correctly for simple dependency chains, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
const insertRefRow = async (db: Knex, ref: DbRefreshStateReferencesRow) => {
|
||||
return db<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
ref,
|
||||
);
|
||||
};
|
||||
|
||||
const insertRefreshStateRow = async (db: Knex, ref: DbRefreshStateRow) => {
|
||||
await db<DbRefreshStateRow>('refresh_state').insert(ref);
|
||||
};
|
||||
|
||||
const createLocations = async (db: Knex, entityRefs: string[]) => {
|
||||
for (const ref of entityRefs) {
|
||||
await insertRefreshStateRow(db, {
|
||||
entity_id: uuid(),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('replaceUnprocessedEntities', () => {
|
||||
it('replaces all existing state correctly for simple dependency chains', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
/*
|
||||
config -> location:default/root -> location:default/root-1 -> location:default/root-2
|
||||
database -> location:default/second -> location:default/root-2
|
||||
@@ -187,13 +181,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
t.source_key === 'config',
|
||||
),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should work for more complex chains, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should work for more complex chains', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
/*
|
||||
config -> location:default/root -> location:default/root-1 -> location:default/root-2
|
||||
config -> location:default/root -> location:default/root-1a -> location:default/root-2
|
||||
@@ -323,13 +314,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
t.target_entity_ref === 'location:default/root-2',
|
||||
),
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should add new locations using the delta options, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should add new locations using the delta options', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
// Existing state and references should stay
|
||||
await createLocations(knex, ['location:default/existing']);
|
||||
@@ -393,13 +381,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
t.target_entity_ref === 'location:default/existing',
|
||||
),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should not remove locations that are referenced elsewhere, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should not remove locations that are referenced elsewhere', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
/*
|
||||
config-1 -> location:default/root
|
||||
config-2 -> location:default/root
|
||||
@@ -443,13 +428,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
entity_ref: 'location:default/root',
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should remove old locations using the delta options, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should remove old locations using the delta options', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
await createLocations(knex, ['location:default/new-root']);
|
||||
|
||||
await insertRefRow(knex, {
|
||||
@@ -492,13 +474,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
t.target_entity_ref === 'location:default/new-root',
|
||||
),
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should update the location key during full replace, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should update the location key during full replace', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
await createLocations(knex, ['location:default/removed']);
|
||||
await insertRefreshStateRow(knex, {
|
||||
entity_id: uuid(),
|
||||
@@ -558,13 +537,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
target_entity_ref: 'location:default/replaced',
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should support replacing modified entities during a full update, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('should support replacing modified entities during a full update', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await db.transaction(async tx => {
|
||||
await db.replaceUnprocessedEntities(tx, {
|
||||
@@ -689,14 +665,11 @@ describe('DefaultProviderDatabase', () => {
|
||||
target_entity_ref: 'component:default/a',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should successfully fall back from batch to individual mode on conflicts, %p',
|
||||
async databaseId => {
|
||||
it('should successfully fall back from batch to individual mode on conflicts', async () => {
|
||||
const fakeLogger = mockServices.logger.mock();
|
||||
const { knex, db } = await createDatabase(databaseId, fakeLogger);
|
||||
const { knex, db } = await createDatabase(fakeLogger);
|
||||
|
||||
await createLocations(knex, ['component:default/a']);
|
||||
|
||||
@@ -738,14 +711,11 @@ describe('DefaultProviderDatabase', () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync, %p',
|
||||
async databaseId => {
|
||||
it('should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync', async () => {
|
||||
const fakeLogger = mockServices.logger.mock();
|
||||
const { knex, db } = await createDatabase(databaseId, fakeLogger);
|
||||
const { knex, db } = await createDatabase(fakeLogger);
|
||||
|
||||
await createLocations(knex, ['component:default/a']);
|
||||
|
||||
@@ -768,14 +738,11 @@ describe('DefaultProviderDatabase', () => {
|
||||
|
||||
const state = await knex<DbRefreshStateRow>('refresh_state').select();
|
||||
expect(state).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should properly translate deltas into add/update/remove, %p',
|
||||
async databaseId => {
|
||||
it('should properly translate deltas into add/update/remove', async () => {
|
||||
const fakeLogger = mockServices.logger.mock();
|
||||
const { knex, db } = await createDatabase(databaseId, fakeLogger);
|
||||
const { knex, db } = await createDatabase(fakeLogger);
|
||||
|
||||
const entity1Before: Entity = {
|
||||
apiVersion: '1',
|
||||
@@ -950,14 +917,11 @@ describe('DefaultProviderDatabase', () => {
|
||||
location_key: 'new', // managed to update only the location key
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can handle large deltas without exploding, %p',
|
||||
async databaseId => {
|
||||
it('can handle large deltas without exploding', async () => {
|
||||
const fakeLogger = mockServices.logger.mock();
|
||||
const { knex, db } = await createDatabase(databaseId, fakeLogger);
|
||||
const { knex, db } = await createDatabase(fakeLogger);
|
||||
|
||||
const count = 10000;
|
||||
const padded = (n: number) => String(n).padStart(8, '0');
|
||||
@@ -989,15 +953,12 @@ describe('DefaultProviderDatabase', () => {
|
||||
unprocessed_entity: JSON.stringify(entities[0].entity),
|
||||
unprocessed_hash: generateStableHash(entities[0].entity),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listReferenceSourceKeys', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'returns the source_keys from "refresh_state_references", %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
describe('listReferenceSourceKeys', () => {
|
||||
it('returns the source_keys from "refresh_state_references"', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await createLocations(knex, [
|
||||
'location:default/root',
|
||||
@@ -1018,13 +979,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
);
|
||||
|
||||
expect(res).toEqual(['bar', 'foo']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'returns only unique source_keys", %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('returns only unique source_keys"', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await createLocations(knex, [
|
||||
'location:default/root',
|
||||
@@ -1045,13 +1003,10 @@ describe('DefaultProviderDatabase', () => {
|
||||
);
|
||||
|
||||
expect(res).toEqual(['foo']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'does not return null source_keys", %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
it('does not return null source_keys"', async () => {
|
||||
const { knex, db } = await createDatabase();
|
||||
|
||||
await createLocations(knex, [
|
||||
'location:default/root',
|
||||
@@ -1071,7 +1026,7 @@ describe('DefaultProviderDatabase', () => {
|
||||
);
|
||||
|
||||
expect(res).toEqual(['foo']);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { applyDatabaseMigrations } from './migrations';
|
||||
@@ -23,10 +23,10 @@ import { createEntitiesCountByKind, queryEntitiesCountByKind } from './metrics';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('metrics', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
describe.each(databases.eachSupportedId())('metrics, %p', databaseId => {
|
||||
async function createDatabase() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
@@ -55,108 +55,99 @@ describe('metrics', () => {
|
||||
}
|
||||
|
||||
describe('queryEntitiesCountByKind', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'counts entities grouped by the kind in entity_ref, %p',
|
||||
async databaseId => {
|
||||
const knex = await createDatabase(databaseId);
|
||||
it('counts entities grouped by the kind in entity_ref', async () => {
|
||||
const knex = await createDatabase();
|
||||
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/svc-a',
|
||||
finalEntity: '{"kind":"Component"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/svc-b',
|
||||
finalEntity: '{"kind":"Component"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'api:default/api-a',
|
||||
finalEntity: '{"kind":"API"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'system:other/sys-a',
|
||||
finalEntity: '{"kind":"System"}',
|
||||
});
|
||||
// Not yet stitched -- must be excluded from the count
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/pending',
|
||||
finalEntity: null,
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/svc-a',
|
||||
finalEntity: '{"kind":"Component"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/svc-b',
|
||||
finalEntity: '{"kind":"Component"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'api:default/api-a',
|
||||
finalEntity: '{"kind":"API"}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'system:other/sys-a',
|
||||
finalEntity: '{"kind":"System"}',
|
||||
});
|
||||
// Not yet stitched -- must be excluded from the count
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/pending',
|
||||
finalEntity: null,
|
||||
});
|
||||
|
||||
const result = await queryEntitiesCountByKind(knex);
|
||||
const result = await queryEntitiesCountByKind(knex);
|
||||
|
||||
expect(Object.fromEntries(result)).toEqual({
|
||||
component: 2,
|
||||
api: 1,
|
||||
system: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(Object.fromEntries(result)).toEqual({
|
||||
component: 2,
|
||||
api: 1,
|
||||
system: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEntitiesCountByKind', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'serves cached results within the TTL and refreshes after, %p',
|
||||
async databaseId => {
|
||||
const knex = await createDatabase(databaseId);
|
||||
const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
|
||||
it('serves cached results within the TTL and refreshes after', async () => {
|
||||
const knex = await createDatabase();
|
||||
const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
|
||||
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/one',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/one',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
|
||||
const first = await getCount();
|
||||
expect(Object.fromEntries(first)).toEqual({ component: 1 });
|
||||
const first = await getCount();
|
||||
expect(Object.fromEntries(first)).toEqual({ component: 1 });
|
||||
|
||||
// A change made within the TTL window must not be visible yet.
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/two',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
const cached = await getCount();
|
||||
expect(Object.fromEntries(cached)).toEqual({ component: 1 });
|
||||
// A change made within the TTL window must not be visible yet.
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/two',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
const cached = await getCount();
|
||||
expect(Object.fromEntries(cached)).toEqual({ component: 1 });
|
||||
|
||||
// After the TTL elapses the next call hits the database again.
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
const refreshed = await getCount();
|
||||
expect(Object.fromEntries(refreshed)).toEqual({ component: 2 });
|
||||
},
|
||||
);
|
||||
// After the TTL elapses the next call hits the database again.
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
const refreshed = await getCount();
|
||||
expect(Object.fromEntries(refreshed)).toEqual({ component: 2 });
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'coalesces overlapping callers into a single underlying query, %p',
|
||||
async databaseId => {
|
||||
const knex = await createDatabase(databaseId);
|
||||
const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
|
||||
it('coalesces overlapping callers into a single underlying query', async () => {
|
||||
const knex = await createDatabase();
|
||||
const getCount = createEntitiesCountByKind(knex, { ttlMs: 50 });
|
||||
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/one',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
await insertEntity(knex, {
|
||||
entityRef: 'component:default/one',
|
||||
finalEntity: '{}',
|
||||
});
|
||||
|
||||
const finalEntitiesQueries: string[] = [];
|
||||
knex.on('query', (q: { sql: string }) => {
|
||||
if (
|
||||
/from\s+["`]?final_entities["`]?/i.test(q.sql) &&
|
||||
/^\s*select/i.test(q.sql)
|
||||
) {
|
||||
finalEntitiesQueries.push(q.sql);
|
||||
}
|
||||
});
|
||||
|
||||
// Five concurrent callers should result in one query, not five.
|
||||
const results = await Promise.all([
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
]);
|
||||
expect(finalEntitiesQueries).toHaveLength(1);
|
||||
for (const r of results) {
|
||||
expect(Object.fromEntries(r)).toEqual({ component: 1 });
|
||||
const finalEntitiesQueries: string[] = [];
|
||||
knex.on('query', (q: { sql: string }) => {
|
||||
if (
|
||||
/from\s+["`]?final_entities["`]?/i.test(q.sql) &&
|
||||
/^\s*select/i.test(q.sql)
|
||||
) {
|
||||
finalEntitiesQueries.push(q.sql);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Five concurrent callers should result in one query, not five.
|
||||
const results = await Promise.all([
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
getCount(),
|
||||
]);
|
||||
expect(finalEntitiesQueries).toHaveLength(1);
|
||||
for (const r of results) {
|
||||
expect(Object.fromEntries(r)).toEqual({ component: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+79
-94
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { applyDatabaseMigrations } from '../../migrations';
|
||||
@@ -27,72 +27,72 @@ import { deleteWithEagerPruningOfChildren } from './deleteWithEagerPruningOfChil
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('deleteWithEagerPruningOfChildren', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
|
||||
async function insertReference(
|
||||
knex: Knex,
|
||||
...refs: DbRefreshStateReferencesRow[]
|
||||
) {
|
||||
return knex<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
refs,
|
||||
);
|
||||
}
|
||||
|
||||
async function insertRelation(
|
||||
knex: Knex,
|
||||
...relations: { from: string; to: string }[]
|
||||
) {
|
||||
for (const rel of relations) {
|
||||
await knex<DbRelationsRow>('relations').insert({
|
||||
originating_entity_id: await knex<DbRefreshStateRow>('refresh_state')
|
||||
.select('entity_id')
|
||||
.then(rows => rows[0].entity_id), // doesn't matter which one, this is consumed pre-deletion
|
||||
source_entity_ref: rel.from,
|
||||
target_entity_ref: rel.to,
|
||||
type: 'fake',
|
||||
});
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'deleteWithEagerPruningOfChildren, %p',
|
||||
databaseId => {
|
||||
async function createDatabase() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
}
|
||||
|
||||
async function insertEntity(knex: Knex, ...entityRefs: string[]) {
|
||||
for (const ref of entityRefs) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: uuid(),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
async function insertReference(
|
||||
knex: Knex,
|
||||
...refs: DbRefreshStateReferencesRow[]
|
||||
) {
|
||||
return knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert(refs);
|
||||
}
|
||||
}
|
||||
|
||||
async function remainingEntities(knex: Knex) {
|
||||
const rows = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref');
|
||||
return rows.map(r => r.entity_ref);
|
||||
}
|
||||
async function insertRelation(
|
||||
knex: Knex,
|
||||
...relations: { from: string; to: string }[]
|
||||
) {
|
||||
for (const rel of relations) {
|
||||
await knex<DbRelationsRow>('relations').insert({
|
||||
originating_entity_id: await knex<DbRefreshStateRow>('refresh_state')
|
||||
.select('entity_id')
|
||||
.then(rows => rows[0].entity_id), // doesn't matter which one, this is consumed pre-deletion
|
||||
source_entity_ref: rel.from,
|
||||
target_entity_ref: rel.to,
|
||||
type: 'fake',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function entitiesMarkedForStitching(knex: Knex) {
|
||||
const rows = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref')
|
||||
.where('result_hash', '=', 'force-stitching');
|
||||
return rows.map(r => r.entity_ref);
|
||||
}
|
||||
async function insertEntity(knex: Knex, ...entityRefs: string[]) {
|
||||
for (const ref of entityRefs) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: uuid(),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for the simple path, %p',
|
||||
async databaseId => {
|
||||
async function remainingEntities(knex: Knex) {
|
||||
const rows = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref');
|
||||
return rows.map(r => r.entity_ref);
|
||||
}
|
||||
|
||||
async function entitiesMarkedForStitching(knex: Knex) {
|
||||
const rows = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref')
|
||||
.where('result_hash', '=', 'force-stitching');
|
||||
return rows.map(r => r.entity_ref);
|
||||
}
|
||||
|
||||
it('works for the simple path', async () => {
|
||||
/*
|
||||
P1 - E1 - E2
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: E1, E2, and E3 deleted; E4 and E5 remain; E4 marked for stitching because it had a relation to a deleted entity
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -124,12 +124,9 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
});
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E4', 'E5']);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual(['E4']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works when there are multiple identical references, %p',
|
||||
async databaseId => {
|
||||
it('works when there are multiple identical references', async () => {
|
||||
/*
|
||||
P1
|
||||
\
|
||||
@@ -143,7 +140,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: E1 deleted; E2 remains; E2 marked for stitching because it had a relation to a deleted entity
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -159,12 +156,9 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
});
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2']);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual(['E2']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'leaves out things that have roots in other source keys, %p',
|
||||
async databaseId => {
|
||||
it('leaves out things that have roots in other source keys', async () => {
|
||||
/*
|
||||
P1 - E1
|
||||
\
|
||||
@@ -176,7 +170,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: E1 deleted; E2 and E3 remain; E2 marked for stitching because it had a relation to a deleted entity
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -197,12 +191,9 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
});
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual(['E2']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'leaves out things that have several different roots for the same source key, %p',
|
||||
async databaseId => {
|
||||
it('leaves out things that have several different roots for the same source key', async () => {
|
||||
/*
|
||||
P1 - E1
|
||||
\
|
||||
@@ -214,7 +205,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: E1 deleted; E2 and E3 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -230,12 +221,9 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
});
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'handles cycles and diamonds gracefully, %p',
|
||||
async databaseId => {
|
||||
it('handles cycles and diamonds gracefully', async () => {
|
||||
/*
|
||||
P1 - E1 <-> E2
|
||||
\
|
||||
@@ -247,7 +235,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: Everything deleted, but in two steps; E4 marked for stitching in the first step because it had a relation to a deleted entity
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5', 'E6');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -281,12 +269,9 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
});
|
||||
await expect(remainingEntities(knex)).resolves.toEqual([]);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'silently ignores attempts to delete things that are not your own and/or are not roots, %p',
|
||||
async databaseId => {
|
||||
it('silently ignores attempts to delete things that are not your own and/or are not roots', async () => {
|
||||
/*
|
||||
P1 - E1 - E2
|
||||
|
||||
@@ -298,7 +283,7 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
|
||||
Result: E3 is deleted; E1, E2 and E4 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4');
|
||||
await insertReference(
|
||||
knex,
|
||||
@@ -318,6 +303,6 @@ describe('deleteWithEagerPruningOfChildren', () => {
|
||||
'E4',
|
||||
]);
|
||||
await expect(entitiesMarkedForStitching(knex)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+15
-15
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { randomUUID as uuid } from 'node:crypto';
|
||||
import { applyDatabaseMigrations } from '../../migrations';
|
||||
import { DbRefreshKeysRow, DbRefreshStateRow } from '../../tables';
|
||||
@@ -23,19 +23,19 @@ import { refreshByRefreshKeys } from './refreshByRefreshKeys';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('refreshByRefreshKeys', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'refreshByRefreshKeys, %p',
|
||||
databaseId => {
|
||||
async function createDatabase() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for the simple path, %p',
|
||||
async databaseId => {
|
||||
const knex = await createDatabase(databaseId);
|
||||
it('works for the simple path', async () => {
|
||||
const knex = await createDatabase();
|
||||
|
||||
const eid1 = uuid();
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
@@ -89,6 +89,6 @@ describe('refreshByRefreshKeys', () => {
|
||||
expect(normalizeTimestamp(before2.next_update_at)).toEqual(
|
||||
normalizeTimestamp(after2.next_update_at),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+8
-8
@@ -21,12 +21,12 @@ import { getDeferredStitchableEntities } from './getDeferredStitchableEntities';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('getDeferredStitchableEntities', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'selects the right rows %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'getDeferredStitchableEntities, %p',
|
||||
databaseId => {
|
||||
it('selects the right rows', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -83,6 +83,6 @@ describe('getDeferredStitchableEntities', () => {
|
||||
|
||||
expect(+new Date(hitRowAfter!)).toBeGreaterThan(+new Date(hitRowBefore!));
|
||||
expect(+new Date(missRowAfter!)).toEqual(+new Date(missRowBefore!));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+8
-8
@@ -21,12 +21,12 @@ import { DbStitchQueueRow } from '../../tables';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('markDeferredStitchCompleted', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'completes only if unchanged %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'markDeferredStitchCompleted, %p',
|
||||
databaseId => {
|
||||
it('completes only if unchanged', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -68,6 +68,6 @@ describe('markDeferredStitchCompleted', () => {
|
||||
stitchTicket: 'the-ticket',
|
||||
});
|
||||
await expect(result()).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('markForStitching', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'marks the right rows in deferred mode %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'markForStitching, %p',
|
||||
databaseId => {
|
||||
it('marks the right rows in deferred mode', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -203,12 +203,9 @@ describe('markForStitching', () => {
|
||||
const final = await result();
|
||||
const entity4Final = final.find(r => r.entity_ref === 'k:ns/four');
|
||||
expect(entity4Final?.stitch_ticket).not.toEqual('old');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'marks the right rows in immediate mode %p',
|
||||
async databaseId => {
|
||||
it('marks the right rows in immediate mode', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -435,12 +432,9 @@ describe('markForStitching', () => {
|
||||
for (let i = 0; i < final.length; ++i) {
|
||||
expect(original[i].next_update_at).not.toEqual(final[i].next_update_at);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'reproduces deadlock scenario when concurrent transactions update overlapping entity sets %p',
|
||||
async databaseId => {
|
||||
it('reproduces deadlock scenario when concurrent transactions update overlapping entity sets', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -567,6 +561,6 @@ describe('markForStitching', () => {
|
||||
expect(row.next_stitch_at).not.toBeNull();
|
||||
expect(row.stitch_ticket).not.toBeNull();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,14 +29,15 @@ import { performStitching } from './performStitching';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('performStitching', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const logger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
// NOTE(freben): Testing the deferred path since it's a superset of the immediate one
|
||||
it.each(databases.eachSupportedId())(
|
||||
'runs the happy path in deferred mode for %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'performStitching, %p',
|
||||
databaseId => {
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
// NOTE(freben): Testing the deferred path since it's a superset of the immediate one
|
||||
it('runs the happy path in deferred mode', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -323,12 +324,9 @@ describe('performStitching', () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'handles conflicts with past stitches %p',
|
||||
async databaseId => {
|
||||
it('handles conflicts with past stitches', async () => {
|
||||
if (databaseId === 'MYSQL_8') {
|
||||
// MySQL doesn't handle conflicts in the same way as the other two, most
|
||||
// likely due to the conflict probably being handled with a merged even
|
||||
@@ -391,12 +389,9 @@ describe('performStitching', () => {
|
||||
'Skipping stitching of k:ns/n, conflict',
|
||||
expect.anything(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'stitches when final_entities row already exists %p',
|
||||
async databaseId => {
|
||||
it('stitches when final_entities row already exists', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -444,6 +439,6 @@ describe('performStitching', () => {
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0].hash).not.toBe('');
|
||||
expect(entities[0].final_entity).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+105
-105
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
import { StitchingStrategy } from '../../../stitching/types';
|
||||
import { applyDatabaseMigrations } from '../../migrations';
|
||||
@@ -28,109 +28,112 @@ import { deleteOrphanedEntities } from './deleteOrphanedEntities';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('deleteOrphanedEntities', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'deleteOrphanedEntities, %p',
|
||||
databaseId => {
|
||||
async function createDatabase() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
|
||||
async function run(knex: Knex, strategy: StitchingStrategy): Promise<number> {
|
||||
let result: number;
|
||||
await knex.transaction(
|
||||
async tx => {
|
||||
// We can't return here, as knex swallows the return type in case the
|
||||
// transaction is rolled back:
|
||||
// https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136
|
||||
result = await deleteOrphanedEntities({ knex: tx, strategy });
|
||||
},
|
||||
{
|
||||
// If we explicitly trigger a rollback, don't fail.
|
||||
doNotRejectOnRollback: true,
|
||||
},
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
async function run(
|
||||
knex: Knex,
|
||||
strategy: StitchingStrategy,
|
||||
): Promise<number> {
|
||||
let result: number;
|
||||
await knex.transaction(
|
||||
async tx => {
|
||||
// We can't return here, as knex swallows the return type in case the
|
||||
// transaction is rolled back:
|
||||
// https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136
|
||||
result = await deleteOrphanedEntities({ knex: tx, strategy });
|
||||
},
|
||||
{
|
||||
// If we explicitly trigger a rollback, don't fail.
|
||||
doNotRejectOnRollback: true,
|
||||
},
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function insertEntity(knex: Knex, ...entityRefs: string[]) {
|
||||
for (const ref of entityRefs) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: `id-${ref}`,
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
result_hash: 'original',
|
||||
});
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: `id-${ref}`,
|
||||
hash: 'original',
|
||||
entity_ref: ref,
|
||||
async function insertEntity(knex: Knex, ...entityRefs: string[]) {
|
||||
for (const ref of entityRefs) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: `id-${ref}`,
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
result_hash: 'original',
|
||||
});
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: `id-${ref}`,
|
||||
hash: 'original',
|
||||
entity_ref: ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function insertReference(
|
||||
knex: Knex,
|
||||
...refs: DbRefreshStateReferencesRow[]
|
||||
) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert(refs);
|
||||
}
|
||||
|
||||
async function insertRelation(knex: Knex, fromRef: string, toRef: string) {
|
||||
const orig = await knex
|
||||
.select('entity_id')
|
||||
.from('refresh_state')
|
||||
.where('entity_ref', fromRef);
|
||||
await knex<DbRelationsRow>('relations').insert({
|
||||
originating_entity_id: orig[0].entity_id,
|
||||
type: 'fake',
|
||||
source_entity_ref: fromRef,
|
||||
target_entity_ref: toRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function insertReference(
|
||||
knex: Knex,
|
||||
...refs: DbRefreshStateReferencesRow[]
|
||||
) {
|
||||
await knex<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
refs,
|
||||
);
|
||||
}
|
||||
async function refreshState(knex: Knex) {
|
||||
return await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref', 'result_hash');
|
||||
}
|
||||
|
||||
async function insertRelation(knex: Knex, fromRef: string, toRef: string) {
|
||||
const orig = await knex
|
||||
.select('entity_id')
|
||||
.from('refresh_state')
|
||||
.where('entity_ref', fromRef);
|
||||
await knex<DbRelationsRow>('relations').insert({
|
||||
originating_entity_id: orig[0].entity_id,
|
||||
type: 'fake',
|
||||
source_entity_ref: fromRef,
|
||||
target_entity_ref: toRef,
|
||||
});
|
||||
}
|
||||
async function stitchQueue(knex: Knex) {
|
||||
return await knex('stitch_queue')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref');
|
||||
}
|
||||
|
||||
async function refreshState(knex: Knex) {
|
||||
return await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref', 'result_hash');
|
||||
}
|
||||
async function finalEntities(knex: Knex) {
|
||||
return await knex<DbFinalEntitiesRow>('final_entities')
|
||||
.join(
|
||||
'refresh_state',
|
||||
'final_entities.entity_id',
|
||||
'refresh_state.entity_id',
|
||||
)
|
||||
.leftOuterJoin(
|
||||
'stitch_queue',
|
||||
'stitch_queue.entity_ref',
|
||||
'refresh_state.entity_ref',
|
||||
)
|
||||
.orderBy('refresh_state.entity_ref')
|
||||
.select({
|
||||
entity_ref: 'refresh_state.entity_ref',
|
||||
hash: 'final_entities.hash',
|
||||
next_stitch_at: 'stitch_queue.next_stitch_at',
|
||||
});
|
||||
}
|
||||
|
||||
async function stitchQueue(knex: Knex) {
|
||||
return await knex('stitch_queue')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref');
|
||||
}
|
||||
|
||||
async function finalEntities(knex: Knex) {
|
||||
return await knex<DbFinalEntitiesRow>('final_entities')
|
||||
.join(
|
||||
'refresh_state',
|
||||
'final_entities.entity_id',
|
||||
'refresh_state.entity_id',
|
||||
)
|
||||
.leftOuterJoin(
|
||||
'stitch_queue',
|
||||
'stitch_queue.entity_ref',
|
||||
'refresh_state.entity_ref',
|
||||
)
|
||||
.orderBy('refresh_state.entity_ref')
|
||||
.select({
|
||||
entity_ref: 'refresh_state.entity_ref',
|
||||
hash: 'final_entities.hash',
|
||||
next_stitch_at: 'stitch_queue.next_stitch_at',
|
||||
});
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for some mixed paths in immediate mode, %p',
|
||||
async databaseId => {
|
||||
it('works for some mixed paths in immediate mode', async () => {
|
||||
/*
|
||||
In this graph, edges represent refresh state references, not entity relations:
|
||||
|
||||
@@ -155,7 +158,7 @@ describe('deleteOrphanedEntities', () => {
|
||||
Result: E3, E4, E5, E6, and E10 deleted; others remain
|
||||
Entities that had relations pointing at orphans are marked for reprocessing
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(
|
||||
knex,
|
||||
'E1',
|
||||
@@ -210,12 +213,9 @@ describe('deleteOrphanedEntities', () => {
|
||||
{ entity_ref: 'E8', hash: 'original', next_stitch_at: null },
|
||||
{ entity_ref: 'E9', hash: 'original', next_stitch_at: null },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for some mixed paths in deferred mode, %p',
|
||||
async databaseId => {
|
||||
it('works for some mixed paths in deferred mode', async () => {
|
||||
/*
|
||||
In this graph, edges represent refresh state references, not entity relations:
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('deleteOrphanedEntities', () => {
|
||||
Result: E3, E4, E5, E6, and E10 deleted; others remain
|
||||
Entities that had relations pointing at orphans are marked for reprocessing
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
const knex = await createDatabase();
|
||||
await insertEntity(
|
||||
knex,
|
||||
'E1',
|
||||
@@ -304,6 +304,6 @@ describe('deleteOrphanedEntities', () => {
|
||||
{ entity_ref: 'E8', hash: 'original', next_stitch_at: null },
|
||||
{ entity_ref: 'E9', hash: 'original', next_stitch_at: null },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
ANNOTATION_ORIGIN_LOCATION,
|
||||
stringifyEntityRef,
|
||||
@@ -37,63 +37,58 @@ import waitFor from 'wait-for-expect';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultLocationStore', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const mockScmEvents = {
|
||||
subscribe: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
markEventActionTaken: jest.fn(),
|
||||
};
|
||||
let subscriber: CatalogScmEventsServiceSubscriber | undefined;
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultLocationStore, %p',
|
||||
databaseId => {
|
||||
const mockScmEvents = {
|
||||
subscribe: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
markEventActionTaken: jest.fn(),
|
||||
};
|
||||
let subscriber: CatalogScmEventsServiceSubscriber | undefined;
|
||||
|
||||
subscriber = undefined;
|
||||
mockScmEvents.subscribe.mockImplementation(sub => {
|
||||
subscriber = sub;
|
||||
return { unsubscribe: () => {} };
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
subscriber = undefined;
|
||||
mockScmEvents.subscribe.mockImplementation(sub => {
|
||||
subscriber = sub;
|
||||
return { unsubscribe: () => {} };
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createLocationStore(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
const connection = { applyMutation: jest.fn(), refresh: jest.fn() };
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents, {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
await store.connect(connection);
|
||||
return { store, connection, knex };
|
||||
}
|
||||
async function createLocationStore() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
const connection = { applyMutation: jest.fn(), refresh: jest.fn() };
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents, {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
await store.connect(connection);
|
||||
return { store, connection, knex };
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should do a full sync with the locations on connect, %p',
|
||||
async databaseId => {
|
||||
const { connection } = await createLocationStore(databaseId);
|
||||
it('should do a full sync with the locations on connect', async () => {
|
||||
const { connection } = await createLocationStore();
|
||||
|
||||
expect(connection.applyMutation).toHaveBeenCalledWith({
|
||||
type: 'full',
|
||||
entities: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('listLocations', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'lists empty locations when there is no locations, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
describe('listLocations', () => {
|
||||
it('lists empty locations when there is no locations', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
expect(await store.listLocations()).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'lists locations that are added to the db, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
it('lists locations that are added to the db', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
await store.createLocation({
|
||||
target:
|
||||
'https://github.com/backstage/demo/blob/master/catalog-info.yml',
|
||||
@@ -111,15 +106,12 @@ describe('DefaultLocationStore', () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLocation', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'throws when the location already exists, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
describe('createLocation', () => {
|
||||
it('throws when the location already exists', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
const spec = {
|
||||
target:
|
||||
'https://github.com/backstage/demo/blob/master/catalog-info.yml',
|
||||
@@ -129,13 +121,10 @@ describe('DefaultLocationStore', () => {
|
||||
await expect(() => store.createLocation(spec)).rejects.toThrow(
|
||||
new RegExp(`Location ${spec.type}:${spec.target} already exists`),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'calls apply mutation when adding a new location, %p',
|
||||
async databaseId => {
|
||||
const { store, connection } = await createLocationStore(databaseId);
|
||||
it('calls apply mutation when adding a new location', async () => {
|
||||
const { store, connection } = await createLocationStore();
|
||||
await store.createLocation({
|
||||
target:
|
||||
'https://github.com/backstage/demo/blob/master/catalog-info.yml',
|
||||
@@ -159,13 +148,10 @@ describe('DefaultLocationStore', () => {
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'updates refresh_state when onConflict is refresh, %p',
|
||||
async databaseId => {
|
||||
const { store, knex } = await createLocationStore(databaseId);
|
||||
it('updates refresh_state when onConflict is refresh', async () => {
|
||||
const { store, knex } = await createLocationStore();
|
||||
const spec = {
|
||||
type: 'url',
|
||||
target:
|
||||
@@ -201,13 +187,10 @@ describe('DefaultLocationStore', () => {
|
||||
expect(new Date(row.next_update_at).getTime()).toBeGreaterThan(
|
||||
oldDate.getTime(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'persists the correct location_entity_ref when creating a location, %p',
|
||||
async databaseId => {
|
||||
const { store, knex } = await createLocationStore(databaseId);
|
||||
it('persists the correct location_entity_ref when creating a location', async () => {
|
||||
const { store, knex } = await createLocationStore();
|
||||
const created = await store.createLocation({
|
||||
type: 'url',
|
||||
target:
|
||||
@@ -222,26 +205,20 @@ describe('DefaultLocationStore', () => {
|
||||
expect(row.location_entity_ref).toBe(
|
||||
'location:default/generated-fa35d9c166e43ab7f4a7c59a00e88e4e8b5aba34',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLocation', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'throws if the location does not exist, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
describe('deleteLocation', () => {
|
||||
it('throws if the location does not exist', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
const id = uuid();
|
||||
await expect(() => store.deleteLocation(id)).rejects.toThrow(
|
||||
new RegExp(`Found no location with ID ${id}`),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'calls apply mutation when adding a new location, %p',
|
||||
async databaseId => {
|
||||
const { store, connection } = await createLocationStore(databaseId);
|
||||
it('calls apply mutation when adding a new location', async () => {
|
||||
const { store, connection } = await createLocationStore();
|
||||
|
||||
const location = await store.createLocation({
|
||||
target:
|
||||
@@ -268,15 +245,12 @@ describe('DefaultLocationStore', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocation', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'throws if the location does not exist, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
describe('updateLocation', () => {
|
||||
it('throws if the location does not exist', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
const id = uuid();
|
||||
await expect(() =>
|
||||
store.updateLocation(id, {
|
||||
@@ -284,13 +258,10 @@ describe('DefaultLocationStore', () => {
|
||||
target: 'https://example.com',
|
||||
}),
|
||||
).rejects.toThrow(new RegExp(`Found no location with ID ${id}`));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'throws ConflictError when updating to a type+target already used by another location, %p',
|
||||
async databaseId => {
|
||||
const { store } = await createLocationStore(databaseId);
|
||||
it('throws ConflictError when updating to a type+target already used by another location', async () => {
|
||||
const { store } = await createLocationStore();
|
||||
|
||||
await store.createLocation({
|
||||
type: 'url',
|
||||
@@ -307,13 +278,10 @@ describe('DefaultLocationStore', () => {
|
||||
target: 'https://example.com/a',
|
||||
}),
|
||||
).rejects.toThrow(/already exists/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'updates type and target and issues a delta mutation with the new entity, %p',
|
||||
async databaseId => {
|
||||
const { store, connection } = await createLocationStore(databaseId);
|
||||
it('updates type and target and issues a delta mutation with the new entity', async () => {
|
||||
const { store, connection } = await createLocationStore();
|
||||
|
||||
const created = await store.createLocation({
|
||||
type: 'url',
|
||||
@@ -345,15 +313,12 @@ describe('DefaultLocationStore', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocationByEntity', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'loads correctly, %p',
|
||||
async databaseId => {
|
||||
const { store, knex } = await createLocationStore(databaseId);
|
||||
describe('getLocationByEntity', () => {
|
||||
it('loads correctly', async () => {
|
||||
const { store, knex } = await createLocationStore();
|
||||
|
||||
const entityId = uuid();
|
||||
const locationId = uuid();
|
||||
@@ -407,16 +372,12 @@ describe('DefaultLocationStore', () => {
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[NotFoundError: found no entity for ref k:ns/n2]`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCM event handling', () => {
|
||||
describe.each(databases.eachSupportedId())('%p', databaseId => {
|
||||
describe('SCM event handling', () => {
|
||||
it('handles location.deleted', async () => {
|
||||
const { store, knex, connection } = await createLocationStore(
|
||||
databaseId,
|
||||
);
|
||||
const { store, knex, connection } = await createLocationStore();
|
||||
expect(subscriber).not.toBeUndefined();
|
||||
|
||||
// Prepare
|
||||
@@ -531,9 +492,7 @@ describe('DefaultLocationStore', () => {
|
||||
});
|
||||
|
||||
it('handles location.moved', async () => {
|
||||
const { store, knex, connection } = await createLocationStore(
|
||||
databaseId,
|
||||
);
|
||||
const { store, knex, connection } = await createLocationStore();
|
||||
expect(subscriber).not.toBeUndefined();
|
||||
|
||||
// Prepare
|
||||
@@ -669,9 +628,7 @@ describe('DefaultLocationStore', () => {
|
||||
});
|
||||
|
||||
it('handles repository.deleted', async () => {
|
||||
const { store, knex, connection } = await createLocationStore(
|
||||
databaseId,
|
||||
);
|
||||
const { store, knex, connection } = await createLocationStore();
|
||||
expect(subscriber).not.toBeUndefined();
|
||||
|
||||
// Prepare
|
||||
@@ -787,9 +744,7 @@ describe('DefaultLocationStore', () => {
|
||||
});
|
||||
|
||||
it('handles repository.moved', async () => {
|
||||
const { store, knex, connection } = await createLocationStore(
|
||||
databaseId,
|
||||
);
|
||||
const { store, knex, connection } = await createLocationStore();
|
||||
expect(subscriber).not.toBeUndefined();
|
||||
|
||||
// Prepare
|
||||
@@ -919,45 +874,42 @@ describe('DefaultLocationStore', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryLocations', () => {
|
||||
const l1 = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/packages/catalog-model/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-0ecbc46527aae891650cc1ad4eb17e15391fa96a',
|
||||
};
|
||||
const l2 = {
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/plugins/catalog/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-888dd2d9775aaf5b722ebdece23c21e2541e90ce',
|
||||
};
|
||||
const l3 = {
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/plugins/scaffolder/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-d4255ab29a8321cb6eae30cee45969a272e1206e',
|
||||
};
|
||||
const l4 = {
|
||||
id: '00000000-0000-0000-0000-000000000004',
|
||||
type: 'file',
|
||||
target: '/tmp/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-d14ac9f97f7d042d45b2130dcf3d087e000f07f2',
|
||||
};
|
||||
describe('queryLocations', () => {
|
||||
const l1 = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/packages/catalog-model/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-0ecbc46527aae891650cc1ad4eb17e15391fa96a',
|
||||
};
|
||||
const l2 = {
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/plugins/catalog/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-888dd2d9775aaf5b722ebdece23c21e2541e90ce',
|
||||
};
|
||||
const l3 = {
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
type: 'url',
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/plugins/scaffolder/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-d4255ab29a8321cb6eae30cee45969a272e1206e',
|
||||
};
|
||||
const l4 = {
|
||||
id: '00000000-0000-0000-0000-000000000004',
|
||||
type: 'file',
|
||||
target: '/tmp/catalog-info.yaml',
|
||||
entityRef:
|
||||
'location:default/generated-d14ac9f97f7d042d45b2130dcf3d087e000f07f2',
|
||||
};
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'queries locations correctly, %p',
|
||||
async databaseId => {
|
||||
const { store, knex } = await createLocationStore(databaseId);
|
||||
it('queries locations correctly', async () => {
|
||||
const { store, knex } = await createLocationStore();
|
||||
|
||||
// Insert locations in a random order to test the sorting
|
||||
const locations = [l1, l2, l3, l4];
|
||||
@@ -1195,7 +1147,7 @@ describe('DefaultLocationStore', () => {
|
||||
items: [],
|
||||
totalItems: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
mockCredentials,
|
||||
mockServices,
|
||||
@@ -42,102 +41,103 @@ import { entitiesResponseToObjects } from './response';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultEntitiesCatalog', () => {
|
||||
let knex: Knex;
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
afterEach(async () => {
|
||||
await knex.destroy();
|
||||
});
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultEntitiesCatalog, %p',
|
||||
databaseId => {
|
||||
let knex: Knex;
|
||||
|
||||
const databases = TestDatabases.create();
|
||||
const stitch = jest.fn();
|
||||
const stitcher: Stitcher = { stitch } as any;
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
}
|
||||
|
||||
async function addEntity(
|
||||
entity: Entity,
|
||||
parents: { source?: string; entity?: Entity }[],
|
||||
) {
|
||||
const id = uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
afterEach(async () => {
|
||||
await knex.destroy();
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
});
|
||||
const stitch = jest.fn();
|
||||
const stitcher: Stitcher = { stitch } as any;
|
||||
|
||||
for (const parent of parents) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_key: parent.source,
|
||||
source_entity_ref: parent.entity && stringifyEntityRef(parent.entity),
|
||||
target_entity_ref: stringifyEntityRef(entity),
|
||||
});
|
||||
async function createDatabase() {
|
||||
knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
}
|
||||
|
||||
const search = await buildEntitySearch(id, entity);
|
||||
await knex<DbSearchRow>('search').insert(search);
|
||||
async function addEntity(
|
||||
entity: Entity,
|
||||
parents: { source?: string; entity?: Entity }[],
|
||||
) {
|
||||
const id = uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function addEntityToSearch(entity: Entity) {
|
||||
const id = entity.metadata.uid || uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
});
|
||||
|
||||
for (const row of buildEntitySearch(id, entity)) {
|
||||
await knex<DbSearchRow>('search').insert({
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
original_value: row.original_value,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
});
|
||||
|
||||
for (const parent of parents) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_key: parent.source,
|
||||
source_entity_ref: parent.entity && stringifyEntityRef(parent.entity),
|
||||
target_entity_ref: stringifyEntityRef(entity),
|
||||
});
|
||||
}
|
||||
|
||||
const search = await buildEntitySearch(id, entity);
|
||||
await knex<DbSearchRow>('search').insert(search);
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
async function addEntityToSearch(entity: Entity) {
|
||||
const id = entity.metadata.uid || uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
describe('entityAncestry', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return the ancestry with one parent, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
});
|
||||
|
||||
for (const row of buildEntitySearch(id, entity)) {
|
||||
await knex<DbSearchRow>('search').insert({
|
||||
entity_id: id,
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
original_value: row.original_value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('entityAncestry', () => {
|
||||
it('should return the ancestry with one parent', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const grandparent: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -188,13 +188,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should throw error if the entity does not exist, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should throw error if the entity does not exist', async () => {
|
||||
await createDatabase();
|
||||
const catalog = new DefaultEntitiesCatalog({
|
||||
database: knex,
|
||||
logger: mockServices.logger.mock(),
|
||||
@@ -203,13 +200,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
await expect(() =>
|
||||
catalog.entityAncestry('k:default/root'),
|
||||
).rejects.toThrow('No such entity k:default/root');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return the ancestry with multiple parents, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return the ancestry with multiple parents', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const grandparent: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -275,15 +269,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entity for simple filter, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
describe('entities', () => {
|
||||
it('should return correct entity for simple filter', async () => {
|
||||
await createDatabase();
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
@@ -317,13 +308,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0]).toEqual(entity2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entity for negation filter, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return correct entity for negation filter', async () => {
|
||||
await createDatabase();
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
@@ -359,13 +347,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0]).toEqual(entity1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entities for nested filter, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return correct entities for nested filter', async () => {
|
||||
await createDatabase();
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
@@ -433,13 +418,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(entities.length).toBe(2);
|
||||
expect(entities).toContainEqual(entity2);
|
||||
expect(entities).toContainEqual(entity4);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entities for complex negation filter, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return correct entities for complex negation filter', async () => {
|
||||
await createDatabase();
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
@@ -480,14 +462,11 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities).toContainEqual(entity1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return no matches for an empty values array, %p',
|
||||
// NOTE: An empty values array is not a sensible input in a realistic scenario.
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return no matches for an empty values array', async () => {
|
||||
// NOTE: An empty values array is not a sensible input in a realistic scenario.
|
||||
await createDatabase();
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
@@ -519,13 +498,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
const entities = entitiesResponseToObjects(res.entities);
|
||||
|
||||
expect(entities.length).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return both target and targetRef for entities in compat mode',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return both target and targetRef for entities in compat mode', async () => {
|
||||
await createDatabase();
|
||||
await addEntity(
|
||||
{
|
||||
apiVersion: 'a',
|
||||
@@ -579,13 +555,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
target: { kind: 'x', namespace: 'y', name: 'z' },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'handles inversion both for existing and missing keys, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('handles inversion both for existing and missing keys', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -638,13 +611,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
filter: { not: { key: 'spec.b', values: ['lonely'] } },
|
||||
}),
|
||||
).resolves.toEqual(['n1', 'n3']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can order and combine with filtering, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('can order and combine with filtering', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -739,13 +709,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual(['n4', 'n3', 'n1', 'n2']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'paginates correctly through single-field ordering, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('paginates correctly through single-field ordering', async () => {
|
||||
await createDatabase();
|
||||
|
||||
// All four entities have metadata.name — fast path uses Phase 1 only
|
||||
for (const name of ['n1', 'n2', 'n3', 'n4']) {
|
||||
@@ -795,13 +762,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(await hasNext(2, 1)).toBe(true);
|
||||
|
||||
await expect(page(100)).resolves.toEqual(['n1', 'n2', 'n3', 'n4']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'paginates across the Phase 1 / Phase 2 boundary, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('paginates across the Phase 1 / Phase 2 boundary', async () => {
|
||||
await createDatabase();
|
||||
|
||||
// n1 and n2 have spec.b (Phase 1); n3 and n4 do not (Phase 2).
|
||||
// Explicit UIDs pin Phase 2 ordering (entity_id ASC) to a known sequence.
|
||||
@@ -867,13 +831,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'n3',
|
||||
'n4',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'treats a null sort-field value the same as a missing sort field, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('treats a null sort-field value the same as a missing sort field', async () => {
|
||||
await createDatabase();
|
||||
|
||||
// n1 has spec.b with a real value (Phase 1)
|
||||
// n2 has spec.b explicitly set to null — buildEntitySearch stores value=NULL
|
||||
@@ -918,15 +879,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
// ordered by entity_id ASC, regardless of primary direction
|
||||
await expect(page('asc')).resolves.toEqual(['n1', 'n2', 'n3']);
|
||||
await expect(page('desc')).resolves.toEqual(['n1', 'n2', 'n3']);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('entitiesBatch', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'queries for entities by ref, including duplicates, and gracefully returns null for missing entities, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
describe('entitiesBatch', () => {
|
||||
it('queries for entities by ref, including duplicates, and gracefully returns null for missing entities', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntity(
|
||||
{
|
||||
@@ -976,13 +934,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
null,
|
||||
'k:default/two',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'queries for entities by ref, including filtering, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('queries for entities by ref, including filtering', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntity(
|
||||
{
|
||||
@@ -1022,15 +977,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'k:default/two',
|
||||
null,
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEntities', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return paginated entities and scroll the items accordingly, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
describe('queryEntities', () => {
|
||||
it('should return paginated entities and scroll the items accordingly', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const names = ['B', 'F', 'A', 'G', 'D', 'C', 'E'];
|
||||
const entities: Entity[] = names.map(name => entityFrom(name));
|
||||
@@ -1201,13 +1153,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response8.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response8.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response8.totalItems).toBe(names.length);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return paginated entities ordered in descending order and scroll the items accordingly, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should return paginated entities ordered in descending order and scroll the items accordingly', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const names = ['B', 'F', 'A', 'G', 'D', 'C', 'E'];
|
||||
const entities: Entity[] = names.map(name => entityFrom(name));
|
||||
@@ -1379,13 +1328,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response8.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response8.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response8.totalItems).toBe(names.length);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should filter the results when query is provided, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should filter the results when query is provided', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const names = ['lion', 'cat', 'atcatss', 'dog', 'dogcat', 'aa', 's'];
|
||||
const entities: Entity[] = names.map(name => entityFrom(name));
|
||||
@@ -1435,13 +1381,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response.totalItems).toBe(3);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should filter the results when query is provided with fullTextFilter for camelCase fields, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should filter the results when query is provided with fullTextFilter for camelCase fields', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
@@ -1489,13 +1432,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response.pageInfo.nextCursor).toBeUndefined();
|
||||
expect(response.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response.totalItems).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should filter the text results when sortOrder is not provided, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should filter the text results when sortOrder is not provided', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const names = ['lion', 'cat', 'atcatss', 'dog', 'dogcat', 'aa', 's'];
|
||||
const entities: Entity[] = names.map((name, index) =>
|
||||
@@ -1577,13 +1517,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
expect(paginatedResponsePrev).toMatchObject(paginatedResponse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should filter the text results by multiple search fields if provided, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should filter the text results by multiple search fields if provided', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const defs = [
|
||||
{
|
||||
@@ -1682,13 +1619,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
expect(paginatedResponsePrev).toMatchObject(paginatedResponse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should include totalItems and empty entities in the response in case limit is zero, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should include totalItems and empty entities in the response in case limit is zero', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all(
|
||||
Array(20)
|
||||
@@ -1718,13 +1652,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
items: { type: 'raw', entities: [] },
|
||||
pageInfo: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can skip totalItems, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('can skip totalItems', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all(
|
||||
Array(15)
|
||||
@@ -1772,13 +1703,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
pageInfo: { prevCursor: expect.anything() },
|
||||
});
|
||||
expect(response.items.entities).toHaveLength(5);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should paginate results accordingly in case of clashing items, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should paginate results accordingly in case of clashing items', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all([
|
||||
addEntityToSearch(entityFrom('AA')),
|
||||
@@ -1871,13 +1799,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response5.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response5.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response5.totalItems).toBe(6);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should exclude filtered entities when paginating, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should exclude filtered entities when paginating', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all([
|
||||
addEntityToSearch(entityFrom('AA', { uid: '1', kind: 'included' })),
|
||||
@@ -1954,13 +1879,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response2.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response2.pageInfo.prevCursor).toBeDefined();
|
||||
expect(response2.totalItems).toBe(6);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should paginate results without sort fields, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should paginate results without sort fields', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all([
|
||||
addEntityToSearch(entityFrom('AA', { uid: 'id1' })),
|
||||
@@ -2058,13 +1980,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(response5.pageInfo.nextCursor).toBeDefined();
|
||||
expect(response5.pageInfo.prevCursor).toBeUndefined();
|
||||
expect(response5.totalItems).toBe(6);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should sort properly for fields that do not exist on all entities, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should sort properly for fields that do not exist on all entities', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await Promise.all([
|
||||
addEntityToSearch(entityFrom('AA', { uid: 'id1' })),
|
||||
@@ -2100,13 +2019,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
),
|
||||
).toEqual(['BB', 'CC']);
|
||||
expect(descResult.totalItems).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should silently skip over entities that are not yet stitched, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should silently skip over entities that are not yet stitched', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const entity1 = entityFrom('AA', { uid: 'id1' });
|
||||
const entity2 = entityFrom('BB', { uid: 'id2' });
|
||||
@@ -2149,13 +2065,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
entitiesResponseToObjects(r.items).map(e => e!.metadata.name),
|
||||
),
|
||||
).resolves.toEqual(['BB']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should not return duplicate entities when using orderField, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should not return duplicate entities when using orderField', async () => {
|
||||
await createDatabase();
|
||||
|
||||
// Create a few test entities with different names to sort by
|
||||
const entities = [
|
||||
@@ -2234,13 +2147,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'b-entity',
|
||||
'c-entity',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should apply both filter and query when both are given, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('should apply both filter and query when both are given', async () => {
|
||||
await createDatabase();
|
||||
|
||||
// Add entities with different kinds and names
|
||||
await addEntityToSearch(entityFrom('A', { kind: 'component' }));
|
||||
@@ -2266,15 +2176,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(resultEntities).toEqual([
|
||||
entityFrom('A', { kind: 'component' }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'also clears parent hashes, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
describe('removeEntityByUid', () => {
|
||||
it('also clears parent hashes', async () => {
|
||||
await createDatabase();
|
||||
|
||||
const grandparent: Entity = {
|
||||
apiVersion: 'a',
|
||||
@@ -2358,15 +2265,12 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
expect(stitch).toHaveBeenCalledWith({
|
||||
entityRefs: new Set(['k:default/unrelated1', 'k:default/unrelated2']),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('facets', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can filter and collect properly, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
describe('facets', () => {
|
||||
it('can filter and collect properly', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2432,13 +2336,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
kind: [{ value: 'k', count: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can match on annotations and labels with dots in them, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('can match on annotations and labels with dots in them', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2483,13 +2384,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'can match on strings in arrays, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('can match on strings in arrays', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2529,13 +2427,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works with a mixture of present and missing facets, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('works with a mixture of present and missing facets', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2573,13 +2468,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
missing: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works when the entity is duplicated in search results, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
it('works when the entity is duplicated in search results', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2621,28 +2513,22 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'metadata.name': [{ value: 'one', count: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
async function setupFacetsCatalog(
|
||||
databaseId: TestDatabaseId,
|
||||
entities: Entity[],
|
||||
) {
|
||||
await createDatabase(databaseId);
|
||||
for (const entity of entities) {
|
||||
await addEntityToSearch(entity);
|
||||
}
|
||||
return new DefaultEntitiesCatalog({
|
||||
database: knex,
|
||||
logger: mockServices.logger.mock(),
|
||||
stitcher,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'excludes not-yet-stitched entities from filtered facets, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
async function setupFacetsCatalog(entities: Entity[]) {
|
||||
await createDatabase();
|
||||
for (const entity of entities) {
|
||||
await addEntityToSearch(entity);
|
||||
}
|
||||
return new DefaultEntitiesCatalog({
|
||||
database: knex,
|
||||
logger: mockServices.logger.mock(),
|
||||
stitcher,
|
||||
});
|
||||
}
|
||||
|
||||
it('excludes not-yet-stitched entities from filtered facets', async () => {
|
||||
await createDatabase();
|
||||
|
||||
await addEntityToSearch({
|
||||
apiVersion: 'a',
|
||||
@@ -2701,13 +2587,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'metadata.name': [{ value: 'stitched', count: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters with a predicate query, %p',
|
||||
async databaseId => {
|
||||
const catalog = await setupFacetsCatalog(databaseId, [
|
||||
it('filters with a predicate query', async () => {
|
||||
const catalog = await setupFacetsCatalog([
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'Component',
|
||||
@@ -2742,13 +2625,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters with a predicate query using $in, %p',
|
||||
async databaseId => {
|
||||
const catalog = await setupFacetsCatalog(databaseId, [
|
||||
it('filters with a predicate query using $in', async () => {
|
||||
const catalog = await setupFacetsCatalog([
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'Component',
|
||||
@@ -2783,13 +2663,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters with compound allOf filter, %p',
|
||||
async databaseId => {
|
||||
const catalog = await setupFacetsCatalog(databaseId, [
|
||||
it('filters with compound allOf filter', async () => {
|
||||
const catalog = await setupFacetsCatalog([
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'Component',
|
||||
@@ -2826,13 +2703,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'metadata.name': [{ value: 'one', count: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters with compound anyOf filter, %p',
|
||||
async databaseId => {
|
||||
const catalog = await setupFacetsCatalog(databaseId, [
|
||||
it('filters with compound anyOf filter', async () => {
|
||||
const catalog = await setupFacetsCatalog([
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'Component',
|
||||
@@ -2872,13 +2746,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters with both filter and query combined, %p',
|
||||
async databaseId => {
|
||||
const catalog = await setupFacetsCatalog(databaseId, [
|
||||
it('filters with both filter and query combined', async () => {
|
||||
const catalog = await setupFacetsCatalog([
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'Component',
|
||||
@@ -2911,10 +2782,10 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
'spec.type': [{ value: 'service', count: 1 }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function entityFrom(
|
||||
name: string,
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import {
|
||||
mockCredentials,
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
@@ -42,166 +41,162 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DefaultRefreshService', () => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
logger: LoggerService = defaultLogger,
|
||||
) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
processingDb: new DefaultProcessingDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
refreshInterval: () => 100,
|
||||
events: mockServices.events.mock(),
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DefaultRefreshService, %p',
|
||||
databaseId => {
|
||||
const defaultLogger = mockServices.logger.mock();
|
||||
|
||||
async function createDatabase(logger: LoggerService = defaultLogger) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return {
|
||||
knex,
|
||||
processingDb: new DefaultProcessingDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
refreshInterval: () => 100,
|
||||
events: mockServices.events.mock(),
|
||||
metrics: metricsServiceMock.mock(),
|
||||
}),
|
||||
catalogDb: new DefaultCatalogDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const createPopulatedEngine = async (options: {
|
||||
db: ProcessingDatabase;
|
||||
knex: Knex;
|
||||
entities: Entity[];
|
||||
references: { [source: string]: string[] };
|
||||
entityProcessor?: (entity: Entity) => void;
|
||||
}) => {
|
||||
const { db, knex, entities, references, entityProcessor } = options;
|
||||
|
||||
const entityMap = new Map(
|
||||
entities.map(entity => [stringifyEntityRef(entity), entity]),
|
||||
);
|
||||
|
||||
for (const entity of entities) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: uuid(),
|
||||
entity_ref: stringifyEntityRef(entity),
|
||||
unprocessed_entity: JSON.stringify(entity),
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
}
|
||||
|
||||
const entitiesWithParent = new Set(Object.values(references).flat());
|
||||
for (const entityRef of entityMap.keys()) {
|
||||
if (!entitiesWithParent.has(entityRef)) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_key: 'ConfigLocationProvider',
|
||||
target_entity_ref: entityRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [sourceRef, targetRefs] of Object.entries(references)) {
|
||||
for (const targetRef of targetRefs) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_entity_ref: sourceRef,
|
||||
target_entity_ref: targetRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stitcher = DefaultStitcher.fromConfig(new ConfigReader({}), {
|
||||
knex,
|
||||
logger: defaultLogger,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
}),
|
||||
catalogDb: new DefaultCatalogDatabase({
|
||||
database: knex,
|
||||
logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const createPopulatedEngine = async (options: {
|
||||
db: ProcessingDatabase;
|
||||
knex: Knex;
|
||||
entities: Entity[];
|
||||
references: { [source: string]: string[] };
|
||||
entityProcessor?: (entity: Entity) => void;
|
||||
}) => {
|
||||
const { db, knex, entities, references, entityProcessor } = options;
|
||||
|
||||
const entityMap = new Map(
|
||||
entities.map(entity => [stringifyEntityRef(entity), entity]),
|
||||
);
|
||||
|
||||
for (const entity of entities) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: uuid(),
|
||||
entity_ref: stringifyEntityRef(entity),
|
||||
unprocessed_entity: JSON.stringify(entity),
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
}
|
||||
const engine = new DefaultCatalogProcessingEngine({
|
||||
config: new ConfigReader({}),
|
||||
logger: defaultLogger,
|
||||
processingDatabase: db,
|
||||
knex: knex,
|
||||
stitcher: stitcher,
|
||||
scheduler: mockServices.scheduler(),
|
||||
orchestrator: {
|
||||
async process(request: EntityProcessingRequest) {
|
||||
const entityRef = stringifyEntityRef(request.entity);
|
||||
const entity = entityMap.get(entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(`Unexpected entity: ${entityRef}`);
|
||||
}
|
||||
const deferredEntities =
|
||||
references[entityRef]?.map(ref => {
|
||||
const e = entityMap.get(ref);
|
||||
if (!e) {
|
||||
throw new Error(`Target entity not found: ${ref}`);
|
||||
}
|
||||
return { entity: e, locationKey: ref };
|
||||
}) || [];
|
||||
|
||||
const entitiesWithParent = new Set(Object.values(references).flat());
|
||||
for (const entityRef of entityMap.keys()) {
|
||||
if (!entitiesWithParent.has(entityRef)) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_key: 'ConfigLocationProvider',
|
||||
target_entity_ref: entityRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [sourceRef, targetRefs] of Object.entries(references)) {
|
||||
for (const targetRef of targetRefs) {
|
||||
await knex<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
).insert({
|
||||
source_entity_ref: sourceRef,
|
||||
target_entity_ref: targetRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
entityProcessor?.(entity);
|
||||
|
||||
const stitcher = DefaultStitcher.fromConfig(new ConfigReader({}), {
|
||||
knex,
|
||||
logger: defaultLogger,
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
const engine = new DefaultCatalogProcessingEngine({
|
||||
config: new ConfigReader({}),
|
||||
logger: defaultLogger,
|
||||
processingDatabase: db,
|
||||
knex: knex,
|
||||
stitcher: stitcher,
|
||||
scheduler: mockServices.scheduler(),
|
||||
orchestrator: {
|
||||
async process(request: EntityProcessingRequest) {
|
||||
const entityRef = stringifyEntityRef(request.entity);
|
||||
const entity = entityMap.get(entityRef);
|
||||
if (!entity) {
|
||||
throw new Error(`Unexpected entity: ${entityRef}`);
|
||||
}
|
||||
const deferredEntities =
|
||||
references[entityRef]?.map(ref => {
|
||||
const e = entityMap.get(ref);
|
||||
if (!e) {
|
||||
throw new Error(`Target entity not found: ${ref}`);
|
||||
}
|
||||
return { entity: e, locationKey: ref };
|
||||
}) || [];
|
||||
|
||||
entityProcessor?.(entity);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
completedEntity: {
|
||||
...entity,
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
annotations: {
|
||||
...entity.metadata.annotations,
|
||||
'refresh-completed': 'true',
|
||||
return {
|
||||
ok: true,
|
||||
completedEntity: {
|
||||
...entity,
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
annotations: {
|
||||
...entity.metadata.annotations,
|
||||
'refresh-completed': 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities,
|
||||
state: {},
|
||||
refreshKeys: [],
|
||||
};
|
||||
relations: [],
|
||||
errors: [],
|
||||
deferredEntities,
|
||||
state: {},
|
||||
refreshKeys: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
createHash: () => createHash('sha1'),
|
||||
pollingIntervalMs: 50,
|
||||
events: mockServices.events.mock(),
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
createHash: () => createHash('sha1'),
|
||||
pollingIntervalMs: 50,
|
||||
events: mockServices.events.mock(),
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
|
||||
return engine;
|
||||
};
|
||||
return engine;
|
||||
};
|
||||
|
||||
const waitForRefresh = async (knex: Knex, entityRef: string) => {
|
||||
for (;;) {
|
||||
const [result] = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.where('entity_ref', entityRef)
|
||||
.select();
|
||||
const waitForRefresh = async (knex: Knex, entityRef: string) => {
|
||||
for (;;) {
|
||||
const [result] = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.where('entity_ref', entityRef)
|
||||
.select();
|
||||
|
||||
const entity = result.processed_entity
|
||||
? (JSON.parse(result.processed_entity) as Entity)
|
||||
: undefined;
|
||||
if (entity?.metadata?.annotations?.['refresh-completed']) {
|
||||
// Reset the annotation so that we can run another verification
|
||||
delete entity.metadata.annotations['refresh-completed'];
|
||||
await knex<DbRefreshStateRow>('refresh_state')
|
||||
.update({
|
||||
processed_entity: JSON.stringify(entity),
|
||||
})
|
||||
.where('entity_ref', entityRef);
|
||||
return true;
|
||||
const entity = result.processed_entity
|
||||
? (JSON.parse(result.processed_entity) as Entity)
|
||||
: undefined;
|
||||
if (entity?.metadata?.annotations?.['refresh-completed']) {
|
||||
// Reset the annotation so that we can run another verification
|
||||
delete entity.metadata.annotations['refresh-completed'];
|
||||
await knex<DbRefreshStateRow>('refresh_state')
|
||||
.update({
|
||||
processed_entity: JSON.stringify(entity),
|
||||
})
|
||||
.where('entity_ref', entityRef);
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should refresh the parent location, %p',
|
||||
async databaseId => {
|
||||
const { knex, processingDb, catalogDb } = await createDatabase(
|
||||
databaseId,
|
||||
);
|
||||
it('should refresh the parent location', async () => {
|
||||
const { knex, processingDb, catalogDb } = await createDatabase();
|
||||
const refreshService = new DefaultRefreshService({ database: catalogDb });
|
||||
const engine = await createPopulatedEngine({
|
||||
db: processingDb,
|
||||
@@ -239,15 +234,10 @@ describe('DefaultRefreshService', () => {
|
||||
).resolves.toBe(true);
|
||||
|
||||
await engine.stop();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should refresh the location further up the tree, %p',
|
||||
async databaseId => {
|
||||
const { knex, processingDb, catalogDb } = await createDatabase(
|
||||
databaseId,
|
||||
);
|
||||
it('should refresh the location further up the tree', async () => {
|
||||
const { knex, processingDb, catalogDb } = await createDatabase();
|
||||
const refreshService = new DefaultRefreshService({ database: catalogDb });
|
||||
const engine = await createPopulatedEngine({
|
||||
db: processingDb,
|
||||
@@ -293,16 +283,11 @@ describe('DefaultRefreshService', () => {
|
||||
);
|
||||
|
||||
await engine.stop();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should refresh even when parent has no changes',
|
||||
async databaseId => {
|
||||
it('should refresh even when parent has no changes', async () => {
|
||||
let secondRound = false;
|
||||
const { knex, processingDb, catalogDb } = await createDatabase(
|
||||
databaseId,
|
||||
);
|
||||
const { knex, processingDb, catalogDb } = await createDatabase();
|
||||
const refreshService = new DefaultRefreshService({ database: catalogDb });
|
||||
const engine = await createPopulatedEngine({
|
||||
db: processingDb,
|
||||
@@ -356,6 +341,6 @@ describe('DefaultRefreshService', () => {
|
||||
).resolves.toBe(true);
|
||||
|
||||
await engine.stop();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ import { wrapServer } from '@backstage/backend-openapi-utils/testUtils';
|
||||
import {
|
||||
mockCredentials,
|
||||
mockServices,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import type { Location } from '@backstage/catalog-client';
|
||||
@@ -1680,59 +1679,59 @@ describe('createRouter readonly and raw json enabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /locations/by-query works end to end', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createTestRouter(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'POST /locations/by-query works end to end, %p',
|
||||
databaseId => {
|
||||
async function createTestRouter() {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
const mockScmEvents = {
|
||||
subscribe: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
markEventActionTaken: jest.fn(),
|
||||
};
|
||||
const mockScmEvents = {
|
||||
subscribe: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
markEventActionTaken: jest.fn(),
|
||||
};
|
||||
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents, {
|
||||
refresh: false,
|
||||
unregister: false,
|
||||
move: false,
|
||||
});
|
||||
await store.connect({ applyMutation: jest.fn(), refresh: jest.fn() });
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents, {
|
||||
refresh: false,
|
||||
unregister: false,
|
||||
move: false,
|
||||
});
|
||||
await store.connect({ applyMutation: jest.fn(), refresh: jest.fn() });
|
||||
|
||||
const locationService = new DefaultLocationService(
|
||||
store,
|
||||
{ process: jest.fn() },
|
||||
{
|
||||
allowedLocationTypes: ['url'],
|
||||
defaultLocationConflictStrategy: 'reject',
|
||||
},
|
||||
);
|
||||
const locationService = new DefaultLocationService(
|
||||
store,
|
||||
{ process: jest.fn() },
|
||||
{
|
||||
allowedLocationTypes: ['url'],
|
||||
defaultLocationConflictStrategy: 'reject',
|
||||
},
|
||||
);
|
||||
|
||||
const router = await createRouter({
|
||||
locationService,
|
||||
logger: mockServices.logger.mock(),
|
||||
config: new ConfigReader(undefined),
|
||||
auth: mockServices.auth(),
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
orchestrator: { process: jest.fn() },
|
||||
permissionsService: mockServices.permissions(),
|
||||
auditor: mockServices.auditor.mock(),
|
||||
});
|
||||
const router = await createRouter({
|
||||
locationService,
|
||||
logger: mockServices.logger.mock(),
|
||||
config: new ConfigReader(undefined),
|
||||
auth: mockServices.auth(),
|
||||
httpAuth: mockServices.httpAuth(),
|
||||
orchestrator: { process: jest.fn() },
|
||||
permissionsService: mockServices.permissions(),
|
||||
auditor: mockServices.auditor.mock(),
|
||||
});
|
||||
|
||||
const errorMiddleware = MiddlewareFactory.create({
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig(),
|
||||
});
|
||||
router.use(errorMiddleware.error());
|
||||
const errorMiddleware = MiddlewareFactory.create({
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig(),
|
||||
});
|
||||
router.use(errorMiddleware.error());
|
||||
|
||||
return { knex, app: express().use(router) };
|
||||
}
|
||||
return { knex, app: express().use(router) };
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'paginates through locations correctly, %p',
|
||||
async databaseId => {
|
||||
const { knex, app } = await createTestRouter(databaseId);
|
||||
it('paginates through locations correctly', async () => {
|
||||
const { knex, app } = await createTestRouter();
|
||||
|
||||
// Insert 5 locations directly into the database
|
||||
const locations = [
|
||||
@@ -1819,13 +1818,10 @@ describe('POST /locations/by-query works end to end', () => {
|
||||
totalItems: 5,
|
||||
pageInfo: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'filters locations with query parameter, %p',
|
||||
async databaseId => {
|
||||
const { knex, app } = await createTestRouter(databaseId);
|
||||
it('filters locations with query parameter', async () => {
|
||||
const { knex, app } = await createTestRouter();
|
||||
|
||||
// Insert locations with different types
|
||||
const locations = [
|
||||
@@ -1874,9 +1870,9 @@ describe('POST /locations/by-query works end to end', () => {
|
||||
totalItems: 2,
|
||||
pageInfo: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function mockCursor(partialCursor?: Partial<Cursor>): Cursor {
|
||||
return {
|
||||
|
||||
@@ -29,248 +29,246 @@ import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('Stitcher', () => {
|
||||
const databases = TestDatabases.create();
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
describe.each(databases.eachSupportedId())('Stitcher, %p', databaseId => {
|
||||
const logger = mockServices.logger.mock();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'runs the happy path for %p',
|
||||
async databaseId => {
|
||||
const db = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(db);
|
||||
it('runs the happy path', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(db);
|
||||
|
||||
const stitcher = new DefaultStitcher({
|
||||
knex: db,
|
||||
logger,
|
||||
strategy: { mode: 'immediate' },
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
let entities: DbFinalEntitiesRow[];
|
||||
let entity: Entity;
|
||||
const stitcher = new DefaultStitcher({
|
||||
knex: db,
|
||||
logger,
|
||||
strategy: { mode: 'immediate' },
|
||||
metrics: metricsServiceMock.mock(),
|
||||
});
|
||||
let entities: DbFinalEntitiesRow[];
|
||||
let entity: Entity;
|
||||
|
||||
await db<DbRefreshStateRow>('refresh_state').insert([
|
||||
await db<DbRefreshStateRow>('refresh_state').insert([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
entity_ref: 'k:ns/n',
|
||||
unprocessed_entity: JSON.stringify({}),
|
||||
processed_entity: JSON.stringify({
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: db.fn.now(),
|
||||
last_discovery_at: db.fn.now(),
|
||||
},
|
||||
]);
|
||||
await db<DbRefreshStateReferencesRow>('refresh_state_references').insert([
|
||||
{ source_key: 'a', target_entity_ref: 'k:ns/n' },
|
||||
]);
|
||||
await db<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/other',
|
||||
},
|
||||
// handles and ignores duplicates
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/other',
|
||||
},
|
||||
]);
|
||||
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entity).toEqual({
|
||||
relations: [
|
||||
{
|
||||
type: 'looksAt',
|
||||
targetRef: 'k:ns/other',
|
||||
},
|
||||
],
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
uid: 'my-id',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entity.metadata.etag).toEqual(entities[0].hash);
|
||||
const last_updated_at = entities[0].last_updated_at;
|
||||
expect(last_updated_at).not.toBeNull();
|
||||
const firstHash = entities[0].hash;
|
||||
|
||||
const search = await db<DbSearchRow>('search');
|
||||
expect(search).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
entity_ref: 'k:ns/n',
|
||||
unprocessed_entity: JSON.stringify({}),
|
||||
processed_entity: JSON.stringify({
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: db.fn.now(),
|
||||
last_discovery_at: db.fn.now(),
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/other',
|
||||
value: 'k:ns/other',
|
||||
},
|
||||
]);
|
||||
await db<DbRefreshStateReferencesRow>('refresh_state_references').insert([
|
||||
{ source_key: 'a', target_entity_ref: 'k:ns/n' },
|
||||
]);
|
||||
await db<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/other',
|
||||
entity_id: 'my-id',
|
||||
key: 'apiversion',
|
||||
original_value: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
// handles and ignores duplicates
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/other',
|
||||
entity_id: 'my-id',
|
||||
key: 'kind',
|
||||
original_value: 'k',
|
||||
value: 'k',
|
||||
},
|
||||
]);
|
||||
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entity).toEqual({
|
||||
relations: [
|
||||
{
|
||||
type: 'looksAt',
|
||||
targetRef: 'k:ns/other',
|
||||
},
|
||||
],
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
uid: 'my-id',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entity.metadata.etag).toEqual(entities[0].hash);
|
||||
const last_updated_at = entities[0].last_updated_at;
|
||||
expect(last_updated_at).not.toBeNull();
|
||||
const firstHash = entities[0].hash;
|
||||
|
||||
const search = await db<DbSearchRow>('search');
|
||||
expect(search).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/other',
|
||||
value: 'k:ns/other',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'apiversion',
|
||||
original_value: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'kind',
|
||||
original_value: 'k',
|
||||
value: 'k',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.name',
|
||||
original_value: 'n',
|
||||
value: 'n',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.namespace',
|
||||
original_value: 'ns',
|
||||
value: 'ns',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.uid',
|
||||
original_value: 'my-id',
|
||||
value: 'my-id',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'spec.k',
|
||||
original_value: 'v',
|
||||
value: 'v',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Re-stitch without any changes
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entities[0].hash).toEqual(firstHash);
|
||||
expect(entity.metadata.etag).toEqual(firstHash);
|
||||
|
||||
// Now add one more relation and re-stitch
|
||||
await db<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.name',
|
||||
original_value: 'n',
|
||||
value: 'n',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.namespace',
|
||||
original_value: 'ns',
|
||||
value: 'ns',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.uid',
|
||||
original_value: 'my-id',
|
||||
value: 'my-id',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'spec.k',
|
||||
original_value: 'v',
|
||||
value: 'v',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Re-stitch without any changes
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entities[0].hash).toEqual(firstHash);
|
||||
expect(entity.metadata.etag).toEqual(firstHash);
|
||||
|
||||
// Now add one more relation and re-stitch
|
||||
await db<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/third',
|
||||
},
|
||||
]);
|
||||
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entity).toEqual({
|
||||
relations: expect.arrayContaining([
|
||||
{
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/third',
|
||||
targetRef: 'k:ns/other',
|
||||
},
|
||||
]);
|
||||
|
||||
await stitcher.stitch({ entityRefs: ['k:ns/n'] });
|
||||
|
||||
entities = await db<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
entity = JSON.parse(entities[0].final_entity!);
|
||||
expect(entity).toEqual({
|
||||
relations: expect.arrayContaining([
|
||||
{
|
||||
type: 'looksAt',
|
||||
targetRef: 'k:ns/other',
|
||||
},
|
||||
{
|
||||
type: 'looksAt',
|
||||
targetRef: 'k:ns/third',
|
||||
},
|
||||
]),
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
uid: 'my-id',
|
||||
{
|
||||
type: 'looksAt',
|
||||
targetRef: 'k:ns/third',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
]),
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
uid: 'my-id',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entities[0].hash).not.toEqual(firstHash);
|
||||
expect(entities[0].hash).toEqual(entity.metadata.etag);
|
||||
|
||||
expect(await db<DbSearchRow>('search')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/other',
|
||||
value: 'k:ns/other',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entities[0].hash).not.toEqual(firstHash);
|
||||
expect(entities[0].hash).toEqual(entity.metadata.etag);
|
||||
|
||||
expect(await db<DbSearchRow>('search')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/other',
|
||||
value: 'k:ns/other',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/third',
|
||||
value: 'k:ns/third',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'apiversion',
|
||||
original_value: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'kind',
|
||||
original_value: 'k',
|
||||
value: 'k',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.name',
|
||||
original_value: 'n',
|
||||
value: 'n',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.namespace',
|
||||
original_value: 'ns',
|
||||
value: 'ns',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.uid',
|
||||
original_value: 'my-id',
|
||||
value: 'my-id',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'spec.k',
|
||||
original_value: 'v',
|
||||
value: 'v',
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'relations.looksat',
|
||||
original_value: 'k:ns/third',
|
||||
value: 'k:ns/third',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'apiversion',
|
||||
original_value: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'kind',
|
||||
original_value: 'k',
|
||||
value: 'k',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.name',
|
||||
original_value: 'n',
|
||||
value: 'n',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.namespace',
|
||||
original_value: 'ns',
|
||||
value: 'ns',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'metadata.uid',
|
||||
original_value: 'my-id',
|
||||
value: 'my-id',
|
||||
},
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
key: 'spec.k',
|
||||
original_value: 'v',
|
||||
value: 'v',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,14 +144,13 @@ class Tracker {
|
||||
}
|
||||
}
|
||||
|
||||
describePerformanceTest('stitchingPerformance', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: [/* 'MYSQL_8', */ 'POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
const databases = TestDatabases.create({
|
||||
ids: [/* 'MYSQL_8', */ 'POSTGRES_18', 'POSTGRES_14', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'runs stitching in immediate mode, %p',
|
||||
async databaseId => {
|
||||
describePerformanceTest('stitchingPerformance', () => {
|
||||
describe.each(databases.eachSupportedId())('%p', databaseId => {
|
||||
it('runs stitching in immediate mode', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -199,12 +198,9 @@ describePerformanceTest('stitchingPerformance', () => {
|
||||
await expect(tracker.completion()).resolves.toBeUndefined();
|
||||
await backend.stop();
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'runs stitching in deferred mode, %p',
|
||||
async databaseId => {
|
||||
it('runs stitching in deferred mode', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
|
||||
@@ -252,6 +248,6 @@ describePerformanceTest('stitchingPerformance', () => {
|
||||
await expect(tracker.completion()).resolves.toBeUndefined();
|
||||
await backend.stop();
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,60 +45,54 @@ const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_9', 'POSTGRES_14', 'POSTGRES_16'],
|
||||
});
|
||||
|
||||
const maybeDescribe =
|
||||
databases.eachSupportedId().length > 0 ? describe : describe.skip;
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20240523100528_init.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
maybeDescribe('migrations', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20240523100528_init.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await migrateUntilBefore(knex, '20240523100528_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
await migrateUntilBefore(knex, '20240523100528_init.js');
|
||||
await migrateUpOnce(knex);
|
||||
await knex('event_bus_events').insert({
|
||||
topic: 'test',
|
||||
created_by: 'abc',
|
||||
data_json: JSON.stringify({ message: 'hello' }),
|
||||
notified_subscribers: ['tester'],
|
||||
});
|
||||
await knex('event_bus_subscriptions').insert({
|
||||
id: 'tester',
|
||||
created_by: 'abc',
|
||||
read_until: '5',
|
||||
topics: ['test', 'test2'],
|
||||
});
|
||||
|
||||
await knex('event_bus_events').insert({
|
||||
topic: 'test',
|
||||
await expect(knex('event_bus_events')).resolves.toEqual([
|
||||
{
|
||||
id: '1',
|
||||
created_by: 'abc',
|
||||
topic: 'test',
|
||||
data_json: JSON.stringify({ message: 'hello' }),
|
||||
created_at: expect.anything(),
|
||||
notified_subscribers: ['tester'],
|
||||
});
|
||||
await knex('event_bus_subscriptions').insert({
|
||||
},
|
||||
]);
|
||||
await expect(knex('event_bus_subscriptions')).resolves.toEqual([
|
||||
{
|
||||
id: 'tester',
|
||||
created_by: 'abc',
|
||||
created_at: expect.anything(),
|
||||
updated_at: expect.anything(),
|
||||
read_until: '5',
|
||||
topics: ['test', 'test2'],
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(knex('event_bus_events')).resolves.toEqual([
|
||||
{
|
||||
id: '1',
|
||||
created_by: 'abc',
|
||||
topic: 'test',
|
||||
data_json: JSON.stringify({ message: 'hello' }),
|
||||
created_at: expect.anything(),
|
||||
notified_subscribers: ['tester'],
|
||||
},
|
||||
]);
|
||||
await expect(knex('event_bus_subscriptions')).resolves.toEqual([
|
||||
{
|
||||
id: 'tester',
|
||||
created_by: 'abc',
|
||||
created_at: expect.anything(),
|
||||
updated_at: expect.anything(),
|
||||
read_until: '5',
|
||||
topics: ['test', 'test2'],
|
||||
},
|
||||
]);
|
||||
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('event_bus_events')).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('event_bus_events')).rejects.toEqual(expect.anything());
|
||||
|
||||
await knex.destroy();
|
||||
},
|
||||
);
|
||||
await knex.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
TestBackend,
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
mockCredentials,
|
||||
mockServices,
|
||||
@@ -105,8 +104,15 @@ describe('eventsPlugin', () => {
|
||||
test: 'fake-ext',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event bus', () => {
|
||||
const eventBusDatabases = TestDatabases.create({
|
||||
ids: ['SQLITE_3', 'MYSQL_8', 'POSTGRES_14', 'POSTGRES_18'],
|
||||
});
|
||||
|
||||
describe.each(eventBusDatabases.eachSupportedId())(
|
||||
'event bus, %p',
|
||||
databaseId => {
|
||||
class ReqHelper {
|
||||
private readonly backend: TestBackend;
|
||||
|
||||
@@ -146,12 +152,8 @@ describe('eventsPlugin', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['SQLITE_3', 'MYSQL_8', 'POSTGRES_14', 'POSTGRES_18'],
|
||||
});
|
||||
|
||||
async function mockKnexFactory(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
async function mockKnexFactory() {
|
||||
const knex = await eventBusDatabases.init(databaseId);
|
||||
return mockServices.database.factory({ knex });
|
||||
}
|
||||
|
||||
@@ -163,278 +165,254 @@ describe('eventsPlugin', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should be possible to publish events as a service, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
it('should be possible to publish events as a service', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.service.header())
|
||||
.expect(204); // 204, since there are no subscribers
|
||||
},
|
||||
);
|
||||
await helper
|
||||
.publish('test', { n: 1 })
|
||||
.set('authorization', mockCredentials.service.header())
|
||||
.expect(204); // 204, since there are no subscribers
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should be possible to subscribe as a service and receive an event, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
it('should be possible to subscribe as a service and receive an event', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
await helper
|
||||
.subscribe('tester', ['test'])
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
await helper
|
||||
.subscribe('tester', ['test'])
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
|
||||
await helper
|
||||
.subscribe('tester', ['test'])
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
await helper
|
||||
.subscribe('tester', ['test'])
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
await helper
|
||||
.readEvents('tester')
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
await helper
|
||||
.readEvents('tester')
|
||||
.set('authorization', mockCredentials.none.header())
|
||||
.expect(401);
|
||||
|
||||
await helper
|
||||
.readEvents('tester')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
await helper
|
||||
.readEvents('tester')
|
||||
.set('authorization', mockCredentials.user.header())
|
||||
.expect(403);
|
||||
|
||||
await helper.publish('test', { n: 1 }).expect(201); // 201, since there is a subscriber
|
||||
await helper.publish('test', { n: 1 }).expect(201); // 201, since there is a subscriber
|
||||
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
},
|
||||
);
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should only send an event for each subscriber once, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
it('should only send an event for each subscriber once', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester-1', ['test']).expect(201);
|
||||
await helper.subscribe('tester-2', ['test']).expect(201);
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester-1', ['test']).expect(201);
|
||||
await helper.subscribe('tester-2', ['test']).expect(201);
|
||||
|
||||
// A single event
|
||||
await helper.publish('test', { n: 1 }).expect(201);
|
||||
// A single event
|
||||
await helper.publish('test', { n: 1 }).expect(201);
|
||||
|
||||
// Single client for subscriber 1 gets the event
|
||||
await helper.readEvents('tester-1').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
// Single client for subscriber 1 gets the event
|
||||
await helper.readEvents('tester-1').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
|
||||
// Two clients for subscriber 2, only one gets the event
|
||||
const res1 = helper.readEvents('tester-2');
|
||||
const res2 = helper.readEvents('tester-2');
|
||||
// Two clients for subscriber 2, only one gets the event
|
||||
const res1 = helper.readEvents('tester-2');
|
||||
const res2 = helper.readEvents('tester-2');
|
||||
|
||||
const res = await Promise.race([res1, res2]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
const res = await Promise.race([res1, res2]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
events: [{ topic: 'test', payload: { n: 1 } }],
|
||||
});
|
||||
|
||||
// Post another event, which triggers the other client to return
|
||||
await helper.publish('test', { n: 2 }).expect(201);
|
||||
// Post another event, which triggers the other client to return
|
||||
await helper.publish('test', { n: 2 }).expect(201);
|
||||
|
||||
const otherRes = await Promise.all([res1, res2]).then(rs =>
|
||||
rs.find(r => r !== res),
|
||||
const otherRes = await Promise.all([res1, res2]).then(rs =>
|
||||
rs.find(r => r !== res),
|
||||
);
|
||||
expect(otherRes?.status).toBe(202);
|
||||
|
||||
// Reading subscriber 2 should now return the second event only
|
||||
await helper.readEvents('tester-2').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 2 } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not notify subscribers that have already consumed the event', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester-1', ['test']).expect(201);
|
||||
await helper.subscribe('tester-2', ['test']).expect(201);
|
||||
|
||||
// A single event for each subscriber, that should not be sent to the other one
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-2' },
|
||||
{
|
||||
notifiedSubscribers: ['tester-1'],
|
||||
},
|
||||
)
|
||||
.expect(201);
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-1' },
|
||||
{
|
||||
notifiedSubscribers: ['tester-2'],
|
||||
},
|
||||
)
|
||||
.expect(201);
|
||||
|
||||
// Single client for subscriber 1 gets the event
|
||||
await helper.readEvents('tester-1').expect(200, {
|
||||
events: [{ topic: 'test', payload: { for: 'tester-1' } }],
|
||||
});
|
||||
// Single client for subscriber 2 gets the event
|
||||
await helper.readEvents('tester-2').expect(200, {
|
||||
events: [{ topic: 'test', payload: { for: 'tester-2' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple events in order', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
// A sequence of events published one at a time
|
||||
for (let n = 0; n < 15; ++n) {
|
||||
await helper.publish('test', { n }).expect(201);
|
||||
}
|
||||
|
||||
// Batch size it 10
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [
|
||||
{ topic: 'test', payload: { n: 0 } },
|
||||
{ topic: 'test', payload: { n: 1 } },
|
||||
{ topic: 'test', payload: { n: 2 } },
|
||||
{ topic: 'test', payload: { n: 3 } },
|
||||
{ topic: 'test', payload: { n: 4 } },
|
||||
{ topic: 'test', payload: { n: 5 } },
|
||||
{ topic: 'test', payload: { n: 6 } },
|
||||
{ topic: 'test', payload: { n: 7 } },
|
||||
{ topic: 'test', payload: { n: 8 } },
|
||||
{ topic: 'test', payload: { n: 9 } },
|
||||
],
|
||||
});
|
||||
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [
|
||||
{ topic: 'test', payload: { n: 10 } },
|
||||
{ topic: 'test', payload: { n: 11 } },
|
||||
{ topic: 'test', payload: { n: 12 } },
|
||||
{ topic: 'test', payload: { n: 13 } },
|
||||
{ topic: 'test', payload: { n: 14 } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip publishing if all subscribers have already consumed the event', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-2' },
|
||||
{ notifiedSubscribers: ['tester'] },
|
||||
)
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should time out when no events are available', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
jest.useFakeTimers({
|
||||
advanceTimers: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Can't use supertest for this one because it can't handle the partially blocking response
|
||||
const res = await fetch(
|
||||
`http://localhost:${backend.server.port()}/api/events/bus/v1/subscriptions/tester/events`,
|
||||
{
|
||||
headers: {
|
||||
authorization: mockCredentials.service.header(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(otherRes?.status).toBe(202);
|
||||
|
||||
// Reading subscriber 2 should now return the second event only
|
||||
await helper.readEvents('tester-2').expect(200, {
|
||||
events: [{ topic: 'test', payload: { n: 2 } }],
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should not notify subscribers that have already consumed the event, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
const reader = res.body!.getReader();
|
||||
const isClosed = () =>
|
||||
Promise.race([
|
||||
reader
|
||||
.read()
|
||||
.then(() => reader.closed)
|
||||
.then(() => true),
|
||||
new Promise<boolean>(r => setTimeout(r, 100, false)),
|
||||
]);
|
||||
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester-1', ['test']).expect(201);
|
||||
await helper.subscribe('tester-2', ['test']).expect(201);
|
||||
await expect(isClosed()).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(30000);
|
||||
await expect(isClosed()).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(30000);
|
||||
await expect(isClosed()).resolves.toBe(true);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// A single event for each subscriber, that should not be sent to the other one
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-2' },
|
||||
{
|
||||
notifiedSubscribers: ['tester-1'],
|
||||
},
|
||||
)
|
||||
.expect(201);
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-1' },
|
||||
{
|
||||
notifiedSubscribers: ['tester-2'],
|
||||
},
|
||||
)
|
||||
.expect(201);
|
||||
it('should refuse listen without a subscription', async () => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory()],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
// Single client for subscriber 1 gets the event
|
||||
await helper.readEvents('tester-1').expect(200, {
|
||||
events: [{ topic: 'test', payload: { for: 'tester-1' } }],
|
||||
});
|
||||
// Single client for subscriber 2 gets the event
|
||||
await helper.readEvents('tester-2').expect(200, {
|
||||
events: [{ topic: 'test', payload: { for: 'tester-2' } }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return multiple events in order, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
// 2 subscribers
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
// A sequence of events published one at a time
|
||||
for (let n = 0; n < 15; ++n) {
|
||||
await helper.publish('test', { n }).expect(201);
|
||||
}
|
||||
|
||||
// Batch size it 10
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [
|
||||
{ topic: 'test', payload: { n: 0 } },
|
||||
{ topic: 'test', payload: { n: 1 } },
|
||||
{ topic: 'test', payload: { n: 2 } },
|
||||
{ topic: 'test', payload: { n: 3 } },
|
||||
{ topic: 'test', payload: { n: 4 } },
|
||||
{ topic: 'test', payload: { n: 5 } },
|
||||
{ topic: 'test', payload: { n: 6 } },
|
||||
{ topic: 'test', payload: { n: 7 } },
|
||||
{ topic: 'test', payload: { n: 8 } },
|
||||
{ topic: 'test', payload: { n: 9 } },
|
||||
],
|
||||
});
|
||||
|
||||
await helper.readEvents('tester').expect(200, {
|
||||
events: [
|
||||
{ topic: 'test', payload: { n: 10 } },
|
||||
{ topic: 'test', payload: { n: 11 } },
|
||||
{ topic: 'test', payload: { n: 12 } },
|
||||
{ topic: 'test', payload: { n: 13 } },
|
||||
{ topic: 'test', payload: { n: 14 } },
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should skip publishing if all subscribers have already consumed the event, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
await helper
|
||||
.publish(
|
||||
'test',
|
||||
{ for: 'tester-2' },
|
||||
{ notifiedSubscribers: ['tester'] },
|
||||
)
|
||||
.expect(204);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should time out when no events are available, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
await helper.subscribe('tester', ['test']).expect(201);
|
||||
|
||||
jest.useFakeTimers({
|
||||
advanceTimers: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Can't use supertest for this one because it can't handle the partially blocking response
|
||||
const res = await fetch(
|
||||
`http://localhost:${backend.server.port()}/api/events/bus/v1/subscriptions/tester/events`,
|
||||
{
|
||||
headers: {
|
||||
authorization: mockCredentials.service.header(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const isClosed = () =>
|
||||
Promise.race([
|
||||
reader
|
||||
.read()
|
||||
.then(() => reader.closed)
|
||||
.then(() => true),
|
||||
new Promise<boolean>(r => setTimeout(r, 100, false)),
|
||||
]);
|
||||
|
||||
await expect(isClosed()).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(30000);
|
||||
await expect(isClosed()).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(30000);
|
||||
await expect(isClosed()).resolves.toBe(true);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should refuse listen without a subscription, %p',
|
||||
async databaseId => {
|
||||
backend = await startTestBackend({
|
||||
features: [eventsPlugin, await mockKnexFactory(databaseId)],
|
||||
});
|
||||
const helper = new ReqHelper(backend);
|
||||
|
||||
await helper.readEvents('nonexistent').expect(404);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
await helper.readEvents('nonexistent').expect(404);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,13 +29,10 @@ const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_14', 'POSTGRES_18'],
|
||||
});
|
||||
|
||||
const maybeDescribe =
|
||||
databases.eachSupportedId().length > 0 ? describe : describe.skip;
|
||||
|
||||
maybeDescribe('DatabaseEventBusStore', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should clean up old events, %p',
|
||||
async databaseId => {
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DatabaseEventBusStore, %p',
|
||||
databaseId => {
|
||||
it('should clean up old events', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
const store = await DatabaseEventBusStore.forTest({ logger, db });
|
||||
|
||||
@@ -79,12 +76,9 @@ maybeDescribe('DatabaseEventBusStore', () => {
|
||||
|
||||
const { events: events3 } = await store.readSubscription('tester-3');
|
||||
expect(events3.length).toBe(5);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should always clean up events outside the max age window, %p',
|
||||
async databaseId => {
|
||||
it('should always clean up events outside the max age window', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
const store = await DatabaseEventBusStore.forTest({
|
||||
logger,
|
||||
@@ -132,12 +126,9 @@ maybeDescribe('DatabaseEventBusStore', () => {
|
||||
|
||||
const { events: events3 } = await store.readSubscription('tester-3');
|
||||
expect(events3.length).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should not clean up events within the min age window, %p',
|
||||
async databaseId => {
|
||||
it('should not clean up events within the min age window', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
const store = await DatabaseEventBusStore.forTest({
|
||||
logger,
|
||||
@@ -162,12 +153,9 @@ maybeDescribe('DatabaseEventBusStore', () => {
|
||||
|
||||
const { events: events1 } = await store.readSubscription('tester-1');
|
||||
expect(events1.length).toBe(10);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should clean up a large number of events, %p',
|
||||
async databaseId => {
|
||||
it('should clean up a large number of events', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
const store = await DatabaseEventBusStore.forTest({
|
||||
logger,
|
||||
@@ -197,12 +185,9 @@ maybeDescribe('DatabaseEventBusStore', () => {
|
||||
await expect(db('event_bus_events').count()).resolves.toEqual([
|
||||
{ count: '5' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should perform well when looking up events by topic, %p',
|
||||
async databaseId => {
|
||||
it('should perform well when looking up events by topic', async () => {
|
||||
const db = await databases.init(databaseId);
|
||||
const store = await DatabaseEventBusStore.forTest({
|
||||
logger,
|
||||
@@ -245,6 +230,6 @@ maybeDescribe('DatabaseEventBusStore', () => {
|
||||
]);
|
||||
|
||||
expect(duration).toBeLessThan(20);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,59 +41,56 @@ 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())(
|
||||
'20221109192547_search_add_original_value_column.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
describe.each(databases.eachSupportedId())('migrations, %p', databaseId => {
|
||||
it('20221109192547_search_add_original_value_column.js', async () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20250317_addTopic.js');
|
||||
await migrateUntilBefore(knex, '20250317_addTopic.js');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
await knex
|
||||
.insert({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
})
|
||||
.into('user_settings');
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
let rows = await knex('user_settings');
|
||||
let normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
})
|
||||
.into('user_settings');
|
||||
settings_key_hash:
|
||||
'73f97aff883b8b08a7f4e366234ef4f86827702b0016574ac4c1bf313c703d15',
|
||||
topic: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
let rows = await knex('user_settings');
|
||||
let normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
rows = await knex('user_settings');
|
||||
normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
settings_key_hash:
|
||||
'73f97aff883b8b08a7f4e366234ef4f86827702b0016574ac4c1bf313c703d15',
|
||||
topic: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
rows = await knex('user_settings');
|
||||
normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,45 +18,37 @@ import { queryPostgresMajorVersion } from './util';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('util', () => {
|
||||
describe('unsupported', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['SQLITE_3', 'MYSQL_8'],
|
||||
});
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should fail on get postgres major version, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await expect(
|
||||
async () => await queryPostgresMajorVersion(knex),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('supported', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14'],
|
||||
});
|
||||
|
||||
if (databases.eachSupportedId().length < 1) {
|
||||
// Only execute tests if at least on database engine is available, e.g. if
|
||||
// not in CI=1. it.each doesn't support an empty array.
|
||||
return;
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should get postgres major version, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
const expectedVersion = +databaseId.slice(9);
|
||||
|
||||
await expect(queryPostgresMajorVersion(knex)).resolves.toBe(
|
||||
expectedVersion,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
const unsupportedDatabases = TestDatabases.create({
|
||||
ids: ['SQLITE_3', 'MYSQL_8'],
|
||||
});
|
||||
|
||||
describe.each(unsupportedDatabases.eachSupportedId())(
|
||||
'util unsupported, %p',
|
||||
databaseId => {
|
||||
it('should fail on get postgres major version', async () => {
|
||||
const knex = await unsupportedDatabases.init(databaseId);
|
||||
|
||||
await expect(
|
||||
async () => await queryPostgresMajorVersion(knex),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const supportedDatabases = TestDatabases.create({
|
||||
ids: ['POSTGRES_18', 'POSTGRES_14'],
|
||||
});
|
||||
|
||||
describe.each(supportedDatabases.eachSupportedId())(
|
||||
'util supported, %p',
|
||||
databaseId => {
|
||||
it('should get postgres major version', async () => {
|
||||
const knex = await supportedDatabases.init(databaseId);
|
||||
const expectedVersion = +databaseId.slice(9);
|
||||
|
||||
await expect(queryPostgresMajorVersion(knex)).resolves.toBe(
|
||||
expectedVersion,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user