Files
agent-desk-ng/web/lib/support-conversation-list.test.mjs

270 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})