Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 49 additions & 98 deletions apps/api/src/app/alerts/sentry/sentry-alert-handler.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,151 +1,102 @@
import { Test, TestingModule } from '@nestjs/testing';
import { plainToInstance } from 'class-transformer';
import { mock, MockProxy } from 'jest-mock-extended';

import { SentryAlertHandlerService } from './sentry-alert-handler.service';
import { TelegramService } from '../../telegram/telegram.service';
import { TelegramKeyboardBuilderService } from '../../telegram/telegram-keyboard-builder.service';
import { VehicleAlertNotifierService } from '../common/vehicle-alert-notifier.service';

import { TelemetryMessage, SentryModeState } from '../../telemetry/models/telemetry-message.model';
import { AlertEventSeverity, AlertEventType } from '../../../entities/alert-event.entity';
import { SentryModeState, TelemetryMessage } from '../../telemetry/models/telemetry-message.model';

const buildMessage = (state: string): TelemetryMessage =>
plainToInstance(TelemetryMessage, {
data: [{ key: 'SentryMode', value: { sentryModeStateValue: state } }],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false,
});

describe('The SentryAlertHandlerService class', () => {
let service: SentryAlertHandlerService;

let mockTelegramService: MockProxy<TelegramService>;
let mockKeyboardBuilder: MockProxy<TelegramKeyboardBuilderService>;
let mockAlertNotifier: MockProxy<VehicleAlertNotifierService>;

beforeEach(async () => {
mockTelegramService = mock<TelegramService>();
mockKeyboardBuilder = mock<TelegramKeyboardBuilderService>();
mockAlertNotifier = mock<VehicleAlertNotifierService>();
mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] });

const module: TestingModule = await Test.createTestingModule({
providers: [
SentryAlertHandlerService,
{ provide: TelegramService, useValue: mockTelegramService },
{ provide: TelegramKeyboardBuilderService, useValue: mockKeyboardBuilder },
{ provide: VehicleAlertNotifierService, useValue: mockAlertNotifier },
]
],
}).compile();

