@luanpoppe/ai 1.1.3 → 1.1.4

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 (38) hide show
  1. package/dist/@types/image.d.ts +4 -0
  2. package/dist/@types/image.d.ts.map +1 -0
  3. package/dist/@types/image.js +10 -0
  4. package/dist/@types/image.js.map +1 -0
  5. package/dist/ai.d.ts.map +1 -1
  6. package/dist/ai.js +6 -0
  7. package/dist/ai.js.map +1 -1
  8. package/dist/index.d.ts +4 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/langchain/messages.d.ts +17 -0
  13. package/dist/langchain/messages.d.ts.map +1 -1
  14. package/dist/langchain/messages.js +21 -0
  15. package/dist/langchain/messages.js.map +1 -1
  16. package/dist/langchain/models.d.ts +4 -0
  17. package/dist/langchain/models.d.ts.map +1 -1
  18. package/dist/langchain/models.js +14 -2
  19. package/dist/langchain/models.js.map +1 -1
  20. package/dist/utils/image-utils.d.ts +8 -0
  21. package/dist/utils/image-utils.d.ts.map +1 -0
  22. package/dist/utils/image-utils.js +91 -0
  23. package/dist/utils/image-utils.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/@types/image.ts +14 -0
  26. package/src/ai.ts +11 -0
  27. package/src/index.ts +5 -0
  28. package/src/langchain/messages.ts +47 -0
  29. package/src/langchain/models.ts +19 -2
  30. package/src/utils/image-utils.ts +113 -0
  31. package/tests/e2e/@fixtures/imagem-teste-bateria.jpg +0 -0
  32. package/tests/e2e/ai-audio.test.ts +3 -3
  33. package/tests/e2e/ai-image.test.ts +173 -0
  34. package/tests/e2e/ai.test.ts +44 -4
  35. package/tests/unit/index.test.ts +95 -0
  36. package/tests/unit/langchain/image-utils.test.ts +127 -0
  37. package/tests/unit/langchain/messages.test.ts +78 -0
  38. package/tests/unit/langchain/models.test.ts +16 -0
