244 lines
9.8 KiB
TypeScript
244 lines
9.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { ChatService } from '../src/services/chat/service.js';
|
|
import { ChatCache } from '../src/services/chat/cache.js';
|
|
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
|
|
import { createMockLogger } from './mockLogger.js';
|
|
import { authenticatedAccountAuth } from './testUtils.js';
|
|
|
|
const creds = {
|
|
homeserver: 'https://matrix.example.test',
|
|
accessToken: 'tok',
|
|
userId: '@me:example.test',
|
|
capturedAt: Date.now(),
|
|
};
|
|
|
|
function buildService() {
|
|
const logger = createMockLogger();
|
|
// Fetch impl never matters here — we drive cooldowns directly via the
|
|
// public API, which is how MatrixClient surfaces them at runtime.
|
|
const fetchImpl = (() =>
|
|
Promise.resolve(
|
|
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
)) as typeof fetch;
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl });
|
|
// Point the cache at a tmp path; without this, the default falls back to
|
|
// ./data/chat-cache.json and the test would clobber the production cache.
|
|
const cache = new ChatCache(
|
|
logger,
|
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
|
);
|
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
return { svc, logger, matrix };
|
|
}
|
|
|
|
describe('ChatService invitation cooldown', () => {
|
|
it('is null by default', () => {
|
|
const { svc } = buildService();
|
|
expect(svc.getInvitationCooldown()).toBeNull();
|
|
});
|
|
|
|
it('records a cooldown from a MatrixRateLimitError', () => {
|
|
const { svc } = buildService();
|
|
const err = new MatrixRateLimitError(
|
|
'429',
|
|
60_000,
|
|
'M_LIMIT_EXCEEDED',
|
|
'rate.invitation_limit_mature_account',
|
|
);
|
|
svc.setInvitationCooldown(err);
|
|
const active = svc.getInvitationCooldown();
|
|
expect(active).not.toBeNull();
|
|
expect(active!.redditCode).toBe('rate.invitation_limit_mature_account');
|
|
expect(active!.until).toBeGreaterThan(Date.now() + 50_000);
|
|
expect(active!.reason).toMatch(/throttling new chat invitations/);
|
|
});
|
|
|
|
it('auto-expires once the until timestamp has passed', () => {
|
|
const { svc } = buildService();
|
|
const err = new MatrixRateLimitError('429', 10, 'M_LIMIT_EXCEEDED', 'rate.foo');
|
|
svc.setInvitationCooldown(err);
|
|
expect(svc.getInvitationCooldown()).not.toBeNull();
|
|
|
|
// Force the clock past the cooldown window.
|
|
const cooldown = svc.getInvitationCooldown()!;
|
|
const realNow = Date.now;
|
|
Date.now = () => cooldown.until + 1;
|
|
try {
|
|
expect(svc.getInvitationCooldown()).toBeNull();
|
|
} finally {
|
|
Date.now = realNow;
|
|
}
|
|
});
|
|
|
|
it('startConversation short-circuits when a cooldown is active', async () => {
|
|
const { svc } = buildService();
|
|
svc.setInvitationCooldown(
|
|
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.foo'),
|
|
);
|
|
const result = await svc.startConversation('someuser', 'hi');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns a retryable delivery result with the persisted cooldown deadline', async () => {
|
|
const { svc } = buildService();
|
|
svc.setInvitationCooldown(
|
|
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.foo'),
|
|
);
|
|
const cooldown = svc.getInvitationCooldown()!;
|
|
|
|
await expect(svc.startConversationDetailed('someuser', 'hi')).resolves.toEqual({
|
|
status: 'retryable',
|
|
reason: cooldown.reason,
|
|
retryAt: cooldown.until,
|
|
});
|
|
});
|
|
|
|
it('classifies recipients that reject chat requests as terminal', async () => {
|
|
const { svc, matrix } = buildService();
|
|
matrix.sendInitialDirectMessage = async () => {
|
|
throw new Error('some of the invited users do not accept chat requests');
|
|
};
|
|
|
|
await expect(svc.startConversationDetailed('someuser', 'hi', 't2_someuser'))
|
|
.resolves.toEqual({ status: 'terminal', reason: 'Recipient does not accept chat requests' });
|
|
});
|
|
|
|
it('sendChatMessage short-circuits when a cooldown is active', async () => {
|
|
const { svc } = buildService();
|
|
svc.setInvitationCooldown(
|
|
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null),
|
|
);
|
|
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('allows replies in existing rooms during a room-creation cooldown', async () => {
|
|
const { svc, matrix } = buildService();
|
|
matrix.sendTextMessage = async () => '$reply';
|
|
svc.setInvitationCooldown(
|
|
new MatrixRateLimitError(
|
|
'429',
|
|
60_000,
|
|
'M_LIMIT_EXCEEDED',
|
|
'rate.room_creation_limit',
|
|
),
|
|
);
|
|
// Simulate a cooldown persisted before operation scopes were recorded.
|
|
delete svc.getInvitationCooldown()!.scope;
|
|
|
|
await expect(svc.sendChatMessage('!room:example.test', 'hi')).resolves.toBe(true);
|
|
});
|
|
|
|
it('treats a generic limit raised while creating a room as room-creation-only', async () => {
|
|
const { svc, matrix } = buildService();
|
|
matrix.sendInitialDirectMessage = async () => {
|
|
throw new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null);
|
|
};
|
|
matrix.sendTextMessage = async () => '$reply';
|
|
|
|
await expect(svc.startConversationDetailed('someuser', 'opener', 't2_someuser'))
|
|
.resolves.toMatchObject({ status: 'retryable' });
|
|
await expect(svc.sendChatMessage('!room:example.test', 'reply')).resolves.toBe(true);
|
|
});
|
|
|
|
it('persists the cooldown to disk and restores it after a fresh service load', async () => {
|
|
const logger = createMockLogger();
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-'));
|
|
const cacheFile = path.join(tmpDir, 'chat-cache.json');
|
|
try {
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
const cache1 = new ChatCache(logger, cacheFile);
|
|
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1, undefined, undefined, authenticatedAccountAuth);
|
|
svc1.setInvitationCooldown(
|
|
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
|
|
);
|
|
// setInvitationCooldown kicks off an async save; flush the queue.
|
|
await cache1.save();
|
|
|
|
const cache2 = new ChatCache(logger, cacheFile);
|
|
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2, undefined, undefined, authenticatedAccountAuth);
|
|
await svc2.loadCache();
|
|
const restored = svc2.getInvitationCooldown();
|
|
expect(restored).not.toBeNull();
|
|
expect(restored!.until).toBeGreaterThan(Date.now() + 100_000);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves on-disk chats and lastFullSyncAt when a cooldown fires before loadCache', async () => {
|
|
const logger = createMockLogger();
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-preserve-'));
|
|
const cacheFile = path.join(tmpDir, 'chat-cache.json');
|
|
try {
|
|
// Pre-seed an existing cache with a completed full sync and known chats.
|
|
const existing = {
|
|
version: 1,
|
|
savedAt: Date.now() - 60_000,
|
|
chats: [
|
|
{
|
|
username: 'alice',
|
|
roomName: 'alice',
|
|
lastMessageTime: Date.now() - 30_000,
|
|
lastMessageSnippet: 'hi',
|
|
isFromUs: false,
|
|
unread: false,
|
|
roomId: '!room:example.test',
|
|
},
|
|
],
|
|
nextBatch: 's42_1',
|
|
lastFullSyncAt: Date.now() - 120_000,
|
|
lastDeltaSyncAt: Date.now() - 30_000,
|
|
cooldown: null,
|
|
};
|
|
await fs.writeFile(cacheFile, JSON.stringify(existing), 'utf-8');
|
|
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
const cache = new ChatCache(logger, cacheFile);
|
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
|
|
// Cooldown fires before loadCache() — the in-memory chats are still [].
|
|
svc.setInvitationCooldown(
|
|
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null),
|
|
);
|
|
// Flush the async persist (read + write).
|
|
await cache.persistCooldown();
|
|
|
|
const raw = await fs.readFile(cacheFile, 'utf-8');
|
|
const after = JSON.parse(raw);
|
|
expect(after.chats).toHaveLength(1);
|
|
expect(after.chats[0].username).toBe('alice');
|
|
expect(after.lastFullSyncAt).toBe(existing.lastFullSyncAt);
|
|
expect(after.nextBatch).toBe('s42_1');
|
|
expect(after.cooldown).not.toBeNull();
|
|
expect(after.cooldown.reason).toMatch(/M_LIMIT_EXCEEDED/);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('sendChatMessage records a cooldown when the homeserver rate-limits a reply', async () => {
|
|
const logger = createMockLogger();
|
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
|
// Force sendTextMessage to throw a rate-limit error — simulates Reddit
|
|
// returning M_LIMIT_EXCEEDED on a reply into an existing room.
|
|
matrix.sendTextMessage = async () => {
|
|
throw new MatrixRateLimitError('429', 90_000, 'M_LIMIT_EXCEEDED', null);
|
|
};
|
|
const cache = new ChatCache(
|
|
logger,
|
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
|
);
|
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
|
|
|
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
|
expect(result).toBe(false);
|
|
const active = svc.getInvitationCooldown();
|
|
expect(active).not.toBeNull();
|
|
expect(active!.until).toBeGreaterThan(Date.now() + 80_000);
|
|
});
|
|
});
|