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

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
3
+ *
4
+ * O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
5
+ * ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
6
+ * lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
7
+ *
8
+ * Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
9
+ * estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
10
+ * a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
11
+ */
12
+
13
+ /**
14
+ * Do `contracts`, que este pacote já consome — não uma cópia.
15
+ *
16
+ * A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
17
+ * duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
18
+ * Contrato compartilhado é o que o `contracts` existe para guardar.
19
+ */
20
+ export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
21
+ import { toPreviewMediaId } from '@adatechnology/meta-whatsapp-contracts'
22
+
23
+ export type PreviewUploadedMedia = {
24
+ readonly mediaId: string
25
+ readonly mimeType?: string
26
+ readonly filename?: string
27
+ }
28
+
29
+ export type PreviewMediaUploadRequest = {
30
+ readonly base64: string
31
+ readonly mimeType: string
32
+ readonly filename: string
33
+ }
34
+
35
+ export type CreatePreviewMediaUploaderParams = {
36
+ /**
37
+ * Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
38
+ *
39
+ * Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
40
+ * HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
41
+ * escolher por elas.
42
+ */
43
+ readonly upload: (request: PreviewMediaUploadRequest) => Promise<{ uploadId: string }>
44
+ /** Nome usado quando o gravador entrega o áudio sem nome próprio. */
45
+ readonly fallbackFilename?: string
46
+ readonly fallbackMimeType?: string
47
+ }
48
+
49
+ /**
50
+ * Converte em blocos, não com `String.fromCharCode(...bytes)` de uma vez.
51
+ *
52
+ * Espalhar centenas de milhares de bytes como argumentos estoura o limite da engine — poucos segundos
53
+ * de áudio já chegam perto. O sintoma seria `RangeError` só nos arquivos grandes: passa no teste com
54
+ * um clipe curto e falha na primeira gravação de verdade.
55
+ */
56
+ const CHUNK_SIZE = 8192
57
+
58
+ async function fileToBase64(file: File): Promise<string> {
59
+ const bytes = new Uint8Array(await file.arrayBuffer())
60
+ let binary = ''
61
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
62
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE))
63
+ }
64
+ return btoa(binary)
65
+ }
66
+
67
+ export function createPreviewMediaUploader(
68
+ params: CreatePreviewMediaUploaderParams,
69
+ ): (file: File) => Promise<PreviewUploadedMedia> {
70
+ const fallbackMimeType = params.fallbackMimeType ?? 'audio/ogg'
71
+ const fallbackFilename = params.fallbackFilename ?? 'audio.ogg'
72
+
73
+ return async function uploadPreviewMedia(file: File): Promise<PreviewUploadedMedia> {
74
+ // Gravação de voz chega sem nome, e sem mime em navegador antigo.
75
+ const mimeType = file.type || fallbackMimeType
76
+ const filename = file.name || fallbackFilename
77
+
78
+ const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename })
79
+
80
+ return { mediaId: toPreviewMediaId(uploadId), mimeType, filename }
81
+ }
82
+ }
@@ -10,8 +10,10 @@ import {
10
10
  assertPreviewEnvironment,
11
11
  createPreviewWebhookClient,
12
12
  PreviewInProductionError,
13
+ PreviewMediaUploadRejectedError,
13
14
  PreviewWebhookRejectedError,
14
15
  } from './createPreviewWebhookClient'
16
+ import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
15
17
 
16
18
  const APP_SECRET = 'dev-app-secret'
17
19
  const FROM = '5511988887777'
@@ -103,3 +105,90 @@ describe('assertPreviewEnvironment', () => {
103
105
  expect(() => assertPreviewEnvironment(false)).not.toThrow()
104
106
  })
105
107
  })
