Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api-radio-communication-system): introduce TETRA SDS, Call Out and Status updates #629

Merged
merged 9 commits into from
Feb 16, 2024
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
with:
fetch-depth: 0
- name: Ensure Conventional Commits
uses: webiny/action-conventional-commits@v1.2.0
uses: webiny/action-conventional-commits@v1.3.0
- name: Setup node
uses: actions/setup-node@v4
with:
Expand All @@ -34,8 +34,12 @@ jobs:
- name: Lint
run: npx nx affected --target=lint --parallel=3

- name: Run Tests with Code Coverage
run: npx nx affected --target=test --parallel=3 --ci --coverage --coverageReporters=lcov
- name: Run all tests
if: github.event_name == 'push'
run: npx nx run-many --all --target=test --parallel --ci --coverage --coverageReporters=lcov
- name: Run affected tests
if: github.event_name == 'pull_request'
run: npx nx affected --target=test --parallel --ci --coverage --coverageReporters=lcov
- name: Merge Coverage files
run: '[ -d "./coverage/" ] && ./node_modules/.bin/lcov-result-merger ./coverage/**/lcov.info ./coverage/lcov.info || exit 0'

Expand Down
1 change: 1 addition & 0 deletions libs/api/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './lib/auth.module';
export * from './lib/decorators/user.decorator';
3 changes: 3 additions & 0 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export class AuthInterceptor implements NestInterceptor {
req = ctx.getContext<KordisGqlContext>().req;
} else {
req = context.switchToHttp().getRequest<KordisRequest>();
if (req.path.startsWith('/webhooks')) {
return next.handle();
}
}

const possibleAuthUser = this.authUserExtractor.getUserFromRequest(req);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ export class ImplOrganizationRepository implements OrganizationRepository {
}

async findById(id: string): Promise<OrganizationEntity | null> {
const orgDoc = await this.organizationModel.findById(id).exec();
const orgDoc = await this.organizationModel.findById(id).lean().exec();

if (!orgDoc) {
return null;
}

return this.mapper.mapAsync(
orgDoc.toObject(),
orgDoc,
OrganizationDocument,
OrganizationEntity,
);
Expand Down
9 changes: 7 additions & 2 deletions libs/api/test-helpers/src/lib/mongo.test-helper.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Model } from 'mongoose';

export function mockModelMethodResult(
model: Model<unknown>,
document: Record<string, any>,
model: Model<any>,
document: Record<string, any> | null,
method: keyof Model<unknown>,
) {
const findByIdSpy = jest.spyOn(model, method as any);
Expand All @@ -12,6 +12,11 @@ export function mockModelMethodResult(
...document,
toObject: jest.fn().mockReturnValue(document),
}),
lean: jest.fn().mockReturnValue({
...document,
toObject: jest.fn().mockReturnValue(document),
exec: jest.fn().mockReturnValue(document),
}),
} as any);

return findByIdSpy;
Expand Down
18 changes: 18 additions & 0 deletions libs/api/tetra/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
15 changes: 15 additions & 0 deletions libs/api/tetra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Radio Communication System

This module covers the radio communication currently over tetra control. The
TetraControlService can send out Call Outs, SDS and Status changes. Also, a
webhook handler for incoming status changes is implemented. The webhook has to
be configured with a key via `TETRA_CONTROL_WEBHOOK_KEY`, which has to be
provided in Tetra Control
`<kordis-api-url>/webhooks/tetra-control?key=<safe-key>`. With
`TETRA_CONTROL_WEBHOOK_VALID_IPS`, a list of allowed IPs can be provided
JSPRH marked this conversation as resolved.
Show resolved Hide resolved
(delimited by a comma). If the list is empty, all IPs are allowed. If a new SDS
is received, a `NewTetraStatusEvent` is emitted.

## Running unit tests

Run `nx test api-tetra` to execute the unit tests via [Jest](https://jestjs.io).
11 changes: 11 additions & 0 deletions libs/api/tetra/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'api-tetra',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/api/tetra',
};
30 changes: 30 additions & 0 deletions libs/api/tetra/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "api-tetra",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/tetra/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/api/tetra/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/api/tetra/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"tags": []
}
2 changes: 2 additions & 0 deletions libs/api/tetra/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lib/infra/tetra.module';
export * from './lib/core/event/new-tetra-status.event';
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { EventBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';

import { TetraConfig } from '../entity/tetra-config.entitiy';
import { NewTetraStatusEvent } from '../event/new-tetra-status.event';
import { UnknownTetraControlWebhookKeyException } from '../exception/unknown-tetra-control-webhook-key.exception';
import { TetraControlStatusPayload } from '../model/tetra-control-status-payload.model';
import {
TETRA_CONFIG_REPOSITORY,
TetraConfigRepository,
} from '../repository/tetra-config.repository';
import { TETRA_SERVICE, TetraService } from '../service/tetra.service';
import { HandleTetraControlWebhookHandler } from './handle-tetra-control-webhook.command';

describe('HandleTetraControlWebhookHandler', () => {
let handler: HandleTetraControlWebhookHandler;
let tetraConfigRepository: DeepMocked<TetraConfigRepository>;
let eventBus: DeepMocked<EventBus>;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
HandleTetraControlWebhookHandler,
{ provide: TETRA_SERVICE, useValue: createMock<TetraService>() },
{
provide: TETRA_CONFIG_REPOSITORY,
useValue: createMock<TetraConfigRepository>(),
},
{ provide: EventBus, useValue: createMock<EventBus>() },
],
}).compile();

