Missing conversations: recipient Reddit accounts that are deleted or suspended left conversations unaccounted for. A new RecipientAccountStatusService polls Reddit for the users behind cached conversations and records their account status durably, with progressive backfill and delta-media fixes in ChatService so history and images are no longer dropped mid-sync. Media: attachMedia now surfaces download failures as MediaAttachmentError so the sync cursor can be retried instead of silently losing an image, recognizes image bytes when Matrix returns a generic content type, and keeps separate gallery occurrences when the same asset appears in more than one event. Manual inbox tab: the conversations list now carries the reply-loop status, so the inbox can separate conversations the operator is driving by hand from the ones automation is still handling. A Manual filter pill and a per-row chip explain why each conversation was handed over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 lines
2.3 KiB
TypeScript
63 lines
2.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { RedditRecipientAccountStatusRepository } from '../src/database/account-repositories.js';
|
|
import { runMigrations } from '../src/database/client.js';
|
|
import { RecipientAccountStatusService } from '../src/services/recipient-account-status.js';
|
|
import { createMockLogger } from './mockLogger.js';
|
|
|
|
describe('RecipientAccountStatusService', () => {
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
});
|
|
|
|
afterEach(() => db.close());
|
|
|
|
it('checks each real username once, persists results, and skips fresh checks', async () => {
|
|
let now = 10_000;
|
|
const repository = new RedditRecipientAccountStatusRepository(db);
|
|
const lookup = {
|
|
getUserAccountStatus: vi.fn(async (username: string) => username === 'Alice' ? 'deleted' as const : 'active' as const),
|
|
};
|
|
const service = new RecipientAccountStatusService(
|
|
{
|
|
getCachedConversations: vi.fn(async () => [
|
|
{ username: 'Alice' }, { username: 'alice' }, { username: 'Bob' },
|
|
{ username: 'unknown' }, { username: 't2_abc' },
|
|
]),
|
|
} as never,
|
|
lookup,
|
|
repository,
|
|
createMockLogger(),
|
|
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
|
|
4_000,
|
|
() => now,
|
|
);
|
|
|
|
await expect(service.checkNow()).resolves.toBe(2);
|
|
expect(lookup.getUserAccountStatus).toHaveBeenCalledTimes(2);
|
|
expect(repository.get('alice')).toMatchObject({ status: 'deleted', checkedAt: 10_000 });
|
|
|
|
now = 12_000;
|
|
await expect(service.checkNow()).resolves.toBe(0);
|
|
now = 15_000;
|
|
await expect(service.checkNow()).resolves.toBe(2);
|
|
});
|
|
|
|
it('does no profile work while account authentication is unavailable', async () => {
|
|
const lookup = { getUserAccountStatus: vi.fn() };
|
|
const service = new RecipientAccountStatusService(
|
|
{ getCachedConversations: vi.fn() } as never,
|
|
lookup,
|
|
new RedditRecipientAccountStatusRepository(db),
|
|
createMockLogger(),
|
|
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
|
|
);
|
|
|
|
await expect(service.checkNow()).resolves.toBe(0);
|
|
expect(lookup.getUserAccountStatus).not.toHaveBeenCalled();
|
|
});
|
|
});
|