108
+
109
+ /**
110
+ * O microfone do simulador depende disto existir no cliente.
111
+ *
112
+ * Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
113
+ * diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
114
+ * pacote: cliente montado, upload assinado, id prefixado.
115
+ */
116
+ describe('createPreviewWebhookClient.uploadMedia', () => {
117
+ function createUploadFetch(status = 201): {
118
+ fetchImplementation: typeof fetch
119
+ calls: Array<{ url: string; signature: string; body: string }>
120
+ } {
121
+ const calls: Array<{ url: string; signature: string; body: string }> = []
122
+
123
+ const fetchImplementation = (async (url: string, init?: RequestInit) => {
124
+ const headers = (init?.headers ?? {}) as Record<string, string>
125
+ calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
126
+ return {
127
+ ok: status >= 200 && status < 300,
128
+ status,
129
+ json: async () => ({ data: { uploadId: 'upl_123' } }),
130
+ } as Response
131
+ }) as unknown as typeof fetch
132
+
133
+ return { fetchImplementation, calls }
134
+ }
135
+
136
+ const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
137
+
138
+ it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
139
+ const { fetchImplementation, calls } = createUploadFetch()
140
+ const client = createPreviewWebhookClient({
141
+ webhookUrl: WEBHOOK_URL,
142
+ appSecret: APP_SECRET,
143
+ from: FROM,
144
+ fetchImplementation,
145
+ })
146
+
147
+ const uploaded = await client.uploadMedia(audioFile())
148
+
149
+ expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
150
+ expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
151
+ expect(uploaded.mimeType).toBe('audio/ogg')
152
+ })
153
+
154
+ it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
155
+ const { fetchImplementation, calls } = createUploadFetch()
156
+ const client = createPreviewWebhookClient({
157
+ webhookUrl: WEBHOOK_URL,
158
+ appSecret: APP_SECRET,
159
+ from: FROM,
160
+ fetchImplementation,
161
+ })
162
+
163
+ await client.uploadMedia(audioFile())
164
+
165
+ const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
166
+ expect(calls[0]?.signature).toBe(expected)
167
+ })
168
+
169
+ it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
170
+ const { fetchImplementation, calls } = createUploadFetch()
171
+ const client = createPreviewWebhookClient({
172
+ webhookUrl: '/v1/webhook/whatsapp',
173
+ appSecret: APP_SECRET,
174
+ from: FROM,
175
+ fetchImplementation,
176
+ })
177
+
178
+ await client.uploadMedia(audioFile())
179
+
180
+ expect(calls[0]?.url).toBe('/v1/preview/media')
181
+ })
182
+
183
+ it('avisa com erro próprio quando a rota de mídia recusa', async () => {
184
+ const { fetchImplementation } = createUploadFetch(404)
185
+ const client = createPreviewWebhookClient({
186
+ webhookUrl: WEBHOOK_URL,
187
+ appSecret: APP_SECRET,
188
+ from: FROM,
189
+ fetchImplementation,
190
+ })
191
+
192
+ await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
193
+ })
194
+ })
@@ -25,6 +25,7 @@ import {
25
25
  type InboundMediaType,
26
26
  type InteractiveReplyOption,
27
27
  } from '@adatechnology/meta-whatsapp-contracts/testing'
28
+ import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
28
29
 
