import {describe, test, expect, beforeEach, vi} from "vitest" import {core} from "simplex-chat" import {SupportBot} from "./src/bot.js" import {CardManager} from "./src/cards.js" import {parseConfig} from "./src/config.js" import {GrokApiClient} from "./src/grok.js" import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js" // Silence console output during tests vi.spyOn(console, "log").mockImplementation(() => {}) vi.spyOn(console, "error").mockImplementation(() => {}) // ─── Type stubs ─── const ChatType = {Direct: "direct" as const, Group: "group" as const, Local: "local" as const} const GroupMemberRole = {Member: "member" as const, Owner: "owner" as const, Admin: "admin" as const} const GroupMemberStatus = {Connected: "connected" as const, Complete: "complete" as const, Announced: "announced" as const, Left: "left" as const} const GroupFeatureEnabled = {On: "on" as const, Off: "off" as const} const CIDeleteMode = {Broadcast: "broadcast" as const} // ─── Mock infrastructure ─── let nextItemId = 1000 class MockChatApi { sent: {chat: [string, number]; text: string}[] = [] added: {groupId: number; contactId: number; role: string}[] = [] removed: {groupId: number; memberIds: number[]}[] = [] joined: number[] = [] deleted: {chatType: string; chatId: number; itemIds: number[]; mode: string}[] = [] customData = new Map() roleChanges: {groupId: number; memberIds: number[]; role: string}[] = [] profileUpdates: {groupId: number; profile: any}[] = [] members = new Map() chatItems = new Map() groups = new Map() activeUserId = 1 private _addMemberFails = false private _addMemberError: any = null private _deleteChatItemsFails = false apiAddMemberWillFail(err?: any) { this._addMemberFails = true; this._addMemberError = err } apiDeleteChatItemsWillFail() { this._deleteChatItemsFails = true } async apiSetActiveUser(userId: number) { this.activeUserId = userId; return {userId, profile: {displayName: "test"}} } async apiSendMessages(chatRef: any, messages: any[]) { // Normalize chat ref: accept both [type, id] tuples and {chatType, chatId} objects const chat: [string, number] = Array.isArray(chatRef) ? chatRef : [chatRef.chatType, chatRef.chatId] return messages.map(msg => { const text = msg.msgContent?.text || "" this.sent.push({chat, text}) const itemId = nextItemId++ return {chatItem: {meta: {itemId}, chatDir: {type: "groupSnd"}, content: {type: "sndMsgContent", msgContent: {type: "text", text}}}} }) } async apiSendTextMessage(chat: [string, number], text: string) { return this.apiSendMessages(chat, [{msgContent: {type: "text", text}, mentions: {}}]) } async apiAddMember(groupId: number, contactId: number, role: string) { if (this._addMemberFails) { this._addMemberFails = false throw this._addMemberError || new Error("apiAddMember failed") } this.added.push({groupId, contactId, role}) const memberId = `member-${contactId}` const groupMemberId = 5000 + contactId return {memberId, groupMemberId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: `Contact${contactId}`}} } async apiRemoveMembers(groupId: number, memberIds: number[]) { this.removed.push({groupId, memberIds}) return memberIds.map(id => ({groupMemberId: id})) } async apiJoinGroup(groupId: number) { this.joined.push(groupId) return {groupId} } async apiSetMembersRole(groupId: number, memberIds: number[], role: string) { this.roleChanges.push({groupId, memberIds, role}) } async apiListMembers(groupId: number) { return this.members.get(groupId) || [] } async apiGetChat(chatType: string, chatId: number, _count: number) { if (chatType === ChatType.Direct) { // Tests don't exercise direct lookups; throw the same shape production // would so getContact() resolves to null instead of synthesizing a contact. throw new core.ChatAPIError("contact not found", { type: "errorStore", storeError: {type: "contactNotFound", contactId: chatId}, } as any) } const baseGroupInfo = this.groups.get(chatId) if (!baseGroupInfo) { // Mirror production behavior: the real apiGetChat throws "groupNotFound" // for an unknown id; getGroupInfo() catches and returns null. throw new core.ChatAPIError("group not found", { type: "errorStore", storeError: {type: "groupNotFound", groupId: chatId}, } as any) } const items = this.chatItems.get(chatId) || [] const groupInfo = {...baseGroupInfo, customData: this.customData.get(chatId)} return { chatInfo: {type: "group", groupInfo}, chatItems: items, chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false}, } } async apiGetChats(_userId: number, _pagination: any, _query?: any, _pcc?: boolean) { return [...this.groups.values()].map(g => ({ chatInfo: {type: "group", groupInfo: {...g, customData: this.customData.get(g.groupId)}}, chatItems: [], chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false}, })) } async apiListGroups(_userId: number) { return [...this.groups.values()].map(g => ({...g, customData: this.customData.get(g.groupId)})) } async apiSetGroupCustomData(groupId: number, data?: any) { if (data === undefined) this.customData.delete(groupId) else this.customData.set(groupId, data) } async apiDeleteChatItems(chatType: string, chatId: number, itemIds: number[], mode: string) { if (this._deleteChatItemsFails) { this._deleteChatItemsFails = false throw new Error("apiDeleteChatItems failed") } this.deleted.push({chatType, chatId, itemIds, mode}) return [] } async apiUpdateGroupProfile(groupId: number, profile: any) { this.profileUpdates.push({groupId, profile}) return this.groups.get(groupId) || makeGroupInfo(groupId) } memberContacts: {groupId: number; groupMemberId: number; contactId: number}[] = [] memberContactInvitations: {contactId: number; text: string}[] = [] async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { const contactId = nextItemId++ this.memberContacts.push({groupId, groupMemberId, contactId}) return {contactId, profile: {displayName: "member"}} } async apiSendMemberContactInvitation(contactId: number, message?: any): Promise { const text = typeof message === "string" ? message : (message?.text ?? "") this.memberContactInvitations.push({contactId, text}) this.sent.push({chat: [ChatType.Direct, contactId], text}) return {contactId, profile: {displayName: "member"}} } rawCmds: string[] = [] async sendChatCmd(cmd: string) { this.rawCmds.push(cmd) return {type: "cmdOk"} } sentTo(groupId: number): string[] { return this.sent.filter(s => s.chat[0] === ChatType.Group && s.chat[1] === groupId).map(s => s.text) } lastSentTo(groupId: number): string | undefined { const msgs = this.sentTo(groupId) return msgs[msgs.length - 1] } sentDirect(contactId: number): string[] { return this.sent.filter(s => s.chat[0] === ChatType.Direct && s.chat[1] === contactId).map(s => s.text) } } class MockGrokApi { calls: {history: any[]; message: string}[] = [] private _response = "Grok answer" private _willFail = false private _gate: {promise: Promise; release: () => void} | null = null willRespond(text: string) { this._response = text; this._willFail = false } willFail() { this._willFail = true } // Block every subsequent chat() call until releaseChat() is invoked. Used to // observe in-flight concurrency without relying on wall-clock timing. blockChat() { let release!: () => void const promise = new Promise(r => { release = r }) this._gate = {promise, release} } releaseChat() { this._gate?.release() this._gate = null } async chat(history: any[], userMessage: string): Promise { this.calls.push({history, message: userMessage}) if (this._gate) await this._gate.promise if (this._willFail) { this._willFail = false; throw new Error("Grok API error") } return this._response } } // ─── Factory helpers ─── const MAIN_USER_ID = 1 const GROK_USER_ID = 2 const TEAM_GROUP_ID = 50 const CUSTOMER_GROUP_ID = 100 const GROK_CONTACT_ID = 10 const TEAM_MEMBER_1_ID = 20 const TEAM_MEMBER_2_ID = 21 const GROK_LOCAL_GROUP_ID = 200 const CUSTOMER_ID = "customer-1" // Commands passed into SupportBot; matches what index.ts constructs when // Grok is enabled. Tests that disable grokApi still pass the full list // because the ctor doesn't care; the value is pushed to a group's // groupPreferences on the first sendToGroup() call. const DESIRED_COMMANDS = [ {type: "command" as const, keyword: "grok", label: "Ask Grok"}, {type: "command" as const, keyword: "team", label: "Switch to team"}, ] // ─── Member factories ─── function makeTeamMember(contactId: number, name = `Contact${contactId}`, groupMemberId?: number) { return { memberId: `team-${contactId}`, groupMemberId: groupMemberId ?? 5000 + contactId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: name}, } } function makeGrokMember(groupMemberId = 7777) { return { memberId: "grok-member", groupMemberId, memberContactId: GROK_CONTACT_ID, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Grok"}, } } function makeCustomerMember(status = GroupMemberStatus.Connected) { return { memberId: CUSTOMER_ID, groupMemberId: 3000, memberStatus: status, memberProfile: {displayName: "Customer"}, } } function makeConfig(overrides: Partial = {}) { return { stateFile: "./test-data/state.json", db: {type: "sqlite", filePrefix: "./test-data/simplex"}, teamGroup: {id: TEAM_GROUP_ID, name: "SupportTeam"}, teamMembers: [ {id: TEAM_MEMBER_1_ID, name: "Alice"}, {id: TEAM_MEMBER_2_ID, name: "Bob"}, ], groupLinks: "", timezone: "UTC", completeHours: 3, cardFlushSeconds: 300, grokApiKey: "test-key", grokContactId: GROK_CONTACT_ID as number | null, ...overrides, } } function makeGroupInfo(groupId: number, opts: Partial = {}): any { return { groupId, groupProfile: {displayName: opts.displayName || `Group${groupId}`, fullName: ""}, businessChat: opts.businessChat !== undefined ? opts.businessChat : { chatType: "business", businessId: "bot-1", customerId: opts.customerId || CUSTOMER_ID, }, membership: {memberId: "bot-member"}, customData: opts.customData, chatSettings: {enableNtfs: "all", favorite: false}, fullGroupPreferences: {}, localDisplayName: `group-${groupId}`, localAlias: "", useRelays: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), chatTags: [], groupSummary: {}, membersRequireAttention: 0, } } function makeUser(userId: number) { return {userId, profile: {displayName: userId === MAIN_USER_ID ? "Ask SimpleX Team" : "Grok"}} } function makeChatItem(opts: { dir: "groupSnd" | "groupRcv" | "directRcv" text?: string memberId?: string memberContactId?: number memberDisplayName?: string msgType?: string groupId?: number }): any { const itemId = nextItemId++ const now = new Date().toISOString() const msgContent = opts.msgType ? {type: opts.msgType, text: opts.text || ""} : {type: "text", text: opts.text || ""} let chatDir: any if (opts.dir === "groupSnd") { chatDir = {type: "groupSnd"} } else if (opts.dir === "groupRcv") { chatDir = { type: "groupRcv", groupMember: { memberId: opts.memberId || CUSTOMER_ID, groupMemberId: 3000, memberContactId: opts.memberContactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: opts.memberDisplayName || "Customer"}, }, } } else { chatDir = {type: "directRcv"} } return { chatDir, meta: {itemId, itemTs: now, createdAt: now, itemText: opts.text || "", itemStatus: {type: "sndSent"}, itemEdited: false}, content: {type: opts.dir === "groupSnd" ? "sndMsgContent" : "rcvMsgContent", msgContent}, mentions: {}, reactions: [], } } function makeAChatItem(chatItem: any, groupId = CUSTOMER_GROUP_ID): any { return { chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, chatItem, } } function makeDirectAChatItem(chatItem: any, contactId: number): any { return { chatInfo: {type: "direct", contact: {contactId, profile: {displayName: "Someone"}}}, chatItem, } } // ─── Shared test state ─── let chat: MockChatApi let grokApi: MockGrokApi let config: ReturnType let bot: InstanceType let cards: InstanceType // ─── Setup and helpers ─── function setup(configOverrides: Partial = {}) { nextItemId = 1000 chat = new MockChatApi() grokApi = new MockGrokApi() config = makeConfig(configOverrides) // Register team group and customer group in mock const teamGroupInfo = makeGroupInfo(TEAM_GROUP_ID, {businessChat: null, displayName: "SupportTeam"}) chat.groups.set(TEAM_GROUP_ID, teamGroupInfo) chat.groups.set(CUSTOMER_GROUP_ID, makeGroupInfo(CUSTOMER_GROUP_ID)) cards = new CardManager(chat as any, config as any, MAIN_USER_ID, 999999999) bot = new SupportBot(chat as any, grokApi as any, config as any, MAIN_USER_ID, GROK_USER_ID, DESIRED_COMMANDS) // Replace cards with our constructed one that has a long flush interval bot.cards = cards } function customerMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeAChatItem(ci, groupId)], } } function customerNonTextMessage(groupId = CUSTOMER_GROUP_ID): any { const ci = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeAChatItem(ci, groupId)], } } function teamMemberMessage(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID): any { const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId, memberDisplayName: "Alice"}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeAChatItem(ci, groupId)], } } function grokResponseMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID, memberDisplayName: "Grok"}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeAChatItem(ci, groupId)], } } function directMessage(text: string, contactId: number): any { const ci = makeChatItem({dir: "directRcv", text}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeDirectAChatItem(ci, contactId)], } } function teamGroupMessage(text: string, senderContactId = TEAM_MEMBER_1_ID): any { const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${senderContactId}`, memberContactId: senderContactId, memberDisplayName: "Alice"}) return { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null})}, chatItem: ci}], } } // Simulate bot sending a message to the customer group (adds it to chatItems history) function addBotMessage(text: string, groupId = CUSTOMER_GROUP_ID) { const ci = makeChatItem({dir: "groupSnd", text}) const items = chat.chatItems.get(groupId) || [] items.push(ci) chat.chatItems.set(groupId, items) } function addCustomerMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) const items = chat.chatItems.get(groupId) || [] items.push(ci) chat.chatItems.set(groupId, items) } function addTeamMemberMessageToHistory(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID) { const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId}) const items = chat.chatItems.get(groupId) || [] items.push(ci) chat.chatItems.set(groupId, items) } function addGrokMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID}) const items = chat.chatItems.get(groupId) || [] items.push(ci) chat.chatItems.set(groupId, items) } // State helpers — reach specific states async function reachQueue(groupId = CUSTOMER_GROUP_ID) { await bot.onNewChatItems(customerMessage("Hello, I need help", groupId)) // This should have sent queue message + created card } async function reachGrok(groupId = CUSTOMER_GROUP_ID) { await reachQueue(groupId) // Add the queue message to history so state derivation sees it addBotMessage("The team will reply to your message", groupId) // Send /grok command. This triggers activateGrok which needs the join flow. // We need to simulate Grok join success. const grokJoinPromise = simulateGrokJoinSuccess(groupId) await bot.onNewChatItems(customerMessage("/grok", groupId)) await grokJoinPromise } async function simulateGrokJoinSuccess(mainGroupId = CUSTOMER_GROUP_ID) { // Wait for apiAddMember to be called, then simulate Grok invitation + join await new Promise(r => setTimeout(r, 10)) // Find the pending grok join via the added members const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID && a.groupId === mainGroupId) if (!addedGrok) return // Simulate Grok receivedGroupInvitation const memberId = `member-${GROK_CONTACT_ID}` await bot.onGrokGroupInvitation({ type: "receivedGroupInvitation", user: makeUser(GROK_USER_ID), groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, contact: {contactId: 99}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, }) // Simulate Grok connectedToGroupMember await bot.onGrokMemberConnected({ type: "connectedToGroupMember", user: makeUser(GROK_USER_ID), groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, }) } async function reachTeamPending(groupId = CUSTOMER_GROUP_ID) { await reachQueue(groupId) addBotMessage("The team will reply to your message", groupId) await bot.onNewChatItems(customerMessage("/team", groupId)) } async function reachTeam(groupId = CUSTOMER_GROUP_ID) { await reachTeamPending(groupId) addBotMessage("We will reply within 24 hours.", groupId) chat.members.set(groupId, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) // Team member sends a text message (triggers one-way gate) addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId) await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId)) } // ─── Assertion helpers ─── function expectSentToGroup(groupId: number, substring: string) { const msgs = chat.sentTo(groupId) expect(msgs.some(m => m.includes(substring)), `Expected message containing "${substring}" sent to group ${groupId}, got:\n${msgs.join("\n")}` ).toBe(true) } function expectNotSentToGroup(groupId: number, substring: string) { expect(chat.sentTo(groupId).every(m => !m.includes(substring))).toBe(true) } function expectDmSent(contactId: number, substring: string) { expect(chat.sentDirect(contactId).some(m => m.includes(substring))).toBe(true) } function expectAnySent(substring: string) { expect(chat.sent.some(s => s.text.includes(substring))).toBe(true) } function expectMemberAdded(groupId: number, contactId: number) { expect(chat.added.some(a => a.groupId === groupId && a.contactId === contactId)).toBe(true) } function expectCardDeleted(cardItemId: number) { expect(chat.deleted.some(d => d.itemIds.includes(cardItemId))).toBe(true) } // ─── Event factories ─── function connectedEvent(groupId: number, member: any, memberContact?: any) { return { type: "connectedToGroupMember" as const, user: makeUser(MAIN_USER_ID), groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), member, ...(memberContact !== undefined ? {memberContact} : {}), } } function leftEvent(groupId: number, member: any) { return { type: "leftMember" as const, user: makeUser(MAIN_USER_ID), groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), member: {...member, memberStatus: GroupMemberStatus.Left}, } } function updatedEvent(groupId: number, chatItem: any, userId = MAIN_USER_ID) { return { type: "chatItemUpdated" as const, user: makeUser(userId), chatItem: { chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {})}, chatItem, }, } } function reactionEvent(groupId: number, added: boolean) { return { type: "chatItemReaction" as const, user: makeUser(MAIN_USER_ID), added, reaction: { chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, chatReaction: {reaction: {type: "emoji", emoji: "👍"}}, }, } } function joinedEvent(groupId: number, member: any, userId = MAIN_USER_ID) { return { type: "joinedGroupMember" as const, user: makeUser(userId), groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), member, } } function grokViewCustomerMessage(text: string, msgType?: string) { chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID, ...(msgType ? {msgType} : {})}) return { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], } } // ═══════════════════════════════════════════════════════════ // Tests // ═══════════════════════════════════════════════════════════ describe("Welcome & First Message", () => { beforeEach(() => setup()) test("first message → queue reply sent, card created in team group", async () => { await bot.onNewChatItems(customerMessage("Hello")) expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") const teamMsgs = chat.sentTo(TEAM_GROUP_ID) expect(teamMsgs.length).toBeGreaterThan(0) expect(teamMsgs[teamMsgs.length - 1]).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) }) test("non-text first message → no queue reply, no card", async () => { await bot.onNewChatItems(customerNonTextMessage()) expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) }) test("second message → no duplicate queue reply", async () => { await bot.onNewChatItems(customerMessage("Hello")) addBotMessage("The team will reply to your message") const countBefore = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length await bot.onNewChatItems(customerMessage("Second message")) const countAfter = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length expect(countAfter).toBe(countBefore) }) test("unrecognized /command → treated as normal message", async () => { await bot.onNewChatItems(customerMessage("/unknown")) expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") }) }) describe("/grok Activation", () => { beforeEach(() => setup()) test("/grok from QUEUE → Grok invited, grokActivatedMessage sent", async () => { await reachQueue() addBotMessage("The team will reply to your message") const joinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await joinPromise await bot.flush() expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) }) test("/grok as first message → WELCOME→GROK directly, no queue message", async () => { const joinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await joinPromise await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) }) test("/grok in TEAM → rejected with teamLockedMessage", async () => { await reachTeam() await bot.onNewChatItems(customerMessage("/grok")) expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage) }) test("/grok when grokContactId is null → grokUnavailableMessage", async () => { setup({grokContactId: null}) await reachQueue() addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") }) test("/grok as first message + Grok join fails → queue message sent as fallback", async () => { chat.apiAddMemberWillFail() await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") }) }) describe("Grok Conversation", () => { beforeEach(() => setup()) test("Grok per-message: reads history, calls API, sends response", async () => { addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) grokApi.willRespond("To create a group, tap +, then New Group.") await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?")) expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toBe("How do I create a group?") expectAnySent("To create a group, tap +, then New Group.") }) test("customer non-text in GROK → no Grok API call", async () => { await bot.onGrokNewChatItems(grokViewCustomerMessage("", "image")) expect(grokApi.calls.length).toBe(0) }) test("Grok API error → error message in group, stays GROK", async () => { grokApi.willFail() await bot.onGrokNewChatItems(grokViewCustomerMessage("A question")) expectAnySent("couldn't process that") }) test("Grok ignores bot commands from customer", async () => { await bot.onGrokNewChatItems(grokViewCustomerMessage("/team")) expect(grokApi.calls.length).toBe(0) }) test("Grok per-message: history includes prior Grok sent response as assistant", async () => { addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID) addCustomerMessageToHistory("How do I invite members?", GROK_LOCAL_GROUP_ID) grokApi.willRespond("Open the group and tap Invite.") await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I invite members?")) expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toBe("How do I invite members?") expect(grokApi.calls[0].history).toEqual([ {role: "user", content: "How do I create a group?"}, {role: "assistant", content: "To create a group, tap + then New Group."}, ]) }) test("Grok ignores non-customer messages", async () => { chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) const ci = makeChatItem({dir: "groupRcv", text: "Team message", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) const grokEvt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], } await bot.onGrokNewChatItems(grokEvt) expect(grokApi.calls.length).toBe(0) }) test("Grok ignores own messages (groupSnd)", async () => { chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) const ci = makeChatItem({dir: "groupSnd", text: "My own response"}) const grokEvt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], } await bot.onGrokNewChatItems(grokEvt) expect(grokApi.calls.length).toBe(0) }) test("batch: multiple customer messages in one event → only last triggers Grok API call", async () => { chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) addCustomerMessageToHistory("First question", GROK_LOCAL_GROUP_ID) addCustomerMessageToHistory("Second question", GROK_LOCAL_GROUP_ID) const ci1 = makeChatItem({dir: "groupRcv", text: "First question", memberId: CUSTOMER_ID}) const ci2 = makeChatItem({dir: "groupRcv", text: "Second question", memberId: CUSTOMER_ID}) const evt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [ {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci1}, {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci2}, ], } await bot.onGrokNewChatItems(evt) expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toBe("Second question") }) test("batch: messages from different groups → each group gets one response", async () => { const GROK_GROUP_A = 201 const GROK_GROUP_B = 202 chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) addCustomerMessageToHistory("Question A", GROK_GROUP_A) addCustomerMessageToHistory("Question B", GROK_GROUP_B) const ciA = makeChatItem({dir: "groupRcv", text: "Question A", memberId: CUSTOMER_ID}) const ciB = makeChatItem({dir: "groupRcv", text: "Question B", memberId: CUSTOMER_ID}) const evt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [ {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, ], } await bot.onGrokNewChatItems(evt) expect(grokApi.calls.length).toBe(2) }) test("batch: non-customer messages filtered, only customer messages trigger response", async () => { chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) addCustomerMessageToHistory("Customer question", GROK_LOCAL_GROUP_ID) const custCi = makeChatItem({dir: "groupRcv", text: "Customer question", memberId: CUSTOMER_ID}) const teamCi = makeChatItem({dir: "groupRcv", text: "Team reply", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) const evt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [ {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: custCi}, {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: teamCi}, ], } await bot.onGrokNewChatItems(evt) expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toBe("Customer question") }) test("batch: across groups → Grok calls overlap in-flight (parallel dispatch)", async () => { const GROK_GROUP_A = 201 const GROK_GROUP_B = 202 chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) addCustomerMessageToHistory("A", GROK_GROUP_A) addCustomerMessageToHistory("B", GROK_GROUP_B) const ciA = makeChatItem({dir: "groupRcv", text: "A", memberId: CUSTOMER_ID}) const ciB = makeChatItem({dir: "groupRcv", text: "B", memberId: CUSTOMER_ID}) const evt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [ {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, ], } // Block both chat() calls until we release. If the handler serialized // per-group work, only one call would enter chat() before release. grokApi.blockChat() const done = bot.onGrokNewChatItems(evt) // Let both tasks run up to the gate. await new Promise(r => setTimeout(r, 10)) expect(grokApi.calls.length).toBe(2) grokApi.releaseChat() await done }) }) describe("/team Activation", () => { beforeEach(() => setup()) test("/team from QUEUE → ALL team members added, teamAddedMessage sent", async () => { await reachQueue() addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/team")) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") }) test("/team as first message → WELCOME→TEAM, no queue message", async () => { await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") }) test("/team when already activated → teamAlreadyInvitedMessage", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, teamAlreadyInvitedMessage) }) test("/team with no team members → noTeamMembersMessage", async () => { setup({teamMembers: []}) await reachQueue() addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, "No team members are available") }) }) describe("One-Way Gate", () => { beforeEach(() => setup()) test("team member sends first TEXT → Grok removed if present", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) }) test("team member non-text (no ciContentText) → Grok NOT removed", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) await bot.onNewChatItems(teamMemberMessage("", TEAM_MEMBER_1_ID)) expect(chat.removed.length).toBe(0) }) test("/grok after gate → teamLockedMessage", async () => { await reachTeam() await bot.onNewChatItems(customerMessage("/grok")) expectSentToGroup(CUSTOMER_GROUP_ID, teamLockedMessage) }) test("customer text in TEAM → card update scheduled, no bot reply", async () => { await reachTeam() const sentBefore = chat.sentTo(CUSTOMER_GROUP_ID).length await bot.onNewChatItems(customerMessage("Follow-up question")) const sentAfter = chat.sentTo(CUSTOMER_GROUP_ID).length expect(sentAfter).toBe(sentBefore) }) test("/grok in TEAM-PENDING → invite Grok if not present", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) const joinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await joinPromise await bot.flush() expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) }) }) describe("One-Way Gate with Grok Disabled", () => { test("team text removes Grok even when grokApi is null", async () => { setup() // Recreate bot without grokApi but with grokContactId still set (simulates disabled Grok with persisted contact) bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) bot.cards = cards // Reach QUEUE state with Grok + team member already present addBotMessage("The team will reply to your message") addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) // Team member sends text → one-way gate should fire await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) }) test("Grok does not respond when disabled even if grokContactId is set", async () => { setup() bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) bot.cards = cards // Set up group with Grok member present chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) addBotMessage("The team will reply to your message") // Customer sends text in GROK state await bot.onNewChatItems(customerMessage("How do I use SimpleX?")) // Grok should not respond (grokApi is null) expect(grokApi.calls.length).toBe(0) }) }) describe("Team Member Lifecycle", () => { beforeEach(() => setup()) test("team member connected → promoted to Owner", async () => { await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true) }) test("/team invites team member → apiSetMembersRole(Owner) called at invite time", async () => { await bot.onNewChatItems(customerMessage("/team")) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner )).toBe(true) }) test("/join invites team member → apiSetMembersRole(Owner) called at invite time", async () => { await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner )).toBe(true) }) test("/team when team member already in group (any non-terminal status) → apiSetMembersRole NOT re-called", async () => { chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) await cards.mergeCustomData(CUSTOMER_GROUP_ID, {state: "TEAM-PENDING"}) chat.added.length = 0 chat.roleChanges.length = 0 await bot.onNewChatItems(customerMessage("/team")) expect(chat.added.length).toBe(0) expect(chat.roleChanges.length).toBe(0) }) test("/join when team member already in group → apiSetMembersRole NOT re-called", async () => { chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) chat.added.length = 0 chat.roleChanges.length = 0 await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) expect(chat.added.length).toBe(0) expect(chat.roleChanges.length).toBe(0) }) test("customer connected → NOT promoted to Owner", async () => { await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) expect(chat.roleChanges.length).toBe(0) }) test("Grok connected → NOT promoted to Owner", async () => { await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeGrokMember())) expect(chat.roleChanges.length).toBe(0) }) test("all team members leave before sending → state stays TEAM-PENDING", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") // Remove team members from the group chat.members.set(CUSTOMER_GROUP_ID, []) // State is authoritative and monotonic — composition changes never demote it. // Customer is still waiting for the team's response. const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("TEAM-PENDING") }) test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, []) chat.added.length = 0 await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) }) test("/team after all team members left (TEAM, msg was sent) → re-adds members", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID) await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) // All team members leave chat.members.set(CUSTOMER_GROUP_ID, []) chat.added.length = 0 await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) }) }) describe("Card Dashboard", () => { beforeEach(() => setup()) test("first message creates card with /'join ' final line", async () => { await bot.onNewChatItems(customerMessage("Hello")) const teamMsgs = chat.sentTo(TEAM_GROUP_ID) expect(teamMsgs.length).toBe(1) const card = teamMsgs[0] expect(card).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) // Join command is the final line of the card const lines = card.split("\n") expect(lines[lines.length - 1]).toBe(`/'join ${CUSTOMER_GROUP_ID}'`) }) test("card update deletes old card then posts new one", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) await cards.flush() expect(chat.deleted.length).toBe(0) cards.scheduleUpdate(CUSTOMER_GROUP_ID) await cards.flush() expectCardDeleted(555) expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) }) test("apiDeleteChatItems failure → ignored, new card posted", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) chat.apiDeleteChatItemsWillFail() cards.scheduleUpdate(CUSTOMER_GROUP_ID) await cards.flush() // New card should still be posted despite delete failure expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) }) test("customData stores cardItemId → survives flush cycle", async () => { await bot.onNewChatItems(customerMessage("Hello")) // After card creation, customData should have cardItemId const data = chat.customData.get(CUSTOMER_GROUP_ID) expect(data).toBeDefined() expect(typeof data.cardItemId).toBe("number") }) test("concurrent mergeCustomData on same group → both patches survive", async () => { // Without per-group serialization, two overlapping mergeCustomData calls // can both read the same snapshot and the second write clobbers the first // patch. The mutex keeps them ordered so both patches land. const GID = 500 chat.groups.set(GID, makeGroupInfo(GID)) const pA = cards.mergeCustomData(GID, {state: "QUEUE"}) const pB = cards.mergeCustomData(GID, {cardItemId: 123}) await Promise.all([pA, pB]) const final = chat.customData.get(GID) expect(final.state).toBe("QUEUE") expect(final.cardItemId).toBe(123) }) test("customer leaves → customData cleared", async () => { await bot.onNewChatItems(customerMessage("Hello")) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 999}) await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) }) }) describe("Card Debouncing", () => { beforeEach(() => setup()) test("rapid events within flush interval → single card update on flush", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 500}) cards.scheduleUpdate(CUSTOMER_GROUP_ID) cards.scheduleUpdate(CUSTOMER_GROUP_ID) cards.scheduleUpdate(CUSTOMER_GROUP_ID) await cards.flush() // Only one delete and one post expect(chat.deleted.length).toBe(1) // Multiple schedules → single update (one card message) const teamMsgs = chat.sentTo(TEAM_GROUP_ID) expect(teamMsgs.length).toBe(1) }) test("multiple groups pending → each reposted once per flush", async () => { const GROUP_A = 101 const GROUP_B = 102 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) chat.customData.set(GROUP_A, {cardItemId: 501}) chat.customData.set(GROUP_B, {cardItemId: 502}) cards.scheduleUpdate(GROUP_A) cards.scheduleUpdate(GROUP_B) await cards.flush() expect(chat.deleted.length).toBe(2) }) test("card create is immediate (not debounced)", async () => { await bot.onNewChatItems(customerMessage("Hello")) // Card should be posted immediately without flush expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) }) test("flush with no pending updates → no-op", async () => { await cards.flush() expect(chat.deleted.length).toBe(0) expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) }) test("flush on group with no cardItemId → createCard posts a new card (retries failed create)", async () => { // customData without cardItemId simulates a prior createCard that failed // mid-flight and re-queued itself. flushOne must dispatch to createCard, // not updateCard (which would early-return). const GID = 777 chat.groups.set(GID, makeGroupInfo(GID)) chat.customData.set(GID, {state: "QUEUE"}) cards.scheduleUpdate(GID) await cards.flush() expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) expect(typeof chat.customData.get(GID).cardItemId).toBe("number") }) }) describe("Card Format & State Derivation", () => { beforeEach(() => setup()) test("QUEUE state read from customData.state", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "QUEUE"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("QUEUE") }) test("WELCOME state when customData.state is absent", async () => { const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("WELCOME") }) test("GROK state read from customData.state", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "GROK"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("GROK") }) test("TEAM-PENDING state read from customData.state", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM-PENDING"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("TEAM-PENDING") }) test("TEAM state read from customData.state", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("TEAM") }) test("message count excludes bot's own messages", async () => { addCustomerMessageToHistory("Hello") addBotMessage("Queue message") addCustomerMessageToHistory("Follow-up") const chatResult = await cards.getChat(CUSTOMER_GROUP_ID, 100) const nonBotCount = chatResult.chatItems.filter((ci: any) => ci.chatDir.type !== "groupSnd").length expect(nonBotCount).toBe(2) }) }) describe("/join Command (Team Group)", () => { beforeEach(() => setup()) test("/join (numeric only) → team member added to customer group", async () => { await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) }) test("/join : (legacy form) → team member added", async () => { await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}:Customer`)) expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) }) test("/join validates target is business group → error if not", async () => { const nonBizGroupId = 999 chat.groups.set(nonBizGroupId, makeGroupInfo(nonBizGroupId, {businessChat: null})) await bot.onNewChatItems(teamGroupMessage(`/join ${nonBizGroupId}:Test`)) expectSentToGroup(TEAM_GROUP_ID, "not a business chat") }) test("/join with non-existent groupId → error in team group", async () => { await bot.onNewChatItems(teamGroupMessage("/join 99999:Nobody")) expect(chat.sentTo(TEAM_GROUP_ID).some(m => m.toLowerCase().includes("error"))).toBe(true) }) test("customer sending /join in customer group → treated as normal message", async () => { await bot.onNewChatItems(customerMessage("/join 50:Test")) expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") }) test("/join with non-numeric params → error reply, no apiAddMember call", async () => { await bot.onNewChatItems(teamGroupMessage("/join abc")) expectSentToGroup(TEAM_GROUP_ID, `Error: invalid group id "abc"`) expect(chat.added.length).toBe(0) }) }) describe("DM Handshake", () => { beforeEach(() => setup()) test("team member joins team group → DM sent with contact ID", async () => { const member = {memberId: "new-team", groupMemberId: 8000, memberContactId: 30, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie"}} await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 30, profile: {displayName: "Charlie"}})) expectDmSent(30, "Your contact ID is 30:Charlie") }) test("DM with spaces in name → name single-quoted", async () => { const member = {memberId: "new-team", groupMemberId: 8001, memberContactId: 31, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie Brown"}} await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 31, profile: {displayName: "Charlie Brown"}})) expectDmSent(31, "31:'Charlie Brown'") }) test("pending DM delivered on contactConnected", async () => { const invEvt = { type: "newMemberContactReceivedInv" as const, user: makeUser(MAIN_USER_ID), contact: {contactId: 32, profile: {displayName: "Dave"}}, groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), member: {memberId: "dave-member", groupMemberId: 8002, memberContactId: 32, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Dave"}}, } await bot.onMemberContactReceivedInv(invEvt) await bot.onContactConnected({ type: "contactConnected" as const, user: makeUser(MAIN_USER_ID), contact: {contactId: 32, profile: {displayName: "Dave"}}, }) expectDmSent(32, "Your contact ID is 32:Dave") }) test("team member with no DM contact → creates member contact and sends invitation", async () => { const member = {memberId: "new-team-no-dm", groupMemberId: 8010, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Frank"}} await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, undefined)) expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8010)).toBe(true) expect(chat.memberContactInvitations.some(i => i.text.includes("Your contact ID is") && i.text.includes("Frank"))).toBe(true) const dms = chat.sent.filter(s => s.chat[0] === ChatType.Direct) expect(dms.some(m => m.text.includes("Your contact ID is") && m.text.includes("Frank"))).toBe(true) }) test("joinedGroupMember in team group → creates member contact and sends invitation", async () => { const member = {memberId: "link-joiner", groupMemberId: 8020, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Grace"}} await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member)) expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8020)).toBe(true) expect(chat.memberContactInvitations.some(i => i.text.includes("Grace"))).toBe(true) }) test("no duplicate DM when both sendTeamMemberDM succeeds and onMemberContactReceivedInv fires", async () => { const invEvt = { type: "newMemberContactReceivedInv" as const, user: makeUser(MAIN_USER_ID), contact: {contactId: 33, profile: {displayName: "Eve"}}, groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), member: {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}}, } await bot.onMemberContactReceivedInv(invEvt) const eveMember = {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}} await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, eveMember, {contactId: 33, profile: {displayName: "Eve"}})) await bot.onContactConnected({ type: "contactConnected" as const, user: makeUser(MAIN_USER_ID), contact: {contactId: 33, profile: {displayName: "Eve"}}, }) const dms = chat.sentDirect(33) const contactIdMsgs = dms.filter(m => m.includes("Your contact ID is 33:Eve")) expect(contactIdMsgs.length).toBe(1) }) }) describe("Direct Message Handling", () => { beforeEach(() => setup()) test("regular DM → bot replies with business address link", async () => { bot.businessAddress = "simplex:/contact#abc123" await bot.onNewChatItems(directMessage("Hi there", 99)) expectDmSent(99, "simplex:/contact#abc123") }) test("DM without business address set → no reply", async () => { bot.businessAddress = null await bot.onNewChatItems(directMessage("Hi there", 99)) expect(chat.sentDirect(99).length).toBe(0) }) test("non-message DM event (e.g. contactConnected) → no reply", async () => { bot.businessAddress = "simplex:/contact#abc123" const ci = { chatDir: {type: "directRcv"}, content: {type: "rcvDirectEvent"}, meta: {itemId: 9999, createdAt: new Date().toISOString()}, } const evt = { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeDirectAChatItem(ci, 99)], } await bot.onNewChatItems(evt) expect(chat.sentDirect(99).length).toBe(0) }) }) describe("Business Request Handler", () => { beforeEach(() => setup()) test("acceptingBusinessRequest → enables file uploads AND visible history", async () => { await bot.onBusinessRequest({ type: "acceptingBusinessRequest" as const, user: makeUser(MAIN_USER_ID), groupInfo: makeGroupInfo(CUSTOMER_GROUP_ID), }) expect(chat.profileUpdates.some(u => u.groupId === CUSTOMER_GROUP_ID && u.profile.groupPreferences?.files?.enable === GroupFeatureEnabled.On && u.profile.groupPreferences?.history?.enable === GroupFeatureEnabled.On )).toBe(true) }) }) describe("chatItemUpdated Handler", () => { beforeEach(() => setup()) test("chatItemUpdated in business group → card update scheduled", async () => { await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited message", memberId: CUSTOMER_ID}))) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 600}) await cards.flush() expectCardDeleted(600) }) test("chatItemUpdated in non-business group → ignored", async () => { await bot.onChatItemUpdated(updatedEvent(TEAM_GROUP_ID, makeChatItem({dir: "groupRcv", text: "team msg"}))) await cards.flush() expect(chat.deleted.length).toBe(0) }) test("chatItemUpdated from wrong user → ignored", async () => { await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited"}), GROK_USER_ID)) await cards.flush() expect(chat.deleted.length).toBe(0) }) }) describe("Reactions", () => { beforeEach(() => setup()) test("reaction in business group → card update scheduled", async () => { await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, true)) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 700}) await cards.flush() expectCardDeleted(700) }) test("reaction removed (added=false) → no card update", async () => { await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, false)) await cards.flush() expect(chat.deleted.length).toBe(0) }) }) describe("Customer Leave", () => { beforeEach(() => setup()) test("customer leaves → customData cleared", async () => { chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 800}) await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) }) test("Grok leaves → in-memory maps cleaned", async () => { await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeGrokMember())) }) test("team member leaves → logged, no crash", async () => { await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) }) test("leftMember in non-business group → ignored", async () => { const member = {memberId: "someone", groupMemberId: 9000, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} await bot.onLeftMember(leftEvent(TEAM_GROUP_ID, member)) }) }) describe("Error Handling", () => { beforeEach(() => setup()) test("apiAddMember fails (Grok invite) → grokUnavailableMessage", async () => { await reachQueue() addBotMessage("The team will reply to your message") chat.apiAddMemberWillFail() await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") }) test("groupDuplicateMember on Grok invite → only inviting message, no result", async () => { await reachQueue() addBotMessage("The team will reply to your message") chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) const sentBefore = chat.sent.length await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() // Only the "Inviting Grok" message is sent — no activated/unavailable result expect(chat.sent.length).toBe(sentBefore + 1) expectSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") expectNotSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) expectNotSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") }) test("/team while members are in Invited status → no second apiAddMember call", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") // Simulate the realistic post-/team state: both members have been invited // but have not yet accepted (memberStatus = "invited"). The SimpleX API // would resend the invitation if apiAddMember is called for an Invited // member — the pre-check in addOrFindTeamMember must skip them. const invited = (contactId: number) => ({ memberId: `team-${contactId}`, groupMemberId: 5000 + contactId, memberContactId: contactId, memberStatus: "invited", memberProfile: {displayName: `Contact${contactId}`}, }) chat.members.set(CUSTOMER_GROUP_ID, [invited(TEAM_MEMBER_1_ID), invited(TEAM_MEMBER_2_ID)]) chat.added.length = 0 await bot.onNewChatItems(customerMessage("/team")) expect(chat.added.filter(a => a.groupId === CUSTOMER_GROUP_ID)).toEqual([]) }) test("/grok in TEAM-PENDING while Grok is in Invited status → no second apiAddMember call", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [ makeTeamMember(TEAM_MEMBER_1_ID, "Alice"), {memberId: "grok-member", groupMemberId: 7777, memberContactId: GROK_CONTACT_ID, memberStatus: "invited", memberProfile: {displayName: "Grok"}}, ]) chat.added.length = 0 await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() expect(chat.added.filter(a => a.contactId === GROK_CONTACT_ID)).toEqual([]) expectNotSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") }) }) describe("Profile / Event Filtering", () => { beforeEach(() => setup()) test("newChatItems from Grok profile → ignored by main handler", async () => { const evt = { type: "newChatItems" as const, user: makeUser(GROK_USER_ID), chatItems: [makeAChatItem(makeChatItem({dir: "groupRcv", text: "test"}))], } const sentBefore = chat.sent.length await bot.onNewChatItems(evt) expect(chat.sent.length).toBe(sentBefore) }) test("Grok events from main profile → ignored by Grok handlers", async () => { const evt = { type: "receivedGroupInvitation" as const, user: makeUser(MAIN_USER_ID), groupInfo: makeGroupInfo(300), contact: {contactId: 1}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, } await bot.onGrokGroupInvitation(evt) expect(chat.joined.length).toBe(0) }) test("own messages (groupSnd) → ignored", async () => { const ci = makeChatItem({dir: "groupSnd", text: "Bot message"}) const evt = { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [makeAChatItem(ci)], } const sentBefore = chat.sent.length await bot.onNewChatItems(evt) expect(chat.sent.length).toBe(sentBefore) }) test("non-business group messages → ignored", async () => { const ci = makeChatItem({dir: "groupRcv", text: "test"}) const nonBizGroup = makeGroupInfo(999, {businessChat: null}) const evt = { type: "newChatItems" as const, user: makeUser(MAIN_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: nonBizGroup}, chatItem: ci}], } const sentBefore = chat.sent.length await bot.onNewChatItems(evt) expect(chat.sent.length).toBe(sentBefore) }) }) describe("Grok Join Flow", () => { beforeEach(() => setup()) test("Grok receivedGroupInvitation → apiJoinGroup called", async () => { // First need to set up a pending grok join // Simulate the main profile side: add Grok to a group await reachQueue() addBotMessage("The team will reply to your message") // This kicks off activateGrok which adds member and waits const joinComplete = new Promise(async (resolve) => { // Simulate Grok invitation after a small delay setTimeout(async () => { const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID) if (addedGrok) { const memberId = `member-${GROK_CONTACT_ID}` await bot.onGrokGroupInvitation({ type: "receivedGroupInvitation", user: makeUser(GROK_USER_ID), groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, contact: {contactId: 99}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, }) } resolve() }, 10) }) // Don't await bot.onNewChatItems yet — let it start const botPromise = bot.onNewChatItems(customerMessage("/grok")) await joinComplete // Complete the join await bot.onGrokMemberConnected({ type: "connectedToGroupMember", user: makeUser(GROK_USER_ID), groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, }) await botPromise expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) }) test("unmatched Grok invitation → buffered, not joined", async () => { const evt = { type: "receivedGroupInvitation" as const, user: makeUser(GROK_USER_ID), groupInfo: {...makeGroupInfo(999), membership: {memberId: "unknown-member"}}, contact: {contactId: 99}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, } await bot.onGrokGroupInvitation(evt) expect(chat.joined.length).toBe(0) }) test("buffered invitation drained after pendingGrokJoins set → apiJoinGroup called", async () => { // Simulate the race: invitation arrives before pendingGrokJoins is set const memberId = `member-${GROK_CONTACT_ID}` const invEvt = { type: "receivedGroupInvitation" as const, user: makeUser(GROK_USER_ID), groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, contact: {contactId: 99}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, } // Buffer the invitation (no pending join registered yet) await bot.onGrokGroupInvitation(invEvt) expect(chat.joined.length).toBe(0) // Now trigger activateGrok — apiAddMember returns, pendingGrokJoins set, buffer drained const joinComplete = new Promise((resolve) => { setTimeout(async () => { // Grok connected after buffer drain processed the invitation await bot.onGrokMemberConnected({ type: "connectedToGroupMember", user: makeUser(GROK_USER_ID), groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, }) resolve() }, 20) }) await reachQueue() addBotMessage("The team will reply to your message") const botPromise = bot.onNewChatItems(customerMessage("/grok")) await joinComplete await botPromise await bot.flush() expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) }) test("per-message responses suppressed during activateGrok initial response", async () => { await reachQueue() addBotMessage("The team will reply to your message") // Customer's message visible in Grok's view (activateGrok reads it for initial response) addCustomerMessageToHistory("Hello, I need help", GROK_LOCAL_GROUP_ID) chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) // Start /grok activation (fireAndForget) const botPromise = bot.onNewChatItems(customerMessage("/grok")) // Wait for apiAddMember to complete await new Promise(r => setTimeout(r, 10)) // Simulate Grok invitation → sets grokGroupMap/reverseGrokMap const memberId = `member-${GROK_CONTACT_ID}` await bot.onGrokGroupInvitation({ type: "receivedGroupInvitation", user: makeUser(GROK_USER_ID), groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, contact: {contactId: 99}, fromMemberRole: GroupMemberRole.Admin, memberRole: GroupMemberRole.Member, }) // grokInitialResponsePending is set, reverseGrokMap is set. // Simulate per-message event (as if message backlog arrived for Grok profile) await bot.onGrokNewChatItems(grokViewCustomerMessage("Hello, I need help")) // Gating: per-message handler must NOT have called Grok API expect(grokApi.calls.length).toBe(0) // Now complete the join → activateGrok sends initial combined response await bot.onGrokMemberConnected({ type: "connectedToGroupMember", user: makeUser(GROK_USER_ID), groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, }) await botPromise await bot.flush() // Only 1 Grok API call: the initial combined response from activateGrok expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toContain("Hello, I need help") }) test("per-message responses resume after activateGrok completes", async () => { await reachGrok() await bot.flush() const callsAfterActivation = grokApi.calls.length // Send a new customer message via Grok's view — should be processed normally addCustomerMessageToHistory("Follow-up question", GROK_LOCAL_GROUP_ID) await bot.onGrokNewChatItems(grokViewCustomerMessage("Follow-up question")) expect(grokApi.calls.length).toBe(callsAfterActivation + 1) expect(grokApi.calls[grokApi.calls.length - 1].message).toBe("Follow-up question") }) test("activateGrok groupDuplicateMember path → gate cleared by outer finally", async () => { // After reachGrok(), gate is cleared and reverseGrokMap is populated. await reachGrok() await bot.flush() const callsBaseline = grokApi.calls.length // Second /grok while Grok is already present → apiAddMember throws duplicate. // The outer try/finally must clear the gate even though the handler returns // silently from inside the try — otherwise per-message responses stay // suppressed for this group forever. chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() // Gate must be clear: a subsequent per-message event triggers Grok. addCustomerMessageToHistory("another question", GROK_LOCAL_GROUP_ID) await bot.onGrokNewChatItems(grokViewCustomerMessage("another question")) expect(grokApi.calls.length).toBe(callsBaseline + 1) }) }) describe("Grok No-History Fallback", () => { beforeEach(() => setup()) test("Grok joins but sees no customer messages → sends grokNoHistoryMessage", async () => { chat.chatItems.set(GROK_LOCAL_GROUP_ID, []) chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) const grokJoinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await grokJoinPromise await bot.flush() expectAnySent("couldn't see your earlier messages") }) }) describe("Non-customer messages trigger card update", () => { beforeEach(() => setup()) test("Grok response in customer group → card update scheduled", async () => { await bot.onNewChatItems(grokResponseMessage("Grok says hi")) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 900}) await cards.flush() expectCardDeleted(900) }) test("team member message → card update scheduled", async () => { await bot.onNewChatItems(teamMemberMessage("Team says hi")) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 901}) await cards.flush() expectCardDeleted(901) }) }) describe("End-to-End Flows", () => { beforeEach(() => setup()) test("WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM", async () => { await bot.onNewChatItems(customerMessage("Help me")) expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/team")) expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) const pendingState = await cards.deriveState(CUSTOMER_GROUP_ID) expect(pendingState).toBe("TEAM-PENDING") addTeamMemberMessageToHistory("I'll help you", TEAM_MEMBER_1_ID) await bot.onNewChatItems(teamMemberMessage("I'll help you")) const teamState = await cards.deriveState(CUSTOMER_GROUP_ID) expect(teamState).toBe("TEAM") }) test("WELCOME → /grok first msg → GROK", async () => { const joinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await joinPromise await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, grokActivatedMessage) expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) }) test("multiple concurrent conversations are independent", async () => { const GROUP_A = 101 const GROUP_B = 102 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A, {customerId: "cust-a"})) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B, {customerId: "cust-b"})) const ciA = makeChatItem({dir: "groupRcv", text: "Help A", memberId: "cust-a"}) await bot.onNewChatItems({ type: "newChatItems", user: makeUser(MAIN_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_A, {customerId: "cust-a"})}, chatItem: ciA}], }) const ciB = makeChatItem({dir: "groupRcv", text: "Help B", memberId: "cust-b"}) await bot.onNewChatItems({ type: "newChatItems", user: makeUser(MAIN_USER_ID), chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_B, {customerId: "cust-b"})}, chatItem: ciB}], }) expectSentToGroup(GROUP_A, "The team will reply to your message") expectSentToGroup(GROUP_B, "The team will reply to your message") }) }) describe("Message Templates", () => { test("welcomeMessage is a non-empty string", () => { expect(typeof welcomeMessage).toBe("string") expect(welcomeMessage.length).toBeGreaterThan(0) }) test("grokActivatedMessage mentions chatting with Grok", () => { expect(grokActivatedMessage).toContain("chatting with Grok") }) test("teamLockedMessage tells customer the team will handle the conversation", () => { expect(teamLockedMessage).toContain("team") }) test("queueMessage mentions hours", () => { const msg = queueMessage("UTC", true) expect(msg).toContain("hours") }) }) describe("State persistence in customData", () => { beforeEach(() => setup()) test("first customer text writes state=QUEUE to customData", async () => { await bot.onNewChatItems(customerMessage("Hello")) expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") }) test("/team writes state=TEAM-PENDING immediately (before team accepts)", async () => { await reachQueue() addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/team")) expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") }) test("/grok writes state=GROK when activation succeeds", async () => { await reachQueue() addBotMessage("The team will reply to your message") const joinPromise = simulateGrokJoinSuccess() await bot.onNewChatItems(customerMessage("/grok")) await joinPromise await bot.flush() expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") }) test("/grok from QUEUE reverts state to QUEUE if activation fails", async () => { await reachQueue() addBotMessage("The team will reply to your message") chat.apiAddMemberWillFail() await bot.onNewChatItems(customerMessage("/grok")) await bot.flush() expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") }) test("concurrent /team during Grok activation timeout does not demote state", async () => { await reachQueue() addBotMessage("The team will reply to your message") // Pause activateGrok at waitForGrokJoin so /team can run in the meantime. // Patching apiAddMember won't work: it's wrapped in withMainProfile's mutex, // which /team's activateTeam also needs. waitForGrokJoin awaits outside the // mutex — that's the real race window in production. let releaseJoin!: (joined: boolean) => void ;(bot as any).waitForGrokJoin = () => new Promise((resolve) => { releaseJoin = resolve }) // /grok: writes state=GROK optimistically, fire-and-forgets activateGrok. await bot.onNewChatItems(customerMessage("/grok")) expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") // Let activateGrok progress past apiAddMember into waitForGrokJoin. await Promise.resolve() await Promise.resolve() // /team while activateGrok is waiting for join — writes TEAM-PENDING + adds members. await bot.onNewChatItems(customerMessage("/team")) expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) // Simulate Grok join timeout — activateGrok's revertStateOnFail runs. releaseJoin(false) await bot.flush() // Fix asserts: revert guard sees state != "GROK" and leaves TEAM-PENDING alone. expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") }) test("first team text writes state=TEAM via gate", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) await bot.onNewChatItems(teamMemberMessage("I'll help")) expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM") }) }) describe("Card Preview Sender Prefixes", () => { beforeEach(() => setup()) // Helper: extract preview line from card text posted to team group function getCardPreview(): string { const teamMsgs = chat.sentTo(TEAM_GROUP_ID) const cardText = teamMsgs[0] if (!cardText) return "" const lines = cardText.split("\n") // Card layout: header, state, preview, /'join N' — preview is second to last return lines[lines.length - 2] || "" } test("customer-only messages: first prefixed, rest not", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("Hello") addCustomerMessageToHistory("Need help") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("Alice: Hello") expect(preview).toContain("!3 /! Need help") // Second message must NOT have prefix (same sender) expect(preview).not.toContain("Alice: Need help") }) test("three consecutive customer messages: only first gets prefix", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("First") addCustomerMessageToHistory("Second") addCustomerMessageToHistory("Third") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() const prefixCount = (preview.match(/Alice:/g) || []).length expect(prefixCount).toBe(1) expect(preview).toContain("Alice: First") }) test("alternating customer and Grok: each sender change triggers prefix", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("How does encryption work?") addGrokMessageToHistory("SimpleX uses double ratchet") addCustomerMessageToHistory("And metadata?") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("Alice: How does encryption work?") expect(preview).toContain("Grok: SimpleX uses double ratchet") expect(preview).toContain("Alice: And metadata?") }) test("Grok identified by grokContactId, not by display name", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) // Grok message uses GROK_CONTACT_ID → labeled "Grok" regardless of memberProfile addGrokMessageToHistory("I am Grok") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("Grok: I am Grok") }) test("team member messages use their memberProfile displayName", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("Help please") // Add team member message with explicit display name const teamCi = makeChatItem({ dir: "groupRcv", text: "On it!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID, memberDisplayName: "Bob", }) const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] items.push(teamCi) chat.chatItems.set(CUSTOMER_GROUP_ID, items) await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("Alice: Help please") expect(preview).toContain("Bob: On it!") }) test("bot messages (groupSnd) excluded from preview", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("Hello") addBotMessage("The team will reply to your message") addCustomerMessageToHistory("Thanks") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).not.toContain("The team will reply to your message") // Both customer messages are from the same sender — only first prefixed expect(preview).toContain("Alice: Hello") expect(preview).toContain("!3 /! Thanks") }) test("media-only message shows type label", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) const imgCi = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] items.push(imgCi) chat.chatItems.set(CUSTOMER_GROUP_ID, items) await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("[image]") }) test("media message with caption shows label + text", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) const imgCi = makeChatItem({dir: "groupRcv", text: "screenshot of the bug", memberId: CUSTOMER_ID, msgType: "image"}) const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] items.push(imgCi) chat.chatItems.set(CUSTOMER_GROUP_ID, items) await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("[image] screenshot of the bug") }) test("long message truncated with [truncated]", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) const longMsg = "x".repeat(300) addCustomerMessageToHistory(longMsg) await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("[truncated]") // Truncated at ~200 chars + prefix expect(preview.length).toBeLessThan(300) }) test("total overflow truncates oldest messages, keeps newest", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) // Add many messages to exceed 1000 chars total for (let i = 0; i < 20; i++) { addCustomerMessageToHistory(`Message number ${i} with some extra padding text to fill space quickly`) } await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toContain("[truncated]") // Newest messages should be present, oldest truncated expect(preview).toContain("Message number 19") expect(preview).not.toContain("Message number 0") // Should not include all 20 messages const slashCount = (preview.match(/ \/ /g) || []).length expect(slashCount).toBeLessThan(19) }) test("empty preview when no messages", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toBe('""') }) test("only bot messages → empty preview", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addBotMessage("Welcome!") addBotMessage("Queue message") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).toBe('""') }) test("newlines in message text → replaced with spaces", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("line1\nline2\n\nline3") await cards.createCard(CUSTOMER_GROUP_ID, gi) const preview = getCardPreview() expect(preview).not.toContain("\n") expect(preview).toContain("line1 line2 line3") }) test("newlines in customer display name → sanitized in card header", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "First\nLast"}) chat.groups.set(CUSTOMER_GROUP_ID, gi) addCustomerMessageToHistory("Hello") await cards.createCard(CUSTOMER_GROUP_ID, gi) const teamMsgs = chat.sentTo(TEAM_GROUP_ID) expect(teamMsgs.length).toBe(1) const cardText = teamMsgs[0] // Card header should have sanitized name (no newlines) expect(cardText).toContain("First Last") // Exactly 4 lines: header, state, preview, /'join N' expect(cardText.split("\n").length).toBe(4) expect(cardText).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) }) }) describe("Restart Card Recovery", () => { beforeEach(() => setup()) test("refreshAllCards refreshes groups with active cards", async () => { const GROUP_A = 101 const GROUP_B = 102 const GROUP_NO_CARD = 103 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) chat.groups.set(GROUP_NO_CARD, makeGroupInfo(GROUP_NO_CARD)) chat.customData.set(GROUP_A, {cardItemId: 501}) chat.customData.set(GROUP_B, {cardItemId: 503}) await cards.refreshAllCards() expectCardDeleted(501) expectCardDeleted(503) expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(2) // 2 cards × 1 message each }) test("refreshAllCards with no active cards → no-op", async () => { await cards.refreshAllCards() expect(chat.deleted.length).toBe(0) expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) }) test("refreshAllCards ignores groups without cardItemId in customData", async () => { const GROUP_A = 101 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.customData.set(GROUP_A, {someOtherData: true}) await cards.refreshAllCards() expect(chat.deleted.length).toBe(0) expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) }) test("refreshAllCards orders by cardItemId ascending (oldest first, newest last)", async () => { // GROUP_C has higher cardItemId (more recent) than GROUP_A and GROUP_B const GROUP_A = 101, GROUP_B = 102, GROUP_C = 103 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) chat.groups.set(GROUP_C, makeGroupInfo(GROUP_C)) chat.customData.set(GROUP_C, {cardItemId: 900}) // newest — should refresh last chat.customData.set(GROUP_A, {cardItemId: 100}) // oldest — should refresh first chat.customData.set(GROUP_B, {cardItemId: 500}) // middle await cards.refreshAllCards() // Verify deletion order: oldest cardItemId first, newest last expect(chat.deleted.length).toBe(3) expect(chat.deleted[0].itemIds).toEqual([100]) expect(chat.deleted[1].itemIds).toEqual([500]) expect(chat.deleted[2].itemIds).toEqual([900]) // Newest card is posted last → appears at bottom of team group const teamMsgs = chat.sentTo(TEAM_GROUP_ID) expect(teamMsgs.length).toBe(3) // 3 cards × 1 message each }) test("refreshAllCards skips cards marked complete", async () => { const GROUP_A = 101, GROUP_B = 102 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) chat.customData.set(GROUP_A, {cardItemId: 100, complete: true}) chat.customData.set(GROUP_B, {cardItemId: 200}) await cards.refreshAllCards() expect(chat.deleted.length).toBe(1) expect(chat.deleted[0].itemIds).toEqual([200]) expect(chat.deleted.some(d => d.itemIds.includes(100))).toBe(false) }) test("refreshAllCards deletes old card before reposting", async () => { const GROUP_A = 101 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.customData.set(GROUP_A, {cardItemId: 501}) await cards.refreshAllCards() // Old card should be deleted expect(chat.deleted.length).toBe(1) expect(chat.deleted[0].itemIds).toEqual([501]) // New card posted expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) }) test("refreshAllCards ignores delete failure (>24h old card)", async () => { const GROUP_A = 101 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.customData.set(GROUP_A, {cardItemId: 501}) chat.apiDeleteChatItemsWillFail() await cards.refreshAllCards() // Delete failed but new card still posted expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) // customData updated with new cardItemId const newData = chat.customData.get(GROUP_A) expect(typeof newData.cardItemId).toBe("number") expect(newData.cardItemId).not.toBe(501) // new ID, not the old one }) test("card flush writes complete: true for auto-completed conversations", async () => { const GROUP_A = 101 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) // Team member message from 4 hours ago (> completeHours=3h) → auto-complete const oldCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) oldCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() chat.chatItems.set(GROUP_A, [oldCi]) // Create initial card data chat.customData.set(GROUP_A, {cardItemId: 500}) cards.scheduleUpdate(GROUP_A) await cards.flush() const data = chat.customData.get(GROUP_A) expect(data.complete).toBe(true) }) test("card flush clears complete flag when conversation becomes active again", async () => { const GROUP_A = 101 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) // Team member message from 4h ago + recent customer message → NOT complete const teamCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) teamCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() const custCi = makeChatItem({dir: "groupRcv", text: "Actually one more question", memberId: CUSTOMER_ID}) chat.chatItems.set(GROUP_A, [teamCi, custCi]) // Previously complete chat.customData.set(GROUP_A, {cardItemId: 500, complete: true}) cards.scheduleUpdate(GROUP_A) await cards.flush() const data = chat.customData.get(GROUP_A) expect(data.complete).toBeUndefined() }) test("refreshAllCards continues on individual card failure", async () => { const GROUP_A = 101, GROUP_B = 102 chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) chat.customData.set(GROUP_A, {cardItemId: 100}) chat.customData.set(GROUP_B, {cardItemId: 200}) chat.apiDeleteChatItemsWillFail() await cards.refreshAllCards() expectCardDeleted(200) }) }) describe("joinedGroupMember Event Filtering", () => { beforeEach(() => setup()) test("joinedGroupMember in non-team group → ignored (no DM)", async () => { const member = {memberId: "someone", groupMemberId: 9000, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} await bot.onJoinedGroupMember(joinedEvent(CUSTOMER_GROUP_ID, member)) expect(chat.rawCmds.length).toBe(0) expect(chat.sent.filter(s => s.chat[0] === ChatType.Direct).length).toBe(0) }) test("joinedGroupMember from wrong user → ignored", async () => { const member = {memberId: "someone", groupMemberId: 9001, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member, GROK_USER_ID)) expect(chat.rawCmds.length).toBe(0) }) }) describe("parseConfig Validation", () => { const baseArgs = ["--team-group", "Support"] test("--complete-hours non-numeric → throws", () => { expect(() => parseConfig([...baseArgs, "--complete-hours", "abc"])) .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) }) test("postgres backend without --pg-conn → throws", () => { const prev = process.env.SIMPLEX_BACKEND process.env.SIMPLEX_BACKEND = "postgres" try { expect(() => parseConfig(baseArgs)) .toThrow(/--pg-conn is required when backend is postgres/) } finally { if (prev === undefined) delete process.env.SIMPLEX_BACKEND else process.env.SIMPLEX_BACKEND = prev } }) test("postgres backend with --pg-conn → db is postgres DbConfig", () => { const prev = process.env.SIMPLEX_BACKEND process.env.SIMPLEX_BACKEND = "postgres" try { const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) } finally { if (prev === undefined) delete process.env.SIMPLEX_BACKEND else process.env.SIMPLEX_BACKEND = prev } }) test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { const prev = process.env.SIMPLEX_BACKEND process.env.SIMPLEX_BACKEND = "postgres" try { const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) } finally { if (prev === undefined) delete process.env.SIMPLEX_BACKEND else process.env.SIMPLEX_BACKEND = prev } }) test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { const prevBackend = process.env.SIMPLEX_BACKEND const prevNpm = process.env.npm_config_simplex_backend delete process.env.SIMPLEX_BACKEND delete process.env.npm_config_simplex_backend try { const cfg = parseConfig(baseArgs) expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) } finally { if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm } }) test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { const cfg = parseConfig([...baseArgs, "--sqlite-key", "secret"]) expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex", encryptionKey: "secret"}) }) test("unknown flag → parseArgs throws", () => { expect(() => parseConfig([...baseArgs, "--team-gropu", "typo"])) .toThrow() }) test("missing --team-group → throws", () => { expect(() => parseConfig([])) .toThrow(/required option '--team-group/) }) test("invalid SIMPLEX_BACKEND → throws", () => { const prev = process.env.SIMPLEX_BACKEND process.env.SIMPLEX_BACKEND = "mysql" try { expect(() => parseConfig(baseArgs)) .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) } finally { if (prev === undefined) delete process.env.SIMPLEX_BACKEND else process.env.SIMPLEX_BACKEND = prev } }) test("--complete-hours negative → throws", () => { // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) .toThrow(/--complete-hours must be a non-negative integer, got "-1"/) }) test("--card-flush-seconds non-numeric → throws", () => { expect(() => parseConfig([...baseArgs, "--card-flush-seconds", "xyz"])) .toThrow(/--card-flush-seconds must be a non-negative integer, got "xyz"/) }) test("--timezone invalid IANA → throws", () => { expect(() => parseConfig([...baseArgs, "--timezone", "Not/AZone"])) .toThrow(/--timezone "Not\/AZone" is not a valid IANA time zone/) }) test("--complete-hours 0 → allowed (disables auto-complete)", () => { const cfg = parseConfig([...baseArgs, "--complete-hours", "0"]) expect(cfg.completeHours).toBe(0) }) test("valid IANA timezone → accepted", () => { const cfg = parseConfig([...baseArgs, "--timezone", "America/New_York"]) expect(cfg.timezone).toBe("America/New_York") }) }) describe("GrokApiClient HTTP timeout", () => { test("chat() calls AbortSignal.timeout(60_000) and passes the signal to fetch", async () => { const timeoutSpy = vi.spyOn(AbortSignal, "timeout") const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}), ) const client = new GrokApiClient("test-key", "system prompt") await client.chat([], "hello") expect(timeoutSpy).toHaveBeenCalledWith(60_000) expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal) fetchSpy.mockRestore() timeoutSpy.mockRestore() }) }) // Lazy per-group command sync. sendToGroup always calls // apiUpdateGroupProfile on the first send per group when the group's // stored groupPreferences.commands don't match desiredCommands. Each // group is synced at most once per process (cache hit on subsequent // sends). describe("Command sync in sendToGroup", () => { beforeEach(() => setup()) test("first send → apiUpdateGroupProfile called once with merged commands", async () => { await bot.sendToGroup(CUSTOMER_GROUP_ID, "Hello, just a greeting.") expect(chat.profileUpdates).toHaveLength(1) const {groupId, profile} = chat.profileUpdates[0] expect(groupId).toBe(CUSTOMER_GROUP_ID) expect(profile.groupPreferences.commands).toEqual(DESIRED_COMMANDS) // Existing groupProfile fields (displayName, fullName) are preserved. expect(profile.displayName).toBe(`Group${CUSTOMER_GROUP_ID}`) expect(profile.fullName).toBe("") // The actual message still goes out after the sync. expect(chat.lastSentTo(CUSTOMER_GROUP_ID)).toBe("Hello, just a greeting.") }) test("group already has desired commands → no apiUpdateGroupProfile, but still cached", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID) gi.groupProfile.groupPreferences = {commands: DESIRED_COMMANDS} chat.groups.set(CUSTOMER_GROUP_ID, gi) await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok for help.") expect(chat.profileUpdates).toHaveLength(0) // Cache was populated — a subsequent send even against a divergent DB // won't re-check. gi.groupProfile.groupPreferences = {commands: []} await bot.sendToGroup(CUSTOMER_GROUP_ID, "Send /team for a human.") expect(chat.profileUpdates).toHaveLength(0) }) test("cache: two sends to same group → sync only once", async () => { await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok first.") await bot.sendToGroup(CUSTOMER_GROUP_ID, "Or send /team.") expect(chat.profileUpdates).toHaveLength(1) expect(chat.sentTo(CUSTOMER_GROUP_ID)).toHaveLength(2) }) test("independent per group: different groups each sync separately", async () => { const gId2 = 101 chat.groups.set(gId2, makeGroupInfo(gId2)) await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") await bot.sendToGroup(gId2, "Send /team.") expect(chat.profileUpdates.map(p => p.groupId).sort()).toEqual([CUSTOMER_GROUP_ID, gId2].sort()) }) test("merge preserves existing group preference fields (files, etc.)", async () => { const gi = makeGroupInfo(CUSTOMER_GROUP_ID) gi.groupProfile.groupPreferences = { files: {enable: "on"}, reactions: {enable: "on"}, } chat.groups.set(CUSTOMER_GROUP_ID, gi) await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") expect(chat.profileUpdates).toHaveLength(1) const prefs = chat.profileUpdates[0].profile.groupPreferences expect(prefs.commands).toEqual(DESIRED_COMMANDS) expect(prefs.files).toEqual({enable: "on"}) expect(prefs.reactions).toEqual({enable: "on"}) }) })