handler = moduleRef.get<HandleTetraControlWebhookHandler>(
HandleTetraControlWebhookHandler,
);
tetraConfigRepository = moduleRef.get<DeepMocked<TetraConfigRepository>>(
TETRA_CONFIG_REPOSITORY,
);
eventBus = moduleRef.get<DeepMocked<EventBus>>(EventBus);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should throw UnknownTetraControlWebhookKeyException when key not found', async () => {
tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce(null);

await expect(
handler.execute({
payload: { data: { type: 'status' } } as TetraControlStatusPayload,
key: 'test',
}),
).rejects.toThrow(UnknownTetraControlWebhookKeyException);
});

it('should dispatch new status', async () => {
tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce({
orgId: 'orgId',
} as TetraConfig);
const date = new Date('1998-09-16');
await handler.execute({
payload: {
data: { type: 'status', status: '1' },
sender: 'sender',
timestamp: `/Date(${date.getTime()})/`,
} as TetraControlStatusPayload,
key: 'test',
});

expect(eventBus.publish).toHaveBeenCalledWith(
new NewTetraStatusEvent('orgId', 'sender', 1, date, 'tetracontrol'),
);
});

it('should not dispatch new status when no', async () => {
tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce({
orgId: 'orgId',
} as TetraConfig);
const date = new Date('1998-09-16');
await handler.execute({
payload: {
data: { type: 'status', status: '1' },
sender: 'sender',
timestamp: `/Date(${date.getTime()})/`,
} as TetraControlStatusPayload,
key: 'test',
});

expect(eventBus.publish).toHaveBeenCalledWith(
new NewTetraStatusEvent('orgId', 'sender', 1, date, 'tetracontrol'),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';

import { NewTetraStatusEvent } from '../event/new-tetra-status.event';
import { UnhandledTetraControlWebhookTypeException } from '../exception/unhandled-tetra-control-webhook-type.exception';
import { UnknownTetraControlWebhookKeyException } from '../exception/unknown-tetra-control-webhook-key.exception';
import { TetraControlStatusPayload } from '../model/tetra-control-status-payload.model';
import {
TETRA_CONFIG_REPOSITORY,
TetraConfigRepository,
} from '../repository/tetra-config.repository';
import { TETRA_SERVICE, TetraService } from '../service/tetra.service';

export class HandleTetraControlWebhookCommand {
constructor(
readonly payload: TetraControlStatusPayload,
readonly key: string,
) {}
}

@CommandHandler(HandleTetraControlWebhookCommand)
export class HandleTetraControlWebhookHandler
implements ICommandHandler<HandleTetraControlWebhookCommand>
{
constructor(
@Inject(TETRA_SERVICE) private readonly tetraService: TetraService,
@Inject(TETRA_CONFIG_REPOSITORY)
private readonly tetraConfigRepository: TetraConfigRepository,
private readonly eventBus: EventBus,
) {}

async execute({
payload,
key,
}: HandleTetraControlWebhookCommand): Promise<void> {
const tetraConfig =
await this.tetraConfigRepository.findByWebhookAccessKey(key);
if (!tetraConfig) {
throw new UnknownTetraControlWebhookKeyException();
}

switch (payload.data.type) {
case 'status':
this.eventBus.publish(
new NewTetraStatusEvent(
tetraConfig.orgId,
payload.sender,
parseInt(payload.data.status),
this.getSanitizedTimestamp(payload.timestamp),
'tetracontrol',
),
);
break;
default:
throw new UnhandledTetraControlWebhookTypeException(payload.data.type);
}
}

private getSanitizedTimestamp(timestamp: string): Date {
const parsedTimestamp = /\/Date\((-?\d+)\)\//.exec(timestamp);
if (parsedTimestamp && parsedTimestamp.length > 1) {
return new Date(parseInt(parsedTimestamp[1]));
}

return new Date();
}
}
51 changes: 51 additions & 0 deletions libs/api/tetra/src/lib/core/command/send-tetra-sds.command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { DeepMocked, createMock } from '@golevelup/ts-jest';

import { TetraControlService } from '../../infra/service/tetra-control.service';
import { SdsNotAbleToSendException } from '../exception/sds-not-able-to-send.exception';
import {
SendTetraSDSCommand,
SendTetraSDSHandler,
} from './send-tetra-sds.command';

describe('SendTetraSDSHandler', () => {
let handler: SendTetraSDSHandler;
let tetraServiceMock: DeepMocked<TetraControlService>;

beforeEach(() => {
tetraServiceMock = createMock<TetraControlService>();
handler = new SendTetraSDSHandler(tetraServiceMock);
});

it('should call send sds through tetra service', async () => {
const orgId = 'orgId';
const issi = '12345';
const message = 'Test message';
const isFlash = true;

const command = new SendTetraSDSCommand(orgId, issi, message, isFlash);

await handler.execute(command);

expect(tetraServiceMock.sendSDS).toHaveBeenCalledWith(
orgId,
issi,
message,
isFlash,
);
});

it('should throw an error if tetraService.sendSDS throws an error', async () => {
const orgId = 'orgId';
const issi = '12345';
const message = 'Test message';
const isFlash = true;

const command = new SendTetraSDSCommand(orgId, issi, message, isFlash);

tetraServiceMock.sendSDS.mockRejectedValue(new Error());

await expect(handler.execute(command)).rejects.toThrow(
SdsNotAbleToSendException,
);
});
});
Loading