29
30
  export type PreviewWebhookClient = {
30
31
  sendText(text: string): Promise<void>
@@ -32,6 +33,19 @@ export type PreviewWebhookClient = {
32
33
  sendListReply(reply: InteractiveReplyOption): Promise<void>
33
34
  sendAudio(mediaId: string): Promise<void>
34
35
  sendMedia(params: SendPreviewMediaParams): Promise<void>
36
+ /**
37
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
38
+ *
39
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
40
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
41
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
42
+ * por esquecimento. Cliente montado, microfone na tela.
43
+ *
44
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
45
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
46
+ * corretamente não desenha o gravador.
47
+ */
48
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia>
35
49
  }
36
50
 
37
51
  export type SendPreviewMediaParams = {
@@ -52,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
52
66
  readonly appSecret: string
53
67
  readonly from: string
54
68
  readonly phoneNumberId?: string
69
+ /**
70
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
71
+ *
72
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
73
+ * publica a API em outro host ou versiona o caminho.
74
+ */
75
+ readonly mediaUploadUrl?: string
55
76
  // Escape hatch para teste; em runtime real é sempre o fetch global.
56
77
  readonly fetchImplementation?: typeof fetch
57
78
  }
58
79
 
80
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
81
+ export class PreviewMediaUploadRejectedError extends Error {
82
+ constructor(readonly status: number) {
83
+ super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
84
+ this.name = 'PreviewMediaUploadRejectedError'
85
+ }
86
+ }
87
+
59
88
  export class PreviewInProductionError extends Error {
60
89
  constructor() {
61
90
  super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
@@ -102,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
102
131
 
103
132
  const signWithWebCrypto = signPreviewPayload
104
133
 
134
+ export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
135
+
136
+ /**
137
+ * Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
138
+ *
139
+ * Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
140
+ * pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
141
+ * lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
142
+ */
143
+ export function defaultMediaUploadUrl(webhookUrl: string): string {
144
+ try {
145
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
146
+ } catch {
147
+ return DEFAULT_MEDIA_UPLOAD_PATH
148
+ }
149
+ }
150
+
151
+ /**
152
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
153
+ *
154
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
155
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
156
+ */
157
+ export function createPreviewMediaPoster(params: {
158
+ readonly url: string
159
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
160
+ readonly fetchImplementation?: typeof fetch
161
+ }): (file: File) => Promise<PreviewUploadedMedia> {
162
+ return createPreviewMediaUploader({
163
+ upload: async (request) => {
164
+ const performRequest = params.fetchImplementation ?? fetch
165
+ const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
166
+
167
+ const response = await performRequest(params.url, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json', ...extraHeaders },
170
+ body: JSON.stringify(request),
171
+ })
172
+
173
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
174
+
175
+ const body = (await response.json()) as { data: { uploadId: string } }
176
+ return { uploadId: body.data.uploadId }
177
+ },
178
+ })
179
+ }
180
+
105
181
  export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
106
182
  const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
107
183
  // Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
@@ -119,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
119
195
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
120
196
  }
121
197
 
198
+ /**
199
+ * Assina o MIME, não o binário — mesmo contrato da rota.
200
+ *
201
+ * Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
202
+ * assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
203
+ */
204
+ const uploadMedia = createPreviewMediaPoster({
205
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
206
+ headers: async (mimeType) => ({
207
+ 'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
208
+ }),
209
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
210
+ })
211
+
122
212
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
123
213
 
124
214
  return {
@@ -127,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
127
217
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
128
218
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
129
219
  sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
220
+ uploadMedia,
130
221
  }
131
222
  }
@@ -38,6 +38,9 @@ export {
38
38
  assertPreviewEnvironment,
39
39
  PreviewInProductionError,
40
40
  PreviewWebhookRejectedError,
41
+ PreviewMediaUploadRejectedError,
42
+ createPreviewMediaPoster,
43
+ DEFAULT_MEDIA_UPLOAD_PATH,
41
44
  } from './createPreviewWebhookClient'
42
45
  export type {
43
46
  PreviewWebhookClient,
@@ -59,3 +62,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
59
62
  export { previewFileBase64 } from './previewMediaSource'
60
63
  export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
61
64
  export type { MediaTypesPreviewProps } from './MediaTypesPreview'
65
+
66
+ export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
67
+ export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
@@ -0,0 +1,61 @@
1
+ /**
2
+ * O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
3
+ * meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
4
+ * reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+ import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
9
+
10
+ import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
11
+
12
+ describe('createPreviewMediaUploader', () => {
13
+ it('reexporta o prefixo do contrato, sem cópia própria', () => {
14
+ expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
15
+ })
16
+
17
+ it('devolve o id prefixado com o uploadId da rota', async () => {
18
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
19
+
20
+ const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
21
+
22
+ expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
23
+ expect(result.mimeType).toBe('audio/ogg')
24
+ expect(result.filename).toBe('nota.ogg')
25
+ })
26
+
27
+ it('manda o binário em base64 para a rota', async () => {
28
+ let recebido: string | undefined
29
+ const upload = createPreviewMediaUploader({
30
+ upload: async (request) => {
31
+ recebido = request.base64
32
+ return { uploadId: 'k' }
33
+ },
34
+ })
35
+
36
+ await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
37
+
38
+ expect(atob(recebido!)).toBe('audio')
39
+ })
40
+
41
+ // Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
42
+ it('preenche nome e mime quando o arquivo vem sem eles', async () => {
43
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
44
+
45
+ const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
46
+
47
+ expect(result.mimeType).toBe('audio/ogg')
48
+ expect(result.filename).toBe('audio.ogg')
49
+ })
50
+
51
+ /**
52
+ * Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
53
+ * aparece na primeira gravação de verdade, nunca no clipe curto do teste.
54
+ */
55
+ it('converte arquivo grande sem estourar a pilha', async () => {
56
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
57
+ const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
58
+
59
+ expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
60
+ })
61
+ })
@@ -1,4 +1,4 @@
1
- import type { MessagePayload } from '../types'
1
+ import type { MessagePayload, MessageTranscription } from '../types'
2
2
  import type { ConversationChannel } from '../conversationChannel'
