import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { runMigrations } from '../src/database/client.js'; import { ConversationReadStateRepository } from '../src/database/repositories.js'; describe('ConversationReadStateRepository', () => { let db: Database.Database; let repo: ConversationReadStateRepository; let other: ConversationReadStateRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new ConversationReadStateRepository(db, 'account-one'); other = new ConversationReadStateRepository(db, 'account-two'); }); afterEach(() => db.close()); it('returns nothing for a conversation that has never been read', () => { expect(repo.get('room-1')).toBeUndefined(); expect(repo.list()).toEqual([]); }); it('records a read watermark and defaults to an open status', () => { repo.markRead('room-1', 500, 1_000); expect(repo.get('room-1')).toEqual({ conversationId: 'room-1', lastReadMessageTs: 500, lastReadAt: 1_000, status: 'open', snoozedUntil: null, }); }); it('never moves the watermark backwards, so a stale write cannot resurrect an unread badge', () => { repo.markRead('room-1', 900, 1_000); repo.markRead('room-1', 400, 2_000); const record = repo.get('room-1'); expect(record?.lastReadMessageTs).toBe(900); // The read *time* still advances even when the watermark does not. expect(record?.lastReadAt).toBe(2_000); }); it('rewinds the watermark just before the named message when marking unread', () => { repo.markRead('room-1', 900, 1_000); repo.markUnread('room-1', 900, 2_000); expect(repo.get('room-1')?.lastReadMessageTs).toBe(899); }); it('reopens and clears any snooze when marking unread', () => { repo.setStatus('room-1', 'snoozed', 5_000, 1_000); repo.markUnread('room-1', 100, 2_000); expect(repo.get('room-1')).toMatchObject({ status: 'open', snoozedUntil: null }); }); it('clamps a negative watermark to zero', () => { repo.markUnread('room-1', 0, 1_000); expect(repo.get('room-1')?.lastReadMessageTs).toBe(0); }); it('stores a snooze deadline and drops it when the status changes', () => { repo.setStatus('room-1', 'snoozed', 5_000, 1_000); expect(repo.get('room-1')).toMatchObject({ status: 'snoozed', snoozedUntil: 5_000 }); repo.setStatus('room-1', 'done', null, 2_000); expect(repo.get('room-1')).toMatchObject({ status: 'done', snoozedUntil: null }); }); it('ignores a snooze deadline for non-snoozed statuses', () => { repo.setStatus('room-1', 'open', 5_000, 1_000); expect(repo.get('room-1')?.snoozedUntil).toBeNull(); }); it('preserves the read watermark when only the status changes', () => { repo.markRead('room-1', 900, 1_000); repo.setStatus('room-1', 'done', null, 2_000); expect(repo.get('room-1')).toMatchObject({ lastReadMessageTs: 900, status: 'done' }); }); it('reopens only snoozes whose deadline has passed', () => { repo.setStatus('room-past', 'snoozed', 1_000, 1); repo.setStatus('room-future', 'snoozed', 9_000, 1); expect(repo.expireSnoozes(5_000)).toEqual(['room-past']); expect(repo.get('room-past')).toMatchObject({ status: 'open', snoozedUntil: null }); expect(repo.get('room-future')).toMatchObject({ status: 'snoozed', snoozedUntil: 9_000 }); }); it('does not reopen an indefinite snooze', () => { repo.setStatus('room-1', 'snoozed', null, 1); expect(repo.expireSnoozes(9_999)).toEqual([]); }); it('keeps read state isolated per account', () => { repo.markRead('room-1', 500, 1_000); expect(other.get('room-1')).toBeUndefined(); expect(other.list()).toEqual([]); expect(repo.list()).toHaveLength(1); }); });