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>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 23:48:33 +01:00
1 parent 71548ec2ad
commit b9df8459e3
4 files changed
+65 -9

No files matched your search

+12 -2
View File
@@ -23,7 +23,13 @@ function buildService() {
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
)) as typeof fetch;
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl });
const svc = new ChatService(matrix, logger);
// 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 };
}
@@ -168,7 +174,11 @@ describe('ChatService invitation cooldown', () => {
matrix.sendTextMessage = async () => {
throw new MatrixRateLimitError('429', 90_000, 'M_LIMIT_EXCEEDED', null);
};
const svc = new ChatService(matrix, logger);
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);
+5 -1
View File
@@ -172,7 +172,11 @@ describe('ChatService delta sync — media attachment', () => {
},
})) as typeof matrix.sync;
const svc = new ChatService(matrix, logger);
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);
await expect(svc.getActiveChats()).resolves.toBeDefined();
});
});
+16 -2
View File
@@ -26,6 +26,13 @@ function buildMatrix() {
}
describe('ChatService username persistence', () => {
function tmpCache(logger: Parameters<typeof ChatCache>[0]) {
return new ChatCache(
logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
);
}
it('seeds the in-memory cache from RedditUsersRepository on construction', () => {
const db = new Database(':memory:');
runMigrations(db);
@@ -36,7 +43,9 @@ describe('ChatService username persistence', () => {
const svc = new ChatService(
buildMatrix(),
logger,
undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined,
tmpCache(logger),
undefined,
repo,
);
@@ -47,7 +56,12 @@ describe('ChatService username persistence', () => {
it('falls back to t2 form when no mapping is known', () => {
const logger = createMockLogger();
const svc = new ChatService(buildMatrix(), logger);
const svc = new ChatService(
buildMatrix(),
logger,
undefined, undefined, undefined, undefined,
tmpCache(logger),
);
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
});