// SPDX-License-Identifier: AGPL-3.0-or-later import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {type ChannelID, createChannelID, createMessageID, createUserID, type UserID} from '../BrandedTypes'; import {fetchOne, setCassandraQueryExecutorForTesting, upsertOne} from '../database/CassandraQueryExecution'; import {defineTable} from '../database/CassandraTableDsl'; import type {ReadStateRow} from '../database/types/ChannelTypes'; import {READ_STATE_COLUMNS} from '../database/types/ChannelTypes'; import {InMemoryCassandraQueryExecutor} from '../test/InMemoryCassandraQueryExecutor'; import {ReadStateRepository} from './ReadStateRepository'; const ReadStates = defineTable({ name: 'read_states', columns: READ_STATE_COLUMNS, primaryKey: ['user_id', 'channel_id'], }); const FETCH_READ_STATE = ReadStates.selectCql({ where: [ReadStates.where.eq('user_id'), ReadStates.where.eq('channel_id')], limit: 1, }); let executor: InMemoryCassandraQueryExecutor; describe('ReadStateRepository row storage', () => { beforeEach(() => { executor = new InMemoryCassandraQueryExecutor(); setCassandraQueryExecutorForTesting(executor); }); afterEach(() => { executor.reset(); setCassandraQueryExecutorForTesting(null); }); it('keeps normal acknowledgements monotonic', async () => { const userId = createUserID(1n); const channelId = createChannelID(10n); await seedReadState(userId, channelId, 100n, 3); const repository = new ReadStateRepository(); const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 0); expect(readState.lastMessageId).toBe(createMessageID(100n)); expect(await loadReadState(userId, channelId)).toMatchObject({ message_id: createMessageID(100n), mention_count: 3, }); }); it('keeps manual acknowledgements able to move backward', async () => { const userId = createUserID(1n); const channelId = createChannelID(10n); await seedReadState(userId, channelId, 100n, 0); const repository = new ReadStateRepository(); const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 2, undefined, true); expect(readState.lastMessageId).toBe(createMessageID(90n)); expect(readState.mentionCount).toBe(2); expect(await loadReadState(userId, channelId)).toMatchObject({ message_id: createMessageID(90n), mention_count: 2, }); }); it('increments mentions only when the message is newer than the read cursor', async () => { const userId = createUserID(1n); const channelId = createChannelID(10n); await seedReadState(userId, channelId, 100n, 1); const repository = new ReadStateRepository(); const skipped = await repository.incrementReadStateMentions(userId, channelId, createMessageID(90n)); const updated = await repository.incrementReadStateMentions(userId, channelId, createMessageID(101n), 2); expect(skipped).toBeNull(); expect(updated?.mentionCount).toBe(3); expect(await loadReadState(userId, channelId)).toMatchObject({ message_id: createMessageID(100n), mention_count: 3, }); }); it('creates mention rows from the channel baseline when no read row exists', async () => { const userId = createUserID(1n); const channelId = createChannelID(10n); const repository = new ReadStateRepository(); const readState = await repository.incrementReadStateMentions(userId, channelId, createMessageID(11n), 4); expect(readState?.lastMessageId).toBe(createMessageID(10n)); expect(readState?.mentionCount).toBe(4); expect(await loadReadState(userId, channelId)).toMatchObject({ message_id: createMessageID(10n), mention_count: 4, }); }); it('bulk acknowledges rows without moving newer cursors backward', async () => { const userId = createUserID(1n); const olderChannelId = createChannelID(10n); const newChannelId = createChannelID(20n); await seedReadState(userId, olderChannelId, 100n, 5); const repository = new ReadStateRepository(); const readStates = await repository.bulkAckMessages(userId, [ {channelId: olderChannelId, messageId: createMessageID(90n)}, {channelId: newChannelId, messageId: createMessageID(200n)}, ]); expect(readStates.map((state) => state.lastMessageId)).toEqual([createMessageID(100n), createMessageID(200n)]); expect(await loadReadState(userId, olderChannelId)).toMatchObject({ message_id: createMessageID(100n), mention_count: 5, }); expect(await loadReadState(userId, newChannelId)).toMatchObject({ message_id: createMessageID(200n), mention_count: 0, }); }); }); async function seedReadState( userId: UserID, channelId: ChannelID, messageId: bigint, mentionCount: number, ): Promise { await upsertOne( ReadStates.upsertAll({ user_id: userId, channel_id: channelId, message_id: createMessageID(messageId), mention_count: mentionCount, last_pin_timestamp: null, }), ); } async function loadReadState(userId: UserID, channelId: ChannelID): Promise { return fetchOne(FETCH_READ_STATE, {user_id: userId, channel_id: channelId}); }