fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
289
web/lib/stores/support-chat.test.mjs
Normal file
289
web/lib/stores/support-chat.test.mjs
Normal 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 module = { exports: {} }
|
||||
modules.set(name, module.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module, exports: module.exports, require: load,
|
||||
console, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id },
|
||||
clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } })
|
||||
return module.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)
|
||||
})
|
||||
Reference in New Issue
Block a user