mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
chore: register integration events in webhooks (#7621)
https://linear.app/unleash/issue/2-2450/register-integration-events-webhook Registers integration events in the **Webhook** integration. Even though this touches a lot of files, most of it is preparation for the next steps. The only actual implementation of registering integration events is in the **Webhook** integration. The rest will follow on separate PRs. Here's an example of how this looks like in the database table: ```json { "id": 7, "integration_id": 2, "created_at": "2024-07-18T18:11:11.376348+01:00", "state": "failed", "state_details": "Webhook request failed with status code: ECONNREFUSED", "event": { "id": 130, "data": null, "tags": [], "type": "feature-environment-enabled", "preData": null, "project": "default", "createdAt": "2024-07-18T17:11:10.821Z", "createdBy": "admin", "environment": "development", "featureName": "test", "createdByUserId": 1 }, "details": { "url": "http://localhost:1337", "body": "{ \"id\": 130, \"type\": \"feature-environment-enabled\", \"createdBy\": \"admin\", \"createdAt\": \"2024-07-18T17: 11: 10.821Z\", \"createdByUserId\": 1, \"data\": null, \"preData\": null, \"tags\": [], \"featureName\": \"test\", \"project\": \"default\", \"environment\": \"development\" }" } } ```
This commit is contained in:
parent
3db1159304
commit
0869e39603
@ -2,28 +2,32 @@ import nock from 'nock';
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
|
||||
import SlackAddon from './slack';
|
||||
import type { IAddonConfig, IFlagResolver } from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
beforeEach(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
const url = 'https://test.some.com';
|
||||
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: url,
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
test('Does not retry if request succeeds', async () => {
|
||||
const url = 'https://test.some.com';
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: url,
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
nock(url).get('/').reply(201);
|
||||
const res = await addon.fetchRetry(url);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('Retries once, and succeeds', async () => {
|
||||
const url = 'https://test.some.com';
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: url,
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
nock(url).get('/').replyWithError('testing retry');
|
||||
nock(url).get('/').reply(200);
|
||||
const res = await addon.fetchRetry(url);
|
||||
@ -32,22 +36,14 @@ test('Retries once, and succeeds', async () => {
|
||||
});
|
||||
|
||||
test('Does not throw if response is error', async () => {
|
||||
const url = 'https://test.some.com';
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: url,
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
nock(url).get('/').twice().replyWithError('testing retry');
|
||||
const res = await addon.fetchRetry(url);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('Supports custom number of retries', async () => {
|
||||
const url = 'https://test.some.com';
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: url,
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
let retries = 0;
|
||||
nock(url).get('/').twice().replyWithError('testing retry');
|
||||
nock(url).get('/').reply(201);
|
||||
|
@ -1,9 +1,11 @@
|
||||
import fetch from 'make-fetch-happen';
|
||||
import { addonDefinitionSchema } from './addon-schema';
|
||||
import type { IUnleashConfig } from '../types/option';
|
||||
import type { Logger } from '../logger';
|
||||
import type { IAddonDefinition } from '../types/model';
|
||||
import type { IAddonConfig, IAddonDefinition } from '../types/model';
|
||||
import type { IEvent } from '../types/events';
|
||||
import type { IntegrationEventsService } from '../features/integration-events/integration-events-service';
|
||||
import type { IntegrationEventWriteModel } from '../features/integration-events/integration-events-store';
|
||||
import type { IFlagResolver } from '../types';
|
||||
|
||||
export default abstract class Addon {
|
||||
logger: Logger;
|
||||
@ -12,9 +14,13 @@ export default abstract class Addon {
|
||||
|
||||
_definition: IAddonDefinition;
|
||||
|
||||
integrationEventsService: IntegrationEventsService;
|
||||
|
||||
flagResolver: IFlagResolver;
|
||||
|
||||
constructor(
|
||||
definition: IAddonDefinition,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
{ getLogger, integrationEventsService, flagResolver }: IAddonConfig,
|
||||
) {
|
||||
this.logger = getLogger(`addon/${definition.name}`);
|
||||
const { error } = addonDefinitionSchema.validate(definition);
|
||||
@ -27,6 +33,8 @@ export default abstract class Addon {
|
||||
}
|
||||
this._name = definition.name;
|
||||
this._definition = definition;
|
||||
this.integrationEventsService = integrationEventsService;
|
||||
this.flagResolver = flagResolver;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
@ -60,13 +68,25 @@ export default abstract class Addon {
|
||||
} status code ${e.code}`,
|
||||
e,
|
||||
);
|
||||
res = { statusCode: e.code, ok: false };
|
||||
res = { status: e.code, ok: false };
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
abstract handleEvent(event: IEvent, parameters: any): Promise<void>;
|
||||
abstract handleEvent(
|
||||
event: IEvent,
|
||||
parameters: any,
|
||||
integrationId: number,
|
||||
): Promise<void>;
|
||||
|
||||
async registerEvent(
|
||||
integrationEvent: IntegrationEventWriteModel,
|
||||
): Promise<void> {
|
||||
if (this.flagResolver.isEnabled('integrationEvents')) {
|
||||
await this.integrationEventsService.registerEvent(integrationEvent);
|
||||
}
|
||||
}
|
||||
|
||||
destroy?(): void;
|
||||
}
|
||||
|
@ -9,9 +9,19 @@ import type { Logger } from '../logger';
|
||||
import DatadogAddon from './datadog';
|
||||
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
import type { IAddonConfig, IFlagResolver } from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
let fetchRetryCalls: any[] = [];
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./addon',
|
||||
() =>
|
||||
@ -32,14 +42,15 @@ jest.mock(
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
|
||||
async registerEvent(_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('Should call datadog webhook', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -59,17 +70,14 @@ test('Should call datadog webhook', async () => {
|
||||
apiKey: 'fakeKey',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call datadog webhook for archived toggle', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -87,17 +95,14 @@ test('Should call datadog webhook for archived toggle', async () => {
|
||||
apiKey: 'fakeKey',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call datadog webhook for archived toggle with project info', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -116,17 +121,14 @@ test('Should call datadog webhook for archived toggle with project info', async
|
||||
apiKey: 'fakeKey',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call datadog webhook for toggled environment', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -146,7 +148,7 @@ test('Should call datadog webhook for toggled environment', async () => {
|
||||
apiKey: 'fakeKey',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
@ -154,10 +156,7 @@ test('Should call datadog webhook for toggled environment', async () => {
|
||||
});
|
||||
|
||||
test('Should include customHeaders in headers when calling service', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -177,7 +176,7 @@ test('Should include customHeaders in headers when calling service', async () =>
|
||||
apiKey: 'fakeKey',
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
@ -186,10 +185,7 @@ test('Should include customHeaders in headers when calling service', async () =>
|
||||
});
|
||||
|
||||
test('Should not include source_type_name when included in the config', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -210,7 +206,7 @@ test('Should not include source_type_name when included in the config', async ()
|
||||
sourceTypeName: 'my-custom-source-type',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(
|
||||
@ -221,10 +217,7 @@ test('Should not include source_type_name when included in the config', async ()
|
||||
});
|
||||
|
||||
test('Should call datadog webhook with JSON when template set', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new DatadogAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -246,7 +239,7 @@ test('Should call datadog webhook with JSON when template set', async () => {
|
||||
'{\n "event": "{{event.type}}",\n "createdBy": "{{event.createdBy}}"\n}',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
|
@ -39,6 +39,7 @@ export default class DatadogAddon extends Addon {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: IDatadogParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
const {
|
||||
url = 'https://api.datadoghq.com/api/v1/events',
|
||||
|
@ -4,26 +4,21 @@ import TeamsAddon from './teams';
|
||||
import DatadogAddon from './datadog';
|
||||
import NewRelicAddon from './new-relic';
|
||||
import type Addon from './addon';
|
||||
import type { LogProvider } from '../logger';
|
||||
import SlackAppAddon from './slack-app';
|
||||
import type { IFlagResolver } from '../types';
|
||||
import type { IAddonConfig } from '../types';
|
||||
|
||||
export interface IAddonProviders {
|
||||
[key: string]: Addon;
|
||||
}
|
||||
|
||||
export const getAddons: (args: {
|
||||
getLogger: LogProvider;
|
||||
unleashUrl: string;
|
||||
flagResolver: IFlagResolver;
|
||||
}) => IAddonProviders = ({ getLogger, unleashUrl, flagResolver }) => {
|
||||
export const getAddons: (args: IAddonConfig) => IAddonProviders = (args) => {
|
||||
const addons: Addon[] = [
|
||||
new Webhook({ getLogger }),
|
||||
new SlackAddon({ getLogger, unleashUrl }),
|
||||
new SlackAppAddon({ getLogger, unleashUrl }),
|
||||
new TeamsAddon({ getLogger, unleashUrl }),
|
||||
new DatadogAddon({ getLogger, unleashUrl }),
|
||||
new NewRelicAddon({ getLogger, unleashUrl }),
|
||||
new Webhook(args),
|
||||
new SlackAddon(args),
|
||||
new SlackAppAddon(args),
|
||||
new TeamsAddon(args),
|
||||
new DatadogAddon(args),
|
||||
new NewRelicAddon(args),
|
||||
];
|
||||
|
||||
return addons.reduce((map, addon) => {
|
||||
|
@ -2,6 +2,8 @@ import {
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_CREATED,
|
||||
FEATURE_ENVIRONMENT_DISABLED,
|
||||
type IFlagResolver,
|
||||
type IAddonConfig,
|
||||
type IEvent,
|
||||
} from '../types';
|
||||
import type { Logger } from '../logger';
|
||||
@ -11,11 +13,20 @@ import NewRelicAddon, { type INewRelicParameters } from './new-relic';
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
import { gunzip } from 'node:zlib';
|
||||
import { promisify } from 'util';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
const asyncGunzip = promisify(gunzip);
|
||||
|
||||
let fetchRetryCalls: any[] = [];
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./addon',
|
||||
() =>
|
||||
@ -36,6 +47,10 @@ jest.mock(
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
|
||||
async registerEvent(_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -59,12 +74,9 @@ const defaultEvent = {
|
||||
} as IEvent;
|
||||
|
||||
const makeAddHandleEvent = (event: IEvent, parameters: INewRelicParameters) => {
|
||||
const addon = new NewRelicAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new NewRelicAddon(ARGS);
|
||||
|
||||
return () => addon.handleEvent(event, parameters);
|
||||
return () => addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
};
|
||||
|
||||
test.each([
|
||||
|
@ -44,6 +44,7 @@ export default class NewRelicAddon extends Addon {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: INewRelicParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
const { url, licenseKey, customHeaders, bodyTemplate } = parameters;
|
||||
const context = {
|
||||
|
@ -1,10 +1,32 @@
|
||||
import { type IEvent, FEATURE_ENVIRONMENT_ENABLED } from '../types/events';
|
||||
import SlackAppAddon from './slack-app';
|
||||
import { type ChatPostMessageArguments, ErrorCode } from '@slack/web-api';
|
||||
import { SYSTEM_USER_ID } from '../types';
|
||||
import {
|
||||
type IAddonConfig,
|
||||
type IFlagResolver,
|
||||
SYSTEM_USER_ID,
|
||||
} from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
const slackApiCalls: ChatPostMessageArguments[] = [];
|
||||
|
||||
const loggerMock = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
fatal: jest.fn(),
|
||||
};
|
||||
const getLogger = jest.fn(() => loggerMock);
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
let postMessage = jest.fn().mockImplementation((options) => {
|
||||
slackApiCalls.push(options);
|
||||
return Promise.resolve();
|
||||
@ -28,14 +50,6 @@ jest.mock('@slack/web-api', () => ({
|
||||
describe('SlackAppAddon', () => {
|
||||
let addon: SlackAppAddon;
|
||||
const accessToken = 'test-access-token';
|
||||
const loggerMock = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
fatal: jest.fn(),
|
||||
};
|
||||
const getLogger = jest.fn(() => loggerMock);
|
||||
const mockError = {
|
||||
code: ErrorCode.PlatformError,
|
||||
data: {
|
||||
@ -64,10 +78,7 @@ describe('SlackAppAddon', () => {
|
||||
jest.useFakeTimers();
|
||||
slackApiCalls.length = 0;
|
||||
postMessage.mockClear();
|
||||
addon = new SlackAppAddon({
|
||||
getLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
addon = new SlackAppAddon(ARGS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -75,20 +86,28 @@ describe('SlackAppAddon', () => {
|
||||
});
|
||||
|
||||
it('should post message when feature is toggled', async () => {
|
||||
await addon.handleEvent(event, {
|
||||
accessToken,
|
||||
defaultChannels: 'general',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
event,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: 'general',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(slackApiCalls.length).toBe(1);
|
||||
expect(slackApiCalls[0].channel).toBe('general');
|
||||
});
|
||||
|
||||
it('should post to all channels in defaultChannels', async () => {
|
||||
await addon.handleEvent(event, {
|
||||
accessToken,
|
||||
defaultChannels: 'general, another-channel-1',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
event,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: 'general, another-channel-1',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(slackApiCalls.length).toBe(2);
|
||||
expect(slackApiCalls[0].channel).toBe('general');
|
||||
@ -104,10 +123,14 @@ describe('SlackAppAddon', () => {
|
||||
],
|
||||
};
|
||||
|
||||
await addon.handleEvent(eventWith2Tags, {
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
eventWith2Tags,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(slackApiCalls.length).toBe(2);
|
||||
expect(slackApiCalls[0].channel).toBe('general');
|
||||
@ -123,10 +146,14 @@ describe('SlackAppAddon', () => {
|
||||
],
|
||||
};
|
||||
|
||||
await addon.handleEvent(eventWith2Tags, {
|
||||
accessToken,
|
||||
defaultChannels: 'another-channel-1, another-channel-2',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
eventWith2Tags,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: 'another-channel-1, another-channel-2',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(slackApiCalls.length).toBe(3);
|
||||
expect(slackApiCalls[0].channel).toBe('general');
|
||||
@ -135,10 +162,14 @@ describe('SlackAppAddon', () => {
|
||||
});
|
||||
|
||||
it('should not post a message if there are no tagged channels and no defaultChannels', async () => {
|
||||
await addon.handleEvent(event, {
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
event,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(slackApiCalls.length).toBe(0);
|
||||
});
|
||||
@ -146,10 +177,14 @@ describe('SlackAppAddon', () => {
|
||||
it('should log error when an API call fails', async () => {
|
||||
postMessage = jest.fn().mockRejectedValue(mockError);
|
||||
|
||||
await addon.handleEvent(event, {
|
||||
accessToken,
|
||||
defaultChannels: 'general',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
event,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: 'general',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
`Error handling event ${event.type}. A platform error occurred: ${JSON.stringify(mockError.data)}`,
|
||||
@ -173,10 +208,14 @@ describe('SlackAppAddon', () => {
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockRejectedValueOnce(mockError);
|
||||
|
||||
await addon.handleEvent(eventWith3Tags, {
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
});
|
||||
await addon.handleEvent(
|
||||
eventWith3Tags,
|
||||
{
|
||||
accessToken,
|
||||
defaultChannels: '',
|
||||
},
|
||||
INTEGRATION_ID,
|
||||
);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(3);
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
|
@ -44,6 +44,7 @@ export default class SlackAppAddon extends Addon {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: ISlackAppAddonParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { accessToken, defaultChannels } = parameters;
|
||||
|
@ -9,10 +9,23 @@ import type { Logger } from '../logger';
|
||||
import SlackAddon from './slack';
|
||||
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
import { SYSTEM_USER_ID } from '../types';
|
||||
import {
|
||||
type IAddonConfig,
|
||||
type IFlagResolver,
|
||||
SYSTEM_USER_ID,
|
||||
} from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
let fetchRetryCalls: any[] = [];
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./addon',
|
||||
() =>
|
||||
@ -33,14 +46,15 @@ jest.mock(
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
|
||||
async registerEvent(_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('Should call slack webhook', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -62,17 +76,14 @@ test('Should call slack webhook', async () => {
|
||||
defaultChannel: 'general',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call slack webhook for archived toggle', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -90,17 +101,14 @@ test('Should call slack webhook for archived toggle', async () => {
|
||||
defaultChannel: 'general',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call slack webhook for archived toggle with project info', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -119,17 +127,14 @@ test('Should call slack webhook for archived toggle with project info', async ()
|
||||
defaultChannel: 'general',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test(`Should call webhook for toggled environment`, async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -149,7 +154,7 @@ test(`Should call webhook for toggled environment`, async () => {
|
||||
defaultChannel: 'general',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
@ -157,10 +162,7 @@ test(`Should call webhook for toggled environment`, async () => {
|
||||
});
|
||||
|
||||
test('Should use default channel', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 3,
|
||||
createdAt: new Date(),
|
||||
@ -180,7 +182,7 @@ test('Should use default channel', async () => {
|
||||
defaultChannel: 'some-channel',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
|
||||
const req = JSON.parse(fetchRetryCalls[0].options.body);
|
||||
|
||||
@ -188,10 +190,7 @@ test('Should use default channel', async () => {
|
||||
});
|
||||
|
||||
test('Should override default channel with data from tag', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 4,
|
||||
createdAt: new Date(),
|
||||
@ -217,7 +216,7 @@ test('Should override default channel with data from tag', async () => {
|
||||
defaultChannel: 'some-channel',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
|
||||
const req = JSON.parse(fetchRetryCalls[0].options.body);
|
||||
|
||||
@ -225,10 +224,7 @@ test('Should override default channel with data from tag', async () => {
|
||||
});
|
||||
|
||||
test('Should post to all channels in tags', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 5,
|
||||
createdAt: new Date(),
|
||||
@ -258,7 +254,7 @@ test('Should post to all channels in tags', async () => {
|
||||
defaultChannel: 'some-channel',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
|
||||
const req1 = JSON.parse(fetchRetryCalls[0].options.body);
|
||||
const req2 = JSON.parse(fetchRetryCalls[1].options.body);
|
||||
@ -269,10 +265,7 @@ test('Should post to all channels in tags', async () => {
|
||||
});
|
||||
|
||||
test('Should include custom headers from parameters in call to service', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new SlackAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -293,7 +286,7 @@ test('Should include custom headers from parameters in call to service', async (
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
|
@ -32,6 +32,7 @@ export default class SlackAddon extends Addon {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: ISlackAddonParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
const {
|
||||
url,
|
||||
|
@ -10,10 +10,23 @@ import {
|
||||
import TeamsAddon from './teams';
|
||||
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
import { SYSTEM_USER_ID } from '../types';
|
||||
import {
|
||||
type IAddonConfig,
|
||||
type IFlagResolver,
|
||||
SYSTEM_USER_ID,
|
||||
} from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
let fetchRetryCalls: any[];
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./addon',
|
||||
() =>
|
||||
@ -34,14 +47,15 @@ jest.mock(
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
|
||||
async registerEvent(_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('Should call teams webhook', async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new TeamsAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -60,17 +74,14 @@ test('Should call teams webhook', async () => {
|
||||
url: 'http://hooks.office.com',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call teams webhook for archived toggle', async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new TeamsAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -87,17 +98,14 @@ test('Should call teams webhook for archived toggle', async () => {
|
||||
url: 'http://hooks.office.com',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call teams webhook for archived toggle with project info', async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new TeamsAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
@ -115,17 +123,14 @@ test('Should call teams webhook for archived toggle with project info', async ()
|
||||
url: 'http://hooks.office.com',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test(`Should call teams webhook for toggled environment`, async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new TeamsAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -144,7 +149,7 @@ test(`Should call teams webhook for toggled environment`, async () => {
|
||||
url: 'http://hooks.slack.com',
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
@ -152,10 +157,7 @@ test(`Should call teams webhook for toggled environment`, async () => {
|
||||
});
|
||||
|
||||
test('Should include custom headers in call to teams', async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const addon = new TeamsAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
@ -175,7 +177,7 @@ test('Should include custom headers in call to teams', async () => {
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls).toHaveLength(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
|
||||
|
@ -24,6 +24,7 @@ export default class TeamsAddon extends Addon {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: ITeamsParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
const { url, customHeaders } = parameters;
|
||||
const { createdBy } = event;
|
||||
|
@ -5,9 +5,24 @@ import { FEATURE_CREATED, type IEvent } from '../types/events';
|
||||
import WebhookAddon from './webhook';
|
||||
|
||||
import noLogger from '../../test/fixtures/no-logger';
|
||||
import { SYSTEM_USER_ID } from '../types';
|
||||
import {
|
||||
type IAddonConfig,
|
||||
type IFlagResolver,
|
||||
serializeDates,
|
||||
SYSTEM_USER_ID,
|
||||
} from '../types';
|
||||
import type { IntegrationEventsService } from '../services';
|
||||
|
||||
let fetchRetryCalls: any[] = [];
|
||||
const registerEventMock = jest.fn();
|
||||
|
||||
const INTEGRATION_ID = 1337;
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./addon',
|
||||
@ -27,127 +42,182 @@ jest.mock(
|
||||
retries,
|
||||
backoff,
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
return Promise.resolve({ ok: true, status: 200 });
|
||||
}
|
||||
|
||||
async registerEvent(event) {
|
||||
return registerEventMock(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('Should handle event without "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
describe('Webhook integration', () => {
|
||||
beforeEach(() => {
|
||||
registerEventMock.mockClear();
|
||||
});
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com',
|
||||
};
|
||||
test('Should handle event without "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
|
||||
});
|
||||
|
||||
test('Should format event with "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers['Content-Type']).toBe('text/plain');
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
});
|
||||
|
||||
test('Should format event with "authorization"', () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
authorization: 'API KEY 123abc',
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers.Authorization).toBe(parameters.authorization);
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
});
|
||||
|
||||
test('Should handle custom headers', async () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
authorization: 'API KEY 123abc',
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers.Authorization).toBe(parameters.authorization);
|
||||
expect(call.options.headers.MY_CUSTOM_HEADER).toBe('MY_CUSTOM_VALUE');
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com',
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
|
||||
});
|
||||
|
||||
test('Should format event with "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers['Content-Type']).toBe('text/plain');
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
});
|
||||
|
||||
test('Should format event with "authorization"', () => {
|
||||
const addon = new WebhookAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
authorization: 'API KEY 123abc',
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers.Authorization).toBe(
|
||||
parameters.authorization,
|
||||
);
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
});
|
||||
|
||||
test('Should handle custom headers', async () => {
|
||||
const addon = new WebhookAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
authorization: 'API KEY 123abc',
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers.Authorization).toBe(
|
||||
parameters.authorization,
|
||||
);
|
||||
expect(call.options.headers.MY_CUSTOM_HEADER).toBe('MY_CUSTOM_VALUE');
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
||||
});
|
||||
|
||||
test('Should call registerEvent', async () => {
|
||||
const addon = new WebhookAddon(ARGS);
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
createdByUserId: SYSTEM_USER_ID,
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
featureName: 'some-toggle',
|
||||
data: {
|
||||
name: 'some-toggle',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
},
|
||||
};
|
||||
|
||||
const parameters = {
|
||||
url: 'http://test.webhook.com/plain',
|
||||
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
|
||||
contentType: 'text/plain',
|
||||
authorization: 'API KEY 123abc',
|
||||
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters, INTEGRATION_ID);
|
||||
|
||||
expect(registerEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(registerEventMock).toHaveBeenCalledWith({
|
||||
integrationId: INTEGRATION_ID,
|
||||
state: 'success',
|
||||
stateDetails:
|
||||
'Webhook request was successful with status code: 200',
|
||||
event: serializeDates(event),
|
||||
details: {
|
||||
url: parameters.url,
|
||||
contentType: parameters.contentType,
|
||||
body: 'feature-created on toggle some-toggle',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,9 @@
|
||||
import Mustache from 'mustache';
|
||||
import Addon from './addon';
|
||||
import definition from './webhook-definition';
|
||||
import type { LogProvider } from '../logger';
|
||||
import type { IEvent } from '../types/events';
|
||||
import { type IAddonConfig, serializeDates } from '../types';
|
||||
import type { IntegrationEventState } from '../features/integration-events/integration-events-store';
|
||||
|
||||
interface IParameters {
|
||||
url: string;
|
||||
@ -13,11 +14,18 @@ interface IParameters {
|
||||
}
|
||||
|
||||
export default class Webhook extends Addon {
|
||||
constructor(args: { getLogger: LogProvider }) {
|
||||
constructor(args: IAddonConfig) {
|
||||
super(definition, args);
|
||||
}
|
||||
|
||||
async handleEvent(event: IEvent, parameters: IParameters): Promise<void> {
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: IParameters,
|
||||
integrationId: number,
|
||||
): Promise<void> {
|
||||
let state: IntegrationEventState = 'success';
|
||||
let stateDetails = '';
|
||||
|
||||
const { url, bodyTemplate, contentType, authorization, customHeaders } =
|
||||
parameters;
|
||||
const context = {
|
||||
@ -39,9 +47,10 @@ export default class Webhook extends Addon {
|
||||
try {
|
||||
extraHeaders = JSON.parse(customHeaders);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Could not parse the json in the customHeaders parameter. [${customHeaders}]`,
|
||||
);
|
||||
state = 'successWithErrors';
|
||||
stateDetails =
|
||||
'Could not parse the JSON in the customHeaders parameter.';
|
||||
this.logger.warn(stateDetails);
|
||||
}
|
||||
}
|
||||
const requestOpts = {
|
||||
@ -58,5 +67,24 @@ export default class Webhook extends Addon {
|
||||
this.logger.info(
|
||||
`Handled event "${event.type}". Status code: ${res.status}`,
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
stateDetails = `Webhook request was successful with status code: ${res.status}`;
|
||||
} else {
|
||||
state = 'failed';
|
||||
stateDetails = `Webhook request failed with status code: ${res.status}`;
|
||||
}
|
||||
|
||||
this.registerEvent({
|
||||
integrationId,
|
||||
state,
|
||||
stateDetails,
|
||||
event: serializeDates(event),
|
||||
details: {
|
||||
url,
|
||||
contentType,
|
||||
body,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ export type IntegrationEventWriteModel = Omit<
|
||||
'id' | 'createdAt'
|
||||
>;
|
||||
|
||||
export type IntegrationEventState = IntegrationEventWriteModel['state'];
|
||||
|
||||
export class IntegrationEventsStore extends CRUDStore<
|
||||
IntegrationEventSchema,
|
||||
IntegrationEventWriteModel
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Addon from '../addons/addon';
|
||||
import getLogger from '../../test/fixtures/no-logger';
|
||||
import type { IAddonDefinition } from '../types/model';
|
||||
import type { IAddonConfig, IAddonDefinition } from '../types/model';
|
||||
import {
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_CREATED,
|
||||
@ -8,6 +8,14 @@ import {
|
||||
FEATURE_UPDATED,
|
||||
type IEvent,
|
||||
} from '../types/events';
|
||||
import type { IFlagResolver, IntegrationEventsService } from '../internals';
|
||||
|
||||
const ARGS: IAddonConfig = {
|
||||
getLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
integrationEventsService: {} as IntegrationEventsService,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
};
|
||||
|
||||
const definition: IAddonDefinition = {
|
||||
name: 'simple',
|
||||
@ -57,7 +65,7 @@ export default class SimpleAddon extends Addon {
|
||||
events: any[];
|
||||
|
||||
constructor() {
|
||||
super(definition, { getLogger });
|
||||
super(definition, ARGS);
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
|
@ -15,8 +15,9 @@ import type { IAddonDto } from '../types/stores/addon-store';
|
||||
import SimpleAddon from './addon-service-test-simple-addon';
|
||||
import type { IAddonProviders } from '../addons';
|
||||
import EventService from '../features/events/event-service';
|
||||
import { SYSTEM_USER, TEST_AUDIT_USER } from '../types';
|
||||
import { type IFlagResolver, SYSTEM_USER, TEST_AUDIT_USER } from '../types';
|
||||
import EventEmitter from 'node:events';
|
||||
import { IntegrationEventsService } from '../internals';
|
||||
|
||||
const MASKED_VALUE = '*****';
|
||||
|
||||
@ -35,6 +36,10 @@ function getSetup() {
|
||||
{ getLogger },
|
||||
eventService,
|
||||
);
|
||||
const integrationEventsService = new IntegrationEventsService(stores, {
|
||||
getLogger,
|
||||
flagResolver: {} as IFlagResolver,
|
||||
});
|
||||
|
||||
addonProvider = { simple: new SimpleAddon() };
|
||||
return {
|
||||
@ -47,6 +52,7 @@ function getSetup() {
|
||||
},
|
||||
tagTypeService,
|
||||
eventService,
|
||||
integrationEventsService,
|
||||
addonProvider,
|
||||
),
|
||||
eventService,
|
||||
|
@ -67,6 +67,7 @@ export default class AddonService {
|
||||
}: Pick<IUnleashConfig, 'getLogger' | 'server' | 'flagResolver'>,
|
||||
tagTypeService: TagTypeService,
|
||||
eventService: EventService,
|
||||
integrationEventsService,
|
||||
addons?: IAddonProviders,
|
||||
) {
|
||||
this.addonStore = addonStore;
|
||||
@ -80,6 +81,7 @@ export default class AddonService {
|
||||
getAddons({
|
||||
getLogger,
|
||||
unleashUrl: server.unleashUrl,
|
||||
integrationEventsService,
|
||||
flagResolver,
|
||||
});
|
||||
this.sensitiveParams = this.loadSensitiveParams(this.addonProviders);
|
||||
@ -145,6 +147,7 @@ export default class AddonService {
|
||||
addonProviders[addon.provider].handleEvent(
|
||||
event,
|
||||
addon.parameters,
|
||||
addon.id,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
@ -201,6 +201,7 @@ export const createServices = (
|
||||
config,
|
||||
tagTypeService,
|
||||
eventService,
|
||||
integrationEventsService,
|
||||
);
|
||||
const sessionService = new SessionService(stores, config);
|
||||
const settingService = new SettingService(stores, config, eventService);
|
||||
|
@ -7,6 +7,8 @@ import type { IProjectStats } from '../features/project/project-service';
|
||||
import type { CreateFeatureStrategySchema } from '../openapi';
|
||||
import type { ProjectEnvironment } from '../features/project/project-store-type';
|
||||
import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema';
|
||||
import type { IntegrationEventsService } from '../features/integration-events/integration-events-service';
|
||||
import type { IFlagResolver } from './experimental';
|
||||
|
||||
export type Operator = (typeof ALL_OPERATORS)[number];
|
||||
|
||||
@ -376,6 +378,8 @@ export interface IAddonAlert {
|
||||
export interface IAddonConfig {
|
||||
getLogger: LogProvider;
|
||||
unleashUrl: string;
|
||||
integrationEventsService: IntegrationEventsService;
|
||||
flagResolver: IFlagResolver;
|
||||
}
|
||||
|
||||
export interface IUserWithRole {
|
||||
|
@ -7,7 +7,7 @@ import { type IUnleashStores, TEST_AUDIT_USER } from '../../../lib/types';
|
||||
import SimpleAddon from '../../../lib/services/addon-service-test-simple-addon';
|
||||
import TagTypeService from '../../../lib/features/tag-type/tag-type-service';
|
||||
import { FEATURE_CREATED } from '../../../lib/types/events';
|
||||
import { EventService } from '../../../lib/services';
|
||||
import { EventService, IntegrationEventsService } from '../../../lib/services';
|
||||
|
||||
const addonProvider = { simple: new SimpleAddon() };
|
||||
|
||||
@ -24,11 +24,16 @@ beforeAll(async () => {
|
||||
stores = db.stores;
|
||||
const eventService = new EventService(stores, config);
|
||||
const tagTypeService = new TagTypeService(stores, config, eventService);
|
||||
const integrationEventsService = new IntegrationEventsService(
|
||||
stores,
|
||||
config,
|
||||
);
|
||||
addonService = new AddonService(
|
||||
stores,
|
||||
config,
|
||||
tagTypeService,
|
||||
eventService,
|
||||
integrationEventsService,
|
||||
addonProvider,
|
||||
);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user