diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 6dac471..498d7d4 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -1,9 +1,10 @@ name: Lint and test -# This workflow is triggered on pushes to the repository. + on: + pull_request: push: branches: - - '**' + - master jobs: lint: @@ -11,11 +12,13 @@ jobs: runs-on: ubuntu-latest container: - image: node:alpine + # Node is pinned to 22 to match the runtime image (see Dockerfile); + # @nestjs/bull 0.1.x is incompatible with Node >= 23. + image: node:22-alpine steps: - - uses: actions/checkout@v2 - - run: yarn + - uses: actions/checkout@v4 + - run: yarn install --frozen-lockfile - run: yarn run lint:ci --max-warnings 1 tests: @@ -24,25 +27,22 @@ jobs: runs-on: ubuntu-latest container: - image: node:lts-alpine + image: node:22-alpine steps: - - uses: actions/checkout@v2 - - run: yarn - - run: yarn run test + - uses: actions/checkout@v4 + - run: yarn install --frozen-lockfile + - run: yarn run test:cov build: needs: [lint, tests] name: Build runs-on: ubuntu-latest - # if: github.event_name == 'push' && github.ref == 'refs/heads/master' container: - image: node:lts-alpine + image: node:22-alpine steps: - - uses: actions/checkout@v2 - - run: yarn - - run: npm install -g typescript@3.8.3 - - run: tsc --version + - uses: actions/checkout@v4 + - run: yarn install --frozen-lockfile - run: yarn run build diff --git a/.yarn-offline-mirror/.gitignore b/.yarn-offline-mirror/.gitignore new file mode 100644 index 0000000..8066f35 --- /dev/null +++ b/.yarn-offline-mirror/.gitignore @@ -0,0 +1,7 @@ +# Only the zencrepes.zindexer (bit.dev) tarballs are vendored: the bit.dev +# registry is gone, so they cannot be fetched anymore. Everything else is +# available from the npm registry and does not need to be committed, even +# though yarn copies every downloaded tarball into this mirror. +* +!.gitignore +!zencrepes.zindexer.*.tgz diff --git a/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.17.tgz b/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.17.tgz new file mode 100644 index 0000000..abe296d Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.17.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.28.tgz b/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.28.tgz new file mode 100644 index 0000000..3a5b716 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.config-0.0.28.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.es-utils-0.0.14.tgz b/.yarn-offline-mirror/zencrepes.zindexer.es-utils-0.0.14.tgz new file mode 100644 index 0000000..c87dd1a Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.es-utils-0.0.14.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-issues-0.0.21.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-issues-0.0.21.tgz new file mode 100644 index 0000000..5ddbd60 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-issues-0.0.21.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-labels-0.0.15.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-labels-0.0.15.tgz new file mode 100644 index 0000000..9daa3c7 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-labels-0.0.15.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-milestones-0.0.10.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-milestones-0.0.10.tgz new file mode 100644 index 0000000..ec7da74 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-milestones-0.0.10.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-projects-0.0.11.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-projects-0.0.11.tgz new file mode 100644 index 0000000..771aad4 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-projects-0.0.11.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-pullrequests-0.0.18.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-pullrequests-0.0.18.tgz new file mode 100644 index 0000000..2be13e7 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-pullrequests-0.0.18.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-releases-0.0.10.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-releases-0.0.10.tgz new file mode 100644 index 0000000..5668d2b Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-releases-0.0.10.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-repos-0.0.18.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-repos-0.0.18.tgz new file mode 100644 index 0000000..df21567 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-repos-0.0.18.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-stargazers-0.0.10.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-stargazers-0.0.10.tgz new file mode 100644 index 0000000..296030f Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-stargazers-0.0.10.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.github-watchers-0.0.10.tgz b/.yarn-offline-mirror/zencrepes.zindexer.github-watchers-0.0.10.tgz new file mode 100644 index 0000000..2fca658 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.github-watchers-0.0.10.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.testing-perfs-0.0.32.tgz b/.yarn-offline-mirror/zencrepes.zindexer.testing-perfs-0.0.32.tgz new file mode 100644 index 0000000..89ecc9f Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.testing-perfs-0.0.32.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.testing-runs-0.0.3.tgz b/.yarn-offline-mirror/zencrepes.zindexer.testing-runs-0.0.3.tgz new file mode 100644 index 0000000..e4b3e0d Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.testing-runs-0.0.3.tgz differ diff --git a/.yarn-offline-mirror/zencrepes.zindexer.testing-states-0.0.5.tgz b/.yarn-offline-mirror/zencrepes.zindexer.testing-states-0.0.5.tgz new file mode 100644 index 0000000..5e162d0 Binary files /dev/null and b/.yarn-offline-mirror/zencrepes.zindexer.testing-states-0.0.5.tgz differ diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..d35ece1 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +yarn-offline-mirror "./.yarn-offline-mirror" diff --git a/package.json b/package.json index 4c815f9..7c71499 100644 --- a/package.json +++ b/package.json @@ -85,9 +85,10 @@ "ts" ], "rootDir": "src", + "setupFiles": ["/../test/jest.setup.js"], "testRegex": ".spec.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.ts$": "ts-jest" }, "coverageDirectory": "../coverage", "testEnvironment": "node" diff --git a/src/config.service.spec.ts b/src/config.service.spec.ts new file mode 100644 index 0000000..f19d786 --- /dev/null +++ b/src/config.service.spec.ts @@ -0,0 +1,96 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as jsYaml from 'js-yaml'; +import * as os from 'os'; +import * as path from 'path'; + +import { ConfigService } from './config.service'; + +describe('ConfigService', () => { + let configDir: string; + let exitSpy: jest.SpyInstance; + const initialEnv = { ...process.env }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zqueue-config-')); + process.env.CONFIG_PATH = configDir; + delete process.env.APP_VERSION; + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + process.env = { ...initialEnv }; + }); + + it('initializes a default configuration file and exits when none exists', () => { + expect(() => new ConfigService()).toThrow('process.exit called'); + + expect(exitSpy).toHaveBeenCalledWith(1); + const configFile = path.join(configDir, 'config.yml'); + expect(fs.existsSync(configFile)).toBe(true); + const written = jsYaml.safeLoad(fs.readFileSync(configFile, 'utf8')); + expect(written).toHaveProperty('elasticsearch'); + expect(written).toHaveProperty('github'); + }); + + it('loads an existing configuration file', () => { + const userConfig = { + elasticsearch: { host: 'http://127.0.0.1:9200' }, + github: { webhook: { secret: 'fake-secret' } }, + }; + fs.writeFileSync( + path.join(configDir, 'config.yml'), + jsYaml.safeDump(userConfig), + ); + + const service = new ConfigService(); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(service.getUserConfig()).toEqual(userConfig); + }); + + it('exposes the environment configuration through get()', () => { + fs.writeFileSync( + path.join(configDir, 'config.yml'), + jsYaml.safeDump({ elasticsearch: {} }), + ); + + const service = new ConfigService(); + + expect(service.get('CONFIG_DIR')).toEqual(configDir); + expect(service.get('APP_VERSION')).toEqual('develop'); + }); + + it('reads the application version from the environment', () => { + process.env.APP_VERSION = '1.2.3'; + fs.writeFileSync( + path.join(configDir, 'config.yml'), + jsYaml.safeDump({ elasticsearch: {} }), + ); + + const service = new ConfigService(); + + expect(service.get('APP_VERSION')).toEqual('1.2.3'); + }); + + it('allows replacing the user configuration', () => { + fs.writeFileSync( + path.join(configDir, 'config.yml'), + jsYaml.safeDump({ elasticsearch: {} }), + ); + const service = new ConfigService(); + const newConfig = { elasticsearch: { host: 'http://other:9200' } } as any; + + service.setUserConfig(newConfig); + + expect(service.getUserConfig()).toEqual(newConfig); + }); +}); diff --git a/src/esClient.service.spec.ts b/src/esClient.service.spec.ts new file mode 100644 index 0000000..96fd490 --- /dev/null +++ b/src/esClient.service.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { Client } from '@elastic/elasticsearch'; +import * as fs from 'fs'; + +import { EsClientService } from './esClient.service'; +import { ConfigService } from './config.service'; + +jest.mock('@elastic/elasticsearch', () => ({ + Client: jest.fn(), +})); + +const buildConfigService = (elasticsearch) => + ({ + getUserConfig: () => ({ elasticsearch }), + } as ConfigService); + +describe('EsClientService', () => { + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('connects to Elastic Cloud when cloud credentials are provided', () => { + new EsClientService( + buildConfigService({ + cloudId: 'deployment:abcdef', + username: 'elastic', + password: 'fake-password', + host: 'http://127.0.0.1:9200', + }), + ); + + expect(Client).toHaveBeenCalledWith({ + cloud: { + id: 'deployment:abcdef', + username: 'elastic', + password: 'fake-password', + }, + }); + }); + + it('connects with an SSL certificate authority when configured', () => { + const readFileSyncSpy = jest + .spyOn(fs, 'readFileSync') + .mockReturnValue('FAKE-CA-CONTENT'); + + new EsClientService( + buildConfigService({ + host: 'https://es.internal:9200', + sslCa: '/path/to/ca.pem', + }), + ); + + expect(readFileSyncSpy).toHaveBeenCalledWith('/path/to/ca.pem'); + expect(Client).toHaveBeenCalledWith({ + node: 'https://es.internal:9200', + ssl: { ca: 'FAKE-CA-CONTENT' }, + }); + readFileSyncSpy.mockRestore(); + }); + + it('connects to a plain host when no cloud or ssl settings are provided', () => { + new EsClientService(buildConfigService({ host: 'http://127.0.0.1:9200' })); + + expect(Client).toHaveBeenCalledWith({ node: 'http://127.0.0.1:9200' }); + }); + + it('ignores incomplete cloud credentials', () => { + new EsClientService( + buildConfigService({ + cloudId: 'deployment:abcdef', + username: '', + password: '', + host: 'http://127.0.0.1:9200', + }), + ); + + expect(Client).toHaveBeenCalledWith({ node: 'http://127.0.0.1:9200' }); + }); + + it('exposes the created client', () => { + const service = new EsClientService( + buildConfigService({ host: 'http://127.0.0.1:9200' }), + ); + + expect(service.getEsClient()).toBe((Client as jest.Mock).mock.instances[0]); + }); +}); diff --git a/src/ghClient.service.spec.ts b/src/ghClient.service.spec.ts new file mode 100644 index 0000000..bf2c7ba --- /dev/null +++ b/src/ghClient.service.spec.ts @@ -0,0 +1,126 @@ +import { Logger } from '@nestjs/common'; + +import { GhClientService } from './ghClient.service'; +import { ConfigService } from './config.service'; + +const buildConfigService = () => + ({ + getUserConfig: () => ({ github: { token: 'fake-token-1234' } }), + } as ConfigService); + +describe('GhClientService', () => { + let service: GhClientService; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + service = new GhClientService(buildConfigService()); + // Avoid real 1s pauses between queries + (service as any).sleep = jest.fn().mockResolvedValue(undefined); + }); + + it('initializes an apollo client', () => { + expect(service.getGhClient()).toBeDefined(); + }); + + it('starts with a full rate limit', () => { + expect(service.getRateLimit()).toEqual({ + limit: 5000, + cost: 1, + remaining: 5000, + resetAt: null, + }); + }); + + it('stores an updated rate limit', () => { + const rateLimit = { limit: 5000, cost: 2, remaining: 42, resetAt: 'later' }; + + service.setRateLimit(rateLimit); + + expect(service.getRateLimit()).toEqual(rateLimit); + }); + + describe('graphqlQuery', () => { + it('returns the query result and tracks the rate limit', async () => { + const rateLimit = { + limit: 5000, + cost: 1, + remaining: 4900, + resetAt: '2026-01-01T00:00:00Z', + }; + const queryMock = jest.fn().mockResolvedValue({ + data: { nodes: ['node-1'], rateLimit }, + }); + (service.getGhClient() as any).query = queryMock; + + const result = await service.graphqlQuery('QUERY', { id: '1' }); + + expect(queryMock).toHaveBeenCalledWith({ + query: 'QUERY', + variables: { id: '1' }, + fetchPolicy: 'no-cache', + errorPolicy: 'ignore', + }); + expect(result.data.nodes).toEqual(['node-1']); + expect(service.getRateLimit()).toEqual(rateLimit); + }); + + it('logs graphql errors returned in the response', async () => { + const warnSpy = jest.spyOn(Logger.prototype, 'warn'); + (service.getGhClient() as any).query = jest.fn().mockResolvedValue({ + data: { + errors: [{ message: 'Something went wrong' }], + rateLimit: { limit: 5000, cost: 1, remaining: 4900, resetAt: 'x' }, + }, + }); + + await service.graphqlQuery('QUERY', {}); + + expect(warnSpy).toHaveBeenCalledWith('Something went wrong'); + }); + }); + + describe('fetchNodesById', () => { + it('returns the fetched nodes', async () => { + (service as any).graphqlQuery = jest + .fn() + .mockResolvedValue({ data: { nodes: ['node-1', 'node-2'] } }); + + const nodes = await service.fetchNodesById('QUERY', { + nodesArray: ['id-1'], + }); + + expect(nodes).toEqual(['node-1', 'node-2']); + }); + + it('retries on failure and eventually recovers', async () => { + (service as any).graphqlQuery = jest + .fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ data: { nodes: ['node-1'] } }); + + const nodes = await service.fetchNodesById('QUERY', { + nodesArray: ['id-1'], + }); + + expect(nodes).toEqual(['node-1']); + expect((service as any).graphqlQuery).toHaveBeenCalledTimes(2); + }); + + it('gives up after too many consecutive failures', async () => { + (service as any).graphqlQuery = jest.fn().mockResolvedValue({}); + + const result = await service.fetchNodesById('QUERY', { + nodesArray: ['id-1'], + }); + + expect(result).toEqual({}); + expect((service as any).graphqlQuery).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/src/github/fetchNode/index.spec.ts b/src/github/fetchNode/index.spec.ts new file mode 100644 index 0000000..1114272 --- /dev/null +++ b/src/github/fetchNode/index.spec.ts @@ -0,0 +1,87 @@ +import fetchNode from './index'; + +import processLabelPayload from './processLabel'; +import processIssuePayload from './processIssue'; +import processPullrequestPayload from './processPullrequest'; +import processMilestonePayload from './processMilestone'; +import processReleasePayload from './processRelease'; +import processProjectPayload from './processProject'; +import processRepositoryPayload from './processRepository'; +import processStargazerPayload from './processStargazer'; +import processWatcherPayload from './processWatcher'; + +jest.mock('./processLabel'); +jest.mock('./processIssue'); +jest.mock('./processPullrequest'); +jest.mock('./processMilestone'); +jest.mock('./processRelease'); +jest.mock('./processProject'); +jest.mock('./processRepository'); +jest.mock('./processStargazer'); +jest.mock('./processWatcher'); + +const processors: Array<[string, jest.Mock]> = [ + ['labels', processLabelPayload as jest.Mock], + ['issues', processIssuePayload as jest.Mock], + ['pullrequests', processPullrequestPayload as jest.Mock], + ['milestones', processMilestonePayload as jest.Mock], + ['releases', processReleasePayload as jest.Mock], + ['projects', processProjectPayload as jest.Mock], + ['repos', processRepositoryPayload as jest.Mock], + ['stargazers', processStargazerPayload as jest.Mock], + ['watchers', processWatcherPayload as jest.Mock], +]; + +describe('fetchNode dispatcher', () => { + const ghClientService = { fetchNodesById: jest.fn() }; + const esClient = {}; + const userConfig = {}; + const logger = { log: jest.fn(), debug: jest.fn() }; + const payload = { action: 'opened' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(processors)( + 'routes a %s event to its processor', + (eventType, processor) => { + fetchNode( + ghClientService, + esClient, + userConfig, + logger, + payload, + eventType, + ); + + expect(processor).toHaveBeenCalledWith( + ghClientService, + esClient, + userConfig, + logger, + payload, + ); + for (const [, other] of processors) { + if (other !== processor) { + expect(other).not.toHaveBeenCalled(); + } + } + }, + ); + + it('does nothing for an unknown event type', () => { + fetchNode( + ghClientService, + esClient, + userConfig, + logger, + payload, + 'unknown', + ); + + for (const [, processor] of processors) { + expect(processor).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/github/fetchNode/processIssue.spec.ts b/src/github/fetchNode/processIssue.spec.ts new file mode 100644 index 0000000..83e62c2 --- /dev/null +++ b/src/github/fetchNode/processIssue.spec.ts @@ -0,0 +1,166 @@ +import processIssuePayload from './processIssue'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-issues'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-issues', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubIssues: 'gh_issues' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + issue: { node_id: 'node-1' }, +}); + +const remoteNode = { + id: 'issue-id', + repository: { + id: 'repo-id', + name: 'zqueue', + owner: { login: 'zencrepes' }, + }, +}; + +describe('processIssuePayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processIssuePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'node-1', + index: 'gh_issues', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('deletes the node from Elasticsearch on a transferred action', async () => { + await processIssuePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('transferred'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'node-1', + index: 'gh_issues', + }); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_issues'); + const userConfig = buildUserConfig(); + + await processIssuePayload( + ghClientService, + esClient, + userConfig, + logger, + buildPayload('opened'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['node-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [remoteNode], + 'zqueue', + userConfig, + 'repo-id', + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_issues', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_issues', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_issues_zencrepes_zqueue'); + + await processIssuePayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('opened'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_issues', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processIssuePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('opened'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processLabel.spec.ts b/src/github/fetchNode/processLabel.spec.ts new file mode 100644 index 0000000..668eb2f --- /dev/null +++ b/src/github/fetchNode/processLabel.spec.ts @@ -0,0 +1,154 @@ +import processLabelPayload from './processLabel'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-labels'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-labels', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubLabels: 'gh_labels' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + label: { node_id: 'label-1' }, +}); + +const remoteNode = { + id: 'label-id', + repository: { + id: 'repo-id', + name: 'zqueue', + owner: { login: 'zencrepes' }, + }, +}; + +describe('processLabelPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processLabelPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'label-1', + index: 'gh_labels', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('survives a failed deletion (node already gone)', async () => { + esClient.delete.mockRejectedValue(new Error('document missing')); + + await expect( + processLabelPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ), + ).resolves.toBeUndefined(); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_labels'); + + await processLabelPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['label-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith([remoteNode], 'zqueue', 'repo-id'); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_labels', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_labels_zencrepes_zqueue'); + + await processLabelPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('created'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_labels', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processLabelPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processMilestone.spec.ts b/src/github/fetchNode/processMilestone.spec.ts new file mode 100644 index 0000000..a9e290c --- /dev/null +++ b/src/github/fetchNode/processMilestone.spec.ts @@ -0,0 +1,145 @@ +import processMilestonePayload from './processMilestone'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-milestones'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-milestones', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubMilestones: 'gh_milestones' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + milestone: { node_id: 'milestone-1' }, +}); + +const remoteNode = { + id: 'milestone-id', + repository: { + id: 'repo-id', + name: 'zqueue', + owner: { login: 'zencrepes' }, + }, +}; + +describe('processMilestonePayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processMilestonePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'milestone-1', + index: 'gh_milestones', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_milestones'); + + await processMilestonePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['milestone-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith([remoteNode], 'zqueue', 'repo-id'); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_milestones', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_milestones', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_milestones_zencrepes_zqueue'); + + await processMilestonePayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('created'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_milestones', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processMilestonePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processProject.spec.ts b/src/github/fetchNode/processProject.spec.ts new file mode 100644 index 0000000..42bdf84 --- /dev/null +++ b/src/github/fetchNode/processProject.spec.ts @@ -0,0 +1,226 @@ +import processProjectPayload from './processProject'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-projects'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-projects', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubProjects: 'gh_projects' }, + }, +}); + +const buildOrgPayload = (action: string) => ({ + action, + project: { node_id: 'project-1' }, + organization: { node_id: 'org-id', login: 'zencrepes' }, +}); + +const buildRepoPayload = (action: string) => ({ + action, + project: { node_id: 'project-1' }, + repository: { + node_id: 'repo-id', + name: 'zqueue', + owner: { node_id: 'owner-id', login: 'zencrepes' }, + }, +}); + +const expectedRepoObj = { + id: 'repo-id', + name: 'zqueue', + url: 'https://github.com/zencrepes/zqueue', + owner: { + id: 'owner-id', + login: 'zencrepes', + url: 'https://github.com/zencrepes', + }, +}; + +const remoteNode = { id: 'project-id' }; + +describe('processProjectPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildRepoPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'project-1', + index: 'gh_projects', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('processes a repo-level project and pushes it to Elasticsearch', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_projects'); + + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildRepoPayload('created'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['project-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [remoteNode], + 'zindexer', + 'repository', + 'repo-id', + expectedRepoObj.owner, + expectedRepoObj, + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_projects', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_projects', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('processes an org-level project and pushes it to Elasticsearch', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildOrgPayload('created'), + ); + + expect(ingestNodes).toHaveBeenCalledWith( + [remoteNode], + 'zqueue', + 'organization', + null, + { + id: 'org-id', + login: 'zencrepes', + url: 'https://github.com/zencrepes', + }, + null, + ); + expect(getEsIndex).not.toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_projects', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('uses a suffixed index for an org-level project with one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildOrgPayload('created'), + ); + + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_projectsabcd', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_projects', + expect.any(Function), + ); + }); + + it('creates an index alias for a repo-level project with one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_projects_zencrepes_zqueue'); + + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildRepoPayload('created'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_projects', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processProjectPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildRepoPayload('created'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processPullrequest.spec.ts b/src/github/fetchNode/processPullrequest.spec.ts new file mode 100644 index 0000000..4430320 --- /dev/null +++ b/src/github/fetchNode/processPullrequest.spec.ts @@ -0,0 +1,151 @@ +import processPullrequestPayload from './processPullrequest'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-pullrequests'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-pullrequests', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubPullrequests: 'gh_prs' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + pull_request: { node_id: 'pr-1' }, +}); + +const remoteNode = { + id: 'pr-id', + repository: { + id: 'repo-id', + name: 'zqueue', + owner: { login: 'zencrepes' }, + }, +}; + +describe('processPullrequestPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processPullrequestPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'pr-1', + index: 'gh_prs', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_prs'); + const userConfig = buildUserConfig(); + + await processPullrequestPayload( + ghClientService, + esClient, + userConfig, + logger, + buildPayload('opened'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['pr-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [remoteNode], + 'zqueue', + userConfig, + 'repo-id', + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_prs', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_prs', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_prs_zencrepes_zqueue'); + + await processPullrequestPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('opened'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_prs', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processPullrequestPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('opened'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processRelease.spec.ts b/src/github/fetchNode/processRelease.spec.ts new file mode 100644 index 0000000..5b76173 --- /dev/null +++ b/src/github/fetchNode/processRelease.spec.ts @@ -0,0 +1,159 @@ +import processReleasePayload from './processRelease'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-releases'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-releases', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubReleases: 'gh_releases' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + release: { node_id: 'release-1' }, + repository: { + node_id: 'repo-id', + name: 'zqueue', + owner: { node_id: 'owner-id', login: 'zencrepes' }, + }, +}); + +const expectedRepoObj = { + id: 'repo-id', + name: 'zqueue', + url: 'https://github.com/zencrepes/zqueue', + owner: { + id: 'owner-id', + login: 'zencrepes', + url: 'https://github.com/zencrepes', + }, +}; + +const remoteNode = { id: 'release-id' }; + +describe('processReleasePayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processReleasePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'release-1', + index: 'gh_releases', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_releases'); + + await processReleasePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('published'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['release-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [remoteNode], + 'zqueue', + 'repo-id', + expectedRepoObj, + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_releases', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_releases', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_releases_zencrepes_zqueue'); + + await processReleasePayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('published'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_releases', + expect.any(Function), + ); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processReleasePayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('published'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processRepository.spec.ts b/src/github/fetchNode/processRepository.spec.ts new file mode 100644 index 0000000..a1c46df --- /dev/null +++ b/src/github/fetchNode/processRepository.spec.ts @@ -0,0 +1,160 @@ +import processRepositoryPayload from './processRepository'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-repos'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-repos', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubRepos: 'gh_repos' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + repository: { node_id: 'repo-1' }, +}); + +const remoteNode = { + id: 'repo-id', + name: 'zqueue', + owner: { login: 'zencrepes' }, +}; + +describe('processRepositoryPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processRepositoryPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'repo-1', + index: 'gh_repos', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('fetches the node and pushes it to Elasticsearch on other actions', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_repos'); + + await processRepositoryPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('edited'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['repo-1'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith([remoteNode], 'zqueue'); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_repos', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_repos', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_repos_zencrepes_zqueue'); + + await processRepositoryPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('edited'), + ); + + // The source reads dataIndices.GithubRepos (capital G), which is not + // defined in the configuration, so the alias name is undefined. + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + undefined, + expect.any(Function), + ); + }); + + it('still attempts the alias when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processRepositoryPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('edited'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + expect(aliasEsIndex).toHaveBeenCalled(); + }); + + it('does not push anything when the node cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processRepositoryPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('edited'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processStargazer.spec.ts b/src/github/fetchNode/processStargazer.spec.ts new file mode 100644 index 0000000..483517e --- /dev/null +++ b/src/github/fetchNode/processStargazer.spec.ts @@ -0,0 +1,181 @@ +import processStargazerPayload from './processStargazer'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-stargazers'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-stargazers', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubStargazers: 'gh_stargazers' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + repository: { + node_id: 'repo-id', + name: 'zqueue', + owner: { node_id: 'owner-id', login: 'zencrepes' }, + }, + sender: { node_id: 'user-id' }, + starred_at: '2026-07-03T00:00:00Z', +}); + +const expectedRepoObj = { + id: 'repo-id', + name: 'zqueue', + url: 'https://github.com/zencrepes/zqueue', + owner: { + id: 'owner-id', + login: 'zencrepes', + url: 'https://github.com/zencrepes', + }, +}; + +const remoteNode = { id: 'user-id', login: 'octocat' }; + +describe('processStargazerPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processStargazerPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'stargazers-repo-iduser-id', + index: 'gh_stargazers', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('survives a failed deletion (node already gone)', async () => { + esClient.delete.mockRejectedValue(new Error('document missing')); + + await expect( + processStargazerPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ), + ).resolves.toBeUndefined(); + }); + + it('fetches the sender, decorates it and pushes it to Elasticsearch', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_stargazers'); + + await processStargazerPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['user-id'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [ + { + ...remoteNode, + repository: expectedRepoObj, + starredAt: '2026-07-03T00:00:00Z', + }, + ], + 'zqueue', + 'repo-id', + expectedRepoObj, + 'stargazers', + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_stargazers', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_stargazers', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_stargazers_zencrepes_zqueue'); + + await processStargazerPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('created'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_stargazers', + expect.any(Function), + ); + }); + + it('does not push anything when the sender cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processStargazerPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('created'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/fetchNode/processWatcher.spec.ts b/src/github/fetchNode/processWatcher.spec.ts new file mode 100644 index 0000000..043e1ca --- /dev/null +++ b/src/github/fetchNode/processWatcher.spec.ts @@ -0,0 +1,181 @@ +import processWatcherPayload from './processWatcher'; + +import { ingestNodes } from '@bit/zencrepes.zindexer.github-watchers'; +import { + getEsIndex, + checkEsIndex, + pushEsNodes, + aliasEsIndex, +} from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.github-watchers', () => ({ + fetchNodesById: 'FETCH_NODES_QUERY', + ingestNodes: jest.fn(), + esMapping: { mappings: {} }, + esSettings: { settings: {} }, +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + getEsIndex: jest.fn(), + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), + aliasEsIndex: jest.fn(), +})); + +const buildUserConfig = (oneIndexPerSource = false) => ({ + elasticsearch: { + oneIndexPerSource, + dataIndices: { githubWatchers: 'gh_watchers' }, + }, +}); + +const buildPayload = (action: string) => ({ + action, + repository: { + node_id: 'repo-id', + name: 'zqueue', + owner: { node_id: 'owner-id', login: 'zencrepes' }, + }, + sender: { node_id: 'user-id' }, +}); + +const expectedRepoObj = { + id: 'repo-id', + name: 'zqueue', + url: 'https://github.com/zencrepes/zqueue', + owner: { + id: 'owner-id', + login: 'zencrepes', + url: 'https://github.com/zencrepes', + }, +}; + +const remoteNode = { id: 'user-id', login: 'octocat' }; + +describe('processWatcherPayload', () => { + let ghClientService: { fetchNodesById: jest.Mock }; + let esClient: { delete: jest.Mock }; + let logger: { log: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + ghClientService = { fetchNodesById: jest.fn().mockResolvedValue([]) }; + esClient = { delete: jest.fn().mockResolvedValue({}) }; + logger = { log: jest.fn(), debug: jest.fn() }; + }); + + afterEach(() => { + (console.log as jest.Mock).mockRestore(); + }); + + it('deletes the node from Elasticsearch on a deleted action', async () => { + await processWatcherPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ); + + expect(esClient.delete).toHaveBeenCalledWith({ + id: 'watchers-repo-iduser-id', + index: 'gh_watchers', + }); + expect(ghClientService.fetchNodesById).not.toHaveBeenCalled(); + }); + + it('survives a failed deletion (node already gone)', async () => { + esClient.delete.mockRejectedValue(new Error('document missing')); + + await expect( + processWatcherPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('deleted'), + ), + ).resolves.toBeUndefined(); + }); + + it('fetches the sender, decorates it and pushes it to Elasticsearch', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_watchers'); + + await processWatcherPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('started'), + ); + + expect(ghClientService.fetchNodesById).toHaveBeenCalledWith( + 'FETCH_NODES_QUERY', + { nodesArray: ['user-id'] }, + ); + expect(ingestNodes).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: 'user-id', + login: 'octocat', + repository: expectedRepoObj, + watchedAt: expect.any(String), + }), + ], + 'zqueue', + 'repo-id', + expectedRepoObj, + 'watchers', + ); + expect(getEsIndex).toHaveBeenCalledWith( + 'gh_watchers', + false, + 'zencrepes/zqueue', + ); + expect(checkEsIndex).toHaveBeenCalled(); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_watchers', + ['ingested-node'], + expect.any(Function), + ); + expect(aliasEsIndex).not.toHaveBeenCalled(); + }); + + it('creates an index alias when using one index per source', async () => { + ghClientService.fetchNodesById.mockResolvedValue([remoteNode]); + (ingestNodes as jest.Mock).mockReturnValue(['ingested-node']); + (getEsIndex as jest.Mock).mockReturnValue('gh_watchers_zencrepes_zqueue'); + + await processWatcherPayload( + ghClientService, + esClient, + buildUserConfig(true), + logger, + buildPayload('started'), + ); + + expect(aliasEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_watchers', + expect.any(Function), + ); + }); + + it('does not push anything when the sender cannot be fetched', async () => { + ghClientService.fetchNodesById.mockResolvedValue([]); + + await processWatcherPayload( + ghClientService, + esClient, + buildUserConfig(), + logger, + buildPayload('started'), + ); + + expect(pushEsNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/github.controller.spec.ts b/src/github/github.controller.spec.ts new file mode 100644 index 0000000..d9a01d6 --- /dev/null +++ b/src/github/github.controller.spec.ts @@ -0,0 +1,200 @@ +import { Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; + +import { GithubController } from './github.controller'; +import { ConfigService } from '../config.service'; +import { WebhookPayload } from './webhookPayload.type'; + +const SECRET = 'test-webhook-secret'; + +const sign = (payload: WebhookPayload, secret: string = SECRET) => { + const hmac = crypto.createHmac('sha1', secret); + return 'sha1=' + hmac.update(JSON.stringify(payload)).digest('hex'); +}; + +const buildUserConfig = (webhookOverrides = {}) => ({ + github: { + webhook: { + secret: SECRET, + events: [ + { githubEvent: 'issues', zencrepesEntity: 'issues' }, + { githubEvent: 'label', zencrepesEntity: 'labels' }, + ], + fetchNode: { includeGithubEvents: ['*'], excludeGithubEvents: [] }, + nodePayload: { includeGithubEvents: ['*'], excludeGithubEvents: [] }, + timelinePayload: { includeGithubEvents: ['*'], excludeGithubEvents: [] }, + ...webhookOverrides, + }, + }, +}); + +describe('GithubController', () => { + let controller: GithubController; + let githubQueue: { add: jest.Mock }; + let storePayloadQueue: { add: jest.Mock }; + let storeRawPayloadQueue: { add: jest.Mock }; + let userConfig; + + const callWebhook = (payload: WebhookPayload, headers = {}) => { + return controller.processWebhook(payload, {} as any, headers as any); + }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + githubQueue = { add: jest.fn() }; + storePayloadQueue = { add: jest.fn() }; + storeRawPayloadQueue = { add: jest.fn() }; + userConfig = buildUserConfig(); + const configService = { + getUserConfig: () => userConfig, + } as ConfigService; + controller = new GithubController( + githubQueue as any, + storePayloadQueue as any, + storeRawPayloadQueue as any, + configService, + ); + }); + + it('ignores requests with an invalid signature', async () => { + const payload = { action: 'opened', issue: { node_id: 'abc' } }; + + await callWebhook(payload, { + 'x-github-event': 'issues', + 'x-hub-signature': sign(payload, 'wrong-secret'), + }); + + expect(githubQueue.add).not.toHaveBeenCalled(); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + expect(storeRawPayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('ignores requests without a signature header', async () => { + const payload = { action: 'opened', issue: { node_id: 'abc' } }; + + await callWebhook(payload, { 'x-github-event': 'issues' }); + + expect(githubQueue.add).not.toHaveBeenCalled(); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + expect(storeRawPayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('does nothing on a ping event', async () => { + const payload = { action: 'ping' } as WebhookPayload; + + await callWebhook(payload, { + 'x-github-event': 'ping', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).not.toHaveBeenCalled(); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + expect(storeRawPayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('queues a known event on all three queues with its zencrepes entity', async () => { + const payload = { action: 'opened', issue: { node_id: 'abc' } }; + + await callWebhook(payload, { + 'x-github-event': 'issues', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).toHaveBeenCalledWith('fetchRemoteNode', { + payload, + eventType: 'issues', + }); + expect(storePayloadQueue.add).toHaveBeenCalledWith('store', { + payload, + eventType: 'issues', + }); + expect(storeRawPayloadQueue.add).toHaveBeenCalledWith('store', { + payload, + eventType: 'issues', + }); + }); + + it('maps the github event to its configured zencrepes entity', async () => { + const payload = { action: 'created', label: { node_id: 'lbl' } }; + + await callWebhook(payload, { + 'x-github-event': 'label', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).toHaveBeenCalledWith('fetchRemoteNode', { + payload, + eventType: 'labels', + }); + }); + + it('does not queue a node fetch for events absent from the events mapping', async () => { + const payload = { action: 'completed' } as WebhookPayload; + + await callWebhook(payload, { + 'x-github-event': 'workflow_run', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).not.toHaveBeenCalled(); + // Payload storage does not depend on the events mapping + expect(storePayloadQueue.add).toHaveBeenCalledWith('store', { + payload, + eventType: 'workflow_run', + }); + expect(storeRawPayloadQueue.add).toHaveBeenCalledWith('store', { + payload, + eventType: 'workflow_run', + }); + }); + + it('skips queues whose configuration excludes the event', async () => { + userConfig = buildUserConfig({ + fetchNode: { + includeGithubEvents: ['*'], + excludeGithubEvents: ['issues'], + }, + nodePayload: { + includeGithubEvents: ['*'], + excludeGithubEvents: ['issues'], + }, + timelinePayload: { includeGithubEvents: ['*'], excludeGithubEvents: [] }, + }); + const payload = { action: 'opened', issue: { node_id: 'abc' } }; + + await callWebhook(payload, { + 'x-github-event': 'issues', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).not.toHaveBeenCalled(); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + expect(storeRawPayloadQueue.add).toHaveBeenCalled(); + }); + + it('processes an explicitly included event even when everything else is excluded', async () => { + userConfig = buildUserConfig({ + fetchNode: { + includeGithubEvents: ['issues'], + excludeGithubEvents: ['*'], + }, + nodePayload: { includeGithubEvents: [], excludeGithubEvents: ['*'] }, + timelinePayload: { includeGithubEvents: [], excludeGithubEvents: ['*'] }, + }); + const payload = { action: 'opened', issue: { node_id: 'abc' } }; + + await callWebhook(payload, { + 'x-github-event': 'issues', + 'x-hub-signature': sign(payload), + }); + + expect(githubQueue.add).toHaveBeenCalled(); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + expect(storeRawPayloadQueue.add).not.toHaveBeenCalled(); + }); +}); diff --git a/src/github/github.processor.spec.ts b/src/github/github.processor.spec.ts new file mode 100644 index 0000000..1d62c47 --- /dev/null +++ b/src/github/github.processor.spec.ts @@ -0,0 +1,44 @@ +import { Logger } from '@nestjs/common'; + +import { GithubProcessor } from './github.processor'; +import fetchNode from './fetchNode/index'; + +jest.mock('./fetchNode/index'); + +describe('GithubProcessor', () => { + const esClient = { search: jest.fn() }; + const userConfig = { github: {} }; + const configService = { getUserConfig: () => userConfig }; + const esClientService = { getEsClient: () => esClient }; + const ghClientService = { fetchNodesById: jest.fn() }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates a fetchRemoteNode job to the fetchNode dispatcher', () => { + const processor = new GithubProcessor( + configService as any, + ghClientService as any, + esClientService as any, + ); + const job = { + data: { payload: { action: 'opened' }, eventType: 'issues' }, + }; + + processor.handlFetchNode(job as any); + + expect(fetchNode).toHaveBeenCalledWith( + ghClientService, + esClient, + userConfig, + expect.any(Logger), + { action: 'opened' }, + 'issues', + ); + }); +}); diff --git a/src/github/githubStorePayload.processor.spec.ts b/src/github/githubStorePayload.processor.spec.ts new file mode 100644 index 0000000..3ed54d4 --- /dev/null +++ b/src/github/githubStorePayload.processor.spec.ts @@ -0,0 +1,41 @@ +import { Logger } from '@nestjs/common'; + +import { GithubStorePayloadProcessor } from './githubStorePayload.processor'; +import storePayload from './storePayload/index'; + +jest.mock('./storePayload/index'); + +describe('GithubStorePayloadProcessor', () => { + const esClient = { search: jest.fn() }; + const userConfig = { github: {} }; + const configService = { getUserConfig: () => userConfig }; + const esClientService = { getEsClient: () => esClient }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates a store job to storePayload', () => { + const processor = new GithubStorePayloadProcessor( + configService as any, + esClientService as any, + ); + const job = { + data: { payload: { action: 'opened' }, eventType: 'issues' }, + }; + + processor.pushNodeToEs(job as any); + + expect(storePayload).toHaveBeenCalledWith( + esClient, + userConfig, + expect.any(Logger), + { action: 'opened' }, + 'issues', + ); + }); +}); diff --git a/src/github/githubStoreRawPayload.processor.spec.ts b/src/github/githubStoreRawPayload.processor.spec.ts new file mode 100644 index 0000000..3f1e206 --- /dev/null +++ b/src/github/githubStoreRawPayload.processor.spec.ts @@ -0,0 +1,41 @@ +import { Logger } from '@nestjs/common'; + +import { GithubStoreRawPayloadProcessor } from './githubStoreRawPayload.processor'; +import storeRawPayload from './storeRawPayload/index'; + +jest.mock('./storeRawPayload/index'); + +describe('GithubStoreRawPayloadProcessor', () => { + const esClient = { search: jest.fn() }; + const userConfig = { github: {} }; + const configService = { getUserConfig: () => userConfig }; + const esClientService = { getEsClient: () => esClient }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates a store job to storeRawPayload', () => { + const processor = new GithubStoreRawPayloadProcessor( + configService as any, + esClientService as any, + ); + const job = { + data: { payload: { action: 'opened' }, eventType: 'issues' }, + }; + + processor.pushRawToEs(job as any); + + expect(storeRawPayload).toHaveBeenCalledWith( + esClient, + userConfig, + expect.any(Logger), + { action: 'opened' }, + 'issues', + ); + }); +}); diff --git a/src/github/storePayload/index.spec.ts b/src/github/storePayload/index.spec.ts new file mode 100644 index 0000000..85f48a5 --- /dev/null +++ b/src/github/storePayload/index.spec.ts @@ -0,0 +1,63 @@ +import storePayload from './index'; + +import { checkEsIndex, pushEsNodes } from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), +})); + +const UUID_V4_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const userConfig = { + github: { + webhook: { + nodePayload: { esIndexPrefix: 'gh_payload_' }, + }, + }, +}; + +describe('storePayload', () => { + const esClient = {}; + const logger = { log: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('checks the index derived from the configured prefix and event type', async () => { + await storePayload(esClient, userConfig, logger, { action: 'x' }, 'issues'); + + expect(checkEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_payload_issues', + expect.anything(), + expect.anything(), + expect.any(Function), + ); + }); + + it('uses the entity node_id as document id when present', async () => { + const payload = { action: 'opened', issue: { node_id: 'node-42' } }; + + await storePayload(esClient, userConfig, logger, payload, 'issue'); + + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'gh_payload_issue', + [{ id: 'node-42', ...payload }], + expect.any(Function), + ); + }); + + it('falls back to a generated uuid when the payload has no node id', async () => { + const payload = { action: 'completed' }; + + await storePayload(esClient, userConfig, logger, payload, 'workflow_run'); + + const pushedNodes = (pushEsNodes as jest.Mock).mock.calls[0][2]; + expect(pushedNodes).toHaveLength(1); + expect(pushedNodes[0].id).toMatch(UUID_V4_REGEXP); + expect(pushedNodes[0].action).toEqual('completed'); + }); +}); diff --git a/src/github/storeRawPayload/index.spec.ts b/src/github/storeRawPayload/index.spec.ts new file mode 100644 index 0000000..9131202 --- /dev/null +++ b/src/github/storeRawPayload/index.spec.ts @@ -0,0 +1,67 @@ +import storeRawPayload from './index'; + +import { checkEsIndex, pushEsNodes } from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), +})); + +const UUID_V4_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const userConfig = { + github: { + webhook: { + timelinePayload: { esIndexPrefix: 'gh_timeline_' }, + }, + }, +}; + +describe('storeRawPayload', () => { + const esClient = {}; + const logger = { log: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('checks the index derived from the configured prefix and event type', async () => { + await storeRawPayload( + esClient, + userConfig, + logger, + { action: 'x' }, + 'issues', + ); + + expect(checkEsIndex).toHaveBeenCalledWith( + esClient, + 'gh_timeline_issues', + expect.anything(), + expect.anything(), + expect.any(Function), + ); + }); + + it('pushes the payload with a generated id and a received date', async () => { + const payload = { action: 'opened', issue: { node_id: 'node-42' } }; + + await storeRawPayload(esClient, userConfig, logger, payload, 'issues'); + + const pushedNodes = (pushEsNodes as jest.Mock).mock.calls[0][2]; + expect(pushedNodes).toHaveLength(1); + expect(pushedNodes[0].id).toMatch(UUID_V4_REGEXP); + expect(pushedNodes[0].dateReceived).toEqual(expect.any(String)); + expect(pushedNodes[0].action).toEqual('opened'); + expect(pushedNodes[0].issue).toEqual({ node_id: 'node-42' }); + }); + + it('generates a different id for every payload', async () => { + await storeRawPayload(esClient, userConfig, logger, { a: 1 }, 'issues'); + await storeRawPayload(esClient, userConfig, logger, { a: 1 }, 'issues'); + + const first = (pushEsNodes as jest.Mock).mock.calls[0][2][0]; + const second = (pushEsNodes as jest.Mock).mock.calls[1][2][0]; + expect(first.id).not.toEqual(second.id); + }); +}); diff --git a/src/perfs/perfs.controller.spec.ts b/src/perfs/perfs.controller.spec.ts new file mode 100644 index 0000000..e537c8e --- /dev/null +++ b/src/perfs/perfs.controller.spec.ts @@ -0,0 +1,68 @@ +import { Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; + +import { PerfsController } from './perfs.controller'; +import { ConfigService } from '../config.service'; + +const SECRET = 'test-perfs-secret'; + +const sign = (payload, secret: string = SECRET) => { + const hmac = crypto.createHmac('sha1', secret); + return 'sha1=' + hmac.update(JSON.stringify(payload)).digest('hex'); +}; + +describe('PerfsController', () => { + let controller: PerfsController; + let storePayloadQueue: { add: jest.Mock }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + storePayloadQueue = { add: jest.fn() }; + const configService = { + getUserConfig: () => ({ + testing: { webhook: { secret: SECRET } }, + }), + } as ConfigService; + controller = new PerfsController(storePayloadQueue as any, configService); + }); + + it('rejects a payload with an invalid signature', async () => { + const payload = { name: 'my-perf-run' } as any; + + const result = await controller.processWebhook(payload, {} as any, { + 'x-hub-signature': sign(payload, 'wrong-secret'), + } as any); + + expect(result).toEqual({ success: false }); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('rejects a payload without a signature header', async () => { + const payload = { name: 'my-perf-run' } as any; + + const result = await controller.processWebhook( + payload, + {} as any, + {} as any, + ); + + expect(result).toEqual({ success: false }); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('queues a correctly signed payload', async () => { + const payload = { name: 'my-perf-run' } as any; + + const result = await controller.processWebhook(payload, {} as any, { + 'x-hub-signature': sign(payload), + } as any); + + expect(result).toEqual({ success: true }); + expect(storePayloadQueue.add).toHaveBeenCalledWith('perfsstore', payload); + }); +}); diff --git a/src/perfs/perfsStorePayload.processor.spec.ts b/src/perfs/perfsStorePayload.processor.spec.ts new file mode 100644 index 0000000..92c1e2f --- /dev/null +++ b/src/perfs/perfsStorePayload.processor.spec.ts @@ -0,0 +1,140 @@ +import { Logger } from '@nestjs/common'; + +import { PerfsStorePayloadProcessor } from './perfsStorePayload.processor'; +import { checkEsIndex, pushEsNodes } from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.testing-perfs', () => ({ + esMapping: { mappings: {} }, + esSettings: { settings: {} }, + getId: jest.fn((node) => `perf-id-${node.name}`), +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), +})); + +const userConfig = { + elasticsearch: { + dataIndices: { testingPerfs: 'testing_perfs' }, + }, +}; + +const buildJobData = (overrides = {}) => ({ + name: 'load-test', + resources: [{ name: 'small-vm', size: '2cpu' }], + runs: [ + { + name: 'run-1', + statistics: { + stat1: { transaction: 'login', mean: 12 }, + stat2: { transaction: 'checkout', mean: 40 }, + }, + }, + { + name: 'run-2', + statistics: { + stat1: { transaction: 'login', mean: 14 }, + }, + }, + ], + ...overrides, +}); + +describe('PerfsStorePayloadProcessor', () => { + let processor: PerfsStorePayloadProcessor; + const esClient = {}; + const configService = { getUserConfig: () => userConfig }; + const esClientService = { getEsClient: () => esClient }; + + const pushedState = () => (pushEsNodes as jest.Mock).mock.calls[0][2][0]; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + processor = new PerfsStorePayloadProcessor( + configService as any, + esClientService as any, + ); + }); + + it('pushes a single transformed document to the perfs index', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + expect(checkEsIndex).toHaveBeenCalledWith( + esClient, + 'testing_perfs', + expect.anything(), + expect.anything(), + expect.any(Function), + ); + expect(pushEsNodes).toHaveBeenCalledWith( + esClient, + 'testing_perfs', + [expect.objectContaining({ id: 'perf-id-load-test' })], + expect.any(Function), + ); + }); + + it('collects unique transaction names across all runs', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + expect(pushedState().transactions).toEqual({ + edges: [{ node: { name: 'login' } }, { node: { name: 'checkout' } }], + totalCount: 2, + }); + }); + + it('transforms resources with a name_size identifier', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + expect(pushedState().resources).toEqual({ + edges: [ + { + node: expect.objectContaining({ + name: 'small-vm', + size: '2cpu', + name_size: 'small-vm_2cpu', + }), + }, + ], + totalCount: 1, + }); + }); + + it('defaults to an empty description and tags when absent', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + expect(pushedState().description).toEqual(''); + expect(pushedState().tags).toEqual({ edges: [], totalCount: 0 }); + }); + + it('maps tags when provided', async () => { + const jobData = buildJobData({ + description: 'nightly perf run', + tags: [{ name: 'nightly' }, { name: 'baseline' }], + }); + + await processor.pushNodeToEs({ data: jobData } as any); + + expect(pushedState().description).toEqual('nightly perf run'); + expect(pushedState().tags).toEqual({ + edges: [{ node: { name: 'nightly' } }, { node: { name: 'baseline' } }], + totalCount: 2, + }); + }); + + it('flattens run statistics into an array', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + const runs = pushedState().runs; + expect(runs.totalCount).toEqual(2); + expect(runs.edges[0].node.statistics).toEqual([ + { transaction: 'login', mean: 12 }, + { transaction: 'checkout', mean: 40 }, + ]); + }); +}); diff --git a/src/testing/cases/utils.spec.ts b/src/testing/cases/utils.spec.ts new file mode 100644 index 0000000..28f87a6 --- /dev/null +++ b/src/testing/cases/utils.spec.ts @@ -0,0 +1,50 @@ +import { getId, getCaseId } from './utils'; + +const UUID_V5_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('testing cases utils', () => { + describe('getId', () => { + it('generates a uuid v5', () => { + const id = getId({ name: 'my-app', version: '1.0.0' }); + expect(id).toMatch(UUID_V5_REGEXP); + }); + + it('is deterministic for the same dependency', () => { + const a = getId({ name: 'my-app', version: '1.0.0' }); + const b = getId({ name: 'my-app', version: '1.0.0' }); + expect(a).toEqual(b); + }); + + it('changes when the version changes', () => { + const a = getId({ name: 'my-app', version: '1.0.0' }); + const b = getId({ name: 'my-app', version: '2.0.0' }); + expect(a).not.toEqual(b); + }); + + it('ignores case and non-alphanumerical characters', () => { + const a = getId({ name: 'My-App', version: '1.0.0' }); + const b = getId({ name: 'myapp', version: '100' }); + expect(a).toEqual(b); + }); + }); + + describe('getCaseId', () => { + const testCase = { + id: 'case-1', + createdAt: '2026-01-01T00:00:00Z', + } as any; + + it('generates a uuid v5', () => { + expect(getCaseId(testCase)).toMatch(UUID_V5_REGEXP); + }); + + it('is deterministic for the same case', () => { + expect(getCaseId(testCase)).toEqual(getCaseId({ ...testCase })); + }); + + it('changes when the creation date changes', () => { + const other = { ...testCase, createdAt: '2026-01-02T00:00:00Z' }; + expect(getCaseId(testCase)).not.toEqual(getCaseId(other)); + }); + }); +}); diff --git a/src/testing/testing.controller.spec.ts b/src/testing/testing.controller.spec.ts new file mode 100644 index 0000000..fabe1d7 --- /dev/null +++ b/src/testing/testing.controller.spec.ts @@ -0,0 +1,68 @@ +import { Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; + +import { TestingController } from './testing.controller'; +import { ConfigService } from '../config.service'; + +const SECRET = 'test-states-secret'; + +const sign = (payload, secret: string = SECRET) => { + const hmac = crypto.createHmac('sha1', secret); + return 'sha1=' + hmac.update(JSON.stringify(payload)).digest('hex'); +}; + +describe('TestingController', () => { + let controller: TestingController; + let storePayloadQueue: { add: jest.Mock }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + storePayloadQueue = { add: jest.fn() }; + const configService = { + getUserConfig: () => ({ + testing: { webhook: { secret: SECRET } }, + }), + } as ConfigService; + controller = new TestingController(storePayloadQueue as any, configService); + }); + + it('rejects a payload with an invalid signature', async () => { + const payload = { name: 'my-component', version: '1.0.0' } as any; + + const result = await controller.processWebhook(payload, {} as any, { + 'x-hub-signature': sign(payload, 'wrong-secret'), + } as any); + + expect(result).toEqual({ success: false }); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('rejects a payload without a signature header', async () => { + const payload = { name: 'my-component', version: '1.0.0' } as any; + + const result = await controller.processWebhook( + payload, + {} as any, + {} as any, + ); + + expect(result).toEqual({ success: false }); + expect(storePayloadQueue.add).not.toHaveBeenCalled(); + }); + + it('queues a correctly signed payload', async () => { + const payload = { name: 'my-component', version: '1.0.0' } as any; + + const result = await controller.processWebhook(payload, {} as any, { + 'x-hub-signature': sign(payload), + } as any); + + expect(result).toEqual({ success: true }); + expect(storePayloadQueue.add).toHaveBeenCalledWith('testingstore', payload); + }); +}); diff --git a/src/testing/testingStorePayload.processor.spec.ts b/src/testing/testingStorePayload.processor.spec.ts new file mode 100644 index 0000000..b9ebab2 --- /dev/null +++ b/src/testing/testingStorePayload.processor.spec.ts @@ -0,0 +1,161 @@ +import { Logger } from '@nestjs/common'; + +import { TestingStorePayloadProcessor } from './testingStorePayload.processor'; +import { checkEsIndex, pushEsNodes } from '@bit/zencrepes.zindexer.es-utils'; + +jest.mock('@bit/zencrepes.zindexer.testing-states', () => ({ + esMapping: { mappings: {} }, + esSettings: { settings: {} }, + getId: jest.fn((d) => `dep-id-${d.name}`), +})); + +jest.mock('@bit/zencrepes.zindexer.testing-runs', () => ({ + esMapping: { mappings: {} }, + esSettings: { settings: {} }, + getRunId: jest.fn(() => 'run-id-1'), +})); + +jest.mock('@bit/zencrepes.zindexer.es-utils', () => ({ + checkEsIndex: jest.fn(), + pushEsNodes: jest.fn(), +})); + +const userConfig = { + elasticsearch: { + dataIndices: { + testingStates: 'testing_states', + testingRuns: 'testing_runs', + testingCases: 'testing_cases', + }, + }, +}; + +const buildJobData = (overrides = {}) => ({ + name: 'my-component', + version: '1.0.0', + url: 'https://ci.example.com/run/1', + runSuccess: 3, + runFailure: 1, + runTotal: 4, + dependencies: [{ name: 'dep-a', version: '2.0.0' }], + ...overrides, +}); + +describe('TestingStorePayloadProcessor', () => { + let processor: TestingStorePayloadProcessor; + const esClient = {}; + const configService = { getUserConfig: () => userConfig }; + const esClientService = { getEsClient: () => esClient }; + + const pushedNodesForIndex = (index: string) => { + const call = (pushEsNodes as jest.Mock).mock.calls.find( + (c) => c[1] === index, + ); + return call !== undefined ? call[2] : undefined; + }; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + processor = new TestingStorePayloadProcessor( + configService as any, + esClientService as any, + ); + }); + + it('pushes a state and a run document, but no cases when absent', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + expect(checkEsIndex).toHaveBeenCalledTimes(2); + expect(pushEsNodes).toHaveBeenCalledTimes(2); + expect(pushedNodesForIndex('testing_states')).toBeDefined(); + expect(pushedNodesForIndex('testing_runs')).toBeDefined(); + expect(pushedNodesForIndex('testing_cases')).toBeUndefined(); + }); + + it('transforms the state with a full name and graphql-like dependencies', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + const state = pushedNodesForIndex('testing_states')[0]; + expect(state.full).toEqual('my-component_1.0.0'); + expect(state.dependencies).toEqual({ + edges: [ + { + node: { + name: 'dep-a', + version: '2.0.0', + full: 'dep-a_2.0.0', + id: 'dep-id-dep-a', + }, + }, + ], + totalCount: 1, + }); + }); + + it('computes success and failure rates on the run document', async () => { + await processor.pushNodeToEs({ data: buildJobData() } as any); + + const run = pushedNodesForIndex('testing_runs')[0]; + expect(run.id).toEqual('run-id-1'); + expect(run.runSuccessRate).toEqual(75); + expect(run.runFailureRate).toEqual(25); + }); + + it('strips the cases from state and run documents', async () => { + const jobData = buildJobData({ + cases: [ + { + id: 'case-1', + createdAt: '2026-01-01T00:00:00Z', + suite: 'login', + name: 'accepts valid credentials', + caseSuccess: 1, + caseFailure: 1, + caseTotal: 2, + }, + ], + }); + + await processor.pushNodeToEs({ data: jobData } as any); + + expect(pushedNodesForIndex('testing_states')[0].cases).toBeUndefined(); + expect(pushedNodesForIndex('testing_runs')[0].cases).toBeUndefined(); + }); + + it('pushes enriched case documents when cases are present', async () => { + const jobData = buildJobData({ + cases: [ + { + id: 'case-1', + createdAt: '2026-01-01T00:00:00Z', + suite: 'login', + name: 'accepts valid credentials', + caseSuccess: 1, + caseFailure: 1, + caseTotal: 2, + }, + ], + }); + + await processor.pushNodeToEs({ data: jobData } as any); + + const cases = pushedNodesForIndex('testing_cases'); + expect(cases).toHaveLength(1); + expect(cases[0]).toEqual( + expect.objectContaining({ + full: 'login - accepts valid credentials', + runId: 'run-id-1', + url: 'https://ci.example.com/run/1', + project: 'my-component', + caseSuccessRate: 50, + caseFailureRate: 50, + }), + ); + // The case id is derived from the case content (uuid v5) + expect(cases[0].id).toMatch(/^[0-9a-f-]{36}$/); + }); +}); diff --git a/test/jest.setup.js b/test/jest.setup.js new file mode 100644 index 0000000..a0800bf --- /dev/null +++ b/test/jest.setup.js @@ -0,0 +1,9 @@ +// @nestjs/bull 0.1.x relies on util.isString, which was removed in Node 23. +// The runtime image is pinned to Node 22 (see Dockerfile); this shim keeps +// the test suite runnable on newer local Node versions until dependencies +// are modernized. +const util = require('util'); + +if (typeof util.isString !== 'function') { + util.isString = (value) => typeof value === 'string'; +} diff --git a/yarn.lock b/yarn.lock index 7987ad4..5fe69d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -326,18 +326,18 @@ "@bit/zencrepes.zindexer.config@0.0.17": version "0.0.17" - resolved "https://node.bit.dev/zencrepes.zindexer.config/-/zencrepes.zindexer.config-0.0.17.tgz#bde38df4bd451d59bbe2d46014aa92e8625c5107" - integrity sha1-veON9L1FHVm74tRgFKqS6GJcUQc= + resolved "https://node.bit.dev/zencrepes.zindexer.config/-/zencrepes.zindexer.config-0.0.17.tgz#17d620ae5f05bd04baf9999f7c7d15aef89ff5dd" + integrity sha1-F9Ygrl8FvQS6+ZmffH0Vrvif9d0= "@bit/zencrepes.zindexer.config@^0.0.28": version "0.0.28" - resolved "https://node.bit.dev/zencrepes.zindexer.config/-/zencrepes.zindexer.config-0.0.28.tgz#872c0ab840cb06c9440e8dc6a6b688a4cd62acff" - integrity sha1-hywKuEDLBslEDo3GpraIpM1irP8= + resolved "https://node.bit.dev/zencrepes.zindexer.config/-/zencrepes.zindexer.config-0.0.28.tgz#3628c137003e3ab9116ab7922485b9da254f4175" + integrity sha1-NijBNwA+OrkRareSJIW52iVPQXU= "@bit/zencrepes.zindexer.es-utils@^0.0.14": version "0.0.14" - resolved "https://node.bit.dev/zencrepes.zindexer.es-utils/-/zencrepes.zindexer.es-utils-0.0.14.tgz#20cd8a27bce36cb13dd71bfb9605985185b7032c" - integrity sha1-IM2KJ7zjbLE91xv7lgWYUYW3Ayw= + resolved "https://node.bit.dev/zencrepes.zindexer.es-utils/-/zencrepes.zindexer.es-utils-0.0.14.tgz#51120563836d996193faf8a6a384fb3e3f988b36" + integrity sha1-URIFY4NtmWGT+vimo4T7Pj+YizY= dependencies: "@elastic/elasticsearch" "^7.8.0" "@types/js-yaml" "^3.12.1" @@ -345,8 +345,8 @@ "@bit/zencrepes.zindexer.github-issues@^0.0.21": version "0.0.21" - resolved "https://node.bit.dev/zencrepes.zindexer.github-issues/-/zencrepes.zindexer.github-issues-0.0.21.tgz#64a0c0a82629096412f344258cd6c723f0d6da90" - integrity sha1-ZKDAqCYpCWQS80QljNbHI/DW2pA= + resolved "https://node.bit.dev/zencrepes.zindexer.github-issues/-/zencrepes.zindexer.github-issues-0.0.21.tgz#c8a87ba62a77100eebb7d12faa07e6fb26d7b88e" + integrity sha1-yKh7pip3EA7rt9Evqgfm+ybXuI4= dependencies: "@bit/zencrepes.zindexer.config" "0.0.17" "@types/xregexp" "^4.3.0" @@ -356,29 +356,29 @@ "@bit/zencrepes.zindexer.github-labels@^0.0.15": version "0.0.15" - resolved "https://node.bit.dev/zencrepes.zindexer.github-labels/-/zencrepes.zindexer.github-labels-0.0.15.tgz#c66f1963463c30599f73c8bdb2caf85b6a69b85d" - integrity sha1-xm8ZY0Y8MFmfc8i9ssr4W2ppuF0= + resolved "https://node.bit.dev/zencrepes.zindexer.github-labels/-/zencrepes.zindexer.github-labels-0.0.15.tgz#3d5b4d0373719195c87b27f9c6ff154e5e1b8868" + integrity sha1-PVtNA3NxkZXIeyf5xv8VTl4biGg= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-milestones@^0.0.10": version "0.0.10" - resolved "https://node.bit.dev/zencrepes.zindexer.github-milestones/-/zencrepes.zindexer.github-milestones-0.0.10.tgz#6f3b1fa627bfe6105d74ce0f7e2c4cc11dece7fe" - integrity sha1-bzsfpie/5hBddM4PfixMwR3s5/4= + resolved "https://node.bit.dev/zencrepes.zindexer.github-milestones/-/zencrepes.zindexer.github-milestones-0.0.10.tgz#2dc484e69a8455ab39e6c678966057102ea7474a" + integrity sha1-LcSE5pqEVas55sZ4lmBXEC6nR0o= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-projects@^0.0.11": version "0.0.11" - resolved "https://node.bit.dev/zencrepes.zindexer.github-projects/-/zencrepes.zindexer.github-projects-0.0.11.tgz#5ee5f18da016130c547d10854b48f329aea5a8c2" - integrity sha1-XuXxjaAWEwxUfRCFS0jzKa6lqMI= + resolved "https://node.bit.dev/zencrepes.zindexer.github-projects/-/zencrepes.zindexer.github-projects-0.0.11.tgz#03f0b9ef9f515117b41c315aedb040db5974462b" + integrity sha1-A/C5759RURe0HDFa7bBA21l0Ris= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-pullrequests@^0.0.18": version "0.0.18" - resolved "https://node.bit.dev/zencrepes.zindexer.github-pullrequests/-/zencrepes.zindexer.github-pullrequests-0.0.18.tgz#bd7aced101da898760311c58b9916d865b777ffa" - integrity sha1-vXrO0QHaiYdgMRxYuZFthlt3f/o= + resolved "https://node.bit.dev/zencrepes.zindexer.github-pullrequests/-/zencrepes.zindexer.github-pullrequests-0.0.18.tgz#2bd5b04a81776b13161e5eb66f82f77e5c194104" + integrity sha1-K9WwSoF3axMWHl62b4L3flwZQQQ= dependencies: "@bit/zencrepes.zindexer.config" "0.0.17" date-fns "^2.8.1" @@ -386,52 +386,52 @@ "@bit/zencrepes.zindexer.github-releases@^0.0.10": version "0.0.10" - resolved "https://node.bit.dev/zencrepes.zindexer.github-releases/-/zencrepes.zindexer.github-releases-0.0.10.tgz#720403ed31ba6c86ab5c459f255688311f787c15" - integrity sha1-cgQD7TG6bIarXEWfJVaIMR94fBU= + resolved "https://node.bit.dev/zencrepes.zindexer.github-releases/-/zencrepes.zindexer.github-releases-0.0.10.tgz#67f0d3bec31de9a654e57193ef547e922db4856f" + integrity sha1-Z/DTvsMd6aZU5XGT71R+ki20hW8= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-repos@^0.0.18": version "0.0.18" - resolved "https://node.bit.dev/zencrepes.zindexer.github-repos/-/zencrepes.zindexer.github-repos-0.0.18.tgz#ebcb9cb4ef815a7e2f7fbe85c35f6b6b93cdc1be" - integrity sha1-68uctO+BWn4vf76Fw19ra5PNwb4= + resolved "https://node.bit.dev/zencrepes.zindexer.github-repos/-/zencrepes.zindexer.github-repos-0.0.18.tgz#b216ec8055259399994fc3210a1063e241302db7" + integrity sha1-shbsgFUlk5mZT8MhChBj4kEwLbc= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-stargazers@^0.0.10": version "0.0.10" - resolved "https://node.bit.dev/zencrepes.zindexer.github-stargazers/-/zencrepes.zindexer.github-stargazers-0.0.10.tgz#33f3a9e0008cdb3bb3b6e6a3e15e404fd3bce12a" - integrity sha1-M/Op4ACM2zuztuaj4V5AT9O84So= + resolved "https://node.bit.dev/zencrepes.zindexer.github-stargazers/-/zencrepes.zindexer.github-stargazers-0.0.10.tgz#457ef372a45e5b645f4d7fca801a47b59088ba49" + integrity sha1-RX7zcqReW2RfTX/KgBpHtZCIukk= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.github-watchers@^0.0.10": version "0.0.10" - resolved "https://node.bit.dev/zencrepes.zindexer.github-watchers/-/zencrepes.zindexer.github-watchers-0.0.10.tgz#2c69f7c62c377c360870f8937aa3d9c5bf9c406c" - integrity sha1-LGn3xiw3fDYIcPiTeqPZxb+cQGw= + resolved "https://node.bit.dev/zencrepes.zindexer.github-watchers/-/zencrepes.zindexer.github-watchers-0.0.10.tgz#95cf204429eb1ff36b580ccad16420739a242c30" + integrity sha1-lc8gRCnrH/NrWAzK0WQgc5okLDA= dependencies: graphql-tag "^2.10.1" "@bit/zencrepes.zindexer.testing-perfs@^0.0.32": version "0.0.32" - resolved "https://node.bit.dev/zencrepes.zindexer.testing-perfs/-/zencrepes.zindexer.testing-perfs-0.0.32.tgz#49d9a9eab2a1891823a57966baede0c58a144a16" - integrity sha1-Sdmp6rKhiRgjpXlmuu3gxYoUShY= + resolved "https://node.bit.dev/zencrepes.zindexer.testing-perfs/-/zencrepes.zindexer.testing-perfs-0.0.32.tgz#83ef420797c1e835f4d794cb3e0ed551715946d0" + integrity sha1-g+9CB5fB6DX015TLPg7VUXFZRtA= dependencies: "@types/uuid" "^8.3.0" uuid "^8.3.1" "@bit/zencrepes.zindexer.testing-runs@^0.0.3": version "0.0.3" - resolved "https://node.bit.dev/zencrepes.zindexer.testing-runs/-/zencrepes.zindexer.testing-runs-0.0.3.tgz#fe46354dc43a746cc3f7401863f6816ea1d5bcc3" - integrity sha1-/kY1TcQ6dGzD90AYY/aBbqHVvMM= + resolved "https://node.bit.dev/zencrepes.zindexer.testing-runs/-/zencrepes.zindexer.testing-runs-0.0.3.tgz#d78734f2d6a0567687347845ce41d7bd0120ece7" + integrity sha1-14c08tagVnaHNHhFzkHXvQEg7Oc= dependencies: "@types/uuid" "^8.3.0" uuid "^8.3.1" "@bit/zencrepes.zindexer.testing-states@^0.0.5": version "0.0.5" - resolved "https://node.bit.dev/zencrepes.zindexer.testing-states/-/zencrepes.zindexer.testing-states-0.0.5.tgz#24b1bb1e1f7f5ae594e0a3b4b7d92a662119af58" - integrity sha1-JLG7Hh9/WuWU4KO0t9kqZiEZr1g= + resolved "https://node.bit.dev/zencrepes.zindexer.testing-states/-/zencrepes.zindexer.testing-states-0.0.5.tgz#634b74980f4f707de6363eb722c903739ea3327c" + integrity sha1-Y0t0mA9PcH3mNj63IskDc56jMnw= dependencies: "@types/uuid" "^8.3.0" uuid "^8.3.1"