270 lines
8.5 KiB
JavaScript
270 lines
8.5 KiB
JavaScript
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)
|
||
})
|