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.
128 lines
5.3 KiB
JavaScript
128 lines
5.3 KiB
JavaScript
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)
|
|
})
|