add cache testing utilities
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': minor
|
||||
---
|
||||
|
||||
Added `TestCaches` that functions just like `TestDatabases`
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
ports:
|
||||
- 3306/tcp
|
||||
redis:
|
||||
image: redis
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
|
||||
BACKSTAGE_TEST_CACHE_REDIS_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
|
||||
# We run the test cases before verifying the specs to prevent any failing tests from causing errors.
|
||||
- name: verify openapi specs against test cases
|
||||
|
||||
@@ -55,6 +55,15 @@ jobs:
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 3306/tcp
|
||||
redis:
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379/tcp
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -115,6 +124,7 @@ jobs:
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
|
||||
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
|
||||
- name: Discord notification
|
||||
if: ${{ failure() }}
|
||||
|
||||
+35
-28
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { mockServices, TestCaches } from '@backstage/backend-test-utils';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import KeyvMemcache from '@keyv/memcache';
|
||||
import { CacheManager } from './CacheManager';
|
||||
|
||||
// This test is in a separate file because the main test file uses other mocking
|
||||
@@ -23,28 +24,32 @@ import { CacheManager } from './CacheManager';
|
||||
|
||||
// Contrived code because it's hard to spy on a default export
|
||||
jest.mock('@keyv/redis', () => {
|
||||
const ActualKeyvRedis = jest.requireActual('@keyv/redis');
|
||||
const Actual = jest.requireActual('@keyv/redis');
|
||||
return jest.fn((...args: any[]) => {
|
||||
return new ActualKeyvRedis(...args);
|
||||
return new Actual(...args);
|
||||
});
|
||||
});
|
||||
jest.mock('@keyv/memcache', () => {
|
||||
const Actual = jest.requireActual('@keyv/memcache');
|
||||
return jest.fn((...args: any[]) => {
|
||||
return new Actual(...args);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CacheManager integration', () => {
|
||||
describe('redis', () => {
|
||||
it('only creates one underlying connection', async () => {
|
||||
const connection =
|
||||
process.env.BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING;
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
const caches = TestCaches.create();
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'only creates one underlying connection, %p',
|
||||
async cacheId => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: { cache: { store: 'redis', connection } },
|
||||
},
|
||||
data: { backend: { cache: { store, connection } } },
|
||||
}),
|
||||
{ onError: e => expect(e).not.toBeDefined() },
|
||||
);
|
||||
|
||||
manager.forPlugin('p1').getClient();
|
||||
@@ -52,25 +57,27 @@ describe('CacheManager integration', () => {
|
||||
manager.forPlugin('p2').getClient();
|
||||
manager.forPlugin('p3').getClient({});
|
||||
|
||||
expect(KeyvRedis).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('interacts correctly with redis', async () => {
|
||||
// TODO(freben): This could be frameworkified as TestCaches just like
|
||||
// TestDatabases, but that will have to come some other day
|
||||
const connection =
|
||||
process.env.BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING;
|
||||
if (!connection) {
|
||||
return;
|
||||
if (store === 'redis') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(KeyvRedis).toHaveBeenCalledTimes(1);
|
||||
} else if (store === 'memcache') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(KeyvMemcache).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(caches.eachSupportedId())(
|
||||
'interacts correctly with store, %p',
|
||||
async cacheId => {
|
||||
const { store, connection } = await caches.init(cacheId);
|
||||
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
backend: { cache: { store: 'redis', connection } },
|
||||
backend: { cache: { store, connection } },
|
||||
},
|
||||
}),
|
||||
{ onError: e => expect(e).not.toBeDefined() },
|
||||
);
|
||||
|
||||
const plugin1 = manager.forPlugin('p1').getClient();
|
||||
@@ -84,6 +91,6 @@ 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');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { HttpRouterFactoryOptions } from '@backstage/backend-app-api';
|
||||
import { HttpRouterService } from '@backstage/backend-plugin-api';
|
||||
import { IdentityService } from '@backstage/backend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import Keyv from 'keyv';
|
||||
import { Knex } from 'knex';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
@@ -423,6 +424,28 @@ export interface TestBackendOptions<TExtensionPoints extends any[]> {
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TestCacheId = 'MEMORY' | 'REDIS_7' | 'MEMCACHED_1';
|
||||
|
||||
// @public
|
||||
export class TestCaches {
|
||||
static create(options?: {
|
||||
ids?: TestCacheId[];
|
||||
disableDocker?: boolean;
|
||||
}): TestCaches;
|
||||
// (undocumented)
|
||||
eachSupportedId(): [TestCacheId][];
|
||||
init(id: TestCacheId): Promise<{
|
||||
store: string;
|
||||
connection: string;
|
||||
keyv: Keyv;
|
||||
}>;
|
||||
// (undocumented)
|
||||
static setDefaults(options: { ids?: TestCacheId[] }): void;
|
||||
// (undocumented)
|
||||
supports(id: TestCacheId): boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TestDatabaseId =
|
||||
| 'POSTGRES_16'
|
||||
|
||||
@@ -53,10 +53,14 @@
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-events-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@keyv/memcache": "^1.3.5",
|
||||
"@keyv/redis": "^2.5.3",
|
||||
"@types/keyv": "^4.2.0",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"cookie": "^0.6.0",
|
||||
"express": "^4.17.1",
|
||||
"fs-extra": "^11.0.0",
|
||||
"keyv": "^4.5.2",
|
||||
"knex": "^3.0.0",
|
||||
"msw": "^1.0.0",
|
||||
"mysql2": "^3.0.0",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isDockerDisabledForTests } from '../util';
|
||||
import { TestCaches } from './TestCaches';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('TestCaches', () => {
|
||||
const caches = TestCaches.create();
|
||||
|
||||
it.each(caches.eachSupportedId())('fires up a cache, %p', async cacheId => {
|
||||
const { keyv } = await caches.init(cacheId);
|
||||
await keyv.set('test', 'value');
|
||||
await expect(keyv.get('test')).resolves.toBe('value');
|
||||
});
|
||||
|
||||
itIfDocker('clears between tests, part 1', async () => {
|
||||
const { keyv } = await caches.init('REDIS_7');
|
||||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
await expect(keyv.get('collision')).resolves.toBeUndefined();
|
||||
await keyv.set('collision', 'something');
|
||||
});
|
||||
|
||||
itIfDocker('clears between tests, part 2', async () => {
|
||||
const { keyv } = await caches.init('REDIS_7');
|
||||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
await expect(keyv.get('collision')).resolves.toBeUndefined();
|
||||
await keyv.set('collision', 'something');
|
||||
});
|
||||
|
||||
itIfDocker('clears between tests, part 3', async () => {
|
||||
const { keyv } = await caches.init('REDIS_7');
|
||||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
await expect(keyv.get('collision')).resolves.toBeUndefined();
|
||||
await keyv.set('collision', 'something');
|
||||
});
|
||||
});
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Keyv from 'keyv';
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { connectToExternalMemcache, startMemcachedContainer } from './memcache';
|
||||
import { connectToExternalRedis, startRedisContainer } from './redis';
|
||||
import { Instance, TestCacheId, TestCacheProperties, allCaches } from './types';
|
||||
|
||||
/**
|
||||
* Encapsulates the creation of ephemeral test cache instances for use inside
|
||||
* unit or integration tests.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class TestCaches {
|
||||
private readonly instanceById: Map<string, Instance>;
|
||||
private readonly supportedIds: TestCacheId[];
|
||||
private static defaultIds?: TestCacheId[];
|
||||
|
||||
/**
|
||||
* Creates an empty `TestCaches` instance, and sets up Jest to clean up all of
|
||||
* its acquired resources after all tests finish.
|
||||
*
|
||||
* You typically want to create just a single instance like this at the top of
|
||||
* your test file or `describe` block, and then call `init` many times on that
|
||||
* instance inside the individual tests. Spinning up a "physical" cache
|
||||
* instance takes a considerable amount of time, slowing down tests. But
|
||||
* wiping the contents of an instance using `init` is very fast.
|
||||
*/
|
||||
static create(options?: {
|
||||
ids?: TestCacheId[];
|
||||
disableDocker?: boolean;
|
||||
}): TestCaches {
|
||||
const ids = options?.ids;
|
||||
const disableDocker = options?.disableDocker ?? isDockerDisabledForTests();
|
||||
|
||||
let testCacheIds: TestCacheId[];
|
||||
if (ids) {
|
||||
testCacheIds = ids;
|
||||
} else if (TestCaches.defaultIds) {
|
||||
testCacheIds = TestCaches.defaultIds;
|
||||
} else {
|
||||
testCacheIds = Object.keys(allCaches) as TestCacheId[];
|
||||
}
|
||||
|
||||
const supportedIds = testCacheIds.filter(id => {
|
||||
const properties = allCaches[id];
|
||||
if (!properties) {
|
||||
return false;
|
||||
}
|
||||
// If the caller has set up the env with an explicit connection string,
|
||||
// we'll assume that this target will work
|
||||
if (
|
||||
properties.connectionStringEnvironmentVariableName &&
|
||||
process.env[properties.connectionStringEnvironmentVariableName]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// If the cache doesn't require docker at all, there's nothing to worry
|
||||
// about
|
||||
if (!properties.dockerImageName) {
|
||||
return true;
|
||||
}
|
||||
// If the cache requires docker, but docker is disabled, we will fail.
|
||||
if (disableDocker) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const caches = new TestCaches(supportedIds);
|
||||
|
||||
if (supportedIds.length > 0) {
|
||||
afterAll(async () => {
|
||||
await caches.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
return caches;
|
||||
}
|
||||
|
||||
static setDefaults(options: { ids?: TestCacheId[] }) {
|
||||
TestCaches.defaultIds = options.ids;
|
||||
}
|
||||
|
||||
private constructor(supportedIds: TestCacheId[]) {
|
||||
this.instanceById = new Map();
|
||||
this.supportedIds = supportedIds;
|
||||
}
|
||||
|
||||
supports(id: TestCacheId): boolean {
|
||||
return this.supportedIds.includes(id);
|
||||
}
|
||||
|
||||
eachSupportedId(): [TestCacheId][] {
|
||||
return this.supportedIds.map(id => [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fresh, empty cache for the given driver.
|
||||
*
|
||||
* @param id - The ID of the cache to use, e.g. 'REDIS_7'
|
||||
* @returns Cache connection properties
|
||||
*/
|
||||
async init(
|
||||
id: TestCacheId,
|
||||
): Promise<{ store: string; connection: string; keyv: Keyv }> {
|
||||
const properties = allCaches[id];
|
||||
if (!properties) {
|
||||
const candidates = Object.keys(allCaches).join(', ');
|
||||
throw new Error(
|
||||
`Unknown test cache ${id}, possible values are ${candidates}`,
|
||||
);
|
||||
}
|
||||
if (!this.supportedIds.includes(id)) {
|
||||
const candidates = this.supportedIds.join(', ');
|
||||
throw new Error(
|
||||
`Unsupported test cache ${id} for this environment, possible values are ${candidates}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure that a testcontainers instance is up for this ID
|
||||
let instance: Instance | undefined = this.instanceById.get(id);
|
||||
if (!instance) {
|
||||
instance = await this.initAny(properties);
|
||||
this.instanceById.set(id, instance);
|
||||
}
|
||||
|
||||
// Ensure that it's cleared of data from previous tests
|
||||
await instance.keyv.clear();
|
||||
|
||||
return {
|
||||
store: instance.store,
|
||||
connection: instance.connection,
|
||||
keyv: instance.keyv,
|
||||
};
|
||||
}
|
||||
|
||||
private async initAny(properties: TestCacheProperties): Promise<Instance> {
|
||||
switch (properties.store) {
|
||||
case 'memcache':
|
||||
return this.initMemcached(properties);
|
||||
case 'redis':
|
||||
return this.initRedis(properties);
|
||||
case 'memory':
|
||||
return {
|
||||
store: 'memory',
|
||||
connection: 'memory',
|
||||
keyv: new Keyv(),
|
||||
stop: async () => {},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown cache store '${properties.store}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private async initMemcached(
|
||||
properties: TestCacheProperties,
|
||||
): Promise<Instance> {
|
||||
// Use the connection string if provided
|
||||
const envVarName = properties.connectionStringEnvironmentVariableName;
|
||||
if (envVarName) {
|
||||
const connectionString = process.env[envVarName];
|
||||
if (connectionString) {
|
||||
return connectToExternalMemcache(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
return await startMemcachedContainer(properties.dockerImageName!);
|
||||
}
|
||||
|
||||
private async initRedis(properties: TestCacheProperties): Promise<Instance> {
|
||||
// Use the connection string if provided
|
||||
const envVarName = properties.connectionStringEnvironmentVariableName;
|
||||
if (envVarName) {
|
||||
const connectionString = process.env[envVarName];
|
||||
if (connectionString) {
|
||||
return connectToExternalRedis(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
return await startRedisContainer(properties.dockerImageName!);
|
||||
}
|
||||
|
||||
private async shutdown() {
|
||||
const instances = [...this.instanceById.values()];
|
||||
this.instanceById.clear();
|
||||
await Promise.all(
|
||||
instances.map(({ stop }) =>
|
||||
stop().catch(error => {
|
||||
console.warn(`TestCaches: Failed to stop container`, { error });
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { TestCaches } from './TestCaches';
|
||||
export type { TestCacheId } from './types';
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { startMemcachedContainer } from './memcache';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('startMemcachedContainer', () => {
|
||||
itIfDocker('successfully launches the container', async () => {
|
||||
const { stop, keyv } = await startMemcachedContainer('memcached:1');
|
||||
const value = uuid();
|
||||
await keyv.set('test', value);
|
||||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
await expect(keyv.get('test')).resolves.toBe(value);
|
||||
await stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Keyv from 'keyv';
|
||||
import KeyvMemcache from '@keyv/memcache';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Instance } from './types';
|
||||
|
||||
async function attemptMemcachedConnection(connection: string): Promise<Keyv> {
|
||||
const startTime = Date.now();
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const store = new KeyvMemcache(connection);
|
||||
const keyv = new Keyv({ store });
|
||||
const value = uuid();
|
||||
await keyv.set('test', value);
|
||||
if ((await keyv.get('test')) === value) {
|
||||
return keyv;
|
||||
}
|
||||
} catch (e) {
|
||||
if (Date.now() - startTime > 30_000) {
|
||||
throw new Error(
|
||||
`Timed out waiting for memcached to be ready for connections, ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToExternalMemcache(
|
||||
connection: string,
|
||||
): Promise<Instance> {
|
||||
const keyv = await attemptMemcachedConnection(connection);
|
||||
return {
|
||||
store: 'memcache',
|
||||
connection,
|
||||
keyv,
|
||||
stop: async () => await keyv.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startMemcachedContainer(
|
||||
image: string,
|
||||
): Promise<Instance> {
|
||||
// Lazy-load to avoid side-effect of importing testcontainers
|
||||
const { GenericContainer } = await import('testcontainers');
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(11211)
|
||||
.start();
|
||||
|
||||
const host = container.getHost();
|
||||
const port = container.getMappedPort(11211);
|
||||
const connection = `${host}:${port}`;
|
||||
|
||||
const keyv = await attemptMemcachedConnection(connection);
|
||||
|
||||
return {
|
||||
store: 'memcache',
|
||||
connection,
|
||||
keyv,
|
||||
stop: async () => {
|
||||
await keyv.disconnect();
|
||||
await container.stop({ timeout: 10_000 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
import { startRedisContainer } from './redis';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const itIfDocker = isDockerDisabledForTests() ? it.skip : it;
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('startRedisContainer', () => {
|
||||
itIfDocker('successfully launches the container', async () => {
|
||||
const { stop, keyv } = await startRedisContainer('redis:7');
|
||||
const value = uuid();
|
||||
await keyv.set('test', value);
|
||||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
await expect(keyv.get('test')).resolves.toBe(value);
|
||||
await stop();
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Keyv from 'keyv';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Instance } from './types';
|
||||
|
||||
async function attemptRedisConnection(connection: string): Promise<Keyv> {
|
||||
const startTime = Date.now();
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const store = new KeyvRedis(connection);
|
||||
const keyv = new Keyv({ store });
|
||||
const value = uuid();
|
||||
await keyv.set('test', value);
|
||||
if ((await keyv.get('test')) === value) {
|
||||
return keyv;
|
||||
}
|
||||
} catch (e) {
|
||||
if (Date.now() - startTime > 30_000) {
|
||||
throw new Error(
|
||||
`Timed out waiting for redis to be ready for connections, ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToExternalRedis(
|
||||
connection: string,
|
||||
): Promise<Instance> {
|
||||
const keyv = await attemptRedisConnection(connection);
|
||||
return {
|
||||
store: 'redis',
|
||||
connection,
|
||||
keyv,
|
||||
stop: async () => await keyv.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startRedisContainer(image: string): Promise<Instance> {
|
||||
// Lazy-load to avoid side-effect of importing testcontainers
|
||||
const { GenericContainer } = await import('testcontainers');
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(6379)
|
||||
.start();
|
||||
|
||||
const host = container.getHost();
|
||||
const port = container.getMappedPort(6379);
|
||||
const connection = `redis://${host}:${port}`;
|
||||
|
||||
const keyv = await attemptRedisConnection(connection);
|
||||
|
||||
return {
|
||||
store: 'redis',
|
||||
connection,
|
||||
keyv,
|
||||
stop: async () => {
|
||||
await keyv.disconnect();
|
||||
await container.stop({ timeout: 10_000 });
|
||||
},
|
||||
};
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Keyv from 'keyv';
|
||||
import { getDockerImageForName } from '../util/getDockerImageForName';
|
||||
|
||||
/**
|
||||
* The possible caches to test against.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TestCacheId = 'MEMORY' | 'REDIS_7' | 'MEMCACHED_1';
|
||||
|
||||
export type TestCacheProperties = {
|
||||
name: string;
|
||||
store: string;
|
||||
dockerImageName?: string;
|
||||
connectionStringEnvironmentVariableName?: string;
|
||||
};
|
||||
|
||||
export type Instance = {
|
||||
store: string;
|
||||
connection: string;
|
||||
keyv: Keyv;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const allCaches: Record<TestCacheId, TestCacheProperties> =
|
||||
Object.freeze({
|
||||
REDIS_7: {
|
||||
name: 'Redis 7.x',
|
||||
store: 'redis',
|
||||
dockerImageName: getDockerImageForName('redis:7'),
|
||||
connectionStringEnvironmentVariableName:
|
||||
'BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING',
|
||||
},
|
||||
MEMCACHED_1: {
|
||||
name: 'Memcached 1.x',
|
||||
store: 'memcache',
|
||||
dockerImageName: getDockerImageForName('memcached:1'),
|
||||
connectionStringEnvironmentVariableName:
|
||||
'BACKSTAGE_TEST_CACHE_MEMCACHED1_CONNECTION_STRING',
|
||||
},
|
||||
MEMORY: {
|
||||
name: 'In-memory',
|
||||
store: 'memory',
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { isDockerDisabledForTests } from '../util/isDockerDisabledForTests';
|
||||
export { TestDatabases } from './TestDatabases';
|
||||
export type { TestDatabaseId } from './types';
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './cache';
|
||||
export * from './database';
|
||||
export * from './msw';
|
||||
export * from './filesystem';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function isDockerDisabledForTests() {
|
||||
// the (relatively heavy, long running) docker based tests. If you want to
|
||||
// still run local tests for all databases, just pass either the CI=1 env
|
||||
// parameter to your test runner, or individual connection strings per
|
||||
// database.
|
||||
// database or cache.
|
||||
return (
|
||||
Boolean(process.env.BACKSTAGE_TEST_DISABLE_DOCKER) ||
|
||||
!Boolean(process.env.CI)
|
||||
|
||||
@@ -3582,11 +3582,15 @@ __metadata:
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/plugin-events-node": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@keyv/memcache": ^1.3.5
|
||||
"@keyv/redis": ^2.5.3
|
||||
"@types/keyv": ^4.2.0
|
||||
"@types/supertest": ^2.0.8
|
||||
better-sqlite3: ^9.0.0
|
||||
cookie: ^0.6.0
|
||||
express: ^4.17.1
|
||||
fs-extra: ^11.0.0
|
||||
keyv: ^4.5.2
|
||||
knex: ^3.0.0
|
||||
msw: ^1.0.0
|
||||
mysql2: ^3.0.0
|
||||
@@ -17510,7 +17514,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:*, @types/keyv@npm:^3.1.1":
|
||||
"@types/keyv@npm:*, @types/keyv@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "@types/keyv@npm:4.2.0"
|
||||
dependencies:
|
||||
keyv: "*"
|
||||
checksum: 8713da9382b9346d664866a6cab2f91b0fd479f61379af891303a618e9a2abad6f347adc38a0850540e3f2dad278427de24e7555339264fddb04d1d17d3b50e0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:^3.1.1":
|
||||
version: 3.1.4
|
||||
resolution: "@types/keyv@npm:3.1.4"
|
||||
dependencies:
|
||||
@@ -31023,6 +31036,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:*, keyv@npm:^4.0.0, keyv@npm:^4.5.2":
|
||||
version: 4.5.4
|
||||
resolution: "keyv@npm:4.5.4"
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^3.0.0":
|
||||
version: 3.1.0
|
||||
resolution: "keyv@npm:3.1.0"
|
||||
@@ -31032,15 +31054,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^4.0.0, keyv@npm:^4.5.2":
|
||||
version: 4.5.4
|
||||
resolution: "keyv@npm:4.5.4"
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"kind-of@npm:^6.0.2, kind-of@npm:^6.0.3":
|
||||
version: 6.0.3
|
||||
resolution: "kind-of@npm:6.0.3"
|
||||
|
||||
Reference in New Issue
Block a user