feat(attendance): add notification delivery and NFC security foundation
This commit is contained in:
@@ -48,13 +48,30 @@ function createMobileHandlers() {
|
||||
invoiceId: payload.invoiceId,
|
||||
status: 'APPROVED',
|
||||
}),
|
||||
registerClientPushToken: async (_actor, payload) => ({
|
||||
tokenId: 'push-token-client-1',
|
||||
platform: payload.platform,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
}),
|
||||
unregisterClientPushToken: async () => ({
|
||||
removedCount: 1,
|
||||
}),
|
||||
applyForShift: async (_actor, payload) => ({
|
||||
shiftId: payload.shiftId,
|
||||
status: 'APPLIED',
|
||||
}),
|
||||
registerStaffPushToken: async (_actor, payload) => ({
|
||||
tokenId: 'push-token-staff-1',
|
||||
platform: payload.platform,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
}),
|
||||
unregisterStaffPushToken: async () => ({
|
||||
removedCount: 1,
|
||||
}),
|
||||
staffClockIn: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
status: 'CLOCK_IN',
|
||||
proofNonce: payload.proofNonce || null,
|
||||
}),
|
||||
staffClockOut: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
@@ -157,6 +174,36 @@ test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice
|
||||
assert.equal(res.body.status, 'APPROVED');
|
||||
});
|
||||
|
||||
test('POST /commands/client/devices/push-tokens registers a client push token', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/devices/push-tokens')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-push-token-1')
|
||||
.send({
|
||||
provider: 'FCM',
|
||||
platform: 'IOS',
|
||||
pushToken: 'f'.repeat(160),
|
||||
deviceId: 'iphone-15-pro',
|
||||
notificationsEnabled: true,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.tokenId, 'push-token-client-1');
|
||||
assert.equal(res.body.platform, 'IOS');
|
||||
});
|
||||
|
||||
test('DELETE /commands/client/devices/push-tokens accepts tokenId from query params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.delete('/commands/client/devices/push-tokens?tokenId=11111111-1111-4111-8111-111111111111&reason=SMOKE_CLEANUP')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-push-token-delete-1');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.removedCount, 1);
|
||||
});
|
||||
|
||||
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
@@ -183,11 +230,13 @@ test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
|
||||
sourceType: 'GEO',
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
proofNonce: 'nonce-12345678',
|
||||
overrideReason: 'GPS timed out near the hub',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.status, 'CLOCK_IN');
|
||||
assert.equal(res.body.proofNonce, 'nonce-12345678');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
|
||||
@@ -230,6 +279,35 @@ test('POST /commands/staff/location-streams accepts batched location payloads',
|
||||
assert.equal(res.body.pointCount, 1);
|
||||
});
|
||||
|
||||
test('POST /commands/staff/devices/push-tokens registers a staff push token', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/devices/push-tokens')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'staff-push-token-1')
|
||||
.send({
|
||||
provider: 'FCM',
|
||||
platform: 'ANDROID',
|
||||
pushToken: 'g'.repeat(170),
|
||||
deviceId: 'pixel-9',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.tokenId, 'push-token-staff-1');
|
||||
assert.equal(res.body.platform, 'ANDROID');
|
||||
});
|
||||
|
||||
test('DELETE /commands/staff/devices/push-tokens accepts tokenId from query params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.delete('/commands/staff/devices/push-tokens?tokenId=22222222-2222-4222-8222-222222222222&reason=SMOKE_CLEANUP')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'staff-push-token-delete-1');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.removedCount, 1);
|
||||
});
|
||||
|
||||
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
|
||||
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeRetryDelayMinutes } from '../src/services/notification-dispatcher.js';
|
||||
import { createPushSender, classifyMessagingError } from '../src/services/notification-fcm.js';
|
||||
|
||||
test('computeRetryDelayMinutes backs off exponentially with a cap', () => {
|
||||
assert.equal(computeRetryDelayMinutes(1), 5);
|
||||
assert.equal(computeRetryDelayMinutes(2), 10);
|
||||
assert.equal(computeRetryDelayMinutes(3), 20);
|
||||
assert.equal(computeRetryDelayMinutes(5), 60);
|
||||
assert.equal(computeRetryDelayMinutes(9), 60);
|
||||
});
|
||||
|
||||
test('classifyMessagingError distinguishes invalid and retryable push failures', () => {
|
||||
assert.equal(classifyMessagingError('messaging/registration-token-not-registered'), 'INVALID_TOKEN');
|
||||
assert.equal(classifyMessagingError('messaging/server-unavailable'), 'RETRYABLE');
|
||||
assert.equal(classifyMessagingError('messaging/unknown-problem'), 'FAILED');
|
||||
});
|
||||
|
||||
test('createPushSender log-only mode simulates successful delivery results', async () => {
|
||||
const sender = createPushSender({ deliveryMode: 'log-only' });
|
||||
const results = await sender.send(
|
||||
{
|
||||
id: 'notification-1',
|
||||
notification_type: 'SHIFT_START_REMINDER',
|
||||
priority: 'HIGH',
|
||||
tenant_id: 'tenant-1',
|
||||
payload: { assignmentId: 'assignment-1' },
|
||||
},
|
||||
[
|
||||
{ id: 'token-1', provider: 'FCM', pushToken: 'demo-token' },
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].deliveryStatus, 'SIMULATED');
|
||||
assert.equal(results[0].transient, false);
|
||||
});
|
||||
Reference in New Issue
Block a user