@hed-hog/core 0.0.400 → 0.0.401

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 (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -0,0 +1,429 @@
1
+ import axios from 'axios';
2
+ import { AiService } from './ai.service';
3
+
4
+ jest.mock('axios');
5
+
6
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
7
+
8
+ /**
9
+ * PDF como documento.
10
+ *
11
+ * O caminho antigo achatava todo PDF em texto: sem camada de texto, o modelo
12
+ * recebia o marcador `[PDF attachment with unreadable text: ...]` e não tinha
13
+ * como extrair coisa alguma — era esse o motivo de uma nota fiscal escaneada
14
+ * voltar com todos os campos vazios. Estes testes fixam o caminho que entrega
15
+ * o documento ao modelo, e os limites em que ele não se aplica.
16
+ */
17
+ describe('AiService — PDF como documento (Responses API)', () => {
18
+ let service: AiService;
19
+
20
+ const aiProfile = {
21
+ apiKey: 'sk-test',
22
+ model: undefined as string | undefined,
23
+ timeoutMs: undefined as number | undefined,
24
+ baseUrl: undefined as string | undefined,
25
+ };
26
+
27
+ const PDF = {
28
+ filename: 'nf.pdf',
29
+ mimeType: 'application/pdf',
30
+ buffer: Buffer.from('%PDF-1.7 conteudo'),
31
+ };
32
+
33
+ const IMAGE = {
34
+ filename: 'nf.png',
35
+ mimeType: 'image/png',
36
+ buffer: Buffer.from('png-bytes'),
37
+ };
38
+
39
+ beforeEach(() => {
40
+ const aiExecution = {
41
+ start: jest.fn().mockResolvedValue(1),
42
+ recordStep: jest.fn().mockResolvedValue(undefined),
43
+ finish: jest.fn().mockResolvedValue(undefined),
44
+ } as any;
45
+
46
+ service = new AiService(
47
+ {} as any,
48
+ { getSettingValues: jest.fn().mockResolvedValue({}) } as any,
49
+ {} as any,
50
+ aiExecution,
51
+ { resolveMany: jest.fn() } as any,
52
+ );
53
+
54
+ jest.spyOn(service as any, 'getAiProfile').mockResolvedValue(aiProfile);
55
+ jest.spyOn(service as any, 'getDefaultProvider').mockResolvedValue('openai');
56
+ jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(PDF);
57
+ jest.spyOn(service as any, 'countPdfPages').mockResolvedValue(3);
58
+ jest.spyOn(service as any, 'extractPdfText').mockResolvedValue('');
59
+
60
+ mockedAxios.post.mockResolvedValue({
61
+ data: {
62
+ status: 'completed',
63
+ output: [
64
+ { type: 'message', content: [{ type: 'output_text', text: 'ok' }] },
65
+ ],
66
+ choices: [{ message: { content: 'ok' } }],
67
+ },
68
+ } as any);
69
+ });
70
+
71
+ afterEach(() => {
72
+ jest.restoreAllMocks();
73
+ mockedAxios.post.mockReset();
74
+ });
75
+
76
+ const lastBody = () =>
77
+ mockedAxios.post.mock.calls.at(-1)?.[1] as Record<string, any>;
78
+ const lastUrl = () => mockedAxios.post.mock.calls.at(-1)?.[0] as string;
79
+ const lastConfig = () =>
80
+ mockedAxios.post.mock.calls.at(-1)?.[2] as Record<string, any>;
81
+
82
+ it('manda o PDF em input_file, com a instrução depois do arquivo', async () => {
83
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
84
+
85
+ expect(lastUrl()).toBe('https://api.openai.com/v1/responses');
86
+
87
+ const [file, text] = lastBody().input[0].content;
88
+ expect(file).toEqual({
89
+ type: 'input_file',
90
+ filename: 'nf.pdf',
91
+ file_data: `data:application/pdf;base64,${PDF.buffer.toString('base64')}`,
92
+ });
93
+ expect(text).toEqual({ type: 'input_text', text: 'Extraia.' });
94
+ });
95
+
96
+ it('sem parâmetros, o corpo é exatamente { input, model }', async () => {
97
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
98
+
99
+ expect(Object.keys(lastBody()).sort()).toEqual(['input', 'model']);
100
+ });
101
+
102
+ it('o system prompt vira instructions de topo, não uma mensagem', async () => {
103
+ await service.chat({
104
+ message: 'Extraia.',
105
+ provider: 'openai',
106
+ systemPrompt: 'Você é um extrator.',
107
+ } as any);
108
+
109
+ expect(lastBody().instructions).toBe('Você é um extrator.');
110
+ expect(lastBody().input.some((item: any) => item.role === 'system')).toBe(
111
+ false,
112
+ );
113
+ });
114
+
115
+ it('renomeia max_tokens e response_format para os nomes da Responses API', async () => {
116
+ await service.chat({
117
+ message: 'Extraia.',
118
+ provider: 'openai',
119
+ max_tokens: 500,
120
+ response_format: 'json_object',
121
+ } as any);
122
+
123
+ expect(lastBody().max_output_tokens).toBe(500);
124
+ expect(lastBody()).not.toHaveProperty('max_tokens');
125
+ expect(lastBody().text).toEqual({ format: { type: 'json_object' } });
126
+ expect(lastBody()).not.toHaveProperty('response_format');
127
+ });
128
+
129
+ it('não trunca nem trava num corpo de megabytes', async () => {
130
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
131
+
132
+ expect(lastConfig().maxBodyLength).toBe(Infinity);
133
+ expect(lastConfig().maxContentLength).toBe(Infinity);
134
+ expect(lastConfig().timeout).toBe(120_000);
135
+ });
136
+
137
+ // `output_text` é conveniência do SDK e costuma faltar no JSON cru; ler só
138
+ // ele é a forma mais provável de entregar isto quebrado.
139
+ it('lê o texto do array output, ignorando o reasoning sem content', async () => {
140
+ mockedAxios.post.mockResolvedValue({
141
+ data: {
142
+ output: [
143
+ { type: 'reasoning', summary: [] },
144
+ {
145
+ type: 'message',
146
+ content: [{ type: 'output_text', text: '{"valor":9500}' }],
147
+ },
148
+ ],
149
+ },
150
+ } as any);
151
+
152
+ const result = await service.chat({
153
+ message: 'Extraia.',
154
+ provider: 'openai',
155
+ } as any);
156
+
157
+ expect(result.content).toBe('{"valor":9500}');
158
+ });
159
+
160
+ it('usa output_text quando ele vem preenchido', async () => {
161
+ mockedAxios.post.mockResolvedValue({
162
+ data: { output_text: '{"valor":1}', output: [] },
163
+ } as any);
164
+
165
+ const result = await service.chat({
166
+ message: 'Extraia.',
167
+ provider: 'openai',
168
+ } as any);
169
+
170
+ expect(result.content).toBe('{"valor":1}');
171
+ });
172
+
173
+ it('mapeia o usage da Responses API, inclusive os tokens em cache', async () => {
174
+ mockedAxios.post.mockResolvedValue({
175
+ data: {
176
+ output: [
177
+ { type: 'message', content: [{ type: 'output_text', text: 'ok' }] },
178
+ ],
179
+ usage: {
180
+ input_tokens: 1200,
181
+ output_tokens: 80,
182
+ total_tokens: 1280,
183
+ input_tokens_details: { cached_tokens: 400 },
184
+ },
185
+ },
186
+ } as any);
187
+
188
+ const recordStep = jest.fn().mockResolvedValue(undefined);
189
+ (service as any).aiExecution.recordStep = recordStep;
190
+
191
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
192
+
193
+ expect(recordStep.mock.calls[0][0].usage).toEqual({
194
+ input: 1200,
195
+ output: 80,
196
+ total: 1280,
197
+ cached: 400,
198
+ });
199
+ });
200
+
201
+ it('reporta que o documento chegou ao modelo', async () => {
202
+ const result = await service.chat({
203
+ message: 'Extraia.',
204
+ provider: 'openai',
205
+ } as any);
206
+
207
+ expect(result.attachmentDelivery).toBe('pdf-document');
208
+ });
209
+
210
+ it('degrada para chat/completions quando a Responses API devolve 400', async () => {
211
+ mockedAxios.post
212
+ .mockRejectedValueOnce({
213
+ response: { status: 400, data: { error: { message: 'unsupported' } } },
214
+ })
215
+ .mockResolvedValue({
216
+ data: { choices: [{ message: { content: 'ok' } }] },
217
+ } as any);
218
+
219
+ const result = await service.chat({
220
+ message: 'Extraia.',
221
+ provider: 'openai',
222
+ } as any);
223
+
224
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2);
225
+ expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
226
+ expect(result.content).toBe('ok');
227
+ });
228
+
229
+ // Degradar aqui só reproduziria o marcador de PDF ilegível e devolveria
230
+ // "não achei nada" — escondendo uma credencial inválida.
231
+ it('não mascara um 401 com uma segunda chamada mais fraca', async () => {
232
+ mockedAxios.post.mockRejectedValue({
233
+ response: { status: 401, data: { error: { message: 'bad key' } } },
234
+ });
235
+
236
+ await expect(
237
+ service.chat({ message: 'Extraia.', provider: 'openai' } as any),
238
+ ).rejects.toMatchObject({ response: { status: 401 } });
239
+
240
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it('PDF acima do teto de páginas volta para o chat/completions', async () => {
244
+ jest.spyOn(service as any, 'countPdfPages').mockResolvedValue(50);
245
+
246
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
247
+
248
+ expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
249
+ });
250
+
251
+ it('imagem continua pelo bloco de visão do chat/completions', async () => {
252
+ jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(IMAGE);
253
+
254
+ const result = await service.chat({
255
+ message: 'Extraia.',
256
+ provider: 'openai',
257
+ } as any);
258
+
259
+ expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
260
+ expect(lastBody().messages[0].content[1].type).toBe('image_url');
261
+ expect(result.attachmentDelivery).toBe('image');
262
+ });
263
+
264
+ // O guarda dos ~25 pontos do repo que chamam IA sem anexo nenhum.
265
+ it('sem anexo, nada muda: chat/completions com o corpo de sempre', async () => {
266
+ jest.spyOn(service as any, 'resolveAttachment').mockResolvedValue(null);
267
+
268
+ const result = await service.chat({
269
+ message: 'oi',
270
+ provider: 'openai',
271
+ } as any);
272
+
273
+ expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
274
+ expect(Object.keys(lastBody()).sort()).toEqual(['messages', 'model']);
275
+ expect(result.attachmentDelivery).toBe('none');
276
+ });
277
+
278
+ it('o DeepSeek não tem /v1/responses: PDF segue no chat/completions', async () => {
279
+ await service.chat({ message: 'Extraia.', provider: 'deepseek' } as any);
280
+
281
+ expect(lastUrl()).toContain('/chat/completions');
282
+ expect(lastUrl()).not.toContain('api.openai.com');
283
+ });
284
+
285
+ it('uma base URL de gateway não recebe /v1/responses', async () => {
286
+ jest
287
+ .spyOn(service as any, 'getAiProfile')
288
+ .mockResolvedValue({ ...aiProfile, baseUrl: 'https://gateway.interno/v1' });
289
+
290
+ await service.chat({ message: 'Extraia.', provider: 'openai' } as any);
291
+
292
+ expect(lastUrl()).toBe('https://gateway.interno/v1/chat/completions');
293
+ });
294
+
295
+ it('modelo sem visão não usa a Responses API', async () => {
296
+ await service.chat({
297
+ message: 'Extraia.',
298
+ provider: 'openai',
299
+ model: 'gpt-3.5-turbo',
300
+ } as any);
301
+
302
+ expect(lastUrl()).toBe('https://api.openai.com/v1/chat/completions');
303
+ });
304
+ });
305
+
306
+ /**
307
+ * Provider x perfil.
308
+ *
309
+ * O setting `ai-gemini-profile-id` apontando para um perfil OpenAI fazia o
310
+ * serviço postar a chave da OpenAI no endpoint do Google e engolir o erro de
311
+ * cada modelo tentado. A trava já existia para o perfil explícito; faltava no
312
+ * caminho que vem do setting global.
313
+ */
314
+ describe('AiService — perfil do provider', () => {
315
+ let service: AiService;
316
+ let integrationProfile: { findUnique: jest.Mock };
317
+ let settingService: { getSettingValues: jest.Mock };
318
+
319
+ const geminiProfile = {
320
+ slug: 'gemini',
321
+ config: { api_key: 'AIza-test' },
322
+ integration_provider: { slug: 'gemini' },
323
+ };
324
+
325
+ const openaiProfile = {
326
+ slug: 'openia-hub',
327
+ config: { api_key: 'sk-test' },
328
+ integration_provider: { slug: 'openai' },
329
+ };
330
+
331
+ beforeEach(() => {
332
+ integrationProfile = { findUnique: jest.fn() };
333
+ settingService = { getSettingValues: jest.fn() };
334
+
335
+ service = new AiService(
336
+ { integration_profile: integrationProfile } as any,
337
+ settingService as any,
338
+ {} as any,
339
+ {
340
+ start: jest.fn().mockResolvedValue(1),
341
+ recordStep: jest.fn().mockResolvedValue(undefined),
342
+ finish: jest.fn().mockResolvedValue(undefined),
343
+ } as any,
344
+ { resolveMany: jest.fn() } as any,
345
+ );
346
+
347
+ mockedAxios.post.mockResolvedValue({
348
+ data: { candidates: [{ content: { parts: [{ text: 'ok' }] } }] },
349
+ } as any);
350
+ });
351
+
352
+ afterEach(() => {
353
+ jest.restoreAllMocks();
354
+ mockedAxios.post.mockReset();
355
+ });
356
+
357
+ it('recusa quando o setting do Gemini aponta para um perfil OpenAI', async () => {
358
+ settingService.getSettingValues.mockResolvedValue({
359
+ 'ai-gemini-profile-id': 'openia-hub',
360
+ });
361
+ integrationProfile.findUnique.mockResolvedValue(openaiProfile);
362
+
363
+ await expect(
364
+ (service as any).getAiProfile('gemini'),
365
+ ).rejects.toThrow(/ai-gemini-profile-id/);
366
+
367
+ // O ponto todo: nenhuma requisição condenada sai.
368
+ expect(mockedAxios.post).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it('aceita quando o provider do perfil bate com o pedido', async () => {
372
+ settingService.getSettingValues.mockResolvedValue({
373
+ 'ai-gemini-profile-id': 'gemini',
374
+ });
375
+ integrationProfile.findUnique.mockResolvedValue(geminiProfile);
376
+
377
+ await expect((service as any).getAiProfile('gemini')).resolves.toMatchObject(
378
+ { apiKey: 'AIza-test' },
379
+ );
380
+ });
381
+
382
+ it('isProviderConfigured responde sem lançar', async () => {
383
+ settingService.getSettingValues.mockResolvedValue({
384
+ 'ai-gemini-profile-id': 'openia-hub',
385
+ });
386
+ integrationProfile.findUnique.mockResolvedValue(openaiProfile);
387
+
388
+ const result = await service.isProviderConfigured('gemini' as any);
389
+
390
+ expect(result.ok).toBe(false);
391
+ expect(result.reason).toMatch(/ai-gemini-profile-id/);
392
+ });
393
+
394
+ it('isProviderConfigured devolve ok quando o perfil está correto', async () => {
395
+ settingService.getSettingValues.mockResolvedValue({
396
+ 'ai-gemini-profile-id': 'gemini',
397
+ });
398
+ integrationProfile.findUnique.mockResolvedValue(geminiProfile);
399
+
400
+ await expect(service.isProviderConfigured('gemini' as any)).resolves.toEqual(
401
+ { ok: true },
402
+ );
403
+ });
404
+
405
+ it('setting vazio continua sendo "não configurado", não incompatibilidade', async () => {
406
+ settingService.getSettingValues.mockResolvedValue({
407
+ 'ai-gemini-profile-id': '',
408
+ });
409
+
410
+ const result = await service.isProviderConfigured('gemini' as any);
411
+
412
+ expect(result.ok).toBe(false);
413
+ expect(result.reason).toMatch(/No gemini AI profile configured/);
414
+ });
415
+
416
+ // O caminho do perfil explícito deriva o provider DO perfil, então nunca cai
417
+ // na trava nova — é o que o SAC, o CMS e o campaign usam.
418
+ it('perfil explícito segue derivando o provider do próprio perfil', async () => {
419
+ integrationProfile.findUnique.mockResolvedValue(openaiProfile);
420
+
421
+ const resolved = await (service as any).resolveProviderAndProfile(
422
+ undefined,
423
+ 'openia-hub',
424
+ );
425
+
426
+ expect(resolved.provider).toBe('openai');
427
+ expect(resolved.profile.apiKey).toBe('sk-test');
428
+ });
429
+ });