Files
edr-platform/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts
Nathnael 2dfea96cb8 fix(chat): retry rate limits, lock instead of deactivate, guard reconcile
Provisioning created nothing on dev. Synapse answers a burst of writes with
429 + retry_after_ms, and a reconcile is nothing but a burst of writes, so the
first throttled m.space.child PUT threw and took the whole run with it. On the
sign-in path ChatSsoService catches that by design, so every employee got a
working sign-in into an Element with no rooms in it. fetchWithRetry now sits
behind all three request wrappers and honours the delay Synapse asks for,
capped at 5 attempts so a wedged homeserver still fails rather than hangs.

Two ways the reconcile could destroy state, both now blocked:

- Zero position holders meant "remove everyone": every member kicked from
  every room, then every account deactivated. It never means that - it means
  the IAM query failed, the org/unit keys drifted, or a migration is
  mid-flight. reconcile() aborts, and syncMembership() refuses to empty a
  populated room, as a per-room backstop.
- Deactivation could not be undone here. Reactivation wants a password and
  password_config.enabled is false, and room memberships do not come back.
  Departed accounts are locked instead - same access block, one PUT to
  reverse - and ensureUser lifts the lock when someone returns in IAM.

Also: join the space itself, not only the rooms inside it, or Element leaves
every dept room loose in Home and never shows the space at all. And drop the
bridge's per-type routing - it pointed at a hardcoded dept-operation alias
while the reconcile derives dept-${positionKey} from IAM, so bridged
notifications went to a room the bridge created and nobody was in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:28:55 +00:00

79 lines
2.7 KiB
TypeScript

import 'reflect-metadata';
import { NotificationType, type NotifyInput } from '@edr/types';
import type { ChatConfig } from '../../config/chat.config';
import { ChatBridgeService } from './chat-bridge.service';
import type { MatrixClient } from './matrix.client';
const config: ChatConfig = {
enabled: true,
baseUrl: 'https://matrix.test',
publicBaseUrl: 'https://matrix.test',
webUrl: 'https://chat.test',
serverName: 'matrix.test',
jwtSecret: 'secret',
adminToken: 'syt_whatever',
};
function harness(overrides: Partial<ChatConfig> = {}) {
const matrix = {
ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`),
sendMessage: jest.fn(
async (_roomId: string, _body: string, _html?: string) => undefined,
),
};
const service = new ChatBridgeService(
{ ...config, ...overrides },
matrix as unknown as MatrixClient,
);
return { service, matrix };
}
const notification = (type: NotificationType): NotifyInput =>
({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput;
describe('ChatBridgeService', () => {
it('posts every notification type into #freight-alerts', async () => {
// This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded
// `dept-operation` alias, but the reconcile derives dept aliases from the
// IAM position key (`edr_freight_app/opn` shaped), so nothing it created
// ever matched. The bridge made its own empty room and posted there, where
// no employee was a member.
const { service, matrix } = harness();
for (const type of [
NotificationType.REQUEST_SUBMITTED,
NotificationType.CLEARANCE_REVIEW,
NotificationType.GENERIC,
]) {
await service.bridge(notification(type));
}
expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual(
new Set(['freight-alerts']),
);
expect(matrix.sendMessage).toHaveBeenCalledTimes(3);
});
it('does nothing at all when chat is switched off', async () => {
const { service, matrix } = harness({ enabled: false });
await service.bridge(notification(NotificationType.GENERIC));
expect(matrix.ensureRoom).not.toHaveBeenCalled();
expect(matrix.sendMessage).not.toHaveBeenCalled();
});
it('never lets a chat failure escape into the notification that triggered it', async () => {
// Same contract as NotificationInboxService.notify(): bridging is
// best-effort and must not roll back the caller's transaction.
const { service, matrix } = harness();
matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429'));
await expect(
service.bridge(notification(NotificationType.GENERIC)),
).resolves.toBeUndefined();
});
});