3
3
 
4
4
  export interface ListConversationsParams {
@@ -134,6 +134,17 @@ export interface ConversationsApi {
134
134
  * nem todo backend expõe a rota — quem não tem continua usando o builder local.
135
135
  */
136
136
  exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
137
+
138
+ /**
139
+ * Transcreve o áudio de uma mensagem e devolve o resultado.
140
+ *
141
+ * **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
142
+ * nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
143
+ * simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
144
+ *
145
+ * `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
146
+ */
147
+ transcribeAudio?(messageId: string): Promise<MessageTranscription>
137
148
  }
138
149
 
139
150
  /**
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
5
+ import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
6
+
7
+ /**
8
+ * Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
9
+ * `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
10
+ */
11
+ function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
12
+ return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
13
+ }
14
+
15
+ function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
16
+ return renderToStaticMarkup(
17
+ <TranscriptionSettingsForm
18
+ enabled={false}
19
+ onEnabledChange={() => {}}
20
+ mode="onDemand"
21
+ onModeChange={() => {}}
22
+ onSave={() => {}}
23
+ {...overrides}
24
+ />,
25
+ )
26
+ }
27
+
28
+ describe('TranscriptionSettingsForm', () => {
29
+ it('esconde a escolha de modo enquanto estiver desligado', () => {
30
+ const markup = render({ enabled: false })
31
+
32
+ expect(markup).not.toContain('Quando transcrever')
33
+ })
34
+
35
+ it('mostra as duas opções de modo ao ligar', () => {
36
+ const markup = render({ enabled: true })
37
+
38
+ expect(markup).toContain('Quando transcrever')
39
+ expect(markup).toContain('Quando o atendente pedir')
40
+ expect(markup).toContain('Automaticamente')
41
+ })
42
+
43
+ it('marca o modo atual', () => {
44
+ const markup = render({ enabled: true, mode: 'auto' })
45
+ const radios = /<input type="radio"[^>]*>/g
46
+
47
+ expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
48
+ expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
49
+ })
50
+
51
+ /**
52
+ * "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
53
+ * interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
54
+ */
55
+ it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
56
+ const markup = render({ isAvailable: false })
57
+
58
+ expect(markup).toContain('não está disponível neste ambiente')
59
+ expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
60
+ })
61
+
62
+ it('não avisa nada quando a capacidade existe', () => {
63
+ const markup = render({ isAvailable: true })
64
+
65
+ expect(markup).not.toContain('não está disponível neste ambiente')
66
+ })
67
+
68
+ it('aceita rótulos do host', () => {
69
+ const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
70
+
71
+ expect(markup).toContain('Áudio em texto')
72
+ expect(markup).not.toContain('Transcrição de áudio')
73
+ })
74
+
75
+ it('desabilita o salvar enquanto salva', () => {
76
+ const markup = render({ saving: true })
77
+
78
+ expect(markup).toContain('Salvando...')
79
+ expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
80
+ })
81
+ })