@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.
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file.service.d.ts +15 -1
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +52 -24
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +21 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +239 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +153 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +108 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +69 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +164 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/messages/en.json +54 -1
- package/hedhog/frontend/messages/pt.json +54 -1
- package/package.json +4 -4
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +72 -34
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +21 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +238 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
- package/src/pdf/document/pdf-document.renderer.ts +215 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +97 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +245 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
|
2
|
-
import { NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
|
3
3
|
import { FileService } from './file.service';
|
|
4
4
|
import { ProviderFactory } from './provider/provider.factory';
|
|
5
5
|
|
|
@@ -274,6 +274,80 @@ describe('FileService — arquivo privado nas rotas por id', () => {
|
|
|
274
274
|
});
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Um 503 do Spaces com corpo que não é XML estourava no parse do SDK
|
|
279
|
+
* (`char 'u' is not expected`) e virava 500 na rota de segmento HLS. O SDK ainda
|
|
280
|
+
* carimba o status em `$metadata`, e é por ele que a falha vira 503 passageiro.
|
|
281
|
+
*/
|
|
282
|
+
describe('FileService — falha do storage na leitura', () => {
|
|
283
|
+
const buildService = (readError: unknown, size: number | null = 1024) => {
|
|
284
|
+
const prisma = {
|
|
285
|
+
file: {
|
|
286
|
+
findUnique: jest.fn().mockResolvedValue({
|
|
287
|
+
id: 9,
|
|
288
|
+
filename: 'seg037.ts',
|
|
289
|
+
location: 'lms/lessons/hls/2274/stream_0',
|
|
290
|
+
path: 'lms/lessons/hls/2274/stream_0/abc.ts',
|
|
291
|
+
size,
|
|
292
|
+
file_mimetype: { name: 'video/MP2T' },
|
|
293
|
+
} as never),
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
const service = new FileService(prisma as any, {} as any, {} as any, {} as any, {} as any);
|
|
297
|
+
const provider = {
|
|
298
|
+
metaData: jest.fn().mockResolvedValue({ ContentLength: 1024 } as never),
|
|
299
|
+
readStream: jest.fn().mockRejectedValue(readError as never),
|
|
300
|
+
};
|
|
301
|
+
jest.spyOn(service, 'getProvider').mockResolvedValue(provider as never);
|
|
302
|
+
return { service, provider };
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
afterEach(() => jest.restoreAllMocks());
|
|
306
|
+
|
|
307
|
+
it('503 do storage com corpo ilegível vira ServiceUnavailable com code STORAGE_UNAVAILABLE', async () => {
|
|
308
|
+
const sdkError = Object.assign(new Error("char 'u' is not expected.:1:1"), {
|
|
309
|
+
$metadata: { httpStatusCode: 503 },
|
|
310
|
+
});
|
|
311
|
+
const { service } = buildService(sdkError);
|
|
312
|
+
|
|
313
|
+
const erro: any = await service.openReadStreamById(9).catch((e) => e);
|
|
314
|
+
|
|
315
|
+
expect(erro).toBeInstanceOf(ServiceUnavailableException);
|
|
316
|
+
expect(erro.getResponse()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' });
|
|
317
|
+
expect(erro.cause).toBe(sdkError);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('throttle (SlowDown) também é indisponibilidade, inclusive na leitura por faixa', async () => {
|
|
321
|
+
const { service } = buildService(Object.assign(new Error('Slow down'), { name: 'SlowDown' }));
|
|
322
|
+
|
|
323
|
+
await expect(service.openReadStreamWithRange(9, 0, 99)).rejects.toBeInstanceOf(
|
|
324
|
+
ServiceUnavailableException,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('NoSuchKey continua 404', async () => {
|
|
329
|
+
const { service } = buildService(Object.assign(new Error('missing'), { name: 'NoSuchKey' }));
|
|
330
|
+
|
|
331
|
+
await expect(service.openReadStreamById(9)).rejects.toBeInstanceOf(NotFoundException);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('erro sem status de storage é repassado como veio', async () => {
|
|
335
|
+
const original = new Error('boom');
|
|
336
|
+
const { service } = buildService(original);
|
|
337
|
+
|
|
338
|
+
await expect(service.openReadStreamById(9)).rejects.toBe(original);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('não faz HEAD no storage quando o tamanho já está na tabela', async () => {
|
|
342
|
+
const { service, provider } = buildService(null);
|
|
343
|
+
provider.readStream.mockResolvedValue({} as never);
|
|
344
|
+
|
|
345
|
+
await service.openReadStreamById(9);
|
|
346
|
+
|
|
347
|
+
expect(provider.metaData).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
277
351
|
/**
|
|
278
352
|
* A varredura de reotimização do acervo passa em todo arquivo, e recomprimir WebP sobre
|
|
279
353
|
* WebP sempre devolve alguns bytes a menos. Sem um ganho mínimo, cada passada trocaria o
|
|
@@ -367,3 +441,96 @@ describe('FileService.reoptimizeFile — ganho mínimo', () => {
|
|
|
367
441
|
expect(result.mimetypeChanged).toBe(true);
|
|
368
442
|
});
|
|
369
443
|
});
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* O Instagram recusa WebP, e toda image/* que passa pela otimizacao vira WebP. Quem
|
|
447
|
+
* entrega o arquivo a um consumidor externo pede os bytes intactos.
|
|
448
|
+
*/
|
|
449
|
+
describe('FileService — skipImageOptimization', () => {
|
|
450
|
+
const makeService = () => {
|
|
451
|
+
const deps: any = {
|
|
452
|
+
prisma: { file: { create: jest.fn().mockImplementation((args: any) => Promise.resolve(args.data)) } },
|
|
453
|
+
pagination: {},
|
|
454
|
+
setting: {},
|
|
455
|
+
jwt: {},
|
|
456
|
+
imageOptimization: {
|
|
457
|
+
optimizeBuffer: jest.fn().mockResolvedValue({
|
|
458
|
+
buffer: Buffer.from('webp'),
|
|
459
|
+
mimetype: 'image/webp',
|
|
460
|
+
originalname: 'foto.webp',
|
|
461
|
+
} as never),
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
const service = new FileService(
|
|
465
|
+
deps.prisma,
|
|
466
|
+
deps.pagination,
|
|
467
|
+
deps.setting,
|
|
468
|
+
deps.jwt,
|
|
469
|
+
deps.imageOptimization,
|
|
470
|
+
);
|
|
471
|
+
const provider = {
|
|
472
|
+
upload: jest.fn().mockResolvedValue('social/foto.jpg' as never),
|
|
473
|
+
uploadFromPath: jest.fn().mockResolvedValue('social/foto.jpg' as never),
|
|
474
|
+
};
|
|
475
|
+
jest.spyOn(service as any, 'getProvider').mockResolvedValue(provider as never);
|
|
476
|
+
jest.spyOn(service as any, 'getMimeType').mockResolvedValue(7 as never);
|
|
477
|
+
return { service, deps, provider };
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
it('upload grava a imagem sem otimizar quando o chamador pede', async () => {
|
|
481
|
+
const { service, deps, provider } = makeService();
|
|
482
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
|
|
483
|
+
|
|
484
|
+
const file: any = await service.upload(
|
|
485
|
+
'social/brands/1',
|
|
486
|
+
{ buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
|
|
487
|
+
undefined,
|
|
488
|
+
{ skipMimeValidation: true, skipSizeValidation: true, skipImageOptimization: true },
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
|
|
492
|
+
const sent = (provider.upload.mock.calls[0] as any[])[1];
|
|
493
|
+
expect(sent.mimetype).toBe('image/jpeg');
|
|
494
|
+
expect(Buffer.compare(sent.buffer, jpeg)).toBe(0);
|
|
495
|
+
expect(file.size).toBe(jpeg.length);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('upload sem a opcao continua otimizando', async () => {
|
|
499
|
+
const { service, deps } = makeService();
|
|
500
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
|
|
501
|
+
|
|
502
|
+
await service.upload(
|
|
503
|
+
'social/brands/1',
|
|
504
|
+
{ buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
|
|
505
|
+
undefined,
|
|
506
|
+
{ skipMimeValidation: true, skipSizeValidation: true },
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
expect(deps.imageOptimization.optimizeBuffer).toHaveBeenCalledTimes(1);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('uploadFromPath manda a imagem pelo caminho de streaming, intacta', async () => {
|
|
513
|
+
const { service, deps, provider } = makeService();
|
|
514
|
+
const os = await import('node:os');
|
|
515
|
+
const fs = await import('node:fs/promises');
|
|
516
|
+
const path = await import('node:path');
|
|
517
|
+
const tmp = path.join(os.tmpdir(), `skip-opt-${process.pid}.jpg`);
|
|
518
|
+
await fs.writeFile(tmp, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
await service.uploadFromPath('social/brands/1', tmp, {
|
|
522
|
+
mimetype: 'image/jpeg',
|
|
523
|
+
originalname: 'foto.jpg',
|
|
524
|
+
skipMimeValidation: true,
|
|
525
|
+
skipSizeValidation: true,
|
|
526
|
+
skipImageOptimization: true,
|
|
527
|
+
});
|
|
528
|
+
} finally {
|
|
529
|
+
await fs.rm(tmp, { force: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
|
|
533
|
+
expect(provider.uploadFromPath).toHaveBeenCalledTimes(1);
|
|
534
|
+
expect(provider.upload).not.toHaveBeenCalled();
|
|
535
|
+
});
|
|
536
|
+
});
|
package/src/file/file.service.ts
CHANGED
|
@@ -31,7 +31,8 @@ import {
|
|
|
31
31
|
Injectable,
|
|
32
32
|
Logger,
|
|
33
33
|
NotFoundException,
|
|
34
|
-
OnModuleInit
|
|
34
|
+
OnModuleInit,
|
|
35
|
+
ServiceUnavailableException
|
|
35
36
|
} from '@nestjs/common';
|
|
36
37
|
import { JwtService } from '@nestjs/jwt';
|
|
37
38
|
import { createHash } from 'crypto';
|
|
@@ -60,6 +61,9 @@ import {
|
|
|
60
61
|
} from './provider/abstract.provider';
|
|
61
62
|
import { ProviderFactory } from './provider/provider.factory';
|
|
62
63
|
|
|
64
|
+
/** Códigos de erro do S3/Spaces que indicam indisponibilidade passageira, não arquivo ruim. */
|
|
65
|
+
const STORAGE_UNAVAILABLE_CODES = ['SlowDown', 'ServiceUnavailable', 'InternalError', 'RequestTimeout'];
|
|
66
|
+
|
|
63
67
|
type ResolvedProvider = {
|
|
64
68
|
provider: AbstractProvider;
|
|
65
69
|
providerId: number;
|
|
@@ -304,16 +308,37 @@ export class FileService implements OnModuleInit {
|
|
|
304
308
|
return file;
|
|
305
309
|
}
|
|
306
310
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Traduz a falha do provider de storage para a resposta HTTP certa.
|
|
313
|
+
*
|
|
314
|
+
* 5xx e throttle do S3/Spaces são indisponibilidade passageira, não defeito:
|
|
315
|
+
* viram 503 com `code: STORAGE_UNAVAILABLE`, que o HttpExceptionFilter da API
|
|
316
|
+
* responde com `Retry-After` e reporta deduplicado. Sem isso, um 503 do Spaces
|
|
317
|
+
* com corpo que não é XML (ex.: `upstream connect error` do balanceador)
|
|
318
|
+
* estourava no parse do SDK como `char 'u' is not expected` e virava 500.
|
|
319
|
+
*
|
|
320
|
+
* O status vem de `$metadata.httpStatusCode`, que o SDK carimba mesmo quando a
|
|
321
|
+
* desserialização do erro falha; `$response.statusCode` cobre o resto.
|
|
322
|
+
*/
|
|
323
|
+
private mapStorageError(error: any, fileId: number): never {
|
|
324
|
+
const status = error?.$metadata?.httpStatusCode ?? error?.$response?.statusCode;
|
|
325
|
+
const codes = [error?.name, error?.Code];
|
|
312
326
|
|
|
313
|
-
if (
|
|
327
|
+
if (codes.includes('NoSuchKey') || status === 404) {
|
|
314
328
|
throw new NotFoundException(`File not found in storage: ${fileId}`);
|
|
315
329
|
}
|
|
316
330
|
|
|
331
|
+
const unavailable =
|
|
332
|
+
(typeof status === 'number' && status >= 500) ||
|
|
333
|
+
codes.some((c) => STORAGE_UNAVAILABLE_CODES.includes(c));
|
|
334
|
+
|
|
335
|
+
if (unavailable) {
|
|
336
|
+
throw new ServiceUnavailableException(
|
|
337
|
+
{ message: 'Storage temporarily unavailable', code: 'STORAGE_UNAVAILABLE' },
|
|
338
|
+
{ cause: error },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
317
342
|
throw error;
|
|
318
343
|
}
|
|
319
344
|
|
|
@@ -338,11 +363,15 @@ export class FileService implements OnModuleInit {
|
|
|
338
363
|
const provider = await this.getProvider();
|
|
339
364
|
const providerPath = this.resolveProviderPath(file);
|
|
340
365
|
|
|
366
|
+
// O HEAD só serve para descobrir o tamanho quando a tabela não o tem. Feito
|
|
367
|
+
// sempre, dobrava as requisições ao storage em cada segmento HLS.
|
|
341
368
|
let metadata: any;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
if (file.size == null) {
|
|
370
|
+
try {
|
|
371
|
+
metadata = await provider.metaData(providerPath);
|
|
372
|
+
} catch {
|
|
373
|
+
metadata = undefined;
|
|
374
|
+
}
|
|
346
375
|
}
|
|
347
376
|
|
|
348
377
|
try {
|
|
@@ -353,7 +382,7 @@ export class FileService implements OnModuleInit {
|
|
|
353
382
|
size: file.size ?? this.getFileSizeFromMetadata(metadata),
|
|
354
383
|
};
|
|
355
384
|
} catch (error) {
|
|
356
|
-
this.
|
|
385
|
+
this.mapStorageError(error, fileId);
|
|
357
386
|
}
|
|
358
387
|
}
|
|
359
388
|
|
|
@@ -375,10 +404,15 @@ export class FileService implements OnModuleInit {
|
|
|
375
404
|
const resolvedEnd = end !== undefined ? end : totalSize > 0 ? totalSize - 1 : undefined;
|
|
376
405
|
const hasRange = start !== undefined;
|
|
377
406
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
407
|
+
let stream: Awaited<ReturnType<AbstractProvider['readStream']>>;
|
|
408
|
+
try {
|
|
409
|
+
stream = await provider.readStream(
|
|
410
|
+
providerPath,
|
|
411
|
+
hasRange ? { start: resolvedStart, end: resolvedEnd } : undefined,
|
|
412
|
+
);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.mapStorageError(error, fileId);
|
|
415
|
+
}
|
|
382
416
|
|
|
383
417
|
const contentLength =
|
|
384
418
|
resolvedEnd !== undefined ? resolvedEnd - resolvedStart + 1 : totalSize;
|
|
@@ -461,16 +495,7 @@ export class FileService implements OnModuleInit {
|
|
|
461
495
|
try {
|
|
462
496
|
providerBuffer = await provider.buffer(providerPath);
|
|
463
497
|
} catch (error: any) {
|
|
464
|
-
|
|
465
|
-
error?.name === 'NoSuchKey' ||
|
|
466
|
-
error?.Code === 'NoSuchKey' ||
|
|
467
|
-
error?.$metadata?.httpStatusCode === 404;
|
|
468
|
-
|
|
469
|
-
if (missingInStorage) {
|
|
470
|
-
throw new NotFoundException(`File not found in storage: ${fileId}`);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
throw error;
|
|
498
|
+
this.mapStorageError(error, fileId);
|
|
474
499
|
}
|
|
475
500
|
|
|
476
501
|
const buffer = Buffer.from(providerBuffer);
|
|
@@ -549,6 +574,10 @@ export class FileService implements OnModuleInit {
|
|
|
549
574
|
// Quem enviou. É o que permite recusar um `fileId` de outro usuário nas tools MCP
|
|
550
575
|
// (ver `assertFileUsable`); fica nulo nos caminhos internos sem usuário.
|
|
551
576
|
createdByUserId?: number | null;
|
|
577
|
+
// Grava a imagem byte a byte, sem a conversão para WebP nem o redimensionamento.
|
|
578
|
+
// Para quem entrega o arquivo a um consumidor externo que exige o formato
|
|
579
|
+
// original — o Instagram recusa WebP. O chamador já normalizou o arquivo.
|
|
580
|
+
skipImageOptimization?: boolean;
|
|
552
581
|
},
|
|
553
582
|
) {
|
|
554
583
|
|
|
@@ -585,12 +614,18 @@ export class FileService implements OnModuleInit {
|
|
|
585
614
|
fileBuffer.buffer,
|
|
586
615
|
);
|
|
587
616
|
|
|
588
|
-
const optimized =
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
617
|
+
const optimized = options?.skipImageOptimization
|
|
618
|
+
? {
|
|
619
|
+
buffer: fileBuffer.buffer,
|
|
620
|
+
mimetype: effectiveMimetype,
|
|
621
|
+
originalname: normalizedFilename,
|
|
622
|
+
}
|
|
623
|
+
: await this.imageOptimizationService.optimizeBuffer(
|
|
624
|
+
fileBuffer.buffer,
|
|
625
|
+
effectiveMimetype,
|
|
626
|
+
normalizedFilename,
|
|
627
|
+
{ maxWidth: resize?.maxWidth, maxHeight: resize?.maxHeight },
|
|
628
|
+
);
|
|
594
629
|
|
|
595
630
|
const normalizedFile = {
|
|
596
631
|
...fileBuffer,
|
|
@@ -635,6 +670,8 @@ export class FileService implements OnModuleInit {
|
|
|
635
670
|
createdByUserId?: number | null;
|
|
636
671
|
// Mesmo par maxWidth/maxHeight que `upload` aceita — só tem efeito em image/*.
|
|
637
672
|
resize?: { maxWidth?: number; maxHeight?: number };
|
|
673
|
+
// Mesma opção de `upload`: a imagem segue o caminho de streaming, intacta.
|
|
674
|
+
skipImageOptimization?: boolean;
|
|
638
675
|
},
|
|
639
676
|
) {
|
|
640
677
|
if (!filePath) {
|
|
@@ -684,8 +721,9 @@ export class FileService implements OnModuleInit {
|
|
|
684
721
|
// streaming direto do disco via provider.uploadFromPath. Isso evita carregar
|
|
685
722
|
// o arquivo inteiro na RAM (fs.readFile estoura o limite de Buffer / causa OOM)
|
|
686
723
|
// e habilita upload multipart no S3. A otimização de imagem só se aplica a
|
|
687
|
-
// image/*, então não há perda nesse caminho.
|
|
688
|
-
|
|
724
|
+
// image/*, então não há perda nesse caminho. `skipImageOptimization` manda a
|
|
725
|
+
// imagem pelo mesmo caminho: o chamador quer os bytes exatamente como estão.
|
|
726
|
+
if (!mimetype.startsWith('image/') || metadata?.skipImageOptimization) {
|
|
689
727
|
const url = await provider.uploadFromPath(destination, filePath, {
|
|
690
728
|
filename: originalname,
|
|
691
729
|
mimetype,
|
|
@@ -35,7 +35,7 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
|
|
|
35
35
|
uploadId: {
|
|
36
36
|
type: 'number',
|
|
37
37
|
description:
|
|
38
|
-
'ID returned by core.files.create-upload-url or core.files.upload.init
|
|
38
|
+
'ID returned by core.files.create-upload-url (after the PUT) or core.files.upload.init (after every chunk was sent with core.files.upload.append). The upload is completed automatically, so core.files.upload.complete is optional here.',
|
|
39
39
|
},
|
|
40
40
|
fileUrl: {
|
|
41
41
|
type: 'string',
|
|
@@ -45,7 +45,7 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
|
|
|
45
45
|
fileBase64: {
|
|
46
46
|
type: 'string',
|
|
47
47
|
description:
|
|
48
|
-
'Base64-encoded content, sent inline. Only for small files (about 256kb): base64 of anything larger overflows the model output limit. Requires fileName and mimeType.',
|
|
48
|
+
'Base64-encoded content, sent inline. Only for small files (about 256kb): base64 of anything larger overflows the model output limit; use core.files.upload.init for larger files instead of giving up. Requires fileName and mimeType.',
|
|
49
49
|
},
|
|
50
50
|
fileName: {
|
|
51
51
|
type: 'string',
|
|
@@ -72,9 +72,12 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
|
|
|
72
72
|
* `storage-max-size`, e é ele que aparece na mensagem de erro.
|
|
73
73
|
*/
|
|
74
74
|
export const FILE_SOURCE_DESCRIPTION = [
|
|
75
|
-
'Exactly one file source is required: fileId, uploadId, fileUrl or fileBase64.',
|
|
76
|
-
'
|
|
77
|
-
'
|
|
75
|
+
'Exactly one file source is required: fileId, uploadId, fileUrl or fileBase64. Pick the first that fits:',
|
|
76
|
+
'(1) file already in storage: fileId.',
|
|
77
|
+
'(2) file publicly reachable on the internet: fileUrl.',
|
|
78
|
+
'(3) client can make HTTP requests to the Hub API host (e.g. a local CLI agent): core.files.create-upload-url, PUT the bytes to the returned url, then pass the uploadId (or the fileId from core.files.upload.complete). Fastest.',
|
|
79
|
+
'(4) client runs in a network-restricted sandbox (e.g. claude.ai), or the PUT fails with host_not_allowed, 403 or a network error: core.files.upload.init, then core.files.upload.append for each chunk (index 0..totalChunks-1, chunkBase64 of at most chunkBytes raw bytes as returned by init), then pass the uploadId (or the fileId from core.files.upload.complete). Nothing leaves the MCP connection, and it works up to the storage max size; generate the chunks programmatically in the sandbox (split the file, print the base64 of each chunk) instead of typing base64 by hand.',
|
|
80
|
+
'(5) small file (about 256kb or less): fileBase64 inline.',
|
|
78
81
|
'Every path enforces the storage-accept-mimetype and storage-max-size settings (10mb by default).',
|
|
79
82
|
].join(' ');
|
|
80
83
|
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,9 @@ export * from './mail-layout/mail-layout.service';
|
|
|
33
33
|
export * from './message-template/message-template.module';
|
|
34
34
|
export * from './message-template/message-template.registry';
|
|
35
35
|
export * from './message-template/message-template.service';
|
|
36
|
+
export * from './document-kit/document-kit.module';
|
|
37
|
+
export * from './document-kit/document-kit.registry';
|
|
38
|
+
export * from './document-kit/document-kit.service';
|
|
36
39
|
export * from './mail/mail.module';
|
|
37
40
|
export * from './mail/mail.service';
|
|
38
41
|
|
|
@@ -57,8 +60,10 @@ export * from './file/mcp-file-source.schema';
|
|
|
57
60
|
export * from './file/mimetype-sniff.util';
|
|
58
61
|
export * from './file/remote-fetch.util';
|
|
59
62
|
|
|
63
|
+
export * from './pdf/document';
|
|
60
64
|
export * from './pdf/pdf.module';
|
|
61
65
|
export * from './pdf/pdf.service';
|
|
66
|
+
export * from './pdf/templates/commercial-proposal.template';
|
|
62
67
|
export * from './setting/brand-asset.service';
|
|
63
68
|
export * from './setting/setting.module';
|
|
64
69
|
export * from './setting/setting.service';
|
|
@@ -76,6 +76,27 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
|
|
|
76
76
|
// uso, então ele é reescrito no perfil a cada renovação: se não estiver aqui,
|
|
77
77
|
// cada renovação grava a credencial em texto puro no banco.
|
|
78
78
|
'melhor-envio': ['access_token', 'client_secret', 'refresh_token'],
|
|
79
|
+
// Redes sociais
|
|
80
|
+
// O app_secret troca o code do OAuth por token de qualquer Pagina que autorize o
|
|
81
|
+
// app; o webhook_verify_token e comparado no handshake dos webhooks da Meta.
|
|
82
|
+
// Os tokens das contas conectadas NAO moram aqui (ficam cifrados em
|
|
83
|
+
// social_channel, uma linha por Pagina/conta).
|
|
84
|
+
'meta-social': ['app_secret', 'webhook_verify_token'],
|
|
85
|
+
// App do Instagram (Instagram API with Instagram Login) — o segredo do app do
|
|
86
|
+
// INSTAGRAM, nao o do app da Meta. Troca o code do OAuth pelo token de
|
|
87
|
+
// qualquer conta que autorize o app; os tokens de 60 dias das contas ficam
|
|
88
|
+
// cifrados em social_channel.
|
|
89
|
+
'instagram-social': ['app_secret'],
|
|
90
|
+
// Apps do Google para o modulo social (um projeto por rede). O client_secret
|
|
91
|
+
// troca o code do OAuth pelo refresh token de qualquer canal/local que
|
|
92
|
+
// autorize o app; os refresh tokens ficam cifrados em social_channel.
|
|
93
|
+
'youtube-social': ['client_secret'],
|
|
94
|
+
'google-business-social': ['client_secret'],
|
|
95
|
+
// App do LinkedIn para paginas de empresa (Community Management API). O
|
|
96
|
+
// client_secret troca o code do OAuth e renova o access token de qualquer
|
|
97
|
+
// pagina que autorize o app; as credenciais das paginas (envelope lir1:/lia1:)
|
|
98
|
+
// ficam cifradas em social_channel.
|
|
99
|
+
'linkedin-social': ['client_secret'],
|
|
79
100
|
};
|
|
80
101
|
|
|
81
102
|
/** Chaves sensíveis de um provider (vazio se desconhecido). */
|
|
@@ -3,11 +3,45 @@ import {
|
|
|
3
3
|
fail,
|
|
4
4
|
pass,
|
|
5
5
|
probeJson,
|
|
6
|
+
redactSecrets,
|
|
6
7
|
requireFields,
|
|
7
8
|
str,
|
|
9
|
+
type ProbeResult,
|
|
8
10
|
} from './http-probe';
|
|
9
11
|
import { IntegrationTester } from './integration-tester.types';
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A frase do veredito mais o que o provider de fato respondeu.
|
|
15
|
+
*
|
|
16
|
+
* Sem isso todo 401 virava a mesma sentenca fixa e a resposta do provider —
|
|
17
|
+
* que o `probeJson` ja captura — era jogada fora. Foi o que fez um header de
|
|
18
|
+
* organizacao errado ser reportado como "chave invalida", mandando conferir o
|
|
19
|
+
* campo que estava certo.
|
|
20
|
+
*
|
|
21
|
+
* O resultado de um tester NAO passa por `sanitizeTesterError` (esse so cobre o
|
|
22
|
+
* caminho de excecao), entao a redacao dos segredos acontece aqui.
|
|
23
|
+
*/
|
|
24
|
+
function withProviderDetail(
|
|
25
|
+
base: string,
|
|
26
|
+
result: ProbeResult,
|
|
27
|
+
secretValues: string[],
|
|
28
|
+
): string {
|
|
29
|
+
const raw = String(result.body?.error?.message ?? '').trim();
|
|
30
|
+
if (!raw) return base;
|
|
31
|
+
|
|
32
|
+
const detail = redactSecrets(raw, secretValues).trim();
|
|
33
|
+
return detail ? `${base} ${detail}` : base;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** O 401 da OpenAI culpa a chave ou a organizacao? A resposta dela ja diz. */
|
|
37
|
+
function isOpenAiOrganizationProblem(result: ProbeResult): boolean {
|
|
38
|
+
const code = String(result.body?.error?.code ?? '');
|
|
39
|
+
const message = String(result.body?.error?.message ?? '');
|
|
40
|
+
return (
|
|
41
|
+
code === 'invalid_organization' || /no such organization/i.test(message)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
/**
|
|
12
46
|
* Testadores dos providers de IA e geração de vídeo.
|
|
13
47
|
*
|
|
@@ -33,7 +67,26 @@ const openAiTester: IntegrationTester = {
|
|
|
33
67
|
ctx.signal,
|
|
34
68
|
);
|
|
35
69
|
|
|
36
|
-
if (result.status === 401)
|
|
70
|
+
if (result.status === 401) {
|
|
71
|
+
// O header de organizacao e opcional e so este testador o envia: o chat
|
|
72
|
+
// real nunca manda. Apontar o campo certo evita trocar uma chave boa.
|
|
73
|
+
if (isOpenAiOrganizationProblem(result)) {
|
|
74
|
+
return fail(
|
|
75
|
+
withProviderDetail(
|
|
76
|
+
organization
|
|
77
|
+
? `OpenAI rejected the organization "${organization}". ` +
|
|
78
|
+
'Check the Organization ID field (it looks like "org-..."), ' +
|
|
79
|
+
'or leave it empty.'
|
|
80
|
+
: 'OpenAI rejected the organization.',
|
|
81
|
+
result,
|
|
82
|
+
ctx.secretValues,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return fail(
|
|
87
|
+
withProviderDetail('Invalid OpenAI API key.', result, ctx.secretValues),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
37
90
|
if (result.status === 403) {
|
|
38
91
|
return fail('This API key has no permission to list models.');
|
|
39
92
|
}
|
|
@@ -72,7 +125,9 @@ const geminiTester: IntegrationTester = {
|
|
|
72
125
|
const result = await probeJson(url, {}, ctx.signal);
|
|
73
126
|
|
|
74
127
|
if (result.status === 400 || result.status === 401 || result.status === 403) {
|
|
75
|
-
return fail(
|
|
128
|
+
return fail(
|
|
129
|
+
withProviderDetail('Invalid Gemini API key.', result, ctx.secretValues),
|
|
130
|
+
);
|
|
76
131
|
}
|
|
77
132
|
if (!result.ok) {
|
|
78
133
|
return fail(describeHttpError(result, 'Google rejected the credentials.'));
|
|
@@ -102,7 +157,11 @@ const claudeTester: IntegrationTester = {
|
|
|
102
157
|
ctx.signal,
|
|
103
158
|
);
|
|
104
159
|
|
|
105
|
-
if (result.status === 401)
|
|
160
|
+
if (result.status === 401) {
|
|
161
|
+
return fail(
|
|
162
|
+
withProviderDetail('Invalid Anthropic API key.', result, ctx.secretValues),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
106
165
|
if (!result.ok) {
|
|
107
166
|
return fail(describeHttpError(result, 'Anthropic rejected the credentials.'));
|
|
108
167
|
}
|
|
@@ -124,7 +183,11 @@ const deepseekTester: IntegrationTester = {
|
|
|
124
183
|
ctx.signal,
|
|
125
184
|
);
|
|
126
185
|
|
|
127
|
-
if (result.status === 401)
|
|
186
|
+
if (result.status === 401) {
|
|
187
|
+
return fail(
|
|
188
|
+
withProviderDetail('Invalid DeepSeek API key.', result, ctx.secretValues),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
128
191
|
if (!result.ok) {
|
|
129
192
|
return fail(describeHttpError(result, 'DeepSeek rejected the credentials.'));
|
|
130
193
|
}
|
|
@@ -147,7 +210,9 @@ const lumaTester: IntegrationTester = {
|
|
|
147
210
|
);
|
|
148
211
|
|
|
149
212
|
if (result.status === 401 || result.status === 403) {
|
|
150
|
-
return fail(
|
|
213
|
+
return fail(
|
|
214
|
+
withProviderDetail('Invalid Luma API key.', result, ctx.secretValues),
|
|
215
|
+
);
|
|
151
216
|
}
|
|
152
217
|
if (!result.ok) {
|
|
153
218
|
return fail(describeHttpError(result, 'Luma rejected the credentials.'));
|
|
@@ -26,11 +26,14 @@ const all = [
|
|
|
26
26
|
const bySlug = (slug: string): IntegrationTester =>
|
|
27
27
|
all.find((tester) => tester.providerSlug === slug)!;
|
|
28
28
|
|
|
29
|
-
const ctx = (
|
|
29
|
+
const ctx = (
|
|
30
|
+
overrides: Partial<IntegrationTestContext> = {},
|
|
31
|
+
): IntegrationTestContext => ({
|
|
30
32
|
signal: AbortSignal.timeout(5_000),
|
|
31
33
|
typeSlug: 'test',
|
|
32
34
|
providerSlug: 'test',
|
|
33
35
|
secretValues: [],
|
|
36
|
+
...overrides,
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
function jsonResponse(body: unknown, status = 200) {
|
|
@@ -252,6 +255,67 @@ describe('openai tester', () => {
|
|
|
252
255
|
error: 'Invalid OpenAI API key.',
|
|
253
256
|
});
|
|
254
257
|
});
|
|
258
|
+
|
|
259
|
+
// O header de organização é opcional e só este testador o envia. Um `org-...`
|
|
260
|
+
// errado devolve 401, e a mensagem fixa de "chave inválida" mandava conferir
|
|
261
|
+
// justamente o campo que estava certo.
|
|
262
|
+
it('culpa a organização, e não a chave, quando é ela que a OpenAI recusa', async () => {
|
|
263
|
+
fetchMock.mockResolvedValue(
|
|
264
|
+
jsonResponse(
|
|
265
|
+
{
|
|
266
|
+
error: {
|
|
267
|
+
message: 'No such organization: org-errada.',
|
|
268
|
+
code: 'invalid_organization',
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
401,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const outcome = await tester.test(
|
|
276
|
+
{ api_key: 'sk-test', organization: 'org-errada' },
|
|
277
|
+
ctx(),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(outcome.ok).toBe(false);
|
|
281
|
+
expect(outcome.error).toContain('organization');
|
|
282
|
+
expect(outcome.error).toContain('org-errada');
|
|
283
|
+
expect(outcome.error).not.toContain('Invalid OpenAI API key');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('acrescenta o motivo que a OpenAI deu quando a chave é mesmo o problema', async () => {
|
|
287
|
+
fetchMock.mockResolvedValue(
|
|
288
|
+
jsonResponse(
|
|
289
|
+
{ error: { message: 'Incorrect API key provided.', code: 'invalid_api_key' } },
|
|
290
|
+
401,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const outcome = await tester.test({ api_key: 'sk-bad' }, ctx());
|
|
295
|
+
|
|
296
|
+
expect(outcome.error).toBe(
|
|
297
|
+
'Invalid OpenAI API key. Incorrect API key provided.',
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// O resultado de um tester não passa por `sanitizeTesterError`, então a
|
|
302
|
+
// redação do segredo tem de acontecer dentro do próprio testador.
|
|
303
|
+
it('não deixa a chave vazar pela mensagem do provider', async () => {
|
|
304
|
+
fetchMock.mockResolvedValue(
|
|
305
|
+
jsonResponse(
|
|
306
|
+
{ error: { message: 'Incorrect API key provided: sk-supersecreta.' } },
|
|
307
|
+
401,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const outcome = await tester.test(
|
|
312
|
+
{ api_key: 'sk-supersecreta' },
|
|
313
|
+
ctx({ secretValues: ['sk-supersecreta'] }),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(outcome.error).not.toContain('sk-supersecreta');
|
|
317
|
+
expect(outcome.error).toContain('***');
|
|
318
|
+
});
|
|
255
319
|
});
|
|
256
320
|
|
|
257
321
|
describe('claude tester', () => {
|
|
@@ -15,6 +15,16 @@ const EXTERNAL_PROVIDERS = new Set([
|
|
|
15
15
|
// Abrir o certificado A1 é código do domínio fiscal: o tester se registra pelo
|
|
16
16
|
// NfseNationalModule (libraries/fiscal).
|
|
17
17
|
'nfse-national',
|
|
18
|
+
// App da Meta para redes sociais: o tester se registra pelo SocialModule
|
|
19
|
+
// (libraries/social), junto do client da Graph API.
|
|
20
|
+
'meta-social',
|
|
21
|
+
// App do Instagram (Instagram Login): o tester vive em libraries/social.
|
|
22
|
+
'instagram-social',
|
|
23
|
+
// Apps do Google do modulo social: os testers vivem em libraries/social.
|
|
24
|
+
'youtube-social',
|
|
25
|
+
'google-business-social',
|
|
26
|
+
// App do LinkedIn (paginas de empresa): o tester vive em libraries/social.
|
|
27
|
+
'linkedin-social',
|
|
18
28
|
]);
|
|
19
29
|
|
|
20
30
|
/** Providers legítimos sem nenhum campo secreto. */
|