feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import test from "node:test"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
function createStore(initialize) {
let state
const setState = (update, replace = false) => {
const next = typeof update === "function" ? update(state) : update
state = replace ? next : { ...state, ...next }
}
state = initialize(setState, () => state)
const initial = state
return { getState: () => state, setState, getInitialState: () => initial }
}
function fixture(api = {}) {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
const loaded = loadTypeScriptModule(new URL("./agent-conversations.ts", import.meta.url), {
zustand: { create: createStore },
"@/lib/api/agent": api,
"@/lib/auth": { readSession: () => session },
"@/lib/utils": { generateUUID: () => "test-id" },
"@/lib/im-message": { summarizeIMMessage: () => "test-summary" },
"@/lib/im-message-merge": {
cursorFromLoadedImMessages: (messages) => String(messages[0]?.id ?? ""),
hasMoreAfterLatestImMessageMerge: () => false,
mergeImMessagesByIdAsc: (left, right) => [...left, ...right],
parseImMessageCursorId: Number,
},
"@/lib/im-realtime-state": {
patchConversationList: (list) => list,
patchConversationListWithMessage: (list) => list,
},
"@/lib/agent-activity-state": {},
"@/lib/operator-presence-state": {},
})
return {
store: loaded.useAgentConversationsStore,
reset: loaded.resetAgentConversations,
set session(next) { session = next },
}
}
test("late conversation list cannot restore private data after reset/account change", async () => {
const old = deferred()
const f = fixture({ fetchAgentConversations: () => old.promise })
const loading = f.store.getState().loadConversations()
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.reset()
f.store.setState({ conversations: [{ id: 20 }], selectedConversationId: 20 })
old.resolve({ results: [{ id: 10 }] })
await loading
assert.equal(f.store.getState().conversations[0].id, 20)
assert.equal(f.store.getState().selectedConversationId, 20)
})
test("same-account relogin invalidates old load generations too", async () => {
const old = deferred()
const f = fixture({ fetchAgentMessages: () => old.promise })
f.store.setState({ selectedConversationId: 10 })
const loading = f.store.getState().loadMessages(10)
f.reset()
f.store.setState({ selectedConversationId: 10, messages: [{ id: 200 }] })
old.resolve({ results: [{ id: 100 }] })
await loading
assert.equal(f.store.getState().messages[0].id, 200)
})
test("operator send binds its recipient, is single-flight, and does not patch the new selection", async () => {
const response = deferred()
const calls = []
const f = fixture({ sendAgentMessage: (payload) => { calls.push(payload); return response.promise } })
f.store.setState({ selectedConversationId: 10 })
assert.equal(await f.store.getState().sendMessage("Wrong recipient", 20), null)
const sending = f.store.getState().sendMessage("For A", 10)
assert.equal(await f.store.getState().sendMessage("Duplicate", 10), null)
f.store.setState({ selectedConversationId: 20, messages: [{ id: 200 }] })
response.resolve({ id: 100, conversationId: 10 })
await sending
assert.equal(calls.length, 1)
assert.equal(calls[0].conversationId, 10)
assert.equal(f.store.getState().messages[0].id, 200)
})
test("old send finally does not unlock a new account's pending send", async () => {
const old = deferred()
const f = fixture({ sendAgentMessage: () => old.promise })
f.store.setState({ selectedConversationId: 10 })
const sending = f.store.getState().sendMessage("For A", 10)
f.reset()
f.store.setState({ sending: true, selectedConversationId: 20 })
old.resolve({ id: 100, conversationId: 10 })
await sending
assert.equal(f.store.getState().sending, true)
})
test("attachment upload cannot send under a new account after identity change", async () => {
const upload = deferred()
let sends = 0
const f = fixture({
uploadAgentConversationAttachment: () => upload.promise,
sendAgentMessage: async () => { sends += 1; return { id: 100 } },
})
f.store.setState({ selectedConversationId: 10 })
const pending = f.store.getState().sendAttachment({}, 10)
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.reset()
upload.resolve({ assetId: "test", filename: "test.txt" })
assert.equal(await pending, null)
assert.equal(sends, 0)
})
test("a fresh snapshot invalidates old pagination without leaving its loading flag stuck", async () => {
const oldPage = deferred()
const fresh = deferred()
const f = fixture({ fetchAgentMessages: (params) => params.cursor ? oldPage.promise : fresh.promise })
f.store.setState({ selectedConversationId: 10, messagesCursor: "100", messagesHasMore: true })
const older = f.store.getState().loadOlderMessages()
assert.equal(f.store.getState().messagesLoadingMore, true)
const refresh = f.store.getState().loadMessages(10)
fresh.resolve({ results: [{ id: 200 }], hasMore: true })
await refresh
oldPage.resolve({ results: [{ id: 50 }], hasMore: false })
await older
assert.equal(f.store.getState().messages[0].id, 200)
assert.equal(f.store.getState().messagesLoadingMore, false)
})

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions.
"use client"
import { create } from "zustand"
@@ -104,9 +106,9 @@ type AgentConversationsStore = {
loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null>
sendMessage: (html: string, conversationId?: number) => Promise<AgentMessage | null>
uploadImage: (file: File, conversationId?: number) => Promise<AgentAsset | null>
sendAttachment: (file: File, conversationId?: number) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null>
applyRealtimeMessageCreated: (message: AgentMessage) => void
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
@@ -119,6 +121,22 @@ type AgentConversationsStore = {
let conversationsRequestSeq = 0
let messagesRequestSeq = 0
let sessionGeneration = 0
function captureSessionScope() {
const generation = sessionGeneration
const userId = readSession()?.user.id
return () =>
generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId
}
/** Invalidates every outstanding request before removing private dashboard data. */
export function resetAgentConversations() {
sessionGeneration += 1
conversationsRequestSeq += 1
messagesRequestSeq += 1
useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true)
}
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
searchKeyword: "",
@@ -167,6 +185,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadConversations: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const requestSeq = ++conversationsRequestSeq
const store = get()
@@ -180,7 +200,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
)
const conversations = ensureArray(data.results)
if (requestSeq !== conversationsRequestSeq) {
if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) {
return
}
@@ -216,7 +236,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
})
}
} catch (error) {
if (requestSeq === conversationsRequestSeq) {
if (requestSeq === conversationsRequestSeq && isCurrentSession()) {
set({ conversationsLoading: false })
}
throw error
@@ -246,10 +266,14 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadMessages: async (conversationId, options = {}) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession() || get().selectedConversationId !== conversationId) return
const requestSeq = ++messagesRequestSeq
const store = get()
const shouldShowLoading =
options.forceLoading || store.messagesLoadedConversationId !== conversationId
// A fresh snapshot supersedes any older page request in the prior generation.
set({ messagesLoadingMore: false })
if (shouldShowLoading) {
set({
@@ -270,7 +294,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
limit: 50,
})
if (requestSeq !== messagesRequestSeq) {
if (requestSeq !== messagesRequestSeq || !isCurrentSession()) {
return
}
@@ -288,7 +312,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
messagesHasMore: Boolean(data.hasMore),
})
} catch (error) {
if (requestSeq === messagesRequestSeq) {
if (requestSeq === messagesRequestSeq && isCurrentSession()) {
set({ messagesLoading: false })
}
throw error
@@ -296,6 +320,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
loadOlderMessages: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
const conversationId = get().selectedConversationId
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
return
@@ -312,7 +339,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
cursor: cursorId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return
}
const incoming = ensureArray(data.results)
@@ -328,12 +355,17 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} catch (error) {
set({ messagesLoadingMore: false })
if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) {
set({ messagesLoadingMore: false })
}
throw error
}
},
syncLatestMessages: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
if (conversationId <= 0) {
return
}
@@ -342,7 +374,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
conversationId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return
}
const batch = ensureArray(data.results)
@@ -370,6 +402,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
markSelectedConversationRead: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const store = get()
const conversationId = store.selectedConversationId
const conversation = store.conversations.find((item) => item.id === conversationId)
@@ -390,6 +424,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
set({ readingMessageId: lastMessage.id })
try {
await markAgentMessageRead(conversationId, lastMessage.id)
if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return
set((current) => {
if (current.selectedConversationId !== conversationId) {
return { readingMessageId: 0 }
@@ -414,7 +449,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} catch (error) {
set({ readingMessageId: 0 })
if (isCurrentSession() && get().readingMessageId === lastMessage.id) {
set({ readingMessageId: 0 })
}
throw error
}
},
@@ -513,7 +550,10 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
},
resyncRealtimeData: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
await get().loadConversations()
if (!isCurrentSession()) return
const selectedConversationId = get().selectedConversationId
const targetConversationId = conversationId ?? selectedConversationId
if (targetConversationId && selectedConversationId === targetConversationId) {
@@ -524,10 +564,15 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
},
sendMessage: async (html) => {
sendMessage: async (html, recipientId) => {
const isCurrentSession = captureSessionScope()
const trimmedContent = html.trim()
const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
!trimmedContent || sending || get().uploadingAsset
) {
return null
}
@@ -540,7 +585,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -561,33 +606,45 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message
} finally {
set({ sending: false })
if (isCurrentSession()) set({ sending: false })
}
},
uploadImage: async (file) => {
uploadImage: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null
}
set({ uploadingAsset: true })
try {
return await uploadAgentConversationImage(selectedConversationId, file)
const asset = await uploadAgentConversationImage(selectedConversationId, file)
return isCurrentSession() ? asset : null
} finally {
set({ uploadingAsset: false })
if (isCurrentSession()) set({ uploadingAsset: false })
}
},
sendAttachment: async (file) => {
sendAttachment: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null
}
set({ uploadingAsset: true })
try {
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
if (!isCurrentSession()) return null
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "attachment",
@@ -596,7 +653,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_attachment_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -617,20 +674,22 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message
} finally {
set({ uploadingAsset: false })
if (isCurrentSession()) set({ uploadingAsset: false })
}
},
recallMessage: async (messageId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, recallingMessageId } = get()
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
return null
}
set({ recallingMessageId: messageId })
try {
const message = await recallAgentMessage(messageId)
if (get().selectedConversationId === selectedConversationId) {
if (!isCurrentSession()) return null
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => {
const nextMessages = current.messages.map((item) =>
item.id === message.id ? message : item
@@ -656,11 +715,11 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}
})
} else {
set({ recallingMessageId: 0 })
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
}
return message
} catch (error) {
set({ recallingMessageId: 0 })
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
throw error
}
},