@@ -0,0 +1,113 @@
1
+ import type { ImageBuffer, ImageMimeType } from "../@types/image";
2
+
3
+ export class ImageUtils {
4
+ static bufferToBase64(buffer: ImageBuffer): string {
5
+ if (buffer instanceof Buffer) {
6
+ return buffer.toString("base64");
7
+ }
8
+
9
+ if (buffer instanceof ArrayBuffer) {
10
+ const uint8Array = new Uint8Array(buffer);
11
+ return Buffer.from(uint8Array).toString("base64");
12
+ }
13
+
14
+ if (buffer instanceof Uint8Array) {
15
+ return Buffer.from(buffer).toString("base64");
16
+ }
17
+
18
+ throw new Error("Tipo de buffer não suportado");
19
+ }
20
+
21
+ static detectMimeTypeFromExtension(filename?: string): ImageMimeType | null {
22
+ if (!filename) {
23
+ return null;
24
+ }
25
+
26
+ const extension = filename.toLowerCase().split(".").pop();
27
+
28
+ const mimeTypeMap: Record<string, ImageMimeType> = {
29
+ jpg: "image/jpeg",
30
+ jpeg: "image/jpeg",
31
+ png: "image/png",
32
+ gif: "image/gif",
33
+ webp: "image/webp",
34
+ };
35
+
36
+ return extension ? mimeTypeMap[extension] ?? null : null;
37
+ }
38
+
39
+ static detectMimeTypeFromBuffer(buffer: ImageBuffer): ImageMimeType | null {
40
+ let bytes: Uint8Array;
41
+
42
+ if (buffer instanceof Buffer) {
43
+ bytes = new Uint8Array(buffer.slice(0, 16));
44
+ } else if (buffer instanceof ArrayBuffer) {
45
+ bytes = new Uint8Array(buffer.slice(0, 16));
46
+ } else {
47
+ bytes = buffer.slice(0, 16);
48
+ }
49
+
50
+ // JPEG: FF D8 FF
51
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
52
+ return "image/jpeg";
53
+ }
54
+
55
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
56
+ if (
57
+ bytes[0] === 0x89 &&
58
+ bytes[1] === 0x50 &&
59
+ bytes[2] === 0x4e &&
60
+ bytes[3] === 0x47 &&
61
+ bytes[4] === 0x0d &&
62
+ bytes[5] === 0x0a &&
63
+ bytes[6] === 0x1a &&
64
+ bytes[7] === 0x0a
65
+ ) {
66
+ return "image/png";
67
+ }
68
+
69
+ // GIF: 47 49 46 38 (GIF8)
70
+ if (
71
+ bytes[0] === 0x47 &&
72
+ bytes[1] === 0x49 &&
73
+ bytes[2] === 0x46 &&
74
+ bytes[3] === 0x38
75
+ ) {
76
+ return "image/gif";
77
+ }
78
+
79
+ // WebP: 52 49 46 46 (RIFF) ... 57 45 42 50 (WEBP)
80
+ if (
81
+ bytes[0] === 0x52 &&
82
+ bytes[1] === 0x49 &&
83
+ bytes[2] === 0x46 &&
84
+ bytes[3] === 0x46 &&
85
+ bytes[8] === 0x57 &&
86
+ bytes[9] === 0x45 &&
87
+ bytes[10] === 0x42 &&
88
+ bytes[11] === 0x50
89
+ ) {
90
+ return "image/webp";
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ static detectImageMimeType(
97
+ buffer: ImageBuffer,
98
+ filename?: string
99
+ ): ImageMimeType {
100
+ const mimeFromExtension =
101
+ ImageUtils.detectMimeTypeFromExtension(filename);
102
+ if (mimeFromExtension) {
103
+ return mimeFromExtension;
104
+ }
105
+
106
+ const mimeFromBuffer = ImageUtils.detectMimeTypeFromBuffer(buffer);
107
+ if (mimeFromBuffer) {
108
+ return mimeFromBuffer;
109
+ }
110
+
111
+ return "image/jpeg";
112
+ }
113
+ }
@@ -58,7 +58,7 @@ describe("AI Audio E2E Tests", () => {
58
58
  }
59
59
  );
60
60
 
61
- it(
61
+ it.skip(
62
62
  "deve processar áudio diretamente com Gemini (multimodal)",
63
63
  { timeout },
64
64
  async () => {
@@ -132,7 +132,7 @@ describe("AI Audio E2E Tests", () => {
132
132
  }
133
133
  );
134
134
 
135
- it(
135
+ it.skip(
136
136
  "deve processar áudio com OpenRouter usando Gemini (multimodal)",
137
137
  { timeout },
138
138
  async () => {
@@ -168,7 +168,7 @@ describe("AI Audio E2E Tests", () => {
168
168
  }
169
169
  );
170
170
 
171
- it(
171
+ it.skip(
172
172
  "deve processar áudio com OpenRouter usando GPT (transcrição prévia com Whisper)",
173
173
  { timeout },
174
174
  async () => {
@@ -0,0 +1,173 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { AI } from "../../src/index";
4
+ import { AIMessages } from "../../src/langchain/messages";
5
+ import "dotenv/config";
6
+
7
+ const IMAGE_FIXTURE_PATH = path.join(
8
+ process.cwd(),
9
+ "tests",
10
+ "e2e",
11
+ "@fixtures",
12
+ "imagem-teste-bateria.jpg"
13
+ );
14
+
15
+ const PROMPT =
16
+ "Descreva brevemente o que há nesta imagem. Responda em uma frase.";
17
+
18
+ const EXPECTED_TERMS = [
19
+ "bateria",
20
+ "baterista",
21
+ "pratos",
22
+ "tambor",
23
+ "drum",
24
+ "cymbal",
25
+ "palco",
26
+ "stage",
27
+ ];
28
+
29
+ function responseRelatesToImage(text: string): boolean {
30
+ const lower = text.toLowerCase();
31
+ return EXPECTED_TERMS.some((term) => lower.includes(term));
32
+ }
33
+
34
+ describe("AI Image E2E Tests", () => {
35
+ const openAIApiKey = process.env.OPENAI_API_KEY;
36
+ const googleGeminiToken = process.env.GOOGLE_GEMINI_TOKEN;
37
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
38
+
39
+ const timeout = 180 * 1000;
40
+
41
+ describe("Suporte a imagens", () => {
42
+ it(
43
+ "deve processar imagem com OpenAI (gpt-4o)",
44
+ { timeout },
45
+ async () => {
46
+ if (!openAIApiKey) {
47
+ console.log("OPENAI_API_KEY não está configurada");
48
+ return;
49
+ }
50
+ const ai = new AI({
51
+ openAIApiKey: openAIApiKey!,
52
+ });
53
+
54
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
55
+ const message = AIMessages.humanImage({
56
+ image: {
57
+ buffer: imageBuffer,
58
+ filename: "imagem-teste-bateria.jpg",
59
+ },
60
+ text: PROMPT,
61
+ });
62
+
63
+ const result = await ai.call({
64
+ aiModel: "gpt-4o",
65
+ messages: [message],
66
+ });
67
+
68
+ expect(result.text).toBeDefined();
69
+ expect(result.text.length).toBeGreaterThan(0);
70
+ expect(result.text).not.toContain("Model call failed");
71
+ expect(result.text).not.toContain("Invalid");
72
+ expect(responseRelatesToImage(result.text)).toBe(true);
73
+ }
74
+ );
75
+
76
+ it.skip(
77
+ "deve processar imagem com Gemini (gemini-2.5-flash)",
78
+ { timeout },
79
+ async () => {
80
+ if (!googleGeminiToken) {
81
+ console.log("GOOGLE_GEMINI_TOKEN não está configurada");
82
+ return;
83
+ }
84
+ const ai = new AI({
85
+ googleGeminiToken: googleGeminiToken!,
86
+ });
87
+
88
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
89
+ const message = AIMessages.humanImage({
90
+ image: {
91
+ buffer: imageBuffer,
92
+ filename: "imagem-teste-bateria.jpg",
93
+ },
94
+ text: PROMPT,
95
+ });
96
+
97
+ const result = await ai.call({
98
+ aiModel: "gemini-2.5-flash",
99
+ messages: [message],
100
+ });
101
+
102
+ expect(result.text).toBeDefined();
103
+ expect(result.text.length).toBeGreaterThan(0);
104
+ expect(responseRelatesToImage(result.text)).toBe(true);
105
+ }
106
+ );
107
+
108
+ it.skip(
109
+ "deve processar imagem com OpenRouter (openrouter/openai/gpt-4o)",
110
+ { timeout },
111
+ async () => {
112
+ if (!openRouterApiKey) {
113
+ console.log("OPENROUTER_API_KEY não está configurada");
114
+ return;
115
+ }
116
+ const ai = new AI({
117
+ openRouterApiKey: openRouterApiKey!,
118
+ });
119
+
120
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
121
+ const message = AIMessages.humanImage({
122
+ image: {
123
+ buffer: imageBuffer,
124
+ filename: "imagem-teste-bateria.jpg",
125
+ },
126
+ text: PROMPT,
127
+ });
128
+
129
+ const result = await ai.call({
130
+ aiModel: "openrouter/openai/gpt-4o",
131
+ messages: [message],
132
+ });
133
+
134
+ expect(result.text).toBeDefined();
135
+ expect(result.text.length).toBeGreaterThan(0);
136
+ expect(result.text).not.toContain("Model call failed");
137
+ expect(responseRelatesToImage(result.text)).toBe(true);
138
+ }
139
+ );
140
+
141
+ it.skip(
142
+ "deve processar imagem com OpenRouter (openrouter/google/gemini-2.5-flash)",
143
+ { timeout },
144
+ async () => {
145
+ if (!openRouterApiKey) {
146
+ console.log("OPENROUTER_API_KEY não está configurada");
147
+ return;
148
+ }
149
+ const ai = new AI({
150
+ openRouterApiKey: openRouterApiKey!,
151
+ });
152
+
153
+ const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
154
+ const message = AIMessages.humanImage({
155
+ image: {
156
+ buffer: imageBuffer,
157
+ filename: "imagem-teste-bateria.jpg",
158
+ },
159
+ text: PROMPT,
160
+ });
161
+
162
+ const result = await ai.call({
163
+ aiModel: "openrouter/google/gemini-2.5-flash",
164
+ messages: [message],
165
+ });
166
+
167
+ expect(result.text).toBeDefined();
168
+ expect(result.text.length).toBeGreaterThan(0);
169
+ expect(responseRelatesToImage(result.text)).toBe(true);
170
+ }
171
+ );
172
+ });
173
+ });
@@ -45,7 +45,7 @@ describe("AI E2E Tests", () => {
45
45
  },
46
46
  );
47
47
 
48
- it(
48
+ it.skip(
49
49
  "deve fazer uma chamada real para Gemini e retornar resposta",
50
50
  { timeout },
51
51
  async () => {
@@ -139,7 +139,7 @@ describe("AI E2E Tests", () => {
139
139
  },
140
140
  );
141
141
 
142
- it(
142
+ it.skip(
143
143
  "deve criar múltiplas mensagens e fazer chamada",
144
144
  { timeout },
145
145
  async () => {
@@ -348,7 +348,7 @@ describe("AI E2E Tests", () => {
348
348
  },
349
349
  );
350
350
 
351
- it(
351
+ it.skip(
352
352
  "deve retornar resposta estruturada com Gemini",
353
353
  { timeout },
354
354
  async () => {
@@ -484,8 +484,9 @@ describe("AI E2E Tests", () => {
484
484
  AIMessages.human("Explique o que é TypeScript em uma frase curta."),
485
485
  ];
486
486
 
487
+ // gpt-4o suporta temperature; gpt-5-nano (reasoning) só aceita default
487
488
  const result = await ai.call({
488
- aiModel: "gpt-5-nano",
489
+ aiModel: "gpt-4o",
489
490
  messages,
490
491
  modelConfig: {
491
492
  maxTokens: 50,
@@ -499,6 +500,45 @@ describe("AI E2E Tests", () => {
499
500
  },
500
501
  );
501
502
 
503
+ // Níveis suportados pela API OpenAI (o1, gpt-5-nano, etc.): low, medium, high.
504
+ // Modelos mais recentes (gpt-5.2) podem suportar também: none, minimal, xhigh.
505
+ it.each([
506
+ { reasoningEffort: "low" as const },
507
+ { reasoningEffort: "medium" as const },
508
+ { reasoningEffort: "high" as const },
509
+ ])(
510
+ "deve usar reasoningEffort=$reasoningEffort em chamada real ao GPT",
511
+ { timeout },
512
+ async ({ reasoningEffort }) => {
513
+ if (!openAIApiKey) {
514
+ console.log("OPENAI_API_KEY não está configurada");
515
+ return;
516
+ }
517
+ const ai = new AI({
518
+ openAIApiKey: openAIApiKey!,
519
+ });
520
+
521
+ const messages = [
522
+ AIMessages.human(
523
+ "Responda apenas com a palavra 'OK' (modelo de reasoning).",
524
+ ),
525
+ ];
526
+
527
+ const result = await ai.call({
528
+ aiModel: "gpt-5-nano",
529
+ messages,
530
+ modelConfig: {
531
+ reasoningEffort,
532
+ },
533
+ });
534
+
535
+ expect(result.text).toBeDefined();
536
+ expect(result.text.length).toBeGreaterThan(0);
537
+ expect(result.messages).toBeDefined();
538
+ expect(result.messages.length).toBeGreaterThan(0);
539
+ },
540
+ );
541
+
502
542
  it(
503
543
  "deve usar maxTokens e temperature corretamente com OpenRouter",
504
544
  { timeout },
@@ -293,6 +293,101 @@ describe("AI", () => {
293
293
  });
294
294
  });
295
295
 
296
+ it("deve passar reasoningEffort no modelConfig", async () => {
297
+ const mockModel = {} as any;
298
+ const mockMessages = [AIMessages.human("Teste")];
299
+ const mockResponse = {
300
+ messages: [{ content: "Resposta" } as any],
301
+ };
302
+
303
+ vi.mocked(AIModels.gpt).mockReturnValue(mockModel);
304
+ vi.mocked(createAgent).mockReturnValue({
305
+ invoke: vi.fn().mockResolvedValue(mockResponse),
306
+ } as any);
307
+
308
+ await ai.call({
309
+ aiModel: "gpt-5-nano",
310
+ messages: mockMessages,
311
+ modelConfig: {
312
+ reasoningEffort: "medium",
313
+ },
314
+ });
315
+
316
+ expect(AIModels.gpt).toHaveBeenCalledWith({
317
+ model: "gpt-5-nano",
318
+ apiKey: mockTokens.openAIApiKey,
319
+ reasoningEffort: "medium",
320
+ });
321
+ });
322
+
323
+ it("deve exibir warning quando reasoningEffort é passado com modelo Gemini", async () => {
324
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
325
+ const mockModel = {} as any;
326
+ const mockMessages = [AIMessages.human("Teste")];
327
+ const mockResponse = {
328
+ messages: [{ content: "Resposta" } as any],
329
+ };
330
+
331
+ vi.mocked(AIModels.gemini).mockReturnValue(mockModel);
332
+ vi.mocked(createAgent).mockReturnValue({
333
+ invoke: vi.fn().mockResolvedValue(mockResponse),
334
+ } as any);
335
+
336
+ const aiWithGemini = new AI({
337
+ openAIApiKey: "test-key",
338
+ googleGeminiToken: "test-gemini-token",
339
+ });
340
+
341
+ await aiWithGemini.call({
342
+ aiModel: "gemini-2.5-flash",
343
+ messages: mockMessages,
344
+ modelConfig: {
345
+ reasoningEffort: "high",
346
+ },
347
+ });
348
+
349
+ expect(warnSpy).toHaveBeenCalledWith(
350
+ expect.stringContaining('O modelo "gemini-2.5-flash" não suporta reasoningEffort'),
351
+ );
352
+ expect(AIModels.gemini).toHaveBeenCalledWith(
353
+ expect.not.objectContaining({ reasoningEffort: expect.anything() }),
354
+ );
355
+ warnSpy.mockRestore();
356
+ });
357
+
358
+ it("deve exibir warning quando reasoningEffort é passado com OpenRouter não-OpenAI", async () => {
359
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
360
+ const mockModel = {} as any;
361
+ const mockMessages = [AIMessages.human("Teste")];
362
+ const mockResponse = {
363
+ messages: [{ content: "Resposta" } as any],
364
+ };
365
+
366
+ vi.mocked(AIModels.openrouter).mockReturnValue(mockModel);
367
+ vi.mocked(createAgent).mockReturnValue({
368
+ invoke: vi.fn().mockResolvedValue(mockResponse),
369
+ } as any);
370
+
371
+ const aiWithOpenRouter = new AI({
372
+ openRouterApiKey: "test-openrouter-key",
373
+ });
374
+
375
+ await aiWithOpenRouter.call({
376
+ aiModel: "openrouter/google/gemini-2.5-flash",
377
+ messages: mockMessages,
378
+ modelConfig: {
379
+ reasoningEffort: "high",
380
+ },
381
+ });
382
+
383
+ expect(warnSpy).toHaveBeenCalledWith(
384
+ expect.stringContaining(
385
+ 'O modelo "openrouter/google/gemini-2.5-flash" não suporta reasoningEffort',
386
+ ),
387
+ );
388
+ warnSpy.mockRestore();
389
+ });
390
+
296
391
  it("deve lançar erro quando memory está ativo e threadId não é fornecido", async () => {
297
392
  const aiWithMemory = new AI({
298
393
  openAIApiKey: "test-key",
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ImageUtils } from "../../../src/utils/image-utils";
3
+
4
+ describe("ImageUtils", () => {
5
+ describe("bufferToBase64", () => {
6
+ it("deve converter Buffer para base64", () => {
7
+ const buffer = Buffer.from("test data");
8
+ const result = ImageUtils.bufferToBase64(buffer);
9
+ expect(result).toBe("dGVzdCBkYXRh");
10
+ });
11
+
12
+ it("deve converter ArrayBuffer para base64", () => {
13
+ const arrayBuffer = new TextEncoder().encode("test data").buffer;
14
+ const result = ImageUtils.bufferToBase64(arrayBuffer);
15
+ expect(result).toBe("dGVzdCBkYXRh");
16
+ });
17
+
18
+ it("deve converter Uint8Array para base64", () => {
19
+ const uint8Array = new TextEncoder().encode("test data");
20
+ const result = ImageUtils.bufferToBase64(uint8Array);
21
+ expect(result).toBe("dGVzdCBkYXRh");
22
+ });
23
+
24
+ it("deve lançar erro para tipo não suportado", () => {
25
+ expect(() => {
26
+ ImageUtils.bufferToBase64("invalid" as any);
27
+ }).toThrow("Tipo de buffer não suportado");
28
+ });
29
+ });
30
+
31
+ describe("detectMimeTypeFromExtension", () => {
32
+ it("deve detectar JPEG pela extensão", () => {
33
+ expect(ImageUtils.detectMimeTypeFromExtension("imagem.jpg")).toBe(
34
+ "image/jpeg"
35
+ );
36
+ expect(ImageUtils.detectMimeTypeFromExtension("foto.jpeg")).toBe(
37
+ "image/jpeg"
38
+ );
39
+ expect(ImageUtils.detectMimeTypeFromExtension("foto.JPG")).toBe(
40
+ "image/jpeg"
41
+ );
42
+ });
43
+
44
+ it("deve detectar PNG pela extensão", () => {
45
+ expect(ImageUtils.detectMimeTypeFromExtension("imagem.png")).toBe(
46
+ "image/png"
47
+ );
48
+ });
49
+
50
+ it("deve detectar GIF pela extensão", () => {
51
+ expect(ImageUtils.detectMimeTypeFromExtension("animacao.gif")).toBe(
52
+ "image/gif"
53
+ );
54
+ });
55
+
56
+ it("deve detectar WebP pela extensão", () => {
57
+ expect(ImageUtils.detectMimeTypeFromExtension("foto.webp")).toBe(
58
+ "image/webp"
59
+ );
60
+ });
61
+
62
+ it("deve retornar null para extensão desconhecida", () => {
63
+ expect(
64
+ ImageUtils.detectMimeTypeFromExtension("arquivo.unknown")
65
+ ).toBeNull();
66
+ });
67
+
68
+ it("deve retornar null quando filename não é fornecido", () => {
69
+ expect(ImageUtils.detectMimeTypeFromExtension(undefined)).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("detectMimeTypeFromBuffer", () => {
74
+ it("deve detectar JPEG pelos magic bytes", () => {
75
+ const buffer = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x00]);
76
+ expect(ImageUtils.detectMimeTypeFromBuffer(buffer)).toBe("image/jpeg");
77
+ });
78
+
79
+ it("deve detectar PNG pelos magic bytes", () => {
80
+ const buffer = Buffer.from([
81
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
82
+ 0x00, 0x00, 0x00, 0x00, 0x00,
83
+ ]);
84
+ expect(ImageUtils.detectMimeTypeFromBuffer(buffer)).toBe("image/png");
85
+ });
86
+
87
+ it("deve detectar GIF pelos magic bytes", () => {
88
+ const buffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x00, 0x00]);
89
+ expect(ImageUtils.detectMimeTypeFromBuffer(buffer)).toBe("image/gif");
90
+ });
91
+
92
+ it("deve detectar WebP pelos magic bytes", () => {
93
+ const buffer = Buffer.from([
94
+ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
95
+ ]);
96
+ expect(ImageUtils.detectMimeTypeFromBuffer(buffer)).toBe("image/webp");
97
+ });
98
+
99
+ it("deve retornar null para buffer desconhecido", () => {
100
+ const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
101
+ expect(ImageUtils.detectMimeTypeFromBuffer(buffer)).toBeNull();
102
+ });
103
+ });
104
+
105
+ describe("detectImageMimeType", () => {
106
+ it("deve priorizar extensão sobre magic bytes", () => {
107
+ const buffer = Buffer.from([0xff, 0xd8, 0xff]); // Magic bytes de JPEG
108
+ const result = ImageUtils.detectImageMimeType(buffer, "imagem.png");
109
+ expect(result).toBe("image/png");
110
+ });
111
+
112
+ it("deve usar magic bytes quando extensão não está disponível", () => {
113
+ const buffer = Buffer.from([
114
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
115
+ 0x00, 0x00, 0x00, 0x00, 0x00,
116
+ ]);
117
+ const result = ImageUtils.detectImageMimeType(buffer);
118
+ expect(result).toBe("image/png");
119
+ });
120
+
121
+ it("deve usar fallback para JPEG quando não consegue detectar", () => {
122
+ const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
123
+ const result = ImageUtils.detectImageMimeType(buffer);
124
+ expect(result).toBe("image/jpeg");
125
+ });
126
+ });
127
+ });