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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-12 11:22:33 +03:00
parent 4206010900
commit 776822afcd
3 changed files with 876 additions and 0 deletions
+17
View File
@@ -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},
}))
@@ -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<ChatBskyConvoDefs.MessageView> & {
senderDid: string
},
): $Typed<ChatBskyConvoDefs.MessageView> {
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<Omit<ChatBskyConvoDefs.SystemMessageView, 'data'>> = {},
): 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<Omit<ChatBskyConvoDefs.ConvoView, 'kind'>> & {
/**
* '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<ChatBskyConvoDefs.GroupConvo>
}
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<ChatBskyConvoDefs.LogBeginConvo> {
return {
$type: 'chat.bsky.convo.defs#logBeginConvo',
rev: r,
convoId,
}
},
acceptConvo(
convoId: string,
r: string,
): $Typed<ChatBskyConvoDefs.LogAcceptConvo> {
return {
$type: 'chat.bsky.convo.defs#logAcceptConvo',
rev: r,
convoId,
}
},
leaveConvo(
convoId: string,
r: string,
): $Typed<ChatBskyConvoDefs.LogLeaveConvo> {
return {
$type: 'chat.bsky.convo.defs#logLeaveConvo',
rev: r,
convoId,
}
},
muteConvo(
convoId: string,
r: string,
): $Typed<ChatBskyConvoDefs.LogMuteConvo> {
return {
$type: 'chat.bsky.convo.defs#logMuteConvo',
rev: r,
convoId,
}
},
unmuteConvo(
convoId: string,
r: string,
): $Typed<ChatBskyConvoDefs.LogUnmuteConvo> {
return {
$type: 'chat.bsky.convo.defs#logUnmuteConvo',
rev: r,
convoId,
}
},
readConvo(
convoId: string,
r: string,
message: $Typed<ChatBskyConvoDefs.MessageView>,
): $Typed<ChatBskyConvoDefs.LogReadConvo> {
return {
$type: 'chat.bsky.convo.defs#logReadConvo',
rev: r,
convoId,
message,
}
},
createMessage(
convoId: string,
r: string,
message: $Typed<ChatBskyConvoDefs.MessageView>,
relatedProfiles?: ChatBskyActorDefs.ProfileViewBasic[],
): $Typed<ChatBskyConvoDefs.LogCreateMessage> {
return {
$type: 'chat.bsky.convo.defs#logCreateMessage',
rev: r,
convoId,
message,
...(relatedProfiles ? {relatedProfiles} : {}),
}
},
editGroup(
convoId: string,
r: string,
): $Typed<ChatBskyConvoDefs.LogEditGroup> {
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<ChatBskyConvoDefs.LogCreateJoinLink> {
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<ChatBskyConvoDefs.LogAddMember> {
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<string, (...args: never[]) => Log>
@@ -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<typeof RQKEY>
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<ConvoListQueryData>(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<ConvoRequestListQueryData>(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<typeof handleConvoLogEvents>[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<ChatBskyConvoDefs.ConvoView>(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<ChatBskyConvoDefs.ConvoView>(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<ReturnType<typeof makeProfile>[]>(
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()
})
})