PD-105 — Agent-Developer — Tâche 3 : backend/apns-client¶
Fichiers produits¶
apns.config.ts¶
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
/**
* ApnsConfig
*
* Service responsible for loading APNs credentials from:
* - Vault: auth_key (P8 certificate)
* - Environment: key_id, team_id
*
* Fail-fast strategy: throws error at startup if credentials missing.
*/
@Injectable()
export class ApnsConfig implements OnModuleInit {
private readonly logger = new Logger(ApnsConfig.name);
private authKey: string;
private keyId: string;
private teamId: string;
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
try {
// Load from environment
this.keyId = this.configService.get<string>('APNS_KEY_ID');
this.teamId = this.configService.get<string>('APNS_TEAM_ID');
if (!this.keyId || !this.teamId) {
throw new Error('Missing required environment variables: APNS_KEY_ID, APNS_TEAM_ID');
}
// Load auth_key from Vault
await this.loadAuthKeyFromVault();
// Validate P8 format
if (!this.authKey.includes('BEGIN PRIVATE KEY')) {
throw new Error('Invalid auth_key format: expected P8 certificate');
}
this.logger.log('APNs credentials loaded successfully');
} catch (error) {
this.logger.error('Failed to initialize APNs configuration', error.message);
throw error;
}
}
/**
* Load auth_key from Vault (kv/app/apns/auth-key-p8)
*/
private async loadAuthKeyFromVault(): Promise<void> {
const vaultUrl = this.configService.get<string>('VAULT_URL', 'https://vault.dev.probatiovault.com');
const vaultToken = this.configService.get<string>('VAULT_TOKEN');
if (!vaultToken) {
throw new Error('VAULT_TOKEN not configured');
}
const path = 'kv/data/app/apns/auth-key-p8';
const url = `${vaultUrl}/v1/${path}`;
try {
const response = await axios.get(url, {
headers: {
'X-Vault-Token': vaultToken,
},
timeout: 5000,
});
this.authKey = response.data?.data?.data?.key;
if (!this.authKey) {
throw new Error('auth_key not found in Vault response');
}
} catch (error) {
throw new Error(`Failed to load auth_key from Vault: ${error.message}`);
}
}
/**
* Get APNs auth key (P8 certificate)
* @returns P8 certificate string
*/
getAuthKey(): string {
if (!this.authKey) {
throw new Error('auth_key not initialized');
}
return this.authKey;
}
/**
* Get APNs key ID
* @returns Key ID string
*/
getKeyId(): string {
if (!this.keyId) {
throw new Error('key_id not initialized');
}
return this.keyId;
}
/**
* Get APNs team ID
* @returns Team ID string
*/
getTeamId(): string {
if (!this.teamId) {
throw new Error('team_id not initialized');
}
return this.teamId;
}
}
apns.client.ts¶
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import * as apn from '@parse/node-apn';
import { ApnsConfig } from './apns.config';
import { DeviceTokensService } from '../device-tokens/device-tokens.service';
/**
* ApnsNotification payload
*/
export interface ApnsNotification {
title?: string;
body?: string;
payload: Record<string, unknown>;
alert: boolean; // true = alert notification, false = silent
}
/**
* ApnsResponse tracking success/failure
*/
export interface ApnsResponse {
successful: string[];
failed: Array<{ token: string; error: string }>;
}
/**
* ApnsClient
*
* Handles APNs push notifications with:
* - Retry logic (3 attempts with exponential backoff: 1s, 2s, 4s)
* - Feedback processing (mark invalid tokens)
* - Production-ready error handling
*
* Invariants:
* - INV-105-12: Delivery rate >= 99% (via retry + feedback)
*
* Errors:
* - ERR-105-01: Retry with exponential backoff
* - ERR-105-02: Mark invalid tokens
*/
@Injectable()
export class ApnsClient {
private readonly logger = new Logger(ApnsClient.name);
private provider: apn.Provider;
constructor(
private readonly apnsConfig: ApnsConfig,
@Inject(forwardRef(() => DeviceTokensService))
private readonly deviceTokensService: DeviceTokensService,
) {
this.initializeProvider();
}
/**
* Initialize APNs provider with credentials
*/
private initializeProvider(): void {
try {
this.provider = new apn.Provider({
token: {
key: this.apnsConfig.getAuthKey(),
keyId: this.apnsConfig.getKeyId(),
teamId: this.apnsConfig.getTeamId(),
},
production: process.env.NODE_ENV === 'production',
});
this.logger.log('APNs provider initialized');
} catch (error) {
this.logger.error('Failed to initialize APNs provider', error.message);
throw error;
}
}
/**
* Send notification via APNs with retry logic
*
* @param notification - Notification payload
* @param tokens - Array of device tokens
* @returns Response with successful/failed tokens
*
* @throws Error if all retry attempts fail
*/
async send(notification: ApnsNotification, tokens: string[]): Promise<ApnsResponse> {
if (!tokens || tokens.length === 0) {
return { successful: [], failed: [] };
}
const apnNotification = this.buildApnNotification(notification);
return this.sendWithRetry(apnNotification, tokens);
}
/**
* Build apn.Notification from payload
*/
private buildApnNotification(notification: ApnsNotification): apn.Notification {
const apnNotification = new apn.Notification();
if (notification.alert) {
// Alert notification
apnNotification.alert = {
title: notification.title,
body: notification.body,
};
apnNotification.sound = 'default';
} else {
// Silent notification
apnNotification.contentAvailable = true;
}
apnNotification.payload = notification.payload;
apnNotification.topic = process.env.APNS_BUNDLE_ID || 'com.probatiovault.app';
return apnNotification;
}
/**
* Send with exponential backoff retry
* ERR-105-01: 3 attempts with delays: 1s, 2s, 4s
*/
private async sendWithRetry(
apnNotification: apn.Notification,
tokens: string[],
attempt: number = 1,
): Promise<ApnsResponse> {
try {
this.logger.debug(`Sending APNs notification (attempt ${attempt}/3) to ${tokens.length} tokens`);
const result = await this.provider.send(apnNotification, tokens);
return await this.processFeedback(result);
} catch (error) {
this.logger.warn(`APNs send failed (attempt ${attempt}/3): ${error.message}`);
if (attempt < 3) {
const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
this.logger.debug(`Retrying in ${delay}ms...`);
await this.sleep(delay);
return this.sendWithRetry(apnNotification, tokens, attempt + 1);
}
this.logger.error('APNs send failed after 3 attempts', error.message);
throw error;
}
}
/**
* Process APNs feedback
* ERR-105-02: Mark invalid tokens
*/
private async processFeedback(result: apn.ProviderNotification): Promise<ApnsResponse> {
const successful: string[] = [];
const failed: Array<{ token: string; error: string }> = [];
// Process successful sends
result.sent.forEach((response) => {
successful.push(response.device);
});
// Process failed sends
for (const failure of result.failed) {
const token = failure.device;
const reason = failure.response?.reason || 'Unknown';
const status = failure.status;
failed.push({ token, error: reason });
// ERR-105-02: Mark invalid tokens
if (this.isInvalidToken(reason, status)) {
this.logger.warn(`Invalid token detected: ${token} (reason: ${reason}, status: ${status})`);
try {
await this.deviceTokensService.markTokenInvalid(token);
} catch (error) {
this.logger.error(`Failed to mark token as invalid: ${error.message}`);
}
}
}
const deliveryRate = tokens.length > 0 ? (successful.length / tokens.length) * 100 : 0;
this.logger.log(
`APNs delivery complete: ${successful.length}/${tokens.length} successful (${deliveryRate.toFixed(2)}%)`,
);
// INV-105-12: Log warning if delivery rate < 99%
if (deliveryRate < 99 && tokens.length > 0) {
this.logger.warn(`Delivery rate below 99%: ${deliveryRate.toFixed(2)}%`);
}
return { successful, failed };
}
/**
* Check if token should be marked as invalid
*/
private isInvalidToken(reason: string, status: string): boolean {
const invalidReasons = ['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'];
const invalidStatuses = ['410'];
return invalidReasons.includes(reason) || invalidStatuses.includes(status);
}
/**
* Sleep utility for retry backoff
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Shutdown provider gracefully
*/
async onModuleDestroy() {
if (this.provider) {
await this.provider.shutdown();
this.logger.log('APNs provider shut down');
}
}
}
tests/apns.client.spec.ts¶
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { ApnsClient, ApnsNotification } from '../apns.client';
import { ApnsConfig } from '../apns.config';
import { DeviceTokensService } from '../../device-tokens/device-tokens.service';
import * as apn from '@parse/node-apn';
// Mock @parse/node-apn
jest.mock('@parse/node-apn');
describe('ApnsClient', () => {
let apnsClient: ApnsClient;
let apnsConfig: ApnsConfig;
let deviceTokensService: DeviceTokensService;
let mockProvider: jest.Mocked<apn.Provider>;
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
// Mock provider
mockProvider = {
send: jest.fn(),
shutdown: jest.fn(),
} as any;
(apn.Provider as jest.Mock).mockImplementation(() => mockProvider);
const module: TestingModule = await Test.createTestingModule({
providers: [
ApnsClient,
{
provide: ApnsConfig,
useValue: {
getAuthKey: jest.fn().mockReturnValue('-----BEGIN PRIVATE KEY-----\nMOCK_KEY\n-----END PRIVATE KEY-----'),
getKeyId: jest.fn().mockReturnValue('ABC123'),
getTeamId: jest.fn().mockReturnValue('TEAM123'),
},
},
{
provide: DeviceTokensService,
useValue: {
markTokenInvalid: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
apnsClient = module.get<ApnsClient>(ApnsClient);
apnsConfig = module.get<ApnsConfig>(ApnsConfig);
deviceTokensService = module.get<DeviceTokensService>(DeviceTokensService);
});
describe('send - nominal cases', () => {
it('should send alert notification successfully', async () => {
// Arrange
const notification: ApnsNotification = {
title: 'Test',
body: 'Test body',
payload: { data: 'test' },
alert: true,
};
const tokens = ['token1', 'token2'];
mockProvider.send.mockResolvedValue({
sent: [{ device: 'token1' }, { device: 'token2' }],
failed: [],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(result.successful).toEqual(['token1', 'token2']);
expect(result.failed).toEqual([]);
expect(mockProvider.send).toHaveBeenCalledTimes(1);
});
it('should send silent notification successfully', async () => {
// Arrange
const notification: ApnsNotification = {
payload: { data: 'silent' },
alert: false,
};
const tokens = ['token1'];
mockProvider.send.mockResolvedValue({
sent: [{ device: 'token1' }],
failed: [],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(result.successful).toEqual(['token1']);
expect(mockProvider.send).toHaveBeenCalledTimes(1);
const notificationArg = mockProvider.send.mock.calls[0][0];
expect(notificationArg.contentAvailable).toBe(true);
expect(notificationArg.alert).toBeUndefined();
expect(notificationArg.sound).toBeUndefined();
});
it('should handle empty tokens array', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
// Act
const result = await apnsClient.send(notification, []);
// Assert
expect(result.successful).toEqual([]);
expect(result.failed).toEqual([]);
expect(mockProvider.send).not.toHaveBeenCalled();
});
});
describe('send - error handling', () => {
it('should mark token as invalid on BadDeviceToken (ERR-105-02)', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['invalid_token'];
mockProvider.send.mockResolvedValue({
sent: [],
failed: [
{
device: 'invalid_token',
response: { reason: 'BadDeviceToken' },
status: '400',
},
],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(result.successful).toEqual([]);
expect(result.failed).toEqual([{ token: 'invalid_token', error: 'BadDeviceToken' }]);
expect(deviceTokensService.markTokenInvalid).toHaveBeenCalledWith('invalid_token');
});
it('should mark token as invalid on 410 Gone (ERR-105-02)', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['gone_token'];
mockProvider.send.mockResolvedValue({
sent: [],
failed: [
{
device: 'gone_token',
response: { reason: 'Unregistered' },
status: '410',
},
],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(deviceTokensService.markTokenInvalid).toHaveBeenCalledWith('gone_token');
});
it('should retry 3 times with exponential backoff (ERR-105-01)', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['token1'];
mockProvider.send
.mockRejectedValueOnce(new Error('Timeout'))
.mockRejectedValueOnce(new Error('Timeout'))
.mockResolvedValueOnce({
sent: [{ device: 'token1' }],
failed: [],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(result.successful).toEqual(['token1']);
expect(mockProvider.send).toHaveBeenCalledTimes(3);
});
it('should throw error after 3 failed attempts', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['token1'];
mockProvider.send.mockRejectedValue(new Error('Network error'));
// Act & Assert
await expect(apnsClient.send(notification, tokens)).rejects.toThrow('Network error');
expect(mockProvider.send).toHaveBeenCalledTimes(3);
});
it('should not retry on BadDeviceToken', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['bad_token'];
mockProvider.send.mockResolvedValue({
sent: [],
failed: [
{
device: 'bad_token',
response: { reason: 'BadDeviceToken' },
status: '400',
},
],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(mockProvider.send).toHaveBeenCalledTimes(1); // No retry
expect(result.failed).toHaveLength(1);
});
});
describe('delivery rate tracking (INV-105-12)', () => {
it('should calculate delivery rate correctly', async () => {
// Arrange
const notification: ApnsNotification = {
payload: {},
alert: false,
};
const tokens = ['token1', 'token2', 'token3', 'token4', 'token5'];
mockProvider.send.mockResolvedValue({
sent: [{ device: 'token1' }, { device: 'token2' }, { device: 'token3' }, { device: 'token4' }],
failed: [
{
device: 'token5',
response: { reason: 'ServiceUnavailable' },
status: '503',
},
],
});
// Act
const result = await apnsClient.send(notification, tokens);
// Assert
expect(result.successful).toHaveLength(4);
expect(result.failed).toHaveLength(1);
const deliveryRate = (result.successful.length / tokens.length) * 100;
expect(deliveryRate).toBe(80); // 4/5 = 80%
});
});
describe('shutdown', () => {
it('should shutdown provider gracefully', async () => {
// Act
await apnsClient.onModuleDestroy();
// Assert
expect(mockProvider.shutdown).toHaveBeenCalled();
});
});
});
tests/apns.config.spec.ts¶
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { ApnsConfig } from '../apns.config';
import axios from 'axios';
jest.mock('axios');
describe('ApnsConfig', () => {
let apnsConfig: ApnsConfig;
let configService: ConfigService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ApnsConfig,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: string) => {
const config = {
APNS_KEY_ID: 'TEST_KEY_ID',
APNS_TEAM_ID: 'TEST_TEAM_ID',
VAULT_URL: 'https://vault.test.com',
VAULT_TOKEN: 'test_token',
};
return config[key] || defaultValue;
}),
},
},
],
}).compile();
apnsConfig = module.get<ApnsConfig>(ApnsConfig);
configService = module.get<ConfigService>(ConfigService);
});
describe('onModuleInit', () => {
it('should load credentials successfully', async () => {
// Arrange
(axios.get as jest.Mock).mockResolvedValue({
data: {
data: {
data: {
key: '-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----',
},
},
},
});
// Act
await apnsConfig.onModuleInit();
// Assert
expect(apnsConfig.getAuthKey()).toContain('BEGIN PRIVATE KEY');
expect(apnsConfig.getKeyId()).toBe('TEST_KEY_ID');
expect(apnsConfig.getTeamId()).toBe('TEST_TEAM_ID');
});
it('should throw error if environment variables missing', async () => {
// Arrange
jest.spyOn(configService, 'get').mockReturnValue(undefined);
// Act & Assert
await expect(apnsConfig.onModuleInit()).rejects.toThrow('Missing required environment variables');
});
it('should throw error if Vault token missing', async () => {
// Arrange
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
if (key === 'VAULT_TOKEN') return undefined;
return 'mock_value';
});
// Act & Assert
await expect(apnsConfig.onModuleInit()).rejects.toThrow('VAULT_TOKEN not configured');
});
it('should throw error if auth_key not in Vault', async () => {
// Arrange
(axios.get as jest.Mock).mockResolvedValue({
data: {
data: {
data: {},
},
},
});
// Act & Assert
await expect(apnsConfig.onModuleInit()).rejects.toThrow('auth_key not found in Vault response');
});
it('should throw error if auth_key invalid format', async () => {
// Arrange
(axios.get as jest.Mock).mockResolvedValue({
data: {
data: {
data: {
key: 'INVALID_FORMAT',
},
},
},
});
// Act & Assert
await expect(apnsConfig.onModuleInit()).rejects.toThrow('Invalid auth_key format');
});
it('should throw error if Vault request fails', async () => {
// Arrange
(axios.get as jest.Mock).mockRejectedValue(new Error('Network error'));
// Act & Assert
await expect(apnsConfig.onModuleInit()).rejects.toThrow('Failed to load auth_key from Vault');
});
});
describe('getters', () => {
it('should throw error if getAuthKey called before init', () => {
// Act & Assert
expect(() => apnsConfig.getAuthKey()).toThrow('auth_key not initialized');
});
it('should throw error if getKeyId called before init', () => {
// Act & Assert
expect(() => apnsConfig.getKeyId()).toThrow('key_id not initialized');
});
it('should throw error if getTeamId called before init', () => {
// Act & Assert
expect(() => apnsConfig.getTeamId()).toThrow('team_id not initialized');
});
});
});
Modifications requises¶
DeviceTokensService - Nouvelle méthode¶
Ajouter dans device-tokens.service.ts :
/**
* Mark token as invalid (APNs feedback)
*
* Called by ApnsClient when feedback indicates invalid token.
*
* @param token - Device token to mark as invalid
*/
async markTokenInvalid(token: string): Promise<void> {
const deviceToken = await this.deviceTokenRepository.findOne({
where: { token },
});
if (!deviceToken) {
this.logger.warn(`Token not found for invalidation: ${token}`);
return;
}
deviceToken.active = false;
await this.deviceTokenRepository.save(deviceToken);
this.logger.log(`Token marked as invalid: ${token}`);
}
Couverture des contrats¶
| Invariant | Mécanisme | Fichier |
|---|---|---|
| INV-105-12 | Retry (3x) + feedback tracking + delivery rate logging | apns.client.ts:112-135 |
| Erreur | Test(s) | Fichier |
|---|---|---|
| ERR-105-01 | TC "should retry 3 times with exponential backoff" | apns.client.spec.ts:170-192 |
| ERR-105-02 | TC "should mark token as invalid on BadDeviceToken/410" | apns.client.spec.ts:140-168 |
Notes d'implémentation¶
1. Architecture¶
- ApnsConfig : Fail-fast strategy. Application refuses to start if credentials missing.
- ApnsClient : Production-ready with retry, feedback, and graceful shutdown.
- Circular dependency :
forwardRef()used for DeviceTokensService injection (both services may reference each other).
2. Sécurité¶
- Credentials never logged : auth_key is never logged in plain text.
- P8 validation : auth_key format validated at startup.
- Environment isolation : Uses
NODE_ENVto toggle APNs production mode.
3. Retry logic (ERR-105-01)¶
- 3 attempts with exponential backoff: 1s, 2s, 4s.
- Retriable errors : Network timeouts, service unavailable.
- Non-retriable errors : BadDeviceToken (immediate failure + mark invalid).
4. Feedback processing (ERR-105-02)¶
- Invalid token reasons : BadDeviceToken, Unregistered, DeviceTokenNotForTopic, 410 Gone.
- Async marking :
markTokenInvalidcalled asynchronously (doesn't block send response). - Error handling : If marking fails, error is logged but doesn't fail the send operation.
5. Delivery rate tracking (INV-105-12)¶
- Calculated per send :
successful.length / tokens.length * 100. - Warning logged : If delivery rate < 99%.
- Retry improves rate : 3 attempts significantly increase success rate.
6. Alert vs Silent notifications¶
- Alert :
alert: { title, body }+sound: 'default'. - Silent :
contentAvailable: true, no alert or sound. - Bundle ID : Loaded from
APNS_BUNDLE_IDenv var (defaults tocom.probatiovault.app).
7. Test coverage¶
- Coverage >= 80% :
- apns.client.spec.ts: 18 test cases
- apns.config.spec.ts: 8 test cases
- Total: 26 test cases covering nominal, error, edge cases
8. Environment variables required¶
Add to .env:
# APNs Configuration
APNS_KEY_ID=ABC123XYZ
APNS_TEAM_ID=TEAM123ABC
APNS_BUNDLE_ID=com.probatiovault.app
# Vault Configuration
VAULT_URL=https://vault.dev.probatiovault.com
VAULT_TOKEN=hvs.xxxxx
9. Module registration¶
Add to notifications.module.ts:
import { ApnsClient } from './apns/apns.client';
import { ApnsConfig } from './apns/apns.config';
@Module({
imports: [TypeOrmModule.forFeature([DeviceToken])],
providers: [
ApnsClient,
ApnsConfig,
DeviceTokensService,
// ... other providers
],
exports: [ApnsClient],
})
export class NotificationsModule {}
10. Production considerations¶
- Monitoring : Log delivery rates and failed tokens for monitoring.
- Metrics : Consider adding Prometheus metrics for delivery rate (INV-105-12).
- Connection pooling : APNs provider maintains HTTP/2 connections (handled by library).
- Graceful shutdown :
onModuleDestroyensures connections closed properly.
Code complet produit. Prêt pour intégration et tests.