Skip to content

Commit

Permalink
feat(api-radio-communication-system): introduce TETRA SDS, Call Out a…
Browse files Browse the repository at this point in the history
…nd Status updates (#629)

Co-authored-by: Jasper Herzberg <jhrzbrg@outlook.com>
  • Loading branch information
timonmasberg and JSPRH authored Feb 16, 2024
1 parent 3a284b6 commit 9e3efef
Show file tree
Hide file tree
Showing 38 changed files with 1,212 additions and 26 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
errorFormatterFactory,
getMongoEncrKmsFromConfig,
} from '@kordis/api/shared';
import { TetraModule } from '@kordis/api/tetra';
import { UsersModule } from '@kordis/api/user';

import { AppResolver } from './app.resolver';
Expand All @@ -29,6 +30,7 @@ const isNextOrProdEnv = ['next', 'prod'].includes(

const FEATURE_MODULES = [
OrganizationModule,
TetraModule,
UsersModule.forRoot(process.env.AUTH_PROVIDER === 'dev' ? 'dev' : 'aadb2c'),
];
const UTILITY_MODULES = [
Expand Down
49 changes: 28 additions & 21 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { CallHandler } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, firstValueFrom, of } from 'rxjs';

Expand Down Expand Up @@ -37,7 +37,7 @@ describe('AuthInterceptor', () => {
await expect(
firstValueFrom(
await service.intercept(
createMock<ExecutionContext>(),
createGqlContextForRequest(createMock<KordisRequest>()),
createMock<CallHandler>(),
),
),
Expand All @@ -60,7 +60,7 @@ describe('AuthInterceptor', () => {
await expect(
firstValueFrom(
await service.intercept(
createMock<ExecutionContext>(),
createGqlContextForRequest(createMock<KordisRequest>()),
createMock<CallHandler>(),
),
),
Expand Down Expand Up @@ -92,31 +92,38 @@ describe('AuthInterceptor', () => {
firstValueFrom(await service.intercept(gqlCtx, handler)),
).resolves.toBeTruthy();

const httpCtx = createHttpContextForRequest(createMock<KordisRequest>());
const httpCtx = createHttpContextForRequest(
createMock<KordisRequest>({
path: '/graphql',
}),
);

await expect(
firstValueFrom(await service.intercept(httpCtx, handler)),
).resolves.toBeTruthy();
});

it('should continue request pipeline for health-check request', async () => {
const handler = createMock<CallHandler>({
handle(): Observable<boolean> {
return of(true);
},
});
it.each(['/health-check', '/webhooks/foo', '/webhooks/bar'])(
'should continue request pipeline for %p and webhook request',
async (path: string) => {
const handler = createMock<CallHandler>({
handle(): Observable<boolean> {
return of(true);
},
});

await expect(
firstValueFrom(
await service.intercept(
createHttpContextForRequest(
createMock<KordisRequest>({
path: '/health-check',
}),
await expect(
firstValueFrom(
await service.intercept(
createHttpContextForRequest(
createMock<KordisRequest>({
path,
}),
),
handler,
),
handler,
),
),
).resolves.toBeTruthy();
});
).resolves.toBeTruthy();
},
);
});
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 @@ -38,7 +38,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();
}
}
Loading

0 comments on commit 9e3efef

Please sign in to comment.