From 776822afcd8d9681105d347ef32d729fa771a4e0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 12 Jun 2026 11:22:33 +0300 Subject: [PATCH] add tests for chat log event cache handling Covers the recently fixed bugs (batch continuation on unknown convos, accept status propagation to 'all'-status caches, rev guard against stale writes) plus core per-log-type cache behaviors. Fixture builders are shared for other chat test suites. Stubs the bottom-sheet native module in jest setup: it reads Platform.Version at import time, which jest-expo leaves undefined, crashing any test that imports the chat/session chain. Co-Authored-By: Claude Fable 5 --- jest/jestSetup.js | 17 + .../queries/messages/__tests__/fixtures.ts | 245 +++++++ .../messages/__tests__/log-handler.test.ts | 614 ++++++++++++++++++ 3 files changed, 876 insertions(+) create mode 100644 src/state/queries/messages/__tests__/fixtures.ts create mode 100644 src/state/queries/messages/__tests__/log-handler.test.ts diff --git a/jest/jestSetup.js b/jest/jestSetup.js index ca04d54261..8f1737035c 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -23,6 +23,7 @@ jest.mock('react-native-safe-area-context', () => { SafeAreaProvider: jest.fn().mockImplementation(({children}) => children), SafeAreaConsumer: jest .fn() + // eslint-disable-next-line @typescript-eslint/no-unsafe-call .mockImplementation(({children}) => children(inset)), useSafeAreaInsets: jest.fn().mockImplementation(() => inset), } @@ -106,3 +107,19 @@ jest.mock('expo-modules-core', () => ({ jest.mock('expo-localization', () => ({ getLocales: () => [], })) + +// The bottom-sheet native component reads Platform.Version at import time, +// which the jest-expo preset leaves undefined, crashing any test that pulls in +// the chat/session import chain. Stub the native module so it's never +// evaluated under jest. +jest.mock('../modules/bottom-sheet', () => ({ + __esModule: true, + BottomSheet: () => null, + BottomSheetNativeComponent: class {}, + BottomSheetOutlet: () => null, + BottomSheetPortalProvider: jest + .fn() + .mockImplementation(({children}) => children), + BottomSheetProvider: jest.fn().mockImplementation(({children}) => children), + BottomSheetSnapPoint: {Hidden: 0, Partial: 1, Full: 2}, +})) diff --git a/src/state/queries/messages/__tests__/fixtures.ts b/src/state/queries/messages/__tests__/fixtures.ts new file mode 100644 index 0000000000..cb58289f6b --- /dev/null +++ b/src/state/queries/messages/__tests__/fixtures.ts @@ -0,0 +1,245 @@ +/** + * Shared, render-free fixtures for chat log-event tests. Builders return objects + * tagged with the correct $type so the @atproto/api ChatBskyConvoDefs.isLogX / + * isConvoView / isGroupConvo type guards (which only check the $type string) + * pass. Kept generic - no jest, no QueryClient - so the event-bus test suite can + * import it too. + */ +import { + type $Typed, + type ChatBskyActorDefs, + type ChatBskyConvoDefs, + type ChatBskyConvoGetLog, +} from '@atproto/api' + +/** + * Revs are fixed-width strings in real data and compared lexically by the + * handler's rev guard. Zero-pad so numeric ordering matches string ordering. + */ +export function rev(n: number): string { + return String(n).padStart(7, '0') +} + +export function makeProfile(did: string): ChatBskyActorDefs.ProfileViewBasic { + return { + $type: 'chat.bsky.actor.defs#profileViewBasic', + did, + handle: `${did.replace(/[^a-z0-9]/gi, '')}.test`, + } satisfies ChatBskyActorDefs.ProfileViewBasic +} + +export function makeMessageView( + overrides: Partial & { + senderDid: string + }, +): $Typed { + const {senderDid, ...rest} = overrides + return { + $type: 'chat.bsky.convo.defs#messageView', + id: 'msg-1', + rev: rev(1), + text: 'hello', + sender: { + $type: 'chat.bsky.convo.defs#messageViewSender', + did: senderDid, + }, + sentAt: '2024-01-01T00:00:00.000Z', + ...rest, + } +} + +export function makeSystemMessageView( + data: ChatBskyConvoDefs.SystemMessageView['data'], + overrides: Partial> = {}, +): ChatBskyConvoDefs.SystemMessageView { + return { + $type: 'chat.bsky.convo.defs#systemMessageView', + id: 'sys-1', + rev: rev(1), + sentAt: '2024-01-01T00:00:00.000Z', + data, + ...overrides, + } +} + +type MakeConvoOverrides = Partial> & { + /** + * 'direct' builds a directConvo kind; 'group' builds a groupConvo kind with + * sensible defaults. Pass a full kind object to override entirely. + */ + kind?: 'direct' | 'group' | ChatBskyConvoDefs.ConvoView['kind'] + groupKind?: Partial +} + +export function makeConvo( + overrides: MakeConvoOverrides = {}, +): ChatBskyConvoDefs.ConvoView { + const {kind = 'direct', groupKind, ...rest} = overrides + + let resolvedKind: ChatBskyConvoDefs.ConvoView['kind'] + if (kind === 'direct') { + resolvedKind = {$type: 'chat.bsky.convo.defs#directConvo'} + } else if (kind === 'group') { + resolvedKind = { + $type: 'chat.bsky.convo.defs#groupConvo', + createdAt: '2024-01-01T00:00:00.000Z', + lockStatus: 'unlocked', + lockStatusModerationOverride: false, + memberCount: 3, + memberLimit: 50, + name: 'Test Group', + ...groupKind, + } satisfies ChatBskyConvoDefs.GroupConvo + } else { + resolvedKind = kind + } + + return { + $type: 'chat.bsky.convo.defs#convoView', + id: 'convo-1', + rev: rev(1), + members: [makeProfile('did:plc:me'), makeProfile('did:plc:other')], + muted: false, + status: 'accepted', + unreadCount: 0, + kind: resolvedKind, + ...rest, + } +} + +type Log = ChatBskyConvoGetLog.OutputSchema['logs'][number] + +/** + * Builders for the log event types the handler tests exercise. Each returns an + * object with the correct $type so the corresponding isLogX guard passes. + */ +export const makeLog = { + beginConvo( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logBeginConvo', + rev: r, + convoId, + } + }, + acceptConvo( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logAcceptConvo', + rev: r, + convoId, + } + }, + leaveConvo( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logLeaveConvo', + rev: r, + convoId, + } + }, + muteConvo( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logMuteConvo', + rev: r, + convoId, + } + }, + unmuteConvo( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logUnmuteConvo', + rev: r, + convoId, + } + }, + readConvo( + convoId: string, + r: string, + message: $Typed, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logReadConvo', + rev: r, + convoId, + message, + } + }, + createMessage( + convoId: string, + r: string, + message: $Typed, + relatedProfiles?: ChatBskyActorDefs.ProfileViewBasic[], + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logCreateMessage', + rev: r, + convoId, + message, + ...(relatedProfiles ? {relatedProfiles} : {}), + } + }, + editGroup( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logEditGroup', + rev: r, + convoId, + message: makeSystemMessageView({ + $type: 'chat.bsky.convo.defs#systemMessageDataEditGroup', + newName: 'Renamed', + }), + } + }, + createJoinLink( + convoId: string, + r: string, + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logCreateJoinLink', + rev: r, + convoId, + message: makeSystemMessageView({ + $type: 'chat.bsky.convo.defs#systemMessageDataCreateJoinLink', + }), + } + }, + addMember( + convoId: string, + r: string, + memberDid: string, + relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], + ): $Typed { + return { + $type: 'chat.bsky.convo.defs#logAddMember', + rev: r, + convoId, + message: makeSystemMessageView({ + $type: 'chat.bsky.convo.defs#systemMessageDataAddMember', + member: { + $type: 'chat.bsky.convo.defs#systemMessageReferredUser', + did: memberDid, + }, + role: 'standard', + addedBy: { + $type: 'chat.bsky.convo.defs#systemMessageReferredUser', + did: 'did:plc:me', + }, + }), + relatedProfiles, + } + }, +} satisfies Record Log> diff --git a/src/state/queries/messages/__tests__/log-handler.test.ts b/src/state/queries/messages/__tests__/log-handler.test.ts new file mode 100644 index 0000000000..de982b46c4 --- /dev/null +++ b/src/state/queries/messages/__tests__/log-handler.test.ts @@ -0,0 +1,614 @@ +import {type ChatBskyConvoDefs} from '@atproto/api' +import {QueryClient} from '@tanstack/react-query' + +import {RQKEY as CONVO_KEY} from '../conversation' +import { + type ConvoRequestListQueryData, + RQKEY as REQUESTS_RQKEY, +} from '../list-conversation-requests' +import {type ConvoListQueryData, RQKEY} from '../list-conversations' +import {listConvoMembersQueryKey} from '../list-convo-members' +import {handleConvoLogEvents} from '../log-handler' +import {makeConvo, makeLog, makeMessageView, makeProfile, rev} from './fixtures' + +const ME = 'did:plc:me' +const OTHER = 'did:plc:other' + +// --------------------------------------------------------------------------- +// Seed helpers - keep the tests readable. All build real QueryClient caches. +// --------------------------------------------------------------------------- + +type ListKey = ReturnType + +function seedList( + qc: QueryClient, + key: ListKey, + pages: ChatBskyConvoDefs.ConvoView[][], +) { + const data: ConvoListQueryData = { + pageParams: pages.map(() => undefined), + pages: pages.map(convos => ({convos, cursor: undefined})), + } + qc.setQueryData(key, data) +} + +/** + * Build a query entry that exists but has undefined data. setQueriesData only + * touches existing entries, so this is needed to exercise the createMessage + * "fabricate a single-page cache" seed branch. + */ +function seedEmptyQuery(qc: QueryClient, key: ListKey) { + qc.getQueryCache().build(qc, {queryKey: key}) +} + +function seedRequestsInbox( + qc: QueryClient, + convos: ChatBskyConvoDefs.ConvoView[], +) { + const data: ConvoRequestListQueryData = { + pageParams: [undefined], + pages: [ + { + requests: convos.map(c => ({ + ...c, + $type: 'chat.bsky.convo.defs#convoView' as const, + })), + cursor: undefined, + }, + ], + } + qc.setQueryData(REQUESTS_RQKEY(), data) +} + +function getList(qc: QueryClient, key: ListKey) { + return qc.getQueryData(key) +} + +function findConvo(data: ConvoListQueryData | undefined, id: string) { + if (!data) return undefined + for (const page of data.pages) { + const found = page.convos.find(c => c.id === id) + if (found) return found + } + return undefined +} + +function getRequestsConvoIds(qc: QueryClient): string[] { + const data = qc.getQueryData(REQUESTS_RQKEY()) + if (!data) return [] + return data.pages.flatMap(p => + p.requests + .filter( + (r): r is ChatBskyConvoDefs.ConvoView & {$type: string} => + (r as {$type?: string}).$type === 'chat.bsky.convo.defs#convoView', + ) + .map(r => r.id), + ) +} + +function call( + qc: QueryClient, + logs: Parameters[0]['logs'], + opts: { + currentConvoId?: string + currentAccountDid?: string + onRefetchNeeded?: () => void + } = {}, +) { + const onRefetchNeeded = opts.onRefetchNeeded ?? jest.fn() + handleConvoLogEvents({ + queryClient: qc, + logs, + currentConvoId: opts.currentConvoId, + currentAccountDid: opts.currentAccountDid ?? ME, + onRefetchNeeded, + }) + return onRefetchNeeded +} + +// =========================================================================== +// 1. Batch continuation - the "return instead of continue" regression +// =========================================================================== + +describe('batch continuation (continue, not return)', () => { + it('applies a later mute after an unknown-convo createMessage in the same batch', () => { + const qc = new QueryClient() + const known = makeConvo({id: 'known', rev: rev(1), muted: false}) + const key = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedList(qc, key, [[known]]) + + const onRefetchNeeded = call( + qc, + [ + // unknown convo - triggers refetch + continue (previously: return) + makeLog.createMessage( + 'unknown', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + // known convo - must still apply despite the earlier miss + makeLog.muteConvo('known', rev(5)), + ], + {currentAccountDid: ME}, + ) + + expect(findConvo(getList(qc, key), 'known')?.muted).toBe(true) + expect(onRefetchNeeded).toHaveBeenCalled() + }) + + it('applies a later log after an unknown-convo acceptConvo in the same batch', () => { + const qc = new QueryClient() + const known = makeConvo({id: 'known', rev: rev(1), muted: false}) + const key = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedList(qc, key, [[known]]) + + const onRefetchNeeded = call(qc, [ + // unknown convo in request caches - triggers refetch + continue + makeLog.acceptConvo('unknown', rev(5)), + makeLog.muteConvo('known', rev(5)), + ]) + + expect(findConvo(getList(qc, key), 'known')?.muted).toBe(true) + expect(onRefetchNeeded).toHaveBeenCalled() + }) +}) + +// =========================================================================== +// 2. Accept updates 'all'-status caches + resurrect prevention +// =========================================================================== + +describe('logAcceptConvo across caches', () => { + function setup() { + const qc = new QueryClient() + const requestConvo = makeConvo({ + id: 'c', + rev: rev(1), + status: 'request', + unreadCount: 1, + }) + const allUnreadKey = RQKEY('all', 'unread', 'all', 'unlocked', 20) + const requestKey = RQKEY('request', 'all', 'all', undefined, 10) + const acceptedKey = RQKEY('accepted', 'all', 'all', undefined, 10) + + seedList(qc, allUnreadKey, [[requestConvo]]) + seedList(qc, requestKey, [[requestConvo]]) + seedList(qc, acceptedKey, [[]]) + seedRequestsInbox(qc, [requestConvo]) + + return {qc, allUnreadKey, requestKey, acceptedKey} + } + + it('flips status in all-cache, drops from request caches, adds to accepted, drops from inbox', () => { + const {qc, allUnreadKey, requestKey, acceptedKey} = setup() + + call(qc, [makeLog.acceptConvo('c', rev(5))]) + + // 'all' copy now accepted + expect(findConvo(getList(qc, allUnreadKey), 'c')?.status).toBe('accepted') + // 'request'-status list no longer contains it + expect(findConvo(getList(qc, requestKey), 'c')).toBeUndefined() + // 'accepted'-status list gained it at the top of page 0 + expect(getList(qc, acceptedKey)?.pages[0].convos[0]?.id).toBe('c') + expect(findConvo(getList(qc, acceptedKey), 'c')?.status).toBe('accepted') + // requests inbox dropped it + expect(getRequestsConvoIds(qc)).not.toContain('c') + }) + + it('does not resurrect the convo in request caches when a later createMessage arrives', () => { + const {qc, allUnreadKey, requestKey, acceptedKey} = setup() + + call(qc, [ + makeLog.acceptConvo('c', rev(5)), + makeLog.createMessage( + 'c', + rev(6), + makeMessageView({senderDid: OTHER, id: 'm2', rev: rev(6)}), + ), + ]) + + // still not in request-status list or inbox + expect(findConvo(getList(qc, requestKey), 'c')).toBeUndefined() + expect(getRequestsConvoIds(qc)).not.toContain('c') + // updated in 'all' and 'accepted' caches, with accepted status + expect(findConvo(getList(qc, allUnreadKey), 'c')?.status).toBe('accepted') + expect(findConvo(getList(qc, acceptedKey), 'c')?.status).toBe('accepted') + }) +}) + +// =========================================================================== +// 3. Rev guard +// =========================================================================== + +describe('rev guard', () => { + it('ignores a stale logReadConvo (rev <= cached rev)', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(5), unreadCount: 3}) + const key = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedList(qc, key, [[convo]]) + + call(qc, [ + makeLog.readConvo( + 'c', + rev(5), // equal - guard skips + makeMessageView({senderDid: OTHER}), + ), + ]) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(3) + expect(findConvo(getList(qc, key), 'c')?.rev).toBe(rev(5)) + }) + + it('ignores a replayed logCreateMessage (rev <= cached rev), no double-bump', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(5), unreadCount: 1}) + const key = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedList(qc, key, [[convo]]) + + call(qc, [ + makeLog.createMessage( + 'c', + rev(5), // not newer - skip + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ]) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(1) + }) + + it('applies a newer logReadConvo and stamps the new rev', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(5), unreadCount: 3}) + const key = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedList(qc, key, [[convo]]) + + call(qc, [ + makeLog.readConvo('c', rev(9), makeMessageView({senderDid: OTHER})), + ]) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(0) + expect(findConvo(getList(qc, key), 'c')?.rev).toBe(rev(9)) + }) +}) + +// =========================================================================== +// 4. Core behaviors +// =========================================================================== + +describe('logCreateMessage core behavior', () => { + it('updates lastMessage, moves to top of page 0, removes from later pages', () => { + const qc = new QueryClient() + const target = makeConvo({id: 'c', rev: rev(1), unreadCount: 0}) + const other = makeConvo({id: 'd', rev: rev(1)}) + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + // target sits on page 1 (not page 0) and another convo is on page 0 + seedList(qc, key, [[other], [target]]) + + const newMsg = makeMessageView({ + senderDid: OTHER, + id: 'm-new', + rev: rev(5), + text: 'newest', + }) + call(qc, [makeLog.createMessage('c', rev(5), newMsg)]) + + const data = getList(qc, key) + // moved to top of page 0 + expect(data?.pages[0].convos[0]?.id).toBe('c') + // removed from page 1 + expect(data?.pages[1].convos.find(c => c.id === 'c')).toBeUndefined() + // lastMessage updated + const updated = findConvo(data, 'c') + expect((updated?.lastMessage as ChatBskyConvoDefs.MessageView)?.text).toBe( + 'newest', + ) + }) + + it('increments unreadCount when message is from someone else and not the current convo', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(1), unreadCount: 2}) + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[convo]]) + + call( + qc, + [ + makeLog.createMessage( + 'c', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ], + {currentConvoId: undefined, currentAccountDid: ME}, + ) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(3) + }) + + it('does not increment unreadCount for own message', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(1), unreadCount: 2}) + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[convo]]) + + call( + qc, + [ + makeLog.createMessage( + 'c', + rev(5), + makeMessageView({senderDid: ME, id: 'm', rev: rev(5)}), + ), + ], + {currentAccountDid: ME}, + ) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(2) + }) + + it('forces unreadCount to 0 when the convo is the current convo', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(1), unreadCount: 4}) + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[convo]]) + + call( + qc, + [ + makeLog.createMessage( + 'c', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ], + {currentConvoId: 'c', currentAccountDid: ME}, + ) + + expect(findConvo(getList(qc, key), 'c')?.unreadCount).toBe(0) + }) + + it('seeds a fabricated single-page cache for an existing-but-empty all+unread query', () => { + const qc = new QueryClient() + const convo = makeConvo({id: 'c', rev: rev(1), unreadCount: 0}) + // The convo must be found somewhere, else the handler bails with refetch. + const acceptedKey = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, acceptedKey, [[convo]]) + + // all+unread query exists but has no data yet + const allUnreadKey = RQKEY('all', 'unread', 'all', 'unlocked', 20) + seedEmptyQuery(qc, allUnreadKey) + + call(qc, [ + makeLog.createMessage( + 'c', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ]) + + // NOTE: pins current behavior - the empty all+unread query is seeded with a + // fabricated single page containing just the updated convo (with the bumped + // unreadCount), rather than left undefined. + const seeded = getList(qc, allUnreadKey) + expect(seeded?.pages).toHaveLength(1) + expect(seeded?.pages[0].convos).toHaveLength(1) + expect(seeded?.pages[0].convos[0]?.id).toBe('c') + expect(seeded?.pages[0].convos[0]?.unreadCount).toBe(1) + }) +}) + +describe('logReadConvo', () => { + it('zeroes unreadCount across multiple list caches', () => { + const qc = new QueryClient() + const a = RQKEY('all', 'unread', 'all', 'unlocked', 20) + const b = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, a, [[makeConvo({id: 'c', rev: rev(1), unreadCount: 3})]]) + seedList(qc, b, [[makeConvo({id: 'c', rev: rev(1), unreadCount: 3})]]) + + call(qc, [ + makeLog.readConvo('c', rev(5), makeMessageView({senderDid: OTHER})), + ]) + + expect(findConvo(getList(qc, a), 'c')?.unreadCount).toBe(0) + expect(findConvo(getList(qc, b), 'c')?.unreadCount).toBe(0) + }) +}) + +describe('mute / unmute', () => { + it('logMuteConvo sets muted on list caches and the single-convo cache', () => { + const qc = new QueryClient() + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[makeConvo({id: 'c', rev: rev(1), muted: false})]]) + qc.setQueryData( + CONVO_KEY('c'), + makeConvo({id: 'c', rev: rev(1), muted: false}), + ) + + call(qc, [makeLog.muteConvo('c', rev(5))]) + + expect(findConvo(getList(qc, key), 'c')?.muted).toBe(true) + expect( + qc.getQueryData(CONVO_KEY('c'))?.muted, + ).toBe(true) + }) + + it('logUnmuteConvo clears muted on list caches and the single-convo cache', () => { + const qc = new QueryClient() + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[makeConvo({id: 'c', rev: rev(1), muted: true})]]) + qc.setQueryData( + CONVO_KEY('c'), + makeConvo({id: 'c', rev: rev(1), muted: true}), + ) + + call(qc, [makeLog.unmuteConvo('c', rev(5))]) + + expect(findConvo(getList(qc, key), 'c')?.muted).toBe(false) + expect( + qc.getQueryData(CONVO_KEY('c'))?.muted, + ).toBe(false) + }) +}) + +describe('logLeaveConvo', () => { + it('removes the convo from all list caches and the requests inbox', () => { + const qc = new QueryClient() + const a = RQKEY('all', 'unread', 'all', 'unlocked', 20) + const b = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, a, [[makeConvo({id: 'c', rev: rev(1)})]]) + seedList(qc, b, [[makeConvo({id: 'c', rev: rev(1)})]]) + seedRequestsInbox(qc, [ + makeConvo({id: 'c', rev: rev(1), status: 'request'}), + ]) + + call(qc, [makeLog.leaveConvo('c', rev(5))]) + + expect(findConvo(getList(qc, a), 'c')).toBeUndefined() + expect(findConvo(getList(qc, b), 'c')).toBeUndefined() + expect(getRequestsConvoIds(qc)).not.toContain('c') + }) +}) + +describe('logCreateMessage for a request convo', () => { + it('moves the convo to the top of the requests inbox', () => { + const qc = new QueryClient() + const reqA = makeConvo({id: 'a', rev: rev(1), status: 'request'}) + const reqB = makeConvo({id: 'b', rev: rev(1), status: 'request'}) + // request convos must be found in a request-status list for foundConvo + const requestKey = RQKEY('request', 'all', 'all', undefined, 10) + seedList(qc, requestKey, [[reqA, reqB]]) + seedRequestsInbox(qc, [reqA, reqB]) + + call(qc, [ + makeLog.createMessage( + 'b', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ]) + + // 'b' moved to the top of the requests inbox + expect(getRequestsConvoIds(qc)[0]).toBe('b') + }) +}) + +describe('member add (logAddMember)', () => { + it('bumps memberCount on a group convo and appends to the members cache', () => { + const qc = new QueryClient() + const group = makeConvo({ + id: 'g', + rev: rev(1), + kind: 'group', + groupKind: {memberCount: 3}, + }) + const key = RQKEY('accepted', 'all', 'group', 'unlocked', 10) + seedList(qc, key, [[group]]) + // members cache exists without the new member + qc.setQueryData(listConvoMembersQueryKey('g'), [ + makeProfile(ME), + makeProfile(OTHER), + ]) + + const newProfile = makeProfile('did:plc:newbie') + call(qc, [makeLog.addMember('g', rev(5), 'did:plc:newbie', [newProfile])]) + + const convo = findConvo(getList(qc, key), 'g') + expect((convo?.kind as ChatBskyConvoDefs.GroupConvo).memberCount).toBe(4) + const members = qc.getQueryData[]>( + listConvoMembersQueryKey('g'), + ) + expect(members?.some(m => m.did === 'did:plc:newbie')).toBe(true) + }) + + it('does not double-bump when the member is already in the members cache', () => { + const qc = new QueryClient() + const group = makeConvo({ + id: 'g', + rev: rev(1), + kind: 'group', + groupKind: {memberCount: 3}, + }) + const key = RQKEY('accepted', 'all', 'group', 'unlocked', 10) + seedList(qc, key, [[group]]) + // newbie already present in the members cache (optimistic add) + qc.setQueryData(listConvoMembersQueryKey('g'), [ + makeProfile(ME), + makeProfile(OTHER), + makeProfile('did:plc:newbie'), + ]) + + const newProfile = makeProfile('did:plc:newbie') + call(qc, [makeLog.addMember('g', rev(5), 'did:plc:newbie', [newProfile])]) + + const convo = findConvo(getList(qc, key), 'g') + // count unchanged - dedup guard + expect((convo?.kind as ChatBskyConvoDefs.GroupConvo).memberCount).toBe(3) + }) + + it('leaves a direct convo unchanged on member add', () => { + const qc = new QueryClient() + const direct = makeConvo({id: 'd', rev: rev(1), kind: 'direct'}) + const key = RQKEY('accepted', 'all', 'direct', undefined, 10) + seedList(qc, key, [[direct]]) + qc.setQueryData(listConvoMembersQueryKey('d'), [ + makeProfile(ME), + makeProfile(OTHER), + ]) + + const newProfile = makeProfile('did:plc:newbie') + call(qc, [makeLog.addMember('d', rev(5), 'did:plc:newbie', [newProfile])]) + + const convo = findConvo(getList(qc, key), 'd') + // direct convo kind has no memberCount; the addMemberToConvoView helper + // returns the convo unchanged for non-group kinds. + expect(convo?.kind?.$type).toBe('chat.bsky.convo.defs#directConvo') + expect( + (convo as ChatBskyConvoDefs.ConvoView & {kind: {memberCount?: number}}) + .kind.memberCount, + ).toBeUndefined() + }) +}) + +// =========================================================================== +// 5. onRefetchNeeded +// =========================================================================== + +describe('onRefetchNeeded', () => { + it('is called for logBeginConvo', () => { + const qc = new QueryClient() + const onRefetchNeeded = call(qc, [makeLog.beginConvo('c', rev(5))]) + expect(onRefetchNeeded).toHaveBeenCalled() + }) + + it('is called for join-link events', () => { + const qc = new QueryClient() + const onRefetchNeeded = call(qc, [makeLog.createJoinLink('c', rev(5))]) + expect(onRefetchNeeded).toHaveBeenCalled() + }) + + it('is called for logEditGroup', () => { + const qc = new QueryClient() + const onRefetchNeeded = call(qc, [makeLog.editGroup('c', rev(5))]) + expect(onRefetchNeeded).toHaveBeenCalled() + }) + + it('is called when a createMessage references an unknown convo', () => { + const qc = new QueryClient() + const onRefetchNeeded = call(qc, [ + makeLog.createMessage( + 'unknown', + rev(5), + makeMessageView({senderDid: OTHER, id: 'm', rev: rev(5)}), + ), + ]) + expect(onRefetchNeeded).toHaveBeenCalled() + }) + + it('is NOT called when a batch is fully handled surgically (mute on a known convo)', () => { + const qc = new QueryClient() + const key = RQKEY('accepted', 'all', 'all', undefined, 10) + seedList(qc, key, [[makeConvo({id: 'c', rev: rev(1), muted: false})]]) + + const onRefetchNeeded = call(qc, [makeLog.muteConvo('c', rev(5))]) + expect(onRefetchNeeded).not.toHaveBeenCalled() + }) +})