@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-4R6Y43DQ.js +726 -0
- package/dist/chunk-NV2RZ5KT.js +56 -0
- package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
- package/dist/flows/index.js +6 -4
- package/dist/index.d.ts +323 -111
- package/dist/index.js +1032 -954
- package/dist/preview/index.d.ts +172 -0
- package/dist/preview/index.js +576 -0
- package/dist/styles.css +198 -0
- package/dist/types-C0PtaO7S.d.ts +207 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +18 -3
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +107 -0
- package/src/ConversationHeader.tsx +239 -0
- package/src/ConversationListItem.tsx +36 -5
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/MediaRenderer.tsx +9 -9
- package/src/MessageBubble.tsx +24 -2
- package/src/MessageComposer.tsx +15 -2
- package/src/Wallpaper.tsx +4 -2
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationDocuments.ts +4 -2
- package/src/index.ts +73 -4
- package/src/lib/cn.ts +15 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +148 -0
- package/src/preview/createMockConversationsApi.ts +111 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -0
- package/src/preview/index.ts +40 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +175 -0
- package/src/preview/previewFixtures.ts +153 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +36 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +8 -0
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O risco aqui não é o cliente montar o payload errado — é o HMAC do WebCrypto divergir do que o
|
|
3
|
+
* servidor calcula com `node:crypto`. Uma divergência de um byte transforma todo envio do preview
|
|
4
|
+
* em 401, então o teste compara as duas implementações diretamente.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHmac } from 'node:crypto'
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
import {
|
|
10
|
+
assertPreviewEnvironment,
|
|
11
|
+
createPreviewWebhookClient,
|
|
12
|
+
PreviewInProductionError,
|
|
13
|
+
PreviewWebhookRejectedError,
|
|
14
|
+
} from './createPreviewWebhookClient'
|
|
15
|
+
|
|
16
|
+
const APP_SECRET = 'dev-app-secret'
|
|
17
|
+
const FROM = '5511988887777'
|
|
18
|
+
const WEBHOOK_URL = 'http://localhost:3000/v1/webhook/whatsapp'
|
|
19
|
+
|
|
20
|
+
type CapturedRequest = {
|
|
21
|
+
body: string
|
|
22
|
+
signature: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch; captured: CapturedRequest[] } {
|
|
26
|
+
const captured: CapturedRequest[] = []
|
|
27
|
+
|
|
28
|
+
const fetchImplementation = (async (_url: string, init?: RequestInit) => {
|
|
29
|
+
const headers = init?.headers as Record<string, string>
|
|
30
|
+
captured.push({ body: String(init?.body), signature: headers['x-hub-signature-256'] ?? '' })
|
|
31
|
+
return { ok: status >= 200 && status < 300, status } as Response
|
|
32
|
+
}) as unknown as typeof fetch
|
|
33
|
+
|
|
34
|
+
return { fetchImplementation, captured }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('createPreviewWebhookClient', () => {
|
|
38
|
+
it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
|
|
39
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
40
|
+
const client = createPreviewWebhookClient({
|
|
41
|
+
webhookUrl: WEBHOOK_URL,
|
|
42
|
+
appSecret: APP_SECRET,
|
|
43
|
+
from: FROM,
|
|
44
|
+
fetchImplementation,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await client.sendText('quero 2kg de arroz')
|
|
48
|
+
|
|
49
|
+
const request = captured[0]
|
|
50
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET)
|
|
51
|
+
.update(request?.body ?? '')
|
|
52
|
+
.digest('hex')}`
|
|
53
|
+
expect(request?.signature).toBe(expected)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('envia exatamente os bytes que assinou', async () => {
|
|
57
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
58
|
+
const client = createPreviewWebhookClient({
|
|
59
|
+
webhookUrl: WEBHOOK_URL,
|
|
60
|
+
appSecret: APP_SECRET,
|
|
61
|
+
from: FROM,
|
|
62
|
+
fetchImplementation,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await client.sendButtonReply({ id: 'confirmar', title: 'Confirmar' })
|
|
66
|
+
|
|
67
|
+
const request = captured[0]
|
|
68
|
+
const reparsed = JSON.stringify(JSON.parse(request?.body ?? '{}'))
|
|
69
|
+
expect(request?.body).toBe(reparsed)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('mensagens de texto idêntico produzem assinaturas distintas', async () => {
|
|
73
|
+
const { fetchImplementation, captured } = createCapturingFetch()
|
|
74
|
+
const client = createPreviewWebhookClient({
|
|
75
|
+
webhookUrl: WEBHOOK_URL,
|
|
76
|
+
appSecret: APP_SECRET,
|
|
77
|
+
from: FROM,
|
|
78
|
+
fetchImplementation,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await client.sendText('sim')
|
|
82
|
+
await client.sendText('sim')
|
|
83
|
+
|
|
84
|
+
expect(captured[0]?.signature).not.toBe(captured[1]?.signature)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('propaga a recusa do webhook em vez de engolir', async () => {
|
|
88
|
+
const { fetchImplementation } = createCapturingFetch(401)
|
|
89
|
+
const client = createPreviewWebhookClient({
|
|
90
|
+
webhookUrl: WEBHOOK_URL,
|
|
91
|
+
appSecret: APP_SECRET,
|
|
92
|
+
from: FROM,
|
|
93
|
+
fetchImplementation,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewWebhookRejectedError)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('assertPreviewEnvironment', () => {
|
|
101
|
+
it('recusa montar em produção', () => {
|
|
102
|
+
expect(() => assertPreviewEnvironment(true)).toThrow(PreviewInProductionError)
|
|
103
|
+
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
|
|
3
|
+
* de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
|
|
4
|
+
* é indistinguível da Meta; o que muda é apenas quem assina.
|
|
5
|
+
*
|
|
6
|
+
* Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
|
|
7
|
+
* (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. Só existe para o docker local, e
|
|
10
|
+
* `assertPreviewEnvironment` recusa rodar em produção — um segredo de dev vazado é irrelevante,
|
|
11
|
+
* mas o hábito de embarcar segredo em frontend não é.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
buildInboundAudioPayload,
|
|
16
|
+
buildInboundInteractivePayload,
|
|
17
|
+
buildInboundTextPayload,
|
|
18
|
+
serializeWebhookPayload,
|
|
19
|
+
type InteractiveReplyOption,
|
|
20
|
+
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
21
|
+
|
|
22
|
+
export type PreviewWebhookClient = {
|
|
23
|
+
sendText(text: string): Promise<void>
|
|
24
|
+
sendButtonReply(reply: InteractiveReplyOption): Promise<void>
|
|
25
|
+
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
26
|
+
sendAudio(mediaId: string): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CreatePreviewWebhookClientParams = {
|
|
30
|
+
readonly webhookUrl: string
|
|
31
|
+
readonly appSecret: string
|
|
32
|
+
readonly from: string
|
|
33
|
+
readonly phoneNumberId?: string
|
|
34
|
+
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
35
|
+
readonly fetchImplementation?: typeof fetch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PreviewInProductionError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
41
|
+
this.name = 'PreviewInProductionError'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class PreviewWebhookRejectedError extends Error {
|
|
46
|
+
constructor(readonly status: number) {
|
|
47
|
+
super(`O webhook recusou a entrega do preview (HTTP ${status}).`)
|
|
48
|
+
this.name = 'PreviewWebhookRejectedError'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Falha alto em vez de degradar em silêncio: um preview que "quase funciona" em produção é pior
|
|
54
|
+
* que um que se recusa a montar.
|
|
55
|
+
*/
|
|
56
|
+
export function assertPreviewEnvironment(isProduction: boolean): void {
|
|
57
|
+
if (isProduction) throw new PreviewInProductionError()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function signWithWebCrypto(params: { rawBody: string; appSecret: string }): Promise<string> {
|
|
61
|
+
const encoder = new TextEncoder()
|
|
62
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
63
|
+
'raw',
|
|
64
|
+
encoder.encode(params.appSecret),
|
|
65
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
66
|
+
false,
|
|
67
|
+
['sign'],
|
|
68
|
+
)
|
|
69
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(params.rawBody))
|
|
70
|
+
|
|
71
|
+
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
75
|
+
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
76
|
+
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
77
|
+
// validação, porque o HMAC cobre os bytes exatos.
|
|
78
|
+
const rawBody = serializeWebhookPayload(payload)
|
|
79
|
+
const signature = await signWithWebCrypto({ rawBody, appSecret: params.appSecret })
|
|
80
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
81
|
+
|
|
82
|
+
const response = await performRequest(params.webhookUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
|
|
85
|
+
body: rawBody,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
|
|
95
|
+
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
96
|
+
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
97
|
+
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export separado (`@adatechnology/conversations-ui/preview`) para que fixtures e mocks nunca
|
|
3
|
+
* entrem no bundle de produção de quem consome o pacote.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createPreviewStore, conversationChannel, GLOBAL_CHANNEL } from './previewStore'
|
|
7
|
+
export type {
|
|
8
|
+
PreviewStore,
|
|
9
|
+
CreatePreviewStoreParams,
|
|
10
|
+
PreviewEmission,
|
|
11
|
+
PreviewStoreListener,
|
|
12
|
+
AppendMessageParams,
|
|
13
|
+
SetModeParams,
|
|
14
|
+
ListConversationsFilters,
|
|
15
|
+
} from './previewStore'
|
|
16
|
+
|
|
17
|
+
export { createMockEventSource } from './mockEventSource'
|
|
18
|
+
export type { MockEventSource } from './mockEventSource'
|
|
19
|
+
|
|
20
|
+
export { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
export type { CreateMockConversationsApiParams } from './createMockConversationsApi'
|
|
22
|
+
|
|
23
|
+
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
|
+
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
|
+
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
27
|
+
|
|
28
|
+
export { ConversationPreview } from './ConversationPreview'
|
|
29
|
+
export type { ConversationPreviewProps } from './ConversationPreview'
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
createPreviewWebhookClient,
|
|
33
|
+
assertPreviewEnvironment,
|
|
34
|
+
PreviewInProductionError,
|
|
35
|
+
PreviewWebhookRejectedError,
|
|
36
|
+
} from './createPreviewWebhookClient'
|
|
37
|
+
export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './createPreviewWebhookClient'
|
|
38
|
+
|
|
39
|
+
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
40
|
+
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EventSource` só existe com um servidor HTTP do outro lado — é justamente o que falta quando se
|
|
3
|
+
* quer a inbox rodando com dados mockados. Este é o objeto mínimo que satisfaz
|
|
4
|
+
* `ConversationEventSource`: assina eventos nomeados, desassina e fecha.
|
|
5
|
+
*
|
|
6
|
+
* Não imita `EventSource` por completo de propósito: um fake "quase real" convida a depender de
|
|
7
|
+
* membros que o pacote não usa, e passa a quebrar a cada mudança de runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConversationEventSource } from '../providers/types'
|
|
11
|
+
|
|
12
|
+
export type MockEventSource = ConversationEventSource & {
|
|
13
|
+
emit(event: string, payload: unknown): void
|
|
14
|
+
readonly closed: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMockEventSource(): MockEventSource {
|
|
18
|
+
const listeners = new Map<string, Set<(event: MessageEvent) => void>>()
|
|
19
|
+
let closed = false
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
get closed(): boolean {
|
|
23
|
+
return closed
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
|
27
|
+
const typeListeners = listeners.get(type) ?? new Set<(event: MessageEvent) => void>()
|
|
28
|
+
typeListeners.add(listener)
|
|
29
|
+
listeners.set(type, typeListeners)
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
|
33
|
+
listeners.get(type)?.delete(listener)
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
close(): void {
|
|
37
|
+
closed = true
|
|
38
|
+
listeners.clear()
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
emit(event: string, payload: unknown): void {
|
|
42
|
+
// Depois de fechado, emitir é no-op: o servidor real também não entrega em socket fechado, e
|
|
43
|
+
// silenciar aqui é o que faz vazamento de listener aparecer como bug de cleanup, não como
|
|
44
|
+
// evento fantasma na UI.
|
|
45
|
+
if (closed) return
|
|
46
|
+
|
|
47
|
+
// `data` é string no fio; entregar o objeto já parseado esconderia erro de serialização que
|
|
48
|
+
// aparece em produção.
|
|
49
|
+
const messageEvent = new MessageEvent(event, { data: JSON.stringify(payload) })
|
|
50
|
+
for (const listener of listeners.get(event) ?? []) listener(messageEvent)
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
3
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
4
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
5
|
+
import { createPreviewStore, type PreviewStore } from './previewStore'
|
|
6
|
+
import { DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
7
|
+
|
|
8
|
+
function createStore(): PreviewStore {
|
|
9
|
+
return createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const WAITING_CONVERSATION_ID = '5511977776666'
|
|
13
|
+
const BOT_CONVERSATION_ID = '5511988887777'
|
|
14
|
+
|
|
15
|
+
describe('previewStore', () => {
|
|
16
|
+
it('filtra a fila de espera humana', () => {
|
|
17
|
+
const store = createStore()
|
|
18
|
+
|
|
19
|
+
const waiting = store.listConversations({ waitingHuman: true })
|
|
20
|
+
|
|
21
|
+
expect(waiting.map((conversation) => conversation.id)).toEqual([WAITING_CONVERSATION_ID])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('busca por nome e por número', () => {
|
|
25
|
+
const store = createStore()
|
|
26
|
+
|
|
27
|
+
expect(store.listConversations({ search: 'marina' })).toHaveLength(1)
|
|
28
|
+
expect(store.listConversations({ search: '9777' })).toHaveLength(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('assumir a conversa apaga a espera por humano', () => {
|
|
32
|
+
const store = createStore()
|
|
33
|
+
|
|
34
|
+
store.setMode({ conversationId: WAITING_CONVERSATION_ID, mode: 'human', assignedUserId: 'agent-2' })
|
|
35
|
+
const conversation = store.listConversations().find((item) => item.id === WAITING_CONVERSATION_ID)
|
|
36
|
+
|
|
37
|
+
expect(conversation?.mode).toBe('human')
|
|
38
|
+
expect(conversation?.assignedUserId).toBe('agent-2')
|
|
39
|
+
expect(conversation?.waitingHuman).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('mensagem recebida incrementa não-lidas e markRead zera', () => {
|
|
43
|
+
const store = createStore()
|
|
44
|
+
const before = store.listConversations().find((item) => item.id === BOT_CONVERSATION_ID)?.unread ?? 0
|
|
45
|
+
|
|
46
|
+
store.appendMessage({
|
|
47
|
+
conversationId: BOT_CONVERSATION_ID,
|
|
48
|
+
content: 'e o troco?',
|
|
49
|
+
direction: 'inbound',
|
|
50
|
+
sender: 'customer',
|
|
51
|
+
})
|
|
52
|
+
const after = store.listConversations().find((item) => item.id === BOT_CONVERSATION_ID)?.unread
|
|
53
|
+
|
|
54
|
+
expect(after).toBe(before + 1)
|
|
55
|
+
|
|
56
|
+
store.markRead(BOT_CONVERSATION_ID)
|
|
57
|
+
expect(store.listConversations().find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('createMockSSEProvider', () => {
|
|
62
|
+
// Paridade com o servidor: o evento `message` é ping, não entrega de dados. Se este teste
|
|
63
|
+
// começar a exigir `content`, o mock passou a mentir sobre o que a produção envia.
|
|
64
|
+
it('entrega evento message como ping, sem conteúdo, com data serializada', () => {
|
|
65
|
+
const store = createStore()
|
|
66
|
+
const sse = createMockSSEProvider({ store })
|
|
67
|
+
const source = sse.connectConversationStream(BOT_CONVERSATION_ID)
|
|
68
|
+
const received: string[] = []
|
|
69
|
+
|
|
70
|
+
source.addEventListener('message', (event) => received.push(event.data as string))
|
|
71
|
+
store.appendMessage({
|
|
72
|
+
conversationId: BOT_CONVERSATION_ID,
|
|
73
|
+
content: 'oi',
|
|
74
|
+
direction: 'inbound',
|
|
75
|
+
sender: 'customer',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(received).toHaveLength(1)
|
|
79
|
+
expect(JSON.parse(received[0] ?? '{}')).toEqual({ direction: 'inbound', sender: 'customer' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('emite mode-changed e data-changed nos canais certos', () => {
|
|
83
|
+
const store = createStore()
|
|
84
|
+
const sse = createMockSSEProvider({ store })
|
|
85
|
+
const conversationEvents: string[] = []
|
|
86
|
+
const globalEvents: string[] = []
|
|
87
|
+
|
|
88
|
+
sse
|
|
89
|
+
.connectConversationStream(WAITING_CONVERSATION_ID)
|
|
90
|
+
.addEventListener('mode-changed', () => conversationEvents.push('mode-changed'))
|
|
91
|
+
sse.connectGlobalStream().addEventListener('data-changed', () => globalEvents.push('data-changed'))
|
|
92
|
+
|
|
93
|
+
store.setMode({ conversationId: WAITING_CONVERSATION_ID, mode: 'human', assignedUserId: 'agent-2' })
|
|
94
|
+
|
|
95
|
+
expect(conversationEvents).toEqual(['mode-changed'])
|
|
96
|
+
expect(globalEvents).toEqual(['data-changed'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// A regressão que isto tranca: sem desassinar do store no close, cada remontagem de componente
|
|
100
|
+
// deixaria um listener preso e a mesma mensagem chegaria duplicada na UI.
|
|
101
|
+
it('desassina do store ao fechar o stream', () => {
|
|
102
|
+
const store = createStore()
|
|
103
|
+
const sse = createMockSSEProvider({ store })
|
|
104
|
+
const source = sse.connectConversationStream(BOT_CONVERSATION_ID)
|
|
105
|
+
const received: string[] = []
|
|
106
|
+
|
|
107
|
+
source.addEventListener('message', (event) => received.push(event.data as string))
|
|
108
|
+
source.close()
|
|
109
|
+
store.appendMessage({
|
|
110
|
+
conversationId: BOT_CONVERSATION_ID,
|
|
111
|
+
content: 'oi',
|
|
112
|
+
direction: 'inbound',
|
|
113
|
+
sender: 'customer',
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(received).toHaveLength(0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('createMockConversationsApi', () => {
|
|
121
|
+
// Modela o fluxo real: o ping avisa, o refetch traz o dado. É o compartilhamento de store que
|
|
122
|
+
// faz os dois concordarem — com estados separados, o ping anunciaria algo que a query não vê.
|
|
123
|
+
it('ping do SSE e refetch da API contam a mesma história', async () => {
|
|
124
|
+
const store = createStore()
|
|
125
|
+
const api = createMockConversationsApi({ store, latencyMs: 0 })
|
|
126
|
+
const sse = createMockSSEProvider({ store })
|
|
127
|
+
const pings: unknown[] = []
|
|
128
|
+
|
|
129
|
+
sse.connectConversationStream(BOT_CONVERSATION_ID).addEventListener('message', (event) => {
|
|
130
|
+
pings.push(JSON.parse(event.data as string))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
await api.sendMessage(BOT_CONVERSATION_ID, 'já separei aqui')
|
|
134
|
+
const messages = await api.fetchMessages(BOT_CONVERSATION_ID)
|
|
135
|
+
|
|
136
|
+
expect(pings).toEqual([{ direction: 'outbound', sender: 'agent' }])
|
|
137
|
+
expect(messages.at(-1)?.content).toBe('já separei aqui')
|
|
138
|
+
expect(messages.at(-1)?.sender).toBe('agent')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('pagina e filtra pela mesma regra do store', async () => {
|
|
142
|
+
const store = createStore()
|
|
143
|
+
const api = createMockConversationsApi({ store, latencyMs: 0 })
|
|
144
|
+
|
|
145
|
+
const firstPage = await api.fetchConversations({ page: 1, limit: 2 })
|
|
146
|
+
const waiting = await api.fetchConversations({ waitingHuman: true })
|
|
147
|
+
|
|
148
|
+
expect(firstPage).toHaveLength(2)
|
|
149
|
+
expect(waiting.every((conversation) => conversation.waitingHuman)).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('markRead zera as não-lidas vistas pela lista', async () => {
|
|
153
|
+
const store = createStore()
|
|
154
|
+
const api = createMockConversationsApi({ store, latencyMs: 0 })
|
|
155
|
+
|
|
156
|
+
await api.markRead(BOT_CONVERSATION_ID)
|
|
157
|
+
const conversations = await api.fetchConversations()
|
|
158
|
+
|
|
159
|
+
expect(conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('roteiro padrão', () => {
|
|
164
|
+
// O roteiro é o que faz a inbox deixar de ser tela estática; se algum passo apontar para uma
|
|
165
|
+
// conversa que não existe nas fixtures, ele passa a ser no-op silencioso.
|
|
166
|
+
it('todos os passos alteram o estado das conversas das fixtures', () => {
|
|
167
|
+
const store = createStore()
|
|
168
|
+
const before = JSON.stringify(store.listConversations())
|
|
169
|
+
|
|
170
|
+
for (const step of DEFAULT_PREVIEW_SCRIPT) step(store)
|
|
171
|
+
|
|
172
|
+
expect(JSON.stringify(store.listConversations())).not.toBe(before)
|
|
173
|
+
expect(store.listMessages(WAITING_CONVERSATION_ID).at(-1)?.sender).toBe('agent')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversas de partida do preview. Cobrem o espaço de estados que a inbox precisa saber desenhar —
|
|
3
|
+
* bot em atendimento, cliente esperando humano, conversa já assumida por atendente, conversa com
|
|
4
|
+
* áudio — porque estado que não aparece em fixture é estado que ninguém testa.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MessagePayload } from '../types'
|
|
8
|
+
import type { ConversationSummary } from '../providers/types'
|
|
9
|
+
|
|
10
|
+
// Datas fixas: fixture com data relativa ao relógio faz o mesmo cenário renderizar diferente a
|
|
11
|
+
// cada execução, e separadores de dia deixam de ser verificáveis.
|
|
12
|
+
const BASE_DAY = '2026-07-26'
|
|
13
|
+
|
|
14
|
+
function at(time: string): string {
|
|
15
|
+
return `${BASE_DAY}T${time}.000Z`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PREVIEW_CONVERSATIONS: readonly ConversationSummary[] = [
|
|
19
|
+
{
|
|
20
|
+
id: '5511988887777',
|
|
21
|
+
whatsappNumber: '5511988887777',
|
|
22
|
+
clientName: 'Marina Alves',
|
|
23
|
+
lastContent: 'quero 2kg de arroz e um óleo',
|
|
24
|
+
lastDirection: 'inbound',
|
|
25
|
+
lastAt: at('14:32:00'),
|
|
26
|
+
lastInboundAt: at('14:32:00'),
|
|
27
|
+
mode: 'bot',
|
|
28
|
+
assignedUserId: null,
|
|
29
|
+
waitingHuman: false,
|
|
30
|
+
unread: 2,
|
|
31
|
+
currentState: 'list_review',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: '5511977776666',
|
|
35
|
+
whatsappNumber: '5511977776666',
|
|
36
|
+
clientName: 'Diego Prado',
|
|
37
|
+
lastContent: 'preciso falar com alguém',
|
|
38
|
+
lastDirection: 'inbound',
|
|
39
|
+
lastAt: at('14:20:00'),
|
|
40
|
+
lastInboundAt: at('14:20:00'),
|
|
41
|
+
mode: 'bot',
|
|
42
|
+
assignedUserId: null,
|
|
43
|
+
waitingHuman: true,
|
|
44
|
+
unread: 1,
|
|
45
|
+
currentState: 'awaiting_human',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: '5511966665555',
|
|
49
|
+
whatsappNumber: '5511966665555',
|
|
50
|
+
clientName: 'Sofia Nakamura',
|
|
51
|
+
lastContent: 'já separei seu pedido, confere?',
|
|
52
|
+
lastDirection: 'outbound',
|
|
53
|
+
lastAt: at('13:58:00'),
|
|
54
|
+
lastInboundAt: at('13:50:00'),
|
|
55
|
+
mode: 'human',
|
|
56
|
+
assignedUserId: 'agent-1',
|
|
57
|
+
waitingHuman: false,
|
|
58
|
+
unread: 0,
|
|
59
|
+
currentState: 'human_handling',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: '5511955554444',
|
|
63
|
+
whatsappNumber: '5511955554444',
|
|
64
|
+
lastContent: 'Áudio',
|
|
65
|
+
lastDirection: 'inbound',
|
|
66
|
+
lastAt: at('13:31:00'),
|
|
67
|
+
lastInboundAt: at('13:31:00'),
|
|
68
|
+
mode: 'bot',
|
|
69
|
+
assignedUserId: null,
|
|
70
|
+
waitingHuman: false,
|
|
71
|
+
unread: 1,
|
|
72
|
+
currentState: 'list_import',
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
export const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>> = {
|
|
77
|
+
'5511988887777': [
|
|
78
|
+
{
|
|
79
|
+
id: 'fixture-1',
|
|
80
|
+
type: 'text',
|
|
81
|
+
content: 'oi, boa tarde',
|
|
82
|
+
direction: 'inbound',
|
|
83
|
+
sender: 'customer',
|
|
84
|
+
timestamp: at('14:30:00'),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'fixture-2',
|
|
88
|
+
type: 'text',
|
|
89
|
+
content: 'Boa tarde! Me manda sua lista de compras que eu monto o carrinho.',
|
|
90
|
+
direction: 'outbound',
|
|
91
|
+
sender: 'bot',
|
|
92
|
+
timestamp: at('14:30:30'),
|
|
93
|
+
status: 'read',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'fixture-3',
|
|
97
|
+
type: 'text',
|
|
98
|
+
content: 'quero 2kg de arroz e um óleo',
|
|
99
|
+
direction: 'inbound',
|
|
100
|
+
sender: 'customer',
|
|
101
|
+
timestamp: at('14:32:00'),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
'5511977776666': [
|
|
105
|
+
{
|
|
106
|
+
id: 'fixture-4',
|
|
107
|
+
type: 'text',
|
|
108
|
+
content: 'esse valor do frete está certo?',
|
|
109
|
+
direction: 'inbound',
|
|
110
|
+
sender: 'customer',
|
|
111
|
+
timestamp: at('14:19:00'),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'fixture-5',
|
|
115
|
+
type: 'text',
|
|
116
|
+
content: 'preciso falar com alguém',
|
|
117
|
+
direction: 'inbound',
|
|
118
|
+
sender: 'customer',
|
|
119
|
+
timestamp: at('14:20:00'),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
'5511966665555': [
|
|
123
|
+
{
|
|
124
|
+
id: 'fixture-6',
|
|
125
|
+
type: 'text',
|
|
126
|
+
content: 'consegue trocar o leite integral por desnatado?',
|
|
127
|
+
direction: 'inbound',
|
|
128
|
+
sender: 'customer',
|
|
129
|
+
timestamp: at('13:50:00'),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'fixture-7',
|
|
133
|
+
type: 'text',
|
|
134
|
+
content: 'já separei seu pedido, confere?',
|
|
135
|
+
direction: 'outbound',
|
|
136
|
+
sender: 'agent',
|
|
137
|
+
timestamp: at('13:58:00'),
|
|
138
|
+
status: 'delivered',
|
|
139
|
+
agentName: 'Ana',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
'5511955554444': [
|
|
143
|
+
{
|
|
144
|
+
id: 'fixture-8',
|
|
145
|
+
type: 'audio',
|
|
146
|
+
mediaId: 'preview-audio-1',
|
|
147
|
+
mimeType: 'audio/ogg',
|
|
148
|
+
direction: 'inbound',
|
|
149
|
+
sender: 'customer',
|
|
150
|
+
timestamp: at('13:31:00'),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}
|