chore: complete clean Agent Desk source snapshot
This commit is contained in:
269
web/lib/support-conversation-list.test.mjs
Normal file
269
web/lib/support-conversation-list.test.mjs
Normal file
@@ -0,0 +1,269 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
import {
|
||||
buildSelectConversationPatch,
|
||||
findConversationById,
|
||||
isOpenImConversation,
|
||||
pickConversationToResume,
|
||||
resolveConversationTitle,
|
||||
sortConversationsByRecency,
|
||||
totalUnreadConversations,
|
||||
upsertConversation,
|
||||
CLOSED_IM_CONVERSATION_STATUS,
|
||||
} from "./support-conversation-list.ts"
|
||||
|
||||
const AISERVING = 1
|
||||
const PENDING = 2
|
||||
const ACTIVE = 3
|
||||
const CLOSED = 4
|
||||
|
||||
function makeConversation(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
channelId: 1,
|
||||
customerName: "Alice",
|
||||
status: ACTIVE,
|
||||
serviceMode: 3,
|
||||
priority: 0,
|
||||
currentAssigneeId: 0,
|
||||
lastMessageId: 0,
|
||||
customerUnreadCount: 0,
|
||||
agentUnreadCount: 0,
|
||||
customerLastReadMessageId: 0,
|
||||
agentLastReadMessageId: 0,
|
||||
closedBy: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("the mirrored closed status still matches the generated backend enum", async () => {
|
||||
const source = await readFile(
|
||||
new URL("./generated/enums.ts", import.meta.url),
|
||||
"utf8"
|
||||
)
|
||||
const block = source.match(/export enum IMConversationStatus \{[^}]*\}/)
|
||||
assert.ok(block, "expected an IMConversationStatus enum in the generated file")
|
||||
|
||||
const values = Object.fromEntries(
|
||||
[...block[0].matchAll(/(\w+)\s*=\s*(\d+)/g)].map((match) => [
|
||||
match[1],
|
||||
Number(match[2]),
|
||||
])
|
||||
)
|
||||
assert.equal(values.Closed, CLOSED_IM_CONVERSATION_STATUS)
|
||||
// The server counts a conversation open while it is any of these three.
|
||||
assert.deepEqual(
|
||||
{ serving: values.AIServing, pending: values.Pending, active: values.Active },
|
||||
{ serving: AISERVING, pending: PENDING, active: ACTIVE }
|
||||
)
|
||||
})
|
||||
|
||||
test("anything not closed is still an open request", () => {
|
||||
assert.equal(isOpenImConversation(makeConversation({ status: AISERVING })), true)
|
||||
assert.equal(isOpenImConversation(makeConversation({ status: PENDING })), true)
|
||||
assert.equal(isOpenImConversation(makeConversation({ status: ACTIVE })), true)
|
||||
assert.equal(isOpenImConversation(makeConversation({ status: CLOSED })), false)
|
||||
// A status the server adds later defaults to open rather than being mistaken
|
||||
// for a finished request.
|
||||
assert.equal(isOpenImConversation(makeConversation({ status: 99 })), true)
|
||||
})
|
||||
|
||||
test("a closed request is not open, and neither is nothing", () => {
|
||||
assert.equal(isOpenImConversation(null), false)
|
||||
assert.equal(isOpenImConversation(undefined), false)
|
||||
})
|
||||
|
||||
test("sorting puts the most recently active request first", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, lastActiveAt: "2026-10-01 10:00:00" }),
|
||||
makeConversation({ id: 2, lastActiveAt: "2026-10-03 10:00:00" }),
|
||||
makeConversation({ id: 3, lastActiveAt: "2026-10-02 10:00:00" }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortConversationsByRecency(list).map((item) => item.id),
|
||||
[2, 3, 1]
|
||||
)
|
||||
})
|
||||
|
||||
// The server writes time.DateTime, which has no timezone marker. Parsing it as
|
||||
// a bare local timestamp keeps it consistent with formatDateTime elsewhere.
|
||||
test("server timestamps without a zone still order correctly", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" }),
|
||||
makeConversation({ id: 2, lastActiveAt: "2026-10-01 11:00:00" }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortConversationsByRecency(list).map((item) => item.id),
|
||||
[2, 1]
|
||||
)
|
||||
})
|
||||
|
||||
test("a brand new request with no timestamps sorts ahead by id", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 9 }),
|
||||
makeConversation({ id: 12 }),
|
||||
makeConversation({ id: 3, lastActiveAt: "2026-10-01 09:00:00" }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortConversationsByRecency(list).map((item) => item.id),
|
||||
[12, 9, 3]
|
||||
)
|
||||
})
|
||||
|
||||
test("an unparsable timestamp does not become NaN and scramble the order", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, lastActiveAt: "not-a-date" }),
|
||||
makeConversation({ id: 2, lastActiveAt: "2026-10-01 09:00:00" }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortConversationsByRecency(list).map((item) => item.id),
|
||||
[2, 1]
|
||||
)
|
||||
})
|
||||
|
||||
test("lastMessageAt stands in when lastActiveAt is missing", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, lastMessageAt: "2026-10-01 08:00:00" }),
|
||||
makeConversation({ id: 2, lastMessageAt: "2026-10-01 10:00:00" }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortConversationsByRecency(list).map((item) => item.id),
|
||||
[2, 1]
|
||||
)
|
||||
})
|
||||
|
||||
test("upsert merges an existing request instead of duplicating it", () => {
|
||||
const list = [makeConversation({ id: 1, subject: "old" })]
|
||||
|
||||
const next = upsertConversation(list, makeConversation({ id: 1, subject: "new" }))
|
||||
|
||||
assert.equal(next.length, 1)
|
||||
assert.equal(next[0].subject, "new")
|
||||
})
|
||||
|
||||
test("upsert keeps fields the patch does not carry", () => {
|
||||
const list = [makeConversation({ id: 1, subject: "kept", customerUnreadCount: 4 })]
|
||||
|
||||
const next = upsertConversation(list, { id: 1, status: CLOSED })
|
||||
|
||||
assert.equal(next[0].subject, "kept")
|
||||
assert.equal(next[0].customerUnreadCount, 4)
|
||||
assert.equal(next[0].status, CLOSED)
|
||||
})
|
||||
|
||||
test("upserting a new request puts it at the top", () => {
|
||||
const list = [makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" })]
|
||||
|
||||
const next = upsertConversation(list, makeConversation({ id: 2 }))
|
||||
|
||||
assert.deepEqual(next.map((item) => item.id), [2, 1])
|
||||
})
|
||||
|
||||
test("upsert does not mutate the input", () => {
|
||||
const list = [makeConversation({ id: 1 })]
|
||||
|
||||
upsertConversation(list, makeConversation({ id: 2 }))
|
||||
|
||||
assert.equal(list.length, 1)
|
||||
})
|
||||
|
||||
test("findConversationById returns null rather than throwing", () => {
|
||||
const list = [makeConversation({ id: 7 })]
|
||||
|
||||
assert.equal(findConversationById(list, 7).id, 7)
|
||||
assert.equal(findConversationById(list, 8), null)
|
||||
assert.equal(findConversationById(list, 0), null)
|
||||
})
|
||||
|
||||
test("resume picks the newest open request, skipping closed ones", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, status: CLOSED, lastActiveAt: "2026-10-05 10:00:00" }),
|
||||
makeConversation({ id: 2, status: ACTIVE, lastActiveAt: "2026-10-01 10:00:00" }),
|
||||
]
|
||||
|
||||
assert.equal(pickConversationToResume(list).id, 2)
|
||||
})
|
||||
|
||||
test("resume returns null when everything is closed so a new one gets started", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, status: CLOSED }),
|
||||
makeConversation({ id: 2, status: CLOSED }),
|
||||
]
|
||||
|
||||
assert.equal(pickConversationToResume(list), null)
|
||||
})
|
||||
|
||||
test("resume returns null for a visitor who never wrote to us", () => {
|
||||
assert.equal(pickConversationToResume([]), null)
|
||||
})
|
||||
|
||||
test("title prefers the visitor's subject", () => {
|
||||
const conversation = makeConversation({
|
||||
subject: "Проблема с оплатой",
|
||||
lastMessageSummary: "Здравствуйте",
|
||||
})
|
||||
|
||||
assert.equal(resolveConversationTitle(conversation, "Запрос"), "Проблема с оплатой")
|
||||
})
|
||||
|
||||
test("title falls back to the last message, then to the localized label", () => {
|
||||
assert.equal(
|
||||
resolveConversationTitle(makeConversation({ lastMessageSummary: "Здравствуйте" }), "Запрос"),
|
||||
"Здравствуйте"
|
||||
)
|
||||
assert.equal(resolveConversationTitle(makeConversation(), "Запрос"), "Запрос")
|
||||
assert.equal(resolveConversationTitle(null, "Запрос"), "Запрос")
|
||||
})
|
||||
|
||||
test("a whitespace-only subject is treated as absent", () => {
|
||||
assert.equal(
|
||||
resolveConversationTitle(makeConversation({ subject: " ", lastMessageSummary: "Привет" }), "Запрос"),
|
||||
"Привет"
|
||||
)
|
||||
})
|
||||
|
||||
test("unread totals across requests include the ones not being viewed", () => {
|
||||
const list = [
|
||||
makeConversation({ id: 1, customerUnreadCount: 2 }),
|
||||
makeConversation({ id: 2, customerUnreadCount: 3 }),
|
||||
makeConversation({ id: 3, customerUnreadCount: 0 }),
|
||||
]
|
||||
|
||||
assert.equal(totalUnreadConversations(list), 5)
|
||||
})
|
||||
|
||||
test("a missing unread count counts as zero", () => {
|
||||
assert.equal(
|
||||
totalUnreadConversations([
|
||||
makeConversation({ id: 1 }),
|
||||
{ ...makeConversation({ id: 2 }), customerUnreadCount: undefined },
|
||||
]),
|
||||
0
|
||||
)
|
||||
})
|
||||
test("switching requests re-derives the conversation along with the active id", () => {
|
||||
const a = makeConversation({ id: 1 })
|
||||
const b = makeConversation({ id: 2 })
|
||||
const patch = buildSelectConversationPatch(
|
||||
{ conversations: [a, b], activeConversationId: 1 },
|
||||
2
|
||||
)
|
||||
assert.equal(patch.activeConversationId, 2)
|
||||
assert.equal(patch.conversation.id, 2)
|
||||
assert.deepEqual(patch.messages, [])
|
||||
assert.equal(patch.messagesHasMore, false)
|
||||
})
|
||||
|
||||
test("switching to an unknown or already active request is a no-op", () => {
|
||||
const a = makeConversation({ id: 1 })
|
||||
const state = { conversations: [a], activeConversationId: 1 }
|
||||
assert.equal(buildSelectConversationPatch(state, 99), null)
|
||||
assert.equal(buildSelectConversationPatch(state, 1), null)
|
||||
})
|
||||
Reference in New Issue
Block a user