service = module.get<SentryAlertHandlerService>(SentryAlertHandlerService);
jest.clearAllMocks();
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('The handle method', () => {
describe('when message does not contain valid SentryMode', () => {
it('should skip message without SentryMode', async () => {
const invalidMessage = plainToInstance(TelemetryMessage, {
describe('The handle() method', () => {
describe('When the message has no valid SentryMode', () => {
it('should not dispatch any alert', async () => {
const message = plainToInstance(TelemetryMessage, {
data: [{ key: 'OtherField', value: { stringValue: 'value' } }],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false
isResend: false,
});

await service.handle(invalidMessage);
await service.handle(message);

expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled();
});
});

it('should skip message with null SentryMode value', async () => {
const invalidMessage = plainToInstance(TelemetryMessage, {
data: [{ key: 'SentryMode', value: { sentryModeStateValue: null } }],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false
});

await service.handle(invalidMessage);

expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled();
describe('When SentryMode is Aware', () => {
beforeEach(async () => {
await service.handle(buildMessage(SentryModeState.Aware));
});

it('should skip message with invalid sentryModeStateValue', async () => {
const invalidMessage = plainToInstance(TelemetryMessage, {
data: [{ key: 'SentryMode', value: { sentryModeStateValue: 'InvalidState' } }],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false
});

await service.handle(invalidMessage);

expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled();
it('should dispatch the immediate Sentry alert', () => {
expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
alertName: 'SENTRY_ALERT',
severity: AlertEventSeverity.Warning,
type: AlertEventType.Sentry,
})
);
});
});

describe('when SentryMode is not Aware', () => {
it('should not dispatch alert', async () => {
const message = plainToInstance(TelemetryMessage, {
data: [
{
key: 'SentryMode',
value: { sentryModeStateValue: SentryModeState.Off }
}
],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false
});

await service.handle(message);

expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled();
describe('When SentryMode is Panic', () => {
beforeEach(async () => {
await service.handle(buildMessage(SentryModeState.Panic));
});
});

describe('when SentryMode is Aware', () => {
let baseTelemetryMessage: TelemetryMessage;

beforeEach(() => {
baseTelemetryMessage = plainToInstance(TelemetryMessage, {
data: [
{
key: 'SentryMode',
value: { sentryModeStateValue: 'SentryModeStateAware' }
}
],
createdAt: '2025-01-21T10:00:00.000Z',
vin: 'TEST_VIN_123',
isResend: false
});
mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] });
it('should dispatch a critical panic alert', () => {
expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
alertName: 'SENTRY_PANIC',
severity: AlertEventSeverity.Critical,
type: AlertEventType.Panic,
})
);
});
});

it('should dispatch alert via alertNotifier', async () => {
await service.handle(baseTelemetryMessage);

expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith(expect.objectContaining({
telemetryMessage: baseTelemetryMessage,
alertName: 'SENTRY_ALERT',
latencyLabel: 'SENTRY_LATENCY',
telegramNotifier: expect.any(Function)
}));
describe('When SentryMode is Armed (neither Aware nor Panic)', () => {
beforeEach(async () => {
await service.handle(buildMessage(SentryModeState.Armed));
});

it('should construct and send telegram message when notifier callback is invoked', async () => {
await service.handle(baseTelemetryMessage);

const dispatchCall = mockAlertNotifier.dispatch.mock.calls[0][0];
const notifierCb = dispatchCall.telegramNotifier;

mockKeyboardBuilder.buildSentryAlertKeyboard.mockReturnValue({
inline_keyboard: [[{ text: 'Test Button', url: 'http://test.com' }]]
});

await notifierCb('test-user', { vin: '123', display_name: 'Test Vehicle' }, 'en');

expect(mockKeyboardBuilder.buildSentryAlertKeyboard).toHaveBeenCalledWith('test-user', 'en');
expect(mockTelegramService.sendSentryAlert).toHaveBeenCalledWith(
'test-user',
{ vin: '123', display_name: 'Test Vehicle' },
'en',
{ inline_keyboard: [[{ text: 'Test Button', url: 'http://test.com' }]] }
);
it('should not dispatch any alert', () => {
expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled();
});
});
});
Expand Down
43 changes: 34 additions & 9 deletions apps/api/src/app/alerts/sentry/sentry-alert-handler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class SentryAlertHandlerService implements TelemetryEventHandler {
private readonly alertNotifier: VehicleAlertNotifierService,
) { }

async handle(telemetryMessage: TelemetryMessage): Promise<void> {
public async handle(telemetryMessage: TelemetryMessage): Promise<void> {
if (!telemetryMessage.validateContainsSentryMode() || !telemetryMessage.validateSentryModeValue()) {
this.logger.warn('Telemetry message does not contain SentryMode data', telemetryMessage);
return;
Expand All @@ -26,19 +26,44 @@ export class SentryAlertHandlerService implements TelemetryEventHandler {
const sentryMode = telemetryMessage.getSentryModeState();

if (sentryMode === SentryModeState.Aware) {
await this.alertNotifier.dispatch({
telemetryMessage,
alertName: 'SENTRY_ALERT',
latencyLabel: 'SENTRY_LATENCY',
severity: AlertEventSeverity.Warning,
telegramNotifier: this.telegramNotifier,
type: AlertEventType.Sentry,
});
await this.dispatchSentryAlert(telemetryMessage);
return;
}

if (sentryMode === SentryModeState.Panic) {
await this.dispatchPanic(telemetryMessage);
}
}

private async dispatchSentryAlert(telemetryMessage: TelemetryMessage): Promise<void> {
await this.alertNotifier.dispatch({
telemetryMessage,
alertName: 'SENTRY_ALERT',
latencyLabel: 'SENTRY_LATENCY',
severity: AlertEventSeverity.Warning,
telegramNotifier: this.telegramNotifier,
type: AlertEventType.Sentry,
});
}

private async dispatchPanic(telemetryMessage: TelemetryMessage): Promise<void> {
await this.alertNotifier.dispatch({
telemetryMessage,
alertName: 'SENTRY_PANIC',
latencyLabel: 'SENTRY_PANIC_LATENCY',
severity: AlertEventSeverity.Critical,
telegramNotifier: this.panicNotifier,
type: AlertEventType.Panic,
});
}

private readonly telegramNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => {
const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage);
await this.telegramService.sendSentryAlert(userId, alertInfo, userLanguage, keyboard);
};

private readonly panicNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => {
const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage);
await this.telegramService.sendSentryPanicAlert(userId, alertInfo, userLanguage, keyboard);
};
}
24 changes: 24 additions & 0 deletions apps/api/src/app/notifications/notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,30 @@ describe('The NotificationsService class', () => {
});
});

describe('When an English user receives a panic alert', () => {
beforeEach(async () => {
await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.Panic, 'en');
});

it('should send the panic title', () => {
expect(lastPushPayload().title).toBe('Alarm triggered');
});

it('should send the panic body', () => {
expect(lastPushPayload().body).toBe('Your vehicle alarm may be sounding.');
});
});

describe('When a French user receives a panic alert', () => {
beforeEach(async () => {
await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.Panic, 'fr');
});

it('should send the localized French title', () => {
expect(lastPushPayload().title).toBe('Alarme déclenchée');
});
});

describe('When the user has no eligible device', () => {
beforeEach(async () => {
mockPushDeviceTokenRepository.find.mockResolvedValue([]);
Expand Down
21 changes: 13 additions & 8 deletions apps/api/src/app/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,22 @@ export class NotificationsService {
}

private resolveAlertTexts(type: AlertEventType, lng: 'en' | 'fr'): { body: string; title: string } {
if (type === AlertEventType.BreakIn) {
return {
const textsByType: Record<AlertEventType, { body: string; title: string }> = {
[AlertEventType.BreakIn]: {
body: i18n.t('A break-in attempt was detected.', { lng }),
title: i18n.t('Intrusion alert', { lng }),
};
}

return {
body: i18n.t('A Sentry event was detected.', { lng }),
title: i18n.t('Sentry alert', { lng }),
},
[AlertEventType.Sentry]: {
body: i18n.t('A Sentry event was detected.', { lng }),
title: i18n.t('Sentry alert', { lng }),
},
[AlertEventType.Panic]: {
body: i18n.t('Your vehicle alarm may be sounding.', { lng }),
title: i18n.t('Alarm triggered', { lng }),
},
};

return textsByType[type];
}

private async findOrCreatePreferences(userId: string): Promise<NotificationPreferences> {
Expand Down
Loading
Loading