Files
RedditBot/tests/chat-service-cooldown.test.ts
T
jjbruntonandClaude Opus 4.7 b9df8459e3 Atomic chat-cache writes + stop tests clobbering prod cache
Three intertwined fixes for the torn-write that corrupted the prod
data/chat-cache.json mid-session:

  - ChatCache.save / persistCooldown now write via temp file + rename.
    rename(2) is atomic on the same filesystem, so a crashed or
    concurrent writer can never leave NULs in the middle of the file.
    A small serialising write queue stops two concurrent saves from
    racing to the same temp name.

  - ChatCache throws if constructed without a filePath under NODE_ENV=test.
    The old default (./data/chat-cache.json) silently routed test
    writes into the production cache — exactly how the corruption
    happened. Future test that forgets to pass a tmp path fails loudly
    instead of stomping data.

  - Cooldown / delta-media / username-persist tests now pass an
    explicit tmp ChatCache so the new guard is satisfied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:48:33 +01:00

190 lines
7.4 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';
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);
return { svc, logger };
}
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('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('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);
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);
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);
// 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);
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);
});
});