diff --git a/.changeset/cyan-jobs-visit.md b/.changeset/cyan-jobs-visit.md new file mode 100644 index 0000000000..ccc8c90adf --- /dev/null +++ b/.changeset/cyan-jobs-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': minor +--- + +Added `TestCaches` that functions just like `TestDatabases` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d2363a36..fd3b66ddaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index 444f3f825d..cd2947a565 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -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() }} diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.integration.test.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts similarity index 61% rename from packages/backend-defaults/src/entrypoints/cache/CacheManager.integration.test.ts rename to packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts index a10b02a62e..6eb907764d 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.integration.test.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts @@ -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'); - }); - }); + }, + ); }); diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 5a192cb61f..f3d790ebfe 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -26,6 +26,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'; @@ -425,6 +426,28 @@ export interface TestBackendOptions { >; } +// @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' diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 00d5b3b4e4..c84956e58a 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -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", diff --git a/packages/backend-test-utils/src/cache/TestCaches.test.ts b/packages/backend-test-utils/src/cache/TestCaches.test.ts new file mode 100644 index 0000000000..7d5ef69704 --- /dev/null +++ b/packages/backend-test-utils/src/cache/TestCaches.test.ts @@ -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'); + }); +}); diff --git a/packages/backend-test-utils/src/cache/TestCaches.ts b/packages/backend-test-utils/src/cache/TestCaches.ts new file mode 100644 index 0000000000..e9ba0e5c4f --- /dev/null +++ b/packages/backend-test-utils/src/cache/TestCaches.ts @@ -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; + 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 { + 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 { + // 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 { + // 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 }); + }), + ), + ); + } +} diff --git a/packages/backend-test-utils/src/cache/index.ts b/packages/backend-test-utils/src/cache/index.ts new file mode 100644 index 0000000000..46d95545bf --- /dev/null +++ b/packages/backend-test-utils/src/cache/index.ts @@ -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'; diff --git a/packages/backend-test-utils/src/cache/memcache.test.ts b/packages/backend-test-utils/src/cache/memcache.test.ts new file mode 100644 index 0000000000..bd912ce9d2 --- /dev/null +++ b/packages/backend-test-utils/src/cache/memcache.test.ts @@ -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(); + }); +}); diff --git a/packages/backend-test-utils/src/cache/memcache.ts b/packages/backend-test-utils/src/cache/memcache.ts new file mode 100644 index 0000000000..b7ac07cb13 --- /dev/null +++ b/packages/backend-test-utils/src/cache/memcache.ts @@ -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 { + 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 { + const keyv = await attemptMemcachedConnection(connection); + return { + store: 'memcache', + connection, + keyv, + stop: async () => await keyv.disconnect(), + }; +} + +export async function startMemcachedContainer( + image: string, +): Promise { + // 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 }); + }, + }; +} diff --git a/packages/backend-test-utils/src/cache/redis.test.ts b/packages/backend-test-utils/src/cache/redis.test.ts new file mode 100644 index 0000000000..6555a26677 --- /dev/null +++ b/packages/backend-test-utils/src/cache/redis.test.ts @@ -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(); + }); +}); diff --git a/packages/backend-test-utils/src/cache/redis.ts b/packages/backend-test-utils/src/cache/redis.ts new file mode 100644 index 0000000000..6185e4d076 --- /dev/null +++ b/packages/backend-test-utils/src/cache/redis.ts @@ -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 { + 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 { + const keyv = await attemptRedisConnection(connection); + return { + store: 'redis', + connection, + keyv, + stop: async () => await keyv.disconnect(), + }; +} + +export async function startRedisContainer(image: string): Promise { + // 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 }); + }, + }; +} diff --git a/packages/backend-test-utils/src/cache/types.ts b/packages/backend-test-utils/src/cache/types.ts new file mode 100644 index 0000000000..1aea522042 --- /dev/null +++ b/packages/backend-test-utils/src/cache/types.ts @@ -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; +}; + +export const allCaches: Record = + 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', + }, + }); diff --git a/packages/backend-test-utils/src/database/index.ts b/packages/backend-test-utils/src/database/index.ts index 69e3f41452..949553cef2 100644 --- a/packages/backend-test-utils/src/database/index.ts +++ b/packages/backend-test-utils/src/database/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export { isDockerDisabledForTests } from '../util/isDockerDisabledForTests'; export { TestDatabases } from './TestDatabases'; export type { TestDatabaseId } from './types'; diff --git a/packages/backend-test-utils/src/index.ts b/packages/backend-test-utils/src/index.ts index ff1f2ee460..72abd908c5 100644 --- a/packages/backend-test-utils/src/index.ts +++ b/packages/backend-test-utils/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './cache'; export * from './database'; export * from './msw'; export * from './filesystem'; diff --git a/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts index b411086728..05eeabd91a 100644 --- a/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts +++ b/packages/backend-test-utils/src/util/isDockerDisabledForTests.ts @@ -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) diff --git a/yarn.lock b/yarn.lock index eaa62d4eb5..fde9aa7d1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -17514,7 +17518,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: @@ -31027,6 +31040,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" @@ -31036,15 +31058,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"