View File

@@ -9,7 +9,8 @@ const deferred = () => {
const promise = new Promise((done) => { resolve = done })
return { promise, resolve }
}
const conversation = (id) => ({ id, status: 3, customerUnreadCount: 0 })
const conversation = (id) => ({ id, channelId: 1, status: 3, lastMessageId: 0,
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0, agentLastReadMessageId: 0 })
const message = (id, conversationId) => ({ id, conversationId, senderType: "agent",
senderId: 1, messageType: "html", content: "Reply", sendStatus: 1,
customerRead: false, agentRead: false })
@@ -24,6 +25,8 @@ function harness(overrides = {}) {
const config = { channelId: "fixture", externalId: "visitor-1" }
const document = { visibilityState: "visible" }
const api = {
getCustomerIdentityScope: () => JSON.stringify(config),
getCustomerEntryScope: () => JSON.stringify(config),
ensureCustomerSession: async () => ({}),
fetchImWidgetConfig: async () => ({}),
fetchImConversationList: async () => page([conversation(1)]),
@@ -41,6 +44,7 @@ function harness(overrides = {}) {
return { getState: () => state, setState: set }
} },
"@/lib/api/im": api,
"@/lib/message-file-identity": { getMessageFileIdentity: async () => "fixture-file" },
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
"@/lib/utils": { generateUUID: () => "fixture" },
@@ -67,9 +71,9 @@ function harness(overrides = {}) {
const compiled = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} })
const module = { exports: {} }
modules.set(name, module.exports)
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
const moduleContext = { exports: {} }
modules.set(name, moduleContext.exports)
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports,
require: load, console, document, AbortController, WebSocket: { OPEN: 1, CONNECTING: 0 },
setTimeout: (callback, delay) => {
const id = ++nextTimer
@@ -78,10 +82,10 @@ function harness(overrides = {}) {
},
clearTimeout: (id) => timers.delete(id),
})
return module.exports
return moduleContext.exports
}
const store = load("./support-chat.ts").useSupportChatStore
store.setState({ initialized: true, isOpen: true, isVisible: true,
store.setState({ identityScope: api.getCustomerIdentityScope(), initialized: true, isOpen: true, isVisible: true,
conversations: [conversation(1)], conversation: conversation(1), activeConversationId: 1 })
return { store, api, config, document, timers, sent,
get connects() { return connects }, get disconnects() { return disconnects },

View File

@@ -0,0 +1,289 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3,
serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0,
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0,
agentLastReadMessageId: 0, ...extra })
const message = (id, conversationId, extra = {}) => ({ id, conversationId,
senderType: "customer", senderId: 1, messageType: "html", content: `<p>${id}</p>`,
sendStatus: 1, customerRead: false, agentRead: false, ...extra })
const page = (results, hasMore = false) => ({ results, hasMore })
// Exercise the actual store, including its asynchronous continuations, without
// connecting a socket, browser, database, or service.
function harness(overrides = {}) {
let identity = "visitor-1", uuid = 0, state, realtimeOptions
let connectCount = 0, disconnectCount = 0
const timers = new Map()
const api = {
getCustomerIdentityScope: () => identity,
getCustomerEntryScope: () => identity,
ensureCustomerSession: async () => ({}),
fetchImWidgetConfig: async () => ({}),
fetchImConversationList: async () => page([conversation(1), conversation(2)]),
createOrMatchImConversation: async () => conversation(1),
fetchImMessages: async () => page([]),
createImConversation: async () => conversation(3),
markImMessageRead: async () => {},
closeImConversation: async () => {},
applyCustomerSessionRefresh: () => {},
...overrides,
}
const modules = new Map()
const mocks = {
zustand: { create: (initializer) => {
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
state = initializer(set, () => state)
return { getState: () => state, setState: set }
} },
"@/lib/api/im": api,
"@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content },
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
"@/lib/utils": { generateUUID: () => `uuid-${++uuid}` },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} },
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
realtimeOptions = options
return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } }
} },
}
const load = (name) => {
if (mocks[name]) return mocks[name]
if (modules.has(name)) return modules.get(name)
const url = name.startsWith("@/")
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
: new URL(name, import.meta.url)
const source = readFileSync(url, "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} })
const moduleContext = { exports: {} }
modules.set(name, moduleContext.exports)
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports, require: load,
console, AbortController, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id },
clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } })
return moduleContext.exports
}
const store = load("./support-chat.ts").useSupportChatStore
const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true,
conversations: [conversation(1), conversation(2)], activeConversationId: 1,
conversation: conversation(1), ...extra })
seed()
return { store, api, seed, timers, setIdentity: (value) => { identity = value },
emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }),
get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } }
}
for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) {
test(`${loader} cannot paint a late response into a newly selected request`, async () => {
const late = deferred()
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) })
h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" })
const pending = h.store.getState()[loader]()
await h.store.getState().selectConversation(2)
late.resolve(page([message(5, 1)], true))
await pending
assert.equal(h.store.getState().conversation.id, 2)
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2])
assert.equal(h.store.getState().messagesLoadingMore, false)
})
}
test("A to B to A selection invalidates the first A response", async () => {
const late = deferred()
let calls = 0
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0
? late.promise : Promise.resolve(page([message(30, conversationId)])) })
const pending = h.store.getState().refreshMessages()
await h.store.getState().selectConversation(2)
await h.store.getState().selectConversation(1)
late.resolve(page([message(10, 1)]))
await pending
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30])
})
test("newer latest request wins and stale failure cannot replace its error state", async () => {
const old = deferred(), current = deferred()
let calls = 0
const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise })
const first = h.store.getState().refreshMessages()
const second = h.store.getState().refreshMessages()
current.resolve(page([message(30, 1)]))
await second
old.reject(new Error("stale error"))
await first
assert.equal(h.store.getState().error, "")
assert.equal(h.store.getState().messages[0].id, 30)
})
test("read acknowledgment updates its original list row without marking the new view", async () => {
const read = deferred()
const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) })
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] })
const pending = h.store.getState().markConversationRead()
await h.store.getState().selectConversation(2)
read.resolve()
await pending
const state = h.store.getState()
assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0)
assert.equal(state.conversation.customerUnreadCount, 2)
assert.equal(state.messages[0].customerRead, false)
})
test("read acknowledgment does not erase a newer unread reply", async () => {
const read = deferred()
const h = harness({ markImMessageRead: () => read.promise })
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] })
const pending = h.store.getState().markConversationRead()
h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 })
read.resolve()
await pending
assert.equal(h.store.getState().conversation.customerUnreadCount, 2)
assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10)
})
test("send is single-flight and failed logical send keeps its idempotency key", async () => {
const first = deferred(), sent = []
const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } })
const pending = h.store.getState().handleSendMessage("<p>Hello</p>")
await assert.rejects(h.store.getState().handleSendMessage("<p>Hello</p>"), /sendInProgress/)
assert.equal(sent.length, 1)
first.reject(new Error("response lost"))
await assert.rejects(pending, /response lost/)
await h.store.getState().handleSendMessage("<p>Hello</p>")
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
await h.store.getState().handleSendMessage("<p>Hello</p>")
assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId)
})
test("delivered send after switching updates only original request summary", async () => {
const send = deferred()
const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) })
const pending = h.store.getState().handleSendMessage("hello")
await h.store.getState().selectConversation(2)
assert.equal(h.store.getState().sending, false)
send.resolve(message(10, 1))
await pending
const state = h.store.getState()
assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10)
assert.equal(state.conversation.lastMessageId, 0)
assert.equal(state.messages[0].conversationId, 2)
})
test("close response for old selection does not close or disconnect current request", async () => {
const close = deferred()
const h = harness({ closeImConversation: () => close.promise })
const pending = h.store.getState().closeConversation()
await h.store.getState().selectConversation(2)
close.resolve()
await pending
assert.equal(h.store.getState().conversation.status, 3)
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
assert.equal(h.disconnectCount, 0)
})
test("identity change invalidates old delivered result even for same numeric conversation", async () => {
const send = deferred()
const h = harness({ sendImMessage: () => send.promise })
const pending = h.store.getState().handleSendMessage("private")
h.setIdentity("visitor-2")
h.seed()
send.resolve(message(10, 1))
await pending
assert.equal(h.store.getState().messages.length, 0)
assert.equal(h.store.getState().conversation.lastMessageId, 0)
})
test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => {
const config = deferred()
const h = harness({ fetchImWidgetConfig: () => config.promise })
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
const pending = h.store.getState().bootstrap()
h.store.getState().disconnectSocket()
config.resolve({ title: "stale" })
await pending
assert.equal(h.store.getState().conversation, null)
assert.equal(h.connectCount, 0)
})
test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => {
let listCalls = 0
const config = deferred()
const h = harness({ fetchImWidgetConfig: () => config.promise,
fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } })
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
const first = h.store.getState().bootstrap()
assert.equal(h.store.getState().bootstrap(), first)
config.resolve({})
await first
assert.equal(h.store.getState().conversation, null)
await h.store.getState().retry()
assert.equal(h.store.getState().conversation.id, 1)
assert.equal(h.store.getState().error, "")
})
test("new request and request switching reconnect after transport was disconnected", async () => {
const h = harness()
h.seed({ isOpen: true, socket: null })
await h.store.getState().startNewConversation()
assert.equal(h.store.getState().conversation.id, 3)
assert.ok(h.connectCount > 0)
const connects = h.connectCount
await h.store.getState().selectConversation(2)
assert.ok(h.connectCount > connects)
})
test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => {
const list = deferred()
const h = harness({ fetchImConversationList: () => list.promise,
sendImMessage: async () => message(10, 1) })
const pending = h.store.getState().refreshConversations()
await h.store.getState().handleSendMessage("hello")
list.resolve(page([conversation(1), conversation(2)]))
await pending
assert.equal(h.store.getState().conversation.lastMessageId, 10)
})
test("image upload completes into captured draft even after selecting another request", async () => {
const upload = deferred()
const h = harness({ uploadImImage: () => upload.promise })
const pending = h.store.getState().uploadMessageImage({})
await h.store.getState().selectConversation(2)
const asset = { assetId: "image-1" }
upload.resolve(asset)
assert.equal(await pending, asset)
})
test("changed identity clears private state while closed, before reopening", async () => {
const h = harness()
h.seed({ messages: [message(10, 1)], isOpen: false })
h.setIdentity("visitor-2")
await h.store.getState().bootstrap()
assert.equal(h.store.getState().conversation, null)
assert.equal(h.store.getState().conversations.length, 0)
assert.equal(h.store.getState().messages.length, 0)
assert.equal(h.store.getState().identityScope, "visitor-2")
})
test("reselected attachment reuses upload and logical send key after uncertain failure", async () => {
const sent = []
let uploads = 0
const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } },
sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } })
await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/)
await h.store.getState().sendAttachment({ content: "same file" })
assert.equal(uploads, 1)
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
})

File diff suppressed because it is too large Load Diff