import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { addClientMessage, assignThread, chatRecipients, clientConversation, closeThread, createOrder, conversation, DomainError, getThreadForStaff, giveConsent, grantSubscription, listThreads, moveThread, openThreadFor, receiveMessage, replyTo, setClientChannel, staffSend, startClient, threadCounts, } from '../src/index.ts'; import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts'; beforeEach(async () => { await fixtures(); }); afterAll(() => db.$disconnect()); const code = (p: Promise) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e))); async function client(ctx: ReturnType['ctx'], id: number, subscriber = false) { const s = await startClient(ctx, tgUser(id)); await giveConsent(ctx, s.clientId); if (subscriber) await grantSubscription(ctx, { clientId: s.clientId, plan: 'BAREV_PLUS', days: 30 }); return s.clientId; } const staff = (name: string, roles: ('SUPERADMIN' | 'SUPPORT' | 'CONCIERGE')[], telegramId?: number) => db.adminUser.create({ data: { name, roles, telegramId: telegramId ? BigInt(telegramId) : undefined } }); describe('чаты: поддержка и консьерж', () => { it('подписчик пишет консьержу, остальные — в поддержку; бот и мини-апп — одна переписка', async () => { const { ctx, events } = makeCtx(NOW); const plain = await client(ctx, 701); const member = await client(ctx, 702, true); const a = await addClientMessage(ctx, plain, 'Где мой заказ?'); expect(a).toMatchObject({ channel: 'SUPPORT', opened: true }); const b = await addClientMessage(ctx, member, 'Нужен нотариус'); expect(b).toMatchObject({ channel: 'CONCIERGE', opened: true }); // второе сообщение из мини-аппа — в тот же диалог, уже открытый const c = await receiveMessage(ctx, { clientId: member }, { text: 'И переводчик', source: 'MINIAPP' }); expect(c).toMatchObject({ opened: false }); expect(c!.thread.id).toBe(b!.thread.id); expect(c!.message.source).toBe('MINIAPP'); expect(events.filter((e) => e.type === 'concierge.message')).toHaveLength(3); // пустое сообщение без файла не сохраняется expect(await receiveMessage(ctx, { clientId: plain }, { text: ' ' })).toBeNull(); }); it('клиент сам выбирает канал; консьерж — только с подпиской', async () => { const { ctx } = makeCtx(NOW); const plain = await client(ctx, 711); const member = await client(ctx, 712, true); expect(await code(setClientChannel(ctx, plain, 'CONCIERGE'))).toBe('CONCIERGE_SUBSCRIBERS_ONLY'); await setClientChannel(ctx, member, 'SUPPORT'); expect((await addClientMessage(ctx, member, 'Вопрос по оплате'))!.channel).toBe('SUPPORT'); await setClientChannel(ctx, member, 'CONCIERGE'); expect((await addClientMessage(ctx, member, 'Столик на субботу'))!.channel).toBe('CONCIERGE'); }); it('ответ: уходит клиенту, снимает «ждёт ответа», диалог за ответившим; заметка клиенту не уходит', async () => { const { ctx, events } = makeCtx(NOW); const clientId = await client(ctx, 721); const op = await staff('Гор', ['SUPPORT']); const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!; expect(thread.waitingSince).toEqual(NOW); const note = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'VIP', internal: true }); expect(events).not.toContainEqual({ type: 'concierge.reply', messageId: note.id }); expect((await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } })).waitingSince).toEqual(NOW); const reply = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'Здравствуйте!' }); expect(events).toContainEqual({ type: 'concierge.reply', messageId: reply.id }); const t = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } }); expect(t).toMatchObject({ waitingSince: null, assigneeId: op.id }); expect(await code(staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: ' ' }))).toBe('INVALID_PARAMS'); // клиент видит ответ, но не заметку; старая админка — тоже без заметок const mine = await clientConversation(ctx, clientId); expect(mine.messages.map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']); expect((await conversation(ctx, { clientId })).map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']); }); it('взять, передать коллеге с доступом, закрыть; закрытый открывается, когда клиент пишет снова', async () => { const { ctx, events } = makeCtx(NOW); const clientId = await client(ctx, 731, true); const anna = await staff('Анна', ['CONCIERGE']); const gor = await staff('Гор', ['SUPPORT']); const boss = await staff('Босс', ['SUPERADMIN']); const { thread } = (await addClientMessage(ctx, clientId, 'Нужен столик'))!; expect(thread.channel).toBe('CONCIERGE'); await assignThread(ctx, { threadId: thread.id, assigneeId: boss.id, byAdminId: boss.id }); // у поддержки нет доступа к консьержу expect(await code(assignThread(ctx, { threadId: thread.id, assigneeId: gor.id, byAdminId: boss.id }))).toBe('ASSIGNEE_NO_ACCESS'); await assignThread(ctx, { threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id }); expect(events).toContainEqual({ type: 'chat.assigned', threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id }); await closeThread(ctx, { threadId: thread.id, byAdminId: anna.id }); const closed = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } }); expect(closed).toMatchObject({ status: 'CLOSED', waitingSince: null }); const again = await addClientMessage(ctx, clientId, 'Ещё вопрос'); expect(again).toMatchObject({ opened: true }); expect(again!.thread).toMatchObject({ id: thread.id, status: 'OPEN', assigneeId: anna.id }); // служебные строки видны команде, но не клиенту const full = await getThreadForStaff(ctx, thread.id); expect(full.messages.filter((m) => m.author === 'SYSTEM').map((m) => m.text)).toEqual(['took', 'transferred', 'closed']); expect((await clientConversation(ctx, clientId)).messages).toHaveLength(2); }); it('перенос в другой канал: переписка переезжает, клиент дальше пишет туда; в консьерж — только подписчика', async () => { const { ctx } = makeCtx(NOW); const member = await client(ctx, 741, true); const plain = await client(ctx, 742); const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']); const t1 = (await addClientMessage(ctx, member, 'Вопрос про оплату подписки'))!.thread; const moved = await moveThread(ctx, { threadId: t1.id, channel: 'SUPPORT', byAdminId: op.id }); expect(moved.channel).toBe('SUPPORT'); expect(await db.chatThread.findUnique({ where: { id: t1.id } })).toBeNull(); expect(await db.conciergeMessage.count({ where: { threadId: moved.id, author: 'CLIENT' } })).toBe(1); expect((await addClientMessage(ctx, member, 'Спасибо'))!.thread.id).toBe(moved.id); const t2 = (await addClientMessage(ctx, plain, 'Хочу консьержа'))!.thread; expect(await code(moveThread(ctx, { threadId: t2.id, channel: 'CONCIERGE', byAdminId: op.id }))).toBe('CONCIERGE_SUBSCRIBERS_ONLY'); }); it('списки и счётчики: только свои каналы, ждущие ответа — сверху; пустой диалог из карточки не виден', async () => { const { ctx, clock } = makeCtx(NOW); const op = await staff('Гор', ['SUPPORT']); const a = await client(ctx, 751); const b = await client(ctx, 752); const m = await client(ctx, 753, true); await addClientMessage(ctx, a, 'первый'); clock.now = new Date(NOW.getTime() + 60_000); const tb = (await addClientMessage(ctx, b, 'второй'))!.thread; await staffSend(ctx, { threadId: tb.id, adminUserId: op.id, text: 'ответили' }); await addClientMessage(ctx, m, 'консьержу'); await openThreadFor(ctx, { clientId: a }, 'CONCIERGE'); const list = await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: op.id }); expect(list.map((x) => x.clientId)).toEqual([a, b]); expect(list[0]).toMatchObject({ unread: 1, waitingSince: NOW }); expect(await listThreads(ctx, { channels: ['SUPPORT'], filter: 'mine', adminUserId: op.id })).toHaveLength(1); expect(await listThreads(ctx, { channels: ['SUPPORT', 'CONCIERGE'], filter: 'open', adminUserId: op.id, search: 'консьерж' })).toHaveLength(1); const counts = await threadCounts(ctx, { channels: ['SUPPORT', 'CONCIERGE'], adminUserId: op.id }); expect(counts).toMatchObject({ waiting: { SUPPORT: 1, CONCIERGE: 1 }, mine: { SUPPORT: 1, CONCIERGE: 0 }, unassigned: { SUPPORT: 1, CONCIERGE: 1 }, total: 2 }); }); it('уведомления: ответственному, иначе — сотрудникам канала', async () => { const { ctx } = makeCtx(NOW); const anna = await staff('Анна', ['CONCIERGE'], 9001); const gor = await staff('Гор', ['SUPPORT'], 9002); const boss = await staff('Босс', ['SUPERADMIN'], 9003); const ids = async (t: { channel: 'SUPPORT' | 'CONCIERGE'; assigneeId: string | null }) => (await chatRecipients(ctx, t)).map((s) => s.name).sort(); expect(await ids({ channel: 'CONCIERGE', assigneeId: null })).toEqual(['Анна', 'Босс']); expect(await ids({ channel: 'SUPPORT', assigneeId: null })).toEqual(['Босс', 'Гор']); expect(await ids({ channel: 'SUPPORT', assigneeId: gor.id })).toEqual(['Гор']); void anna; void boss; }); it('исполнитель: сообщение помечается текущим заказом, без заказа — пометка «нет заказа»', async () => { const { ctx, clock } = makeCtx(NOW); const fx = await db.provider.findFirstOrThrow({ where: { name: 'Maria' } }); const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } }); const clientId = await client(ctx, 771); const order = await createOrder(ctx, { clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Абовяна 10', districtId: kentron.id }); // заказ через двое суток — сейчас исполнитель без заказа, но он «ближайший» await db.order.update({ where: { id: order.id }, data: { providerId: fx.id, status: 'ASSIGNED' } }); const free = (await receiveMessage(ctx, { providerId: fx.id }, { text: 'Есть работа на завтра?' }))!; expect(free.message.orderId).toBeNull(); const view = await getThreadForStaff(ctx, free.thread.id); expect(view.currentOrder).toBeNull(); expect(view.nextOrder).toMatchObject({ number: order.number, address: 'Абовяна 10' }); expect(view.messages[0]!.order).toBeNull(); // за 2 часа до начала — уже текущий clock.now = new Date(order.scheduledAt.getTime() - 2 * 3600_000); const soon = (await receiveMessage(ctx, { providerId: fx.id }, { text: 'Код от подъезда?' }))!; expect(soon.message.orderId).toBe(order.id); // начатый заказ — текущий даже после планового времени await db.order.update({ where: { id: order.id }, data: { status: 'CLEANING' } }); clock.now = new Date(order.scheduledAt.getTime() + 5 * 3600_000); expect((await receiveMessage(ctx, { providerId: fx.id }, { text: 'Задерживаюсь' }))!.message.orderId).toBe(order.id); // завершён — снова без заказа; клиентские сообщения заказ не помечает await db.order.update({ where: { id: order.id }, data: { status: 'DONE' } }); expect((await receiveMessage(ctx, { providerId: fx.id }, { text: 'Готово' }))!.message.orderId).toBeNull(); expect((await addClientMessage(ctx, clientId, 'Спасибо'))!.message.orderId).toBeNull(); expect((await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: 'x' })).find((x) => x.providerId === fx.id)!.lastOrder).toBeNull(); }); it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => { const { ctx } = makeCtx(NOW); const clientId = await client(ctx, 761, true); const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']); const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!; const msg = await replyTo(ctx, { clientId, adminUserId: op.id, text: 'Добрый день' }); expect(msg.threadId).toBe(thread.id); // клиент без переписки — диалог создаётся в его канале const fresh = await client(ctx, 762); const first = await replyTo(ctx, { clientId: fresh, adminUserId: op.id, text: 'Здравствуйте, это поддержка' }); expect((await db.chatThread.findUniqueOrThrow({ where: { id: first.threadId! } })).channel).toBe('SUPPORT'); }); });