@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.6

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.
@@ -1,7 +1,29 @@
1
1
  import { useRef, type ReactNode } from 'react'
2
2
  import { Bold, Italic, Strikethrough } from 'lucide-react'
3
3
 
4
+ export interface WhatsAppMessageEditorLabels {
5
+ bold: string
6
+ /** Tooltip da negrito — traz a sintaxe do WhatsApp junto, por isso é separado do `aria-label`. */
7
+ boldHint: string
8
+ italic: string
9
+ italicHint: string
10
+ strikethrough: string
11
+ strikethroughHint: string
12
+ insertPlaceholder: (token: string) => string
13
+ }
14
+
15
+ export const DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS: WhatsAppMessageEditorLabels = {
16
+ bold: 'Negrito',
17
+ boldHint: 'Negrito (*texto*)',
18
+ italic: 'Itálico',
19
+ italicHint: 'Itálico (_texto_)',
20
+ strikethrough: 'Tachado',
21
+ strikethroughHint: 'Tachado (~texto~)',
22
+ insertPlaceholder: (token: string) => `Inserir ${token}`,
23
+ }
24
+
4
25
  export interface WhatsAppMessageEditorProps {
26
+ labels?: Partial<WhatsAppMessageEditorLabels>
5
27
  value: string
6
28
  onChange: (value: string) => void
7
29
  placeholder?: string
@@ -56,7 +78,9 @@ export function WhatsAppMessageEditor({
56
78
  rows = 4,
57
79
  previewLabel = 'Prévia (como aparece no WhatsApp)',
58
80
  emptyPreviewText = 'Sua mensagem aparecerá aqui…',
81
+ labels,
59
82
  }: WhatsAppMessageEditorProps) {
83
+ const editorLabels = { ...DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, ...labels }
60
84
  const textareaRef = useRef<HTMLTextAreaElement>(null)
61
85
 
62
86
  function wrapSelection(marker: string): void {
@@ -93,13 +117,13 @@ export function WhatsAppMessageEditor({
93
117
  return (
94
118
  <div>
95
119
  <div className="flex flex-wrap items-center gap-1.5 mb-2">
96
- <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title="Negrito (*texto*)" aria-label="Negrito">
120
+ <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title={editorLabels.boldHint} aria-label={editorLabels.bold}>
97
121
  <Bold size={15} />
98
122
  </button>
99
- <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title="Itálico (_texto_)" aria-label="Itálico">
123
+ <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title={editorLabels.italicHint} aria-label={editorLabels.italic}>
100
124
  <Italic size={15} />
101
125
  </button>
102
- <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title="Tachado (~texto~)" aria-label="Tachado">
126
+ <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
103
127
  <Strikethrough size={15} />
104
128
  </button>
105
129
  {placeholders.length > 0 && (
@@ -111,7 +135,7 @@ export function WhatsAppMessageEditor({
111
135
  type="button"
112
136
  onClick={() => insertAtCursor(token)}
113
137
  className="inline-flex items-center h-8 px-2 rounded-lg border border-gray-200 dark:border-gray-600 text-xs text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition-colors"
114
- title={`Inserir ${token}`}
138
+ title={editorLabels.insertPlaceholder(token)}
115
139
  >
116
140
  {token}
117
141
  </button>
package/src/index.ts CHANGED
@@ -3,16 +3,16 @@ export { ConversationWallpaper } from './Wallpaper'
3
3
  export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
4
4
  export { AudioPlayer } from './AudioPlayer'
5
5
  export { EmojiPicker } from './EmojiPicker'
6
- export { MessageComposer } from './MessageComposer'
7
- export { WhatsAppMessageEditor } from './WhatsAppMessageEditor'
6
+ export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS } from './MessageComposer'
7
+ export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
8
8
  export { SimpleEmojiPicker } from './SimpleEmojiPicker'
9
9
  export { DateDivider } from './DateDivider'
10
- export { Avatar } from './Avatar'
11
- export { ConversationListItem } from './ConversationListItem'
10
+ export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
11
+ export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
12
12
  export { ToastProvider, useToast, toast } from './Toast'
13
13
 
14
14
  export { StatusTicks } from './StatusTicks'
15
- export { Lightbox } from './Lightbox'
15
+ export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
16
16
  export { MediaRenderer } from './MediaRenderer'
17
17
  export { FileIcon } from './FileIcon'
18
18
  export { MessageText } from './MessageText'
@@ -65,6 +65,10 @@ export type {
65
65
  } from './ConversationContextPanel'
66
66
  export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS } from './WindowExpiredNotice'
67
67
  export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
68
+ export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
69
+ export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
70
+ export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
71
+ export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
68
72
  export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
69
73
  export { ConversationDocumentsPanel, DEFAULT_CONVERSATION_DOCUMENTS_LABELS } from './ConversationDocumentsPanel'
70
74
  export type { ConversationDocumentsPanelProps, ConversationDocumentsPanelLabels } from './ConversationDocumentsPanel'
@@ -118,6 +122,8 @@ export type {
118
122
  ConversationDocument,
119
123
  ConversationPage,
120
124
  ConversationDocumentPage,
125
+ CompanyDocument,
126
+ CompanyDocumentPage,
121
127
  ConversationTemplate,
122
128
  ListConversationsParams,
123
129
  ListDocumentsParams,
@@ -129,14 +135,14 @@ export type { ConversationWallpaperProps } from './Wallpaper'
129
135
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
130
136
  export type { AudioPlayerProps } from './AudioPlayer'
131
137
  export type { EmojiPickerProps } from './EmojiPicker'
132
- export type { MessageComposerProps, MessageComposerClassNames } from './MessageComposer'
133
- export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
138
+ export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
139
+ export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
134
140
  export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
135
141
  export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
136
- export type { AvatarProps } from './Avatar'
137
- export type { ConversationListItemProps } from './ConversationListItem'
142
+ export type { AvatarProps, AvatarLabels } from './Avatar'
143
+ export type { ConversationListItemProps, ConversationListItemLabels } from './ConversationListItem'
138
144
  export type { StatusTicksProps } from './StatusTicks'
139
- export type { LightboxProps } from './Lightbox'
145
+ export type { LightboxProps, LightboxLabels } from './Lightbox'
140
146
  export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
141
147
  export type { FileIconProps } from './FileIcon'
142
148
  export type { MessageTextProps } from './MessageText'
@@ -168,3 +174,4 @@ export type { UseConversationContextResult } from './hooks/useConversationContex
168
174
  export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
169
175
  export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
170
176
  export type { AsyncResourceState } from './hooks/useAsyncResource'
177
+ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve a mídia de uma mensagem numa URL exibível, usando só o `ConversationsApi`.
3
+ *
4
+ * Mora no pacote, e não em cada host, porque a regra não tem nada de específico de produto: é a
5
+ * tradução de `uploadId`/`mediaId` pelos dois métodos que o próprio contrato já declara. Deixá-la no
6
+ * host significava que todo projeto que adotasse o SDK reescreveria as mesmas oito linhas — e, na
7
+ * prática, ninguém escrevia: o `MediaRenderer` só busca mídia pela porta `onResolveMediaUrl`, então
8
+ * onde nada era injetado foto, vídeo e áudio ficavam no placeholder para sempre.
9
+ */
10
+
11
+ import type { MessagePayload } from '../types'
12
+ import type { ConversationsApi } from '../providers/types'
13
+ import type { ResolveMediaUrl } from '../MediaRenderer'
14
+
15
+ export function createMediaUrlResolver(
16
+ api: Pick<ConversationsApi, 'getDocumentUrl' | 'getMediaProxyUrl'>,
17
+ ): ResolveMediaUrl {
18
+ return async (message: MessagePayload): Promise<string | null> => {
19
+ // Mídia já copiada para o storage do host: sai por URL assinada e o binário não passa pela API.
20
+ // `inline` porque aqui o arquivo é para VER na tela — `attachment` faria o navegador baixar.
21
+ if (message.uploadId) return api.getDocumentUrl(message.uploadId, 'inline')
22
+
23
+ // Antes da ingestão só existe o id na Meta, cuja URL expira; o backend busca e devolve base64.
24
+ // Data URL serve de `src` para `<img>`/`<video>`/`<audio>`: o bloqueio do Chrome a `data:` vale
25
+ // para navegação de topo, não para carregar mídia dentro da página.
26
+ if (message.mediaId) {
27
+ const { mimeType, data } = await api.getMediaProxyUrl(message.mediaId)
28
+ return `data:${mimeType};base64,${data}`
29
+ }
30
+
31
+ return null
32
+ }
33
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
3
+ *
4
+ * Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
5
+ * do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
6
+ * sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
7
+ * superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
8
+ *
9
+ * Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
10
+ * `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
11
+ * `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
12
+ */
13
+
14
+ import { useMemo } from 'react'
15
+ import { ConversationsProvider } from '../providers/ConversationsProvider'
16
+ import { DocumentsLibrary } from '../DocumentsLibrary'
17
+ import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
18
+ import { MessageBubble } from '../MessageBubble'
19
+ import { ConversationWallpaper } from '../Wallpaper'
20
+ import { createMockConversationsApi } from './createMockConversationsApi'
21
+ import { createMockSSEProvider } from './createMockSSEProvider'
22
+ import { createPreviewStore } from './previewStore'
23
+ import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
24
+
25
+ /** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
26
+ export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
27
+
28
+ export type MediaTypesPreviewProps = {
29
+ /** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
30
+ conversationId?: string
31
+ className?: string
32
+ }
33
+
34
+ export function MediaTypesPreview({
35
+ conversationId = MEDIA_TYPES_CONVERSATION_ID,
36
+ className,
37
+ }: MediaTypesPreviewProps) {
38
+ const store = useMemo(
39
+ () => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
40
+ [],
41
+ )
42
+ const api = useMemo(() => createMockConversationsApi({ store }), [store])
43
+ const sse = useMemo(() => createMockSSEProvider({ store }), [store])
44
+
45
+ const messages = PREVIEW_MESSAGES[conversationId] ?? []
46
+ const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
47
+ const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
48
+
49
+ return (
50
+ <ConversationsProvider api={api} sse={sse}>
51
+ <div className={className}>
52
+ <header className="border-b px-4 py-3 dark:border-gray-700">
53
+ <h1 className="text-lg font-semibold">Teste manual de mídia</h1>
54
+ <p className="text-sm text-gray-500">
55
+ {documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
56
+ botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
57
+ </p>
58
+ </header>
59
+
60
+ <div className="grid gap-4 p-4 lg:grid-cols-2">
61
+ <section className="space-y-3">
62
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
63
+ {/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
64
+ <DocumentsLibrary perPage={documents.length || 20} />
65
+ </section>
66
+
67
+ <section className="space-y-3">
68
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
69
+ <ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
70
+
71
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
72
+ <ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
73
+ {messages.map((message, index) => (
74
+ <MessageBubble
75
+ key={message.id}
76
+ message={message}
77
+ isMine={message.direction === 'outbound'}
78
+ isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
79
+ />
80
+ ))}
81
+ </ConversationWallpaper>
82
+ </section>
83
+ </div>
84
+ </div>
85
+ </ConversationsProvider>
86
+ )
87
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { MessagePayload } from '../types'
11
11
  import type {
12
+ CompanyDocumentPage,
12
13
  ConversationDocumentPage,
13
14
  ConversationPage,
14
15
  ConversationTemplate,
@@ -16,12 +17,9 @@ import type {
16
17
  ListConversationsParams,
17
18
  } from '../providers/types'
18
19
  import { PREVIEW_DOCUMENTS } from './previewFixtures'
20
+ import { previewFileBase64, previewFileUrl } from './previewMediaSource'
19
21
  import type { PreviewStore } from './previewStore'
20
22
 
21
- // PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
22
- const PREVIEW_IMAGE_BASE64 =
23
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
24
-
25
23
  export type CreateMockConversationsApiParams = {
26
24
  readonly store: PreviewStore
27
25
  readonly latencyMs?: number
@@ -178,12 +176,68 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
178
176
  })
179
177
  },
180
178
 
181
- getDocumentUrl(): Promise<string> {
182
- return withLatency(() => `data:image/png;base64,${PREVIEW_IMAGE_BASE64}`)
179
+ /** Junta as bibliotecas de todas as conversas do fixture, com a origem de cada arquivo. */
180
+ getAllDocuments(documentParams): Promise<CompanyDocumentPage> {
181
+ return withLatency(() => {
182
+ let all = Object.entries(PREVIEW_DOCUMENTS).flatMap(([conversationId, docs]) =>
183
+ docs.map((document) => ({ ...document, conversationId })),
184
+ )
185
+
186
+ // Mesma regra do backend (`companyDocumentSearch`): o termo casa nome do arquivo OU
187
+ // telefone da conversa, e o telefone só pelos dígitos — o preview mostra o número
188
+ // formatado, então é assim que o atendente vai colá-lo na busca.
189
+ const search = documentParams?.search?.trim().toLowerCase()
190
+ if (search) {
191
+ const digits = search.replace(/\D/g, '')
192
+ all = all.filter(
193
+ (document) =>
194
+ document.filename.toLowerCase().includes(search) ||
195
+ (digits !== '' && document.conversationId.includes(digits)),
196
+ )
197
+ }
198
+
199
+ const source = documentParams?.source
200
+ if (source === 'team') {
201
+ all = all.filter((document) => document.source === 'agent' || document.source === 'bot')
202
+ } else if (source) {
203
+ all = all.filter((document) => document.source === source)
204
+ }
205
+
206
+ all.sort((left, right) =>
207
+ documentParams?.sortDirection === 'asc'
208
+ ? left.linkedAt.localeCompare(right.linkedAt)
209
+ : right.linkedAt.localeCompare(left.linkedAt),
210
+ )
211
+
212
+ const total = all.length
213
+ const limit = documentParams?.limit ?? total
214
+ const page = documentParams?.page ?? 1
215
+ return { documents: all.slice((page - 1) * limit, page * limit), total }
216
+ })
183
217
  },
184
218
 
185
- getMediaProxyUrl(): Promise<{ mimeType: string; data: string }> {
186
- return withLatency(() => ({ mimeType: 'image/png', data: PREVIEW_IMAGE_BASE64 }))
219
+ // Devolve os bytes DO TIPO do documento, não uma imagem para tudo: antes, abrir um PDF entregava
220
+ // um PNG rotulado `application/pdf` e o leitor recusava o arquivo. O `uploadId` é a única pista
221
+ // que o contrato dá, então o tipo vem da própria biblioteca.
222
+ getDocumentUrl(uploadId): Promise<string> {
223
+ return withLatency(() => {
224
+ const found = Object.values(PREVIEW_DOCUMENTS)
225
+ .flat()
226
+ .find((document) => document.id === uploadId)
227
+ return previewFileUrl(found?.mimeType, found?.filename)
228
+ })
229
+ },
230
+
231
+ // Caminho da mídia ainda não ingerida: o backend busca na Meta e devolve base64. Resolve pelo
232
+ // id para a bolha receber os bytes DO TIPO dela — devolvendo um PNG para todo id, vídeo e áudio
233
+ // apareciam quebrados na thread mesmo havendo amostra válida do formato.
234
+ getMediaProxyUrl(mediaId): Promise<{ mimeType: string; data: string }> {
235
+ return withLatency(() => {
236
+ const found = Object.values(PREVIEW_DOCUMENTS)
237
+ .flat()
238
+ .find((document) => document.id === `preview/inbound/${mediaId}`)
239
+ return previewFileBase64(found?.mimeType, found?.filename)
240
+ })
187
241
  },
188
242
 
189
243
  takeover(conversationId): Promise<void> {
@@ -38,3 +38,8 @@ export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './c
38
38
 
39
39
  export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
40
40
  export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
41
+ export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
42
+ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
43
+ export { previewFileBase64 } from './previewMediaSource'
44
+ export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
45
+ export type { MediaTypesPreviewProps } from './MediaTypesPreview'
@@ -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,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
+ })