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
3 changes: 2 additions & 1 deletion apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
errorFormatterFactory,
getMongoEncrKmsFromConfig,
} from '@kordis/api/shared';
import { TetraModule } from '@kordis/api/tetra';

import { AppResolver } from './app.resolver';
import { AppService } from './app.service';
Expand All @@ -27,7 +28,7 @@ const isNextOrProdEnv = ['next', 'prod'].includes(
process.env.ENVIRONMENT_NAME ?? '',
);

const FEATURE_MODULES = [OrganizationModule];
const FEATURE_MODULES = [OrganizationModule, TetraModule];
const UTILITY_MODULES = [
SharedKernel,
AuthModule.forRoot(isNextOrProdEnv ? 'aadb2c' : 'dev'),
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';
2 changes: 1 addition & 1 deletion libs/api/auth/src/lib/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class AuthInterceptor implements NestInterceptor {
} else {
req = context.switchToHttp().getRequest<KordisRequest>();

if (req.path === '/health-check') {
if (req.path === '/health-check' || req.path.startsWith('/webhooks')) {
return next.handle();
}
}
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": {}
}
]
}
17 changes: 17 additions & 0 deletions libs/api/tetra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Radio Communication System

This module covers the radio communication currently via tetra control. The
`TetraControlService` can send Call Outs, SDS and Status changes. Also, a
webhook handler for incoming status changes is implemented.

The configuraton for Tetra (credentials for sending and receiving data, and the
API URL of the Tetra server) are stored in the database per tenant. When the
incoming webhook is called, Kordis retrieves the Tetra settings by the provided
API key. The webhook for incoming requests is
`<kordis-api-url>/webhooks/tetra-control?key=<safe-key>`.

Upon receiving a new status change, 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