Persist t2→username mappings in reddit_users table
Adds a durable mapping so the UI never has to show raw t2_xxx IDs or MXIDs just because the in-memory cache is cold after a restart. ChatService seeds from the table on construction and writes through every resolution path; cached chats whose username is still t2_xxx get upgraded from the DB on loadCache. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
160172403b
commit
1b6dfd67e3
7 files changed
+407
-18
No files matched your search
+6
-3
@@ -7,6 +7,7 @@ import {
|
||||
MessagesRepository,
|
||||
SuccessfulMessagesRepository,
|
||||
ActivityLogRepository,
|
||||
RedditUsersRepository,
|
||||
} from './database/repositories.js';
|
||||
import { BrowserClient } from './browser/client.js';
|
||||
import { RedditMessagePage } from './browser/message-page.js';
|
||||
@@ -79,6 +80,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
const messagesRepo = new MessagesRepository(db);
|
||||
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
|
||||
const activityLogRepo = new ActivityLogRepository(db);
|
||||
const redditUsersRepo = new RedditUsersRepository(db);
|
||||
logger.info('Database initialized');
|
||||
|
||||
// Browser
|
||||
@@ -208,22 +210,23 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
usernameResolver,
|
||||
undefined,
|
||||
messagesRepo,
|
||||
redditUsersRepo,
|
||||
);
|
||||
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
|
||||
const rateLimiter = new RateLimiter(config, logger);
|
||||
|
||||
await chatService.loadCache();
|
||||
// Lazy enrichment in the background — first sync may show t2 IDs briefly.
|
||||
chatService.enrichUsernames().catch((err) => logger.debug('enrichUsernames failed:', err));
|
||||
|
||||
// Fire-and-forget startup sync: full if we've never backfilled, delta
|
||||
// otherwise. Non-blocking so the rest of the bot can come up immediately;
|
||||
// the dashboard's sync-status banner shows progress.
|
||||
chatService
|
||||
.runStartupSync()
|
||||
.then(({ kind, chats }) => {
|
||||
.then(async ({ kind, chats }) => {
|
||||
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
|
||||
// Resolve t2 IDs now that the full chat list is available.
|
||||
await chatService.enrichUsernames();
|
||||
})
|
||||
.catch((err) => logger.error('[chat] startup sync failed:', err));
|
||||
|
||||
|
||||
@@ -66,6 +66,15 @@ export function runMigrations(db: Database.Database): void {
|
||||
image_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reddit_users (
|
||||
t2_id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
mxid TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reddit_users_username
|
||||
ON reddit_users(username);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
|
||||
ON processed_posts(subreddit);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_processed_at
|
||||
|
||||
@@ -566,3 +566,83 @@ export class SuccessfulMessagesRepository {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RedditUserRecord {
|
||||
t2Id: string;
|
||||
username: string;
|
||||
mxid: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable mapping of Reddit account IDs (`t2_xxx`) to current usernames.
|
||||
* Populated as the chat sync resolves IDs via Reddit's alfred/user_data
|
||||
* endpoints; read back on startup so we never have to show raw `t2_xxx`
|
||||
* or MXIDs in the UI just because the in-memory cache is cold.
|
||||
*/
|
||||
export class RedditUsersRepository {
|
||||
private upsertStmt: Database.Statement;
|
||||
private getByT2Stmt: Database.Statement;
|
||||
private getByUsernameStmt: Database.Statement;
|
||||
private getAllStmt: Database.Statement;
|
||||
|
||||
constructor(private db: Database.Database) {
|
||||
this.upsertStmt = db.prepare(`
|
||||
INSERT INTO reddit_users (t2_id, username, mxid, updated_at)
|
||||
VALUES (@t2Id, @username, @mxid, @updatedAt)
|
||||
ON CONFLICT(t2_id) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
mxid = COALESCE(excluded.mxid, reddit_users.mxid),
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.username != ''
|
||||
`);
|
||||
this.getByT2Stmt = db.prepare(`SELECT * FROM reddit_users WHERE t2_id = ?`);
|
||||
this.getByUsernameStmt = db.prepare(`SELECT * FROM reddit_users WHERE username = ? COLLATE NOCASE`);
|
||||
this.getAllStmt = db.prepare(`SELECT * FROM reddit_users`);
|
||||
}
|
||||
|
||||
upsert(record: { t2Id: string; username: string; mxid?: string | null }): void {
|
||||
if (!record.t2Id || !record.username) return;
|
||||
if (record.username.startsWith('t2_') || record.username.startsWith('@')) return;
|
||||
const t2Id = record.t2Id.startsWith('t2_') ? record.t2Id : `t2_${record.t2Id}`;
|
||||
this.upsertStmt.run({
|
||||
t2Id,
|
||||
username: record.username,
|
||||
mxid: record.mxid ?? null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
upsertMany(records: Array<{ t2Id: string; username: string; mxid?: string | null }>): void {
|
||||
const tx = this.db.transaction((items: typeof records) => {
|
||||
for (const r of items) this.upsert(r);
|
||||
});
|
||||
tx(records);
|
||||
}
|
||||
|
||||
getByT2(t2Id: string): RedditUserRecord | null {
|
||||
const normalized = t2Id.startsWith('t2_') ? t2Id : `t2_${t2Id}`;
|
||||
const row = this.getByT2Stmt.get(normalized) as Record<string, unknown> | undefined;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
getByUsername(username: string): RedditUserRecord | null {
|
||||
const row = this.getByUsernameStmt.get(username) as Record<string, unknown> | undefined;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
/** Bulk load — used to seed the in-memory cache on startup. */
|
||||
getAll(): RedditUserRecord[] {
|
||||
const rows = this.getAllStmt.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => this.mapRow(r));
|
||||
}
|
||||
|
||||
private mapRow(row: Record<string, unknown>): RedditUserRecord {
|
||||
return {
|
||||
t2Id: row.t2_id as string,
|
||||
username: row.username as string,
|
||||
mxid: (row.mxid as string | null) ?? null,
|
||||
updatedAt: row.updated_at as number,
|
||||
};
|
||||
}
|
||||
}
|
||||
+148
-13
@@ -1,5 +1,9 @@
|
||||
import type { Logger } from '../../utils/logger.js';
|
||||
import type { MessagesRepository, SuccessfulMessagesRepository } from '../../database/repositories.js';
|
||||
import type {
|
||||
MessagesRepository,
|
||||
RedditUsersRepository,
|
||||
SuccessfulMessagesRepository,
|
||||
} from '../../database/repositories.js';
|
||||
import type { MediaService } from '../media.js';
|
||||
import { ChatCache } from './cache.js';
|
||||
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
|
||||
@@ -115,8 +119,37 @@ export class ChatService {
|
||||
private usernameResolver?: UsernameResolver,
|
||||
cache?: ChatCache,
|
||||
private messagesRepo?: MessagesRepository,
|
||||
private redditUsersRepo?: RedditUsersRepository,
|
||||
) {
|
||||
this.cache = cache ?? new ChatCache(logger);
|
||||
if (this.redditUsersRepo) {
|
||||
try {
|
||||
for (const u of this.redditUsersRepo.getAll()) {
|
||||
const bare = u.t2Id.replace(/^t2_/, '');
|
||||
this.t2ToUsername.set(bare, u.username);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('[chat] failed to seed username cache from db:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a t2→username resolution; safe to call with no repo configured. */
|
||||
private persistUsername(t2Id: string, username: string): void {
|
||||
if (!this.redditUsersRepo) return;
|
||||
try {
|
||||
this.redditUsersRepo.upsert({ t2Id, username });
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] failed to persist reddit user mapping:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve `@t2_abc:reddit.com` or `t2_abc` to a real username if known. */
|
||||
resolveSenderDisplay(mxidOrT2: string): string {
|
||||
const mxidMatch = mxidOrT2.match(/^@(t2_[^:]+):/);
|
||||
const t2 = mxidMatch ? mxidMatch[1] : mxidOrT2;
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
return this.t2ToUsername.get(bare) ?? t2;
|
||||
}
|
||||
|
||||
// ------------ scheduling + auth helpers ------------
|
||||
@@ -154,8 +187,25 @@ export class ChatService {
|
||||
const t2 = this.extractT2(chat);
|
||||
if (t2 && chat.username && !chat.username.startsWith('t2_')) {
|
||||
this.t2ToUsername.set(t2, chat.username);
|
||||
this.persistUsername(t2, chat.username);
|
||||
}
|
||||
}
|
||||
// Upgrade any `t2_xxx` cache entries using DB-known mappings so the UI
|
||||
// never has to display a raw t2 ID when we already know the username.
|
||||
let upgraded = 0;
|
||||
for (const chat of this.cache.get()) {
|
||||
const t2 = this.extractT2(chat);
|
||||
if (!t2) continue;
|
||||
const known = this.t2ToUsername.get(t2);
|
||||
if (known) {
|
||||
chat.username = known;
|
||||
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = known;
|
||||
upgraded++;
|
||||
}
|
||||
}
|
||||
if (upgraded > 0) {
|
||||
this.logger.info(`[chat] upgraded ${upgraded} cached chats from db username map`);
|
||||
}
|
||||
const persisted = this.cache.getCooldown();
|
||||
if (persisted && persisted.until > Date.now()) {
|
||||
this.invitationCooldown = persisted;
|
||||
@@ -468,6 +518,7 @@ export class ChatService {
|
||||
);
|
||||
|
||||
this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username);
|
||||
this.persistUsername(t2Id, username);
|
||||
|
||||
const chat: ActiveChat = {
|
||||
username,
|
||||
@@ -832,23 +883,106 @@ export class ChatService {
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Convert `@t2_abc:reddit.com` → `t2_abc`. */
|
||||
/**
|
||||
* Convert a Matrix sender into a display label. Prefers the resolved Reddit
|
||||
* username when known (so message bubbles read "alice" rather than
|
||||
* "t2_abc123"); falls back to the bare t2 form, then the raw MXID.
|
||||
*/
|
||||
private shortenSender(mxid: string): string {
|
||||
const m = mxid.match(/^@([^:]+):/);
|
||||
return m ? m[1] : mxid;
|
||||
const bare = m ? m[1] : mxid;
|
||||
const t2 = bare.replace(/^t2_/, '');
|
||||
return this.t2ToUsername.get(t2) ?? bare;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort batch resolution of t2 IDs to usernames. Uses the injected
|
||||
* `UsernameResolver` (if any); failures are logged and ignored, with the
|
||||
* t2 ID staying as the fallback display.
|
||||
*/
|
||||
private async resolveUsernamesForRooms(_roomIds: string[]): Promise<Map<string, string>> {
|
||||
// We don't have the recipient mxids until rooms are projected, so this
|
||||
// method returns the in-memory cache and lets `roomToActiveChat` fall
|
||||
// back to the t2 ID. Resolution happens lazily via `enrichUsernames()`
|
||||
// which the caller can invoke after building the chat list.
|
||||
return new Map(this.t2ToUsername);
|
||||
* Best-effort batch resolution of t2 IDs to usernames. Queries Reddit's
|
||||
* `alfred` API to look up usernames from t2 IDs, and also resolves
|
||||
* room-state MXIDs via `UsernameResolver`. Uses the in-memory cache
|
||||
* as a fallback. Failures are logged and ignored, with the t2 ID
|
||||
* staying as the fallback display.
|
||||
*/
|
||||
private async resolveUsernamesForRooms(roomIds: string[]): Promise<Map<string, string>> {
|
||||
// Collect all known t2 IDs (from the in-memory cache) and query
|
||||
// Reddit's alfred API for reverse-resolution.
|
||||
const cached = new Map(this.t2ToUsername);
|
||||
|
||||
const staleT2s = Array.from(cached.keys()).filter(id => {
|
||||
const name = cached.get(id);
|
||||
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name);
|
||||
});
|
||||
|
||||
if (staleT2s.length > 0) {
|
||||
const numericIds = staleT2s.map(id => id.replace(/^t2_/, ''));
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
'https://www.reddit.com/alfred/users/by_id.json',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'User-Agent': 'reddit-bot-chat/1.0',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
alchemyType: 'Account',
|
||||
ids: numericIds.join(','),
|
||||
currencyType: '1',
|
||||
max_rating: '100',
|
||||
size: '100',
|
||||
}).toString(),
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = await res.json() as { results?: Array<Record<string, unknown>> };
|
||||
for (const obj of body.results ?? []) {
|
||||
const t2 = `t2_${obj.Id ?? obj.id ?? ''}`;
|
||||
const idKey = t2.replace(/^t2_/, '');
|
||||
const displayName = (obj.name ?? obj.alias ?? '') as string;
|
||||
if (displayName) {
|
||||
cached.set(idKey, displayName);
|
||||
this.t2ToUsername.set(idKey, displayName);
|
||||
this.persistUsername(idKey, displayName);
|
||||
for (const chat of this.cache.get()) {
|
||||
if (chat.username === `t2_${idKey}`) {
|
||||
chat.username = displayName;
|
||||
if (!chat.roomName || chat.roomName.startsWith('t2_')) {
|
||||
chat.roomName = displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] alfred reverse-resolution failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Also resolve MXID-based t2 IDs via the lifted UsernameResolver,
|
||||
// while avoiding re-queries for IDs we already resolved via alfred.
|
||||
const mxidT2s = Array.from(new Set(staleT2s.filter(id => !cached.get(id.replace(/^t2_/, '')))));
|
||||
if (mxidT2s.length > 0 && this.usernameResolver) {
|
||||
try {
|
||||
const resolved = await this.usernameResolver.resolveT2Ids(mxidT2s);
|
||||
for (const [t2, name] of Object.entries(resolved)) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
cached.set(bare, name);
|
||||
this.t2ToUsername.set(bare, name);
|
||||
this.persistUsername(t2, name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] MXID resolution failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Return true if `name` looks like it isn't a real Reddit username. */
|
||||
private isStaleUsername(name: string): boolean {
|
||||
return !name || /^[t2_0-9@:]{3,}$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,6 +1003,7 @@ export class ChatService {
|
||||
for (const [t2, name] of Object.entries(got)) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
this.t2ToUsername.set(bare, name);
|
||||
this.persistUsername(t2, name);
|
||||
for (const chat of this.cache.get()) {
|
||||
if (chat.username === `t2_${bare}`) {
|
||||
chat.username = name;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { RedditUsersRepository } from '../src/database/repositories.js';
|
||||
import { ChatService } from '../src/services/chat/service.js';
|
||||
import { ChatCache } from '../src/services/chat/cache.js';
|
||||
import { MatrixClient } 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 buildMatrix() {
|
||||
const logger = createMockLogger();
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
)) as typeof fetch;
|
||||
return new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl });
|
||||
}
|
||||
|
||||
describe('ChatService username persistence', () => {
|
||||
it('seeds the in-memory cache from RedditUsersRepository on construction', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new RedditUsersRepository(db);
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
|
||||
|
||||
const logger = createMockLogger();
|
||||
const svc = new ChatService(
|
||||
buildMatrix(),
|
||||
logger,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
repo,
|
||||
);
|
||||
|
||||
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
|
||||
expect(svc.resolveSenderDisplay('t2_abc')).toBe('alice');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('falls back to t2 form when no mapping is known', () => {
|
||||
const logger = createMockLogger();
|
||||
const svc = new ChatService(buildMatrix(), logger);
|
||||
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
|
||||
});
|
||||
|
||||
it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new RedditUsersRepository(db);
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
|
||||
|
||||
const logger = createMockLogger();
|
||||
const tmpFile = path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`);
|
||||
const cache = new ChatCache(logger, tmpFile);
|
||||
cache.set([
|
||||
{
|
||||
username: 't2_abc',
|
||||
roomName: 't2_abc',
|
||||
lastMessageTime: Date.now(),
|
||||
lastMessageSnippet: 'hi',
|
||||
isFromUs: false,
|
||||
unread: false,
|
||||
url: 'https://chat.reddit.com/room/x',
|
||||
roomId: '!room:reddit.com',
|
||||
messages: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const svc = new ChatService(
|
||||
buildMatrix(),
|
||||
logger,
|
||||
undefined, undefined, undefined, undefined,
|
||||
cache,
|
||||
undefined,
|
||||
repo,
|
||||
);
|
||||
|
||||
await svc.loadCache();
|
||||
const chats = await svc.getCachedConversations();
|
||||
expect(chats[0].username).toBe('alice');
|
||||
expect(chats[0].roomName).toBe('alice');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -103,8 +103,8 @@ describe('Database Client', () => {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// processed_posts, sent_messages, successful_messages, activity_log
|
||||
expect(tables.length).toBe(4);
|
||||
// processed_posts, sent_messages, successful_messages, activity_log, reddit_users
|
||||
expect(tables.length).toBe(5);
|
||||
});
|
||||
|
||||
it('processed_posts has correct schema', () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { RedditUsersRepository } from '../src/database/repositories.js';
|
||||
|
||||
describe('RedditUsersRepository', () => {
|
||||
let db: Database.Database;
|
||||
let repo: RedditUsersRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new RedditUsersRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('upserts a new user and reads it back', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
|
||||
const got = repo.getByT2('t2_abc');
|
||||
expect(got?.username).toBe('alice');
|
||||
expect(got?.t2Id).toBe('t2_abc');
|
||||
});
|
||||
|
||||
it('normalizes a bare t2 id when looking up or writing', () => {
|
||||
repo.upsert({ t2Id: 'abc', username: 'alice' });
|
||||
expect(repo.getByT2('abc')?.username).toBe('alice');
|
||||
expect(repo.getByT2('t2_abc')?.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('updates the username when the same t2 is upserted twice', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'old_name' });
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'new_name' });
|
||||
expect(repo.getByT2('t2_abc')?.username).toBe('new_name');
|
||||
});
|
||||
|
||||
it('refuses to store t2_-prefixed pseudo-usernames', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: 't2_abc' });
|
||||
expect(repo.getByT2('t2_abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to store MXID-shaped pseudo-usernames', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: '@t2_abc:reddit.com' });
|
||||
expect(repo.getByT2('t2_abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('looks up by username case-insensitively', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'Alice' });
|
||||
expect(repo.getByUsername('alice')?.t2Id).toBe('t2_abc');
|
||||
expect(repo.getByUsername('ALICE')?.t2Id).toBe('t2_abc');
|
||||
});
|
||||
|
||||
it('preserves an existing mxid when a later upsert passes null', () => {
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'alice', mxid: '@t2_abc:reddit.com' });
|
||||
repo.upsert({ t2Id: 't2_abc', username: 'alice' });
|
||||
expect(repo.getByT2('t2_abc')?.mxid).toBe('@t2_abc:reddit.com');
|
||||
});
|
||||
|
||||
it('bulk-inserts via upsertMany', () => {
|
||||
repo.upsertMany([
|
||||
{ t2Id: 't2_a', username: 'alice' },
|
||||
{ t2Id: 't2_b', username: 'bob' },
|
||||
]);
|
||||
const all = repo.getAll();
|
||||
expect(all.map((u) => u.username).sort()).toEqual(['alice', 'bob']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user