@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.20

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.
Files changed (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,131 @@
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
+ * ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
10
+ * seja servido — em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
11
+ * equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
12
+ * mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
13
+ * homologação passaria, então a barreira não basta.
14
+ *
15
+ * Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
16
+ * o servidor assina com o segredo que ele já tem.
17
+ */
18
+
19
+ import {
20
+ buildInboundAudioPayload,
21
+ buildInboundInteractivePayload,
22
+ buildInboundMediaPayload,
23
+ buildInboundTextPayload,
24
+ serializeWebhookPayload,
25
+ type InboundMediaType,
26
+ type InteractiveReplyOption,
27
+ } from '@adatechnology/meta-whatsapp-contracts/testing'
28
+
29
+ export type PreviewWebhookClient = {
30
+ sendText(text: string): Promise<void>
31
+ sendButtonReply(reply: InteractiveReplyOption): Promise<void>
32
+ sendListReply(reply: InteractiveReplyOption): Promise<void>
33
+ sendAudio(mediaId: string): Promise<void>
34
+ sendMedia(params: SendPreviewMediaParams): Promise<void>
35
+ }
36
+
37
+ export type SendPreviewMediaParams = {
38
+ readonly mediaType: InboundMediaType
39
+ /**
40
+ * Id que o host já usa para buscar o arquivo. Não é bytes: o webhook da Meta entrega mídia por
41
+ * referência, e o consumidor baixa depois — mandar base64 aqui simularia um payload que a Meta
42
+ * nunca produz, e o caminho testado deixaria de ser o de produção.
43
+ */
44
+ readonly mediaId: string
45
+ readonly mimeType?: string
46
+ readonly filename?: string
47
+ readonly caption?: string
48
+ }
49
+
50
+ export type CreatePreviewWebhookClientParams = {
51
+ readonly webhookUrl: string
52
+ readonly appSecret: string
53
+ readonly from: string
54
+ readonly phoneNumberId?: string
55
+ // Escape hatch para teste; em runtime real é sempre o fetch global.
56
+ readonly fetchImplementation?: typeof fetch
57
+ }
58
+
59
+ export class PreviewInProductionError extends Error {
60
+ constructor() {
61
+ super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
62
+ this.name = 'PreviewInProductionError'
63
+ }
64
+ }
65
+
66
+ export class PreviewWebhookRejectedError extends Error {
67
+ constructor(readonly status: number) {
68
+ super(`O webhook recusou a entrega do preview (HTTP ${status}).`)
69
+ this.name = 'PreviewWebhookRejectedError'
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Falha alto em vez de degradar em silêncio: um preview que "quase funciona" em produção é pior
75
+ * que um que se recusa a montar.
76
+ */
77
+ export function assertPreviewEnvironment(isProduction: boolean): void {
78
+ if (isProduction) throw new PreviewInProductionError()
79
+ }
80
+
81
+ /**
82
+ * Assina um texto qualquer com o app secret, no mesmo formato do header da Meta.
83
+ *
84
+ * Exportada porque o preview precisa provar identidade em MAIS de um lugar: além de entregar a
85
+ * mensagem no webhook, ele lê o transcript de volta — e ler pela API de admin exigia uma sessão que
86
+ * a aba do simulador não tem. Assinar a leitura com o segredo que ele já carrega resolve sem token
87
+ * de admin e sem rota aberta.
88
+ */
89
+ export async function signPreviewPayload(params: { rawBody: string; appSecret: string }): Promise<string> {
90
+ const encoder = new TextEncoder()
91
+ const key = await globalThis.crypto.subtle.importKey(
92
+ 'raw',
93
+ encoder.encode(params.appSecret),
94
+ { name: 'HMAC', hash: 'SHA-256' },
95
+ false,
96
+ ['sign'],
97
+ )
98
+ const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(params.rawBody))
99
+
100
+ return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
101
+ }
102
+
103
+ const signWithWebCrypto = signPreviewPayload
104
+
105
+ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
106
+ const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
107
+ // Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
108
+ // validação, porque o HMAC cobre os bytes exatos.
109
+ const rawBody = serializeWebhookPayload(payload)
110
+ const signature = await signWithWebCrypto({ rawBody, appSecret: params.appSecret })
111
+ const performRequest = params.fetchImplementation ?? fetch
112
+
113
+ const response = await performRequest(params.webhookUrl, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
116
+ body: rawBody,
117
+ })
118
+
119
+ if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
120
+ }
121
+
122
+ const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
123
+
124
+ return {
125
+ sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
126
+ sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
127
+ sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
128
+ sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
129
+ sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
130
+ }
131
+ }
@@ -0,0 +1,61 @@
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, PREVIEW_DOCUMENTS } from './previewFixtures'
27
+
28
+ export { ConversationPreview } from './ConversationPreview'
29
+ export { mediaTypeOf } from './ConversationPreview'
30
+ export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
31
+
32
+ export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
33
+ export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
34
+
35
+ export {
36
+ createPreviewWebhookClient,
37
+ signPreviewPayload,
38
+ assertPreviewEnvironment,
39
+ PreviewInProductionError,
40
+ PreviewWebhookRejectedError,
41
+ } from './createPreviewWebhookClient'
42
+ export type {
43
+ PreviewWebhookClient,
44
+ CreatePreviewWebhookClientParams,
45
+ SendPreviewMediaParams,
46
+ } from './createPreviewWebhookClient'
47
+
48
+ export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
49
+ export type {
50
+ CreatePreviewBridgeClientParams,
51
+ PreviewInboundCommand,
52
+ SendPreviewInboundCommand,
53
+ } from './createPreviewBridgeClient'
54
+
55
+ export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
56
+ export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
57
+ export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
58
+ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
59
+ export { previewFileBase64 } from './previewMediaSource'
60
+ export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
61
+ export type { MediaTypesPreviewProps } from './MediaTypesPreview'
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { mediaTypeOf } from './ConversationPreview'
3
+
4
+ describe('mediaTypeOf', () => {
5
+ it('deriva imagem, vídeo e áudio pelo prefixo do MIME', () => {
6
+ expect(mediaTypeOf('image/jpeg')).toBe('image')
7
+ expect(mediaTypeOf('video/mp4')).toBe('video')
8
+ expect(mediaTypeOf('audio/webm')).toBe('audio')
9
+ })
10
+
11
+ it('trata o resto como documento, inclusive MIME desconhecido', () => {
12
+ expect(mediaTypeOf('application/pdf')).toBe('document')
13
+ expect(mediaTypeOf('')).toBe('document')
14
+ })
15
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Guarda a busca da biblioteca no mock — que é a implementação de referência da UI do SDK e tem de
3
+ * casar com o `companyDocumentSearch` do backend, senão o preview ensina um comportamento que a
4
+ * tela real não entrega.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+
9
+ import { createMockConversationsApi } from './createMockConversationsApi'
10
+ import { createPreviewStore } from './previewStore'
11
+ import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
12
+
13
+ function buildApi() {
14
+ const store = createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
15
+ return createMockConversationsApi({ store })
16
+ }
17
+
18
+ async function search(term: string): Promise<{ total: number; conversations: string[] }> {
19
+ const page = await buildApi().getAllDocuments!({ search: term })
20
+ return { total: page.total, conversations: [...new Set(page.documents.map((document) => document.conversationId))] }
21
+ }
22
+
23
+ describe('busca da biblioteca no mock', () => {
24
+ it('acha por nome do arquivo', async () => {
25
+ const result = await search('contrato')
26
+
27
+ expect(result.total).toBe(1)
28
+ })
29
+
30
+ it('acha por telefone da conversa', async () => {
31
+ const porNumero = await search('94444')
32
+
33
+ expect(porNumero.conversations).toEqual(['5511944443333'])
34
+ expect(porNumero.total).toBeGreaterThan(0)
35
+ // Discrimina de verdade: 98888 é conversa que existe nas fixtures, mas sem arquivo — se o
36
+ // predicado do telefone não estivesse valendo, esta busca devolveria a biblioteca inteira.
37
+ expect((await search('98888')).total).toBe(0)
38
+ })
39
+
40
+ // O caso real: o atendente copia o número como a tela o mostra.
41
+ it('acha com o telefone formatado, como aparece na tela', async () => {
42
+ const cru = await search('5511944443333')
43
+ const formatado = await search('+55 (11) 94444-3333')
44
+
45
+ expect(formatado.total).toBe(cru.total)
46
+ expect(formatado.total).toBeGreaterThan(0)
47
+ })
48
+
49
+ it('não devolve nada para número de outra conversa', async () => {
50
+ expect((await search('99999999999')).total).toBe(0)
51
+ })
52
+
53
+ // Sem dígito, o predicado do telefone não existe — senão qualquer palavra casaria toda conversa.
54
+ it('termo sem dígito filtra só por nome', async () => {
55
+ expect((await search('zzz-inexistente')).total).toBe(0)
56
+ })
57
+ })
@@ -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,177 @@
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.conversations).toHaveLength(2)
149
+ // O total conta o conjunto filtrado inteiro, não a fatia — é o que a paginação da UI lê.
150
+ expect(firstPage.total).toBeGreaterThan(2)
151
+ expect(waiting.conversations.every((conversation) => conversation.waitingHuman)).toBe(true)
152
+ })
153
+
154
+ it('markRead zera as não-lidas vistas pela lista', async () => {
155
+ const store = createStore()
156
+ const api = createMockConversationsApi({ store, latencyMs: 0 })
157
+
158
+ await api.markRead(BOT_CONVERSATION_ID)
159
+ const conversations = await api.fetchConversations()
160
+
161
+ expect(conversations.conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
162
+ })
163
+ })
164
+
165
+ describe('roteiro padrão', () => {
166
+ // O roteiro é o que faz a inbox deixar de ser tela estática; se algum passo apontar para uma
167
+ // conversa que não existe nas fixtures, ele passa a ser no-op silencioso.
168
+ it('todos os passos alteram o estado das conversas das fixtures', () => {
169
+ const store = createStore()
170
+ const before = JSON.stringify(store.listConversations())
171
+
172
+ for (const step of DEFAULT_PREVIEW_SCRIPT) step(store)
173
+
174
+ expect(JSON.stringify(store.listConversations())).not.toBe(before)
175
+ expect(store.listMessages(WAITING_CONVERSATION_ID).at(-1)?.sender).toBe('agent')
176
+ })
177
+ })
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Guarda que o preview entrega arquivo do tipo que promete.
3
+ *
4
+ * O defeito que motivou o arquivo: `getDocumentUrl` devolvia sempre a mesma imagem PNG, então
5
+ * "visualizar" num PDF abria um PNG rotulado `application/pdf` e o leitor dizia que o arquivo era
6
+ * inválido. Aqui os bytes são decodificados e conferidos pela assinatura de cada formato — teste que
7
+ * só olhasse o prefixo da data URL passaria com o bug de volta.
8
+ */
9
+
10
+ import { describe, expect, it } from 'bun:test'
11
+
12
+ import { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
13
+ import { PREVIEW_DOCUMENTS } from './previewFixtures'
14
+
15
+ function decode(dataUrl: string): { mimeType: string; bytes: Uint8Array } {
16
+ const [head, payload] = dataUrl.split(',')
17
+ const mimeType = head!.replace(/^data:/, '').replace(/;base64$/, '')
18
+ if (!head!.endsWith(';base64')) {
19
+ return { mimeType, bytes: new TextEncoder().encode(decodeURIComponent(payload!)) }
20
+ }
21
+ return { mimeType, bytes: Uint8Array.from(atob(payload!), (char) => char.charCodeAt(0)) }
22
+ }
23
+
24
+ function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
25
+ return signature.every((byte, index) => bytes[offset + index] === byte)
26
+ }
27
+
28
+ const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
29
+
30
+ // Assinatura real de cada formato, não o rótulo do mimeType.
31
+ const SIGNATURES: Record<string, (bytes: Uint8Array) => boolean> = {
32
+ 'application/pdf': (bytes) => startsWith(bytes, ascii('%PDF-')),
33
+ 'image/png': (bytes) => startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]),
34
+ 'image/jpeg': (bytes) => startsWith(bytes, [0xff, 0xd8, 0xff]),
35
+ 'image/webp': (bytes) => startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8),
36
+ 'video/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
37
+ 'audio/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
38
+ // ID3 quando há tag, ou o sync do primeiro frame MPEG.
39
+ 'audio/mpeg': (bytes) => startsWith(bytes, ascii('ID3')) || (bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0),
40
+ 'audio/ogg': (bytes) => startsWith(bytes, ascii('OggS')),
41
+ 'audio/aac': (bytes) => bytes[0] === 0xff && (bytes[1]! & 0xf0) === 0xf0,
42
+ 'text/plain': (bytes) => bytes.length > 0,
43
+ 'text/csv': (bytes) => bytes.length > 0,
44
+ // Todo pacote Office é zip: assinatura PK\x03\x04.
45
+ 'application/zip': (bytes) => startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
46
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (bytes) =>
47
+ startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
48
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': (bytes) =>
49
+ startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
50
+ }
51
+
52
+ describe('amostras de arquivo do preview', () => {
53
+ it('toda amostra declara o mimeType que anuncia', () => {
54
+ for (const [mimeType, dataUrl] of Object.entries(PREVIEW_FILE_SAMPLES)) {
55
+ expect(decode(dataUrl).mimeType).toBe(mimeType)
56
+ }
57
+ })
58
+
59
+ for (const [mimeType, isValid] of Object.entries(SIGNATURES)) {
60
+ it(`os bytes de ${mimeType} são realmente desse formato`, () => {
61
+ const sample = PREVIEW_FILE_SAMPLES[mimeType]
62
+ expect(sample).toBeDefined()
63
+ expect(isValid(decode(sample!).bytes)).toBe(true)
64
+ })
65
+ }
66
+
67
+ // O PDF sem xref/startxref é o caso que alguns leitores recusam de cara.
68
+ it('o PDF tem tabela xref e termina em %%EOF', () => {
69
+ const text = new TextDecoder().decode(decode(PREVIEW_FILE_SAMPLES['application/pdf']!).bytes)
70
+
71
+ expect(text).toContain('\nxref\n')
72
+ expect(text).toContain('startxref')
73
+ expect(text.trimEnd().endsWith('%%EOF')).toBe(true)
74
+ })
75
+ })
76
+
77
+ describe('resolvePreviewFileSample', () => {
78
+ it('descarta parâmetro do mimeType — áudio de WhatsApp chega com codecs', () => {
79
+ expect(resolvePreviewFileSample('audio/ogg; codecs=opus')).toBe(PREVIEW_FILE_SAMPLES['audio/ogg'])
80
+ })
81
+
82
+ // Tipos aceitos pela Meta sem amostra local (sem encoder amr/3gp nesta máquina): tocam pela
83
+ // família, em vez de devolver bytes de outro formato.
84
+ it('cai na família quando não há amostra do tipo exato', () => {
85
+ expect(resolvePreviewFileSample('audio/amr')).toBe(PREVIEW_FILE_SAMPLES['audio/mpeg'])
86
+ expect(resolvePreviewFileSample('video/3gp')).toBe(PREVIEW_FILE_SAMPLES['video/mp4'])
87
+ expect(resolvePreviewFileSample('image/heic')).toBe(PREVIEW_FILE_SAMPLES['image/png'])
88
+ })
89
+
90
+ // Office legado é OLE2, não zip: servir o docx aqui daria arquivo corrompido no Word. Melhor um
91
+ // texto que abre e se explica.
92
+ it('explica em texto quando não há como forjar o binário', () => {
93
+ const resolved = resolvePreviewFileSample('application/msword', 'procuracao.doc')
94
+
95
+ expect(resolved.startsWith('data:text/plain')).toBe(true)
96
+ expect(decodeURIComponent(resolved)).toContain('procuracao.doc')
97
+ })
98
+
99
+ it('não devolve vazio para tipo desconhecido', () => {
100
+ expect(resolvePreviewFileSample(undefined).length).toBeGreaterThan(0)
101
+ expect(resolvePreviewFileSample('application/octet-stream').length).toBeGreaterThan(0)
102
+ })
103
+ })
104
+
105
+ describe('biblioteca do preview', () => {
106
+ // A infidelidade que existia: a biblioteca listava só `document`, enquanto o backend linka as
107
+ // cinco espécies de mídia. Foto, vídeo, áudio e sticker apareciam na tela real e não no preview.
108
+ it('lista foto, vídeo, áudio e sticker junto dos documentos', () => {
109
+ const mimeTypes = (PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType)
110
+
111
+ expect(mimeTypes.some((mimeType) => mimeType.startsWith('image/'))).toBe(true)
112
+ expect(mimeTypes.some((mimeType) => mimeType.startsWith('video/'))).toBe(true)
113
+ expect(mimeTypes.some((mimeType) => mimeType.startsWith('audio/'))).toBe(true)
114
+ expect(mimeTypes).toContain('image/webp')
115
+ })
116
+
117
+ // Cada tipo que a Cloud API da Meta aceita para envio de mídia.
118
+ const ACCEPTED_BY_META = [
119
+ 'image/jpeg',
120
+ 'image/png',
121
+ 'image/webp',
122
+ 'video/mp4',
123
+ 'video/3gp',
124
+ 'audio/aac',
125
+ 'audio/amr',
126
+ 'audio/mpeg',
127
+ 'audio/mp4',
128
+ 'audio/ogg',
129
+ 'application/pdf',
130
+ 'application/msword',
131
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
132
+ 'application/vnd.ms-excel',
133
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
134
+ 'application/vnd.ms-powerpoint',
135
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
136
+ 'text/plain',
137
+ ]
138
+
139
+ it('cobre todo tipo aceito pela Meta', () => {
140
+ const present = new Set((PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType))
141
+ const faltando = ACCEPTED_BY_META.filter((mimeType) => !present.has(mimeType))
142
+
143
+ expect(faltando).toEqual([])
144
+ })
145
+
146
+ it('todo item da biblioteca abre com bytes não vazios', () => {
147
+ for (const document of PREVIEW_DOCUMENTS['5511944443333'] ?? []) {
148
+ expect(decode(resolvePreviewFileSample(document.mimeType, document.filename)).bytes.length).toBeGreaterThan(0)
149
+ }
150
+ })
151
+ })