@hed-hog/core 0.0.371 → 0.0.374

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 (107) hide show
  1. package/README.md +83 -1
  2. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  3. package/dist/integration-profile/integration-profile.secrets.js +4 -0
  4. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  5. package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
  6. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  7. package/dist/integration-profile/integration-profile.utils.js +1 -1
  8. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  9. package/dist/oauth/oauth.controller.d.ts +11 -3
  10. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  11. package/dist/oauth/oauth.controller.js +52 -14
  12. package/dist/oauth/oauth.controller.js.map +1 -1
  13. package/dist/oauth/oauth.module.d.ts.map +1 -1
  14. package/dist/oauth/oauth.module.js +4 -0
  15. package/dist/oauth/oauth.module.js.map +1 -1
  16. package/dist/oauth/oauth.service.d.ts +38 -2
  17. package/dist/oauth/oauth.service.d.ts.map +1 -1
  18. package/dist/oauth/oauth.service.js +122 -5
  19. package/dist/oauth/oauth.service.js.map +1 -1
  20. package/dist/oauth/providers/apple.provider.d.ts +34 -0
  21. package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
  22. package/dist/oauth/providers/apple.provider.js +197 -0
  23. package/dist/oauth/providers/apple.provider.js.map +1 -0
  24. package/dist/oauth/providers/facebook.provider.d.ts +1 -1
  25. package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
  26. package/dist/oauth/providers/facebook.provider.js +3 -2
  27. package/dist/oauth/providers/facebook.provider.js.map +1 -1
  28. package/dist/oauth/providers/google.provider.d.ts +1 -1
  29. package/dist/oauth/providers/google.provider.d.ts.map +1 -1
  30. package/dist/oauth/providers/google.provider.js +3 -2
  31. package/dist/oauth/providers/google.provider.js.map +1 -1
  32. package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
  33. package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
  34. package/dist/oauth/providers/linkedin.provider.js +99 -0
  35. package/dist/oauth/providers/linkedin.provider.js.map +1 -0
  36. package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
  37. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  38. package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
  39. package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
  40. package/dist/oauth/providers/microsoft.provider.js +3 -2
  41. package/dist/oauth/providers/microsoft.provider.js.map +1 -1
  42. package/dist/pdf/pdf.service.d.ts.map +1 -1
  43. package/dist/pdf/pdf.service.js +19 -2
  44. package/dist/pdf/pdf.service.js.map +1 -1
  45. package/dist/setting/setting.service.d.ts.map +1 -1
  46. package/dist/setting/setting.service.js +4 -1
  47. package/dist/setting/setting.service.js.map +1 -1
  48. package/hedhog/data/integration_provider.yaml +16 -0
  49. package/hedhog/data/setting_group.yaml +98 -0
  50. package/hedhog/data/setting_subgroup.yaml +16 -0
  51. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
  52. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
  53. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +57 -9
  54. package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
  55. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
  56. package/hedhog/frontend/messages/en.json +35 -0
  57. package/hedhog/frontend/messages/pt.json +35 -0
  58. package/hedhog/table/user_account.yaml +1 -0
  59. package/package.json +4 -4
  60. package/src/access-log/access-log.service.spec.ts +235 -0
  61. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  62. package/src/auth/auth.service.spec.ts +891 -0
  63. package/src/auth/guards/auth.guard.spec.ts +177 -0
  64. package/src/challenge/challenge.service.spec.ts +334 -0
  65. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  66. package/src/file/image-optimization.service.spec.ts +283 -0
  67. package/src/file/provider/provider.factory.spec.ts +111 -0
  68. package/src/install/install.service.spec.ts +139 -0
  69. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  70. package/src/integration/services/inbox.service.spec.ts +183 -0
  71. package/src/integration/services/integration-link.service.spec.ts +217 -0
  72. package/src/integration/services/outbox.service.spec.ts +290 -0
  73. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  74. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  75. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  76. package/src/integration-profile/integration-profile.utils.ts +14 -1
  77. package/src/locale/locale.service.spec.ts +78 -0
  78. package/src/mail/mail.service.spec.ts +257 -0
  79. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  80. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  81. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  82. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  83. package/src/mcp/mcp-server.service.spec.ts +187 -0
  84. package/src/menu/menu.service.spec.ts +76 -0
  85. package/src/notification/notification.service.spec.ts +297 -0
  86. package/src/oauth/oauth.controller.ts +53 -10
  87. package/src/oauth/oauth.module.ts +4 -0
  88. package/src/oauth/oauth.service.spec.ts +332 -0
  89. package/src/oauth/oauth.service.ts +156 -1
  90. package/src/oauth/providers/apple.provider.ts +175 -0
  91. package/src/oauth/providers/facebook.provider.ts +3 -2
  92. package/src/oauth/providers/google.provider.ts +3 -2
  93. package/src/oauth/providers/linkedin.provider.ts +96 -0
  94. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  95. package/src/oauth/providers/microsoft.provider.ts +3 -2
  96. package/src/pdf/pdf.service.ts +20 -2
  97. package/src/role/guards/role.guard.spec.ts +157 -0
  98. package/src/role/role.service.spec.ts +179 -0
  99. package/src/session/session.service.spec.ts +376 -0
  100. package/src/setting/setting.service.spec.ts +316 -0
  101. package/src/setting/setting.service.ts +4 -1
  102. package/src/task/task.service.spec.ts +47 -0
  103. package/src/token/token.service.spec.ts +351 -0
  104. package/src/user/user.service.spec.ts +256 -0
  105. package/src/utils/locale-context.spec.ts +22 -0
  106. package/src/utils/webauthn-origin.spec.ts +73 -0
  107. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+
3
+ jest.mock('@hed-hog/api-prisma', () => ({
4
+ __esModule: true,
5
+ PrismaService: class {},
6
+ Prisma: {
7
+ sql: (...args: any[]) => ({ __sql: args }),
8
+ raw: (v: string) => ({ __raw: v }),
9
+ },
10
+ }));
11
+
12
+ jest.mock('@hed-hog/api-pagination', () => ({
13
+ __esModule: true,
14
+ PaginationService: class {},
15
+ }));
16
+
17
+ jest.mock('geoip-lite', () => ({
18
+ __esModule: true,
19
+ lookup: jest.fn(),
20
+ }));
21
+
22
+ jest.mock('ua-parser-js', () => ({
23
+ __esModule: true,
24
+ UAParser: jest.fn(),
25
+ }));
26
+
27
+ import * as geoip from 'geoip-lite';
28
+ import { UAParser } from 'ua-parser-js';
29
+ import { AccessLogService } from './access-log.service';
30
+
31
+ const mockLookup = geoip.lookup as unknown as jest.Mock;
32
+ const mockUAParser = UAParser as unknown as jest.Mock;
33
+
34
+ const makeService = () => {
35
+ const prisma = {
36
+ access_log: { create: jest.fn() },
37
+ $executeRaw: jest.fn(),
38
+ };
39
+ const pagination = {
40
+ paginate: jest.fn(),
41
+ };
42
+ const service = new AccessLogService(prisma as any, pagination as any);
43
+ return { service, prisma, pagination };
44
+ };
45
+
46
+ describe('AccessLogService', () => {
47
+ beforeEach(() => {
48
+ mockUAParser.mockImplementation(() => ({
49
+ getResult: () => ({
50
+ browser: { name: 'Chrome', version: '120' },
51
+ os: { name: 'Windows', version: '11' },
52
+ }),
53
+ }));
54
+ mockLookup.mockReturnValue({ country: 'BR', city: 'Sao Paulo' });
55
+ });
56
+
57
+ afterEach(() => {
58
+ jest.clearAllMocks();
59
+ });
60
+
61
+ describe('recordHttp', () => {
62
+ it('persiste o log HTTP com os campos mapeados', () => {
63
+ const { service, prisma } = makeService();
64
+ prisma.access_log.create.mockResolvedValue({});
65
+
66
+ service.recordHttp({
67
+ userId: 3,
68
+ ip: '1.2.3.4',
69
+ userAgent: 'UA',
70
+ method: 'GET',
71
+ path: '/api/x',
72
+ });
73
+
74
+ expect(prisma.access_log.create).toHaveBeenCalledWith({
75
+ data: {
76
+ user_id: 3,
77
+ ip: '1.2.3.4',
78
+ user_agent: 'UA',
79
+ method: 'GET',
80
+ path: '/api/x',
81
+ },
82
+ });
83
+ });
84
+
85
+ it('usa null para campos opcionais ausentes', () => {
86
+ const { service, prisma } = makeService();
87
+ prisma.access_log.create.mockResolvedValue({});
88
+
89
+ service.recordHttp({ method: 'POST', path: '/x' });
90
+
91
+ expect(prisma.access_log.create).toHaveBeenCalledWith({
92
+ data: { user_id: null, ip: null, user_agent: null, method: 'POST', path: '/x' },
93
+ });
94
+ });
95
+
96
+ it('não propaga erro de persistência (loga warn)', async () => {
97
+ const { service, prisma } = makeService();
98
+ prisma.access_log.create.mockRejectedValue(new Error('db down'));
99
+ const warnSpy = jest
100
+ .spyOn((service as any).logger, 'warn')
101
+ .mockImplementation(() => undefined);
102
+
103
+ expect(() => service.recordHttp({ method: 'GET', path: '/x' })).not.toThrow();
104
+ await new Promise((r) => process.nextTick(r));
105
+
106
+ expect(warnSpy).toHaveBeenCalled();
107
+ });
108
+ });
109
+
110
+ describe('recordMcp', () => {
111
+ it('persiste o log MCP com tool e prompt', () => {
112
+ const { service, prisma } = makeService();
113
+ prisma.access_log.create.mockResolvedValue({});
114
+
115
+ service.recordMcp({ userId: 9, mcpTool: 'search', mcpPrompt: 'q' });
116
+
117
+ expect(prisma.access_log.create).toHaveBeenCalledWith({
118
+ data: { user_id: 9, mcp_tool: 'search', mcp_prompt: 'q' },
119
+ });
120
+ });
121
+ });
122
+
123
+ describe('list', () => {
124
+ const paginateResult = (data: any[]) => ({ data, total: data.length, page: 1 });
125
+
126
+ it('sem filtros usa where vazio e mantém orderBy/include', async () => {
127
+ const { service, pagination } = makeService();
128
+ pagination.paginate.mockResolvedValue(paginateResult([]));
129
+
130
+ await service.list({});
131
+
132
+ const [, , opts] = pagination.paginate.mock.calls[0];
133
+ expect(opts.where).toEqual({});
134
+ expect(opts.orderBy).toEqual({ created_at: 'desc' });
135
+ expect(opts.include.user.select).toMatchObject({ id: true, name: true });
136
+ });
137
+
138
+ it('filtra por userId', async () => {
139
+ const { service, pagination } = makeService();
140
+ pagination.paginate.mockResolvedValue(paginateResult([]));
141
+
142
+ await service.list({ userId: 5 });
143
+
144
+ expect(pagination.paginate.mock.calls[0][2].where).toMatchObject({ user_id: 5 });
145
+ });
146
+
147
+ it('filtra por userSearch com contains insensitive', async () => {
148
+ const { service, pagination } = makeService();
149
+ pagination.paginate.mockResolvedValue(paginateResult([]));
150
+
151
+ await service.list({ userSearch: 'ana' });
152
+
153
+ expect(pagination.paginate.mock.calls[0][2].where.user).toEqual({
154
+ OR: [{ name: { contains: 'ana', mode: 'insensitive' } }],
155
+ });
156
+ });
157
+
158
+ it('monta o range de datas com fim de dia em createdAtTo', async () => {
159
+ const { service, pagination } = makeService();
160
+ pagination.paginate.mockResolvedValue(paginateResult([]));
161
+
162
+ await service.list({ createdAtFrom: '2026-01-01', createdAtTo: '2026-01-31' });
163
+
164
+ const where = pagination.paginate.mock.calls[0][2].where;
165
+ expect(where.created_at.gte).toEqual(new Date('2026-01-01'));
166
+ const lte: Date = where.created_at.lte;
167
+ expect(lte.getHours()).toBe(23);
168
+ expect(lte.getMinutes()).toBe(59);
169
+ expect(lte.getSeconds()).toBe(59);
170
+ expect(lte.getMilliseconds()).toBe(999);
171
+ });
172
+
173
+ it('type=http filtra method not null; type=mcp filtra mcp_tool not null', async () => {
174
+ const { service, pagination } = makeService();
175
+ pagination.paginate.mockResolvedValue(paginateResult([]));
176
+
177
+ await service.list({ type: 'http' });
178
+ expect(pagination.paginate.mock.calls[0][2].where).toMatchObject({
179
+ method: { not: null },
180
+ });
181
+
182
+ await service.list({ type: 'mcp' });
183
+ expect(pagination.paginate.mock.calls[1][2].where).toMatchObject({
184
+ mcp_tool: { not: null },
185
+ });
186
+ });
187
+
188
+ it('enriquece cada log com dados de user-agent e geoip', async () => {
189
+ const { service, pagination } = makeService();
190
+ pagination.paginate.mockResolvedValue(
191
+ paginateResult([{ id: 1, user_agent: 'UA', ip: '8.8.8.8' }]),
192
+ );
193
+
194
+ const result = await service.list({});
195
+
196
+ expect(result.data[0]).toMatchObject({
197
+ browser_name: 'Chrome',
198
+ browser_version: '120',
199
+ os_name: 'Windows',
200
+ os_version: '11',
201
+ country: 'BR',
202
+ city: 'Sao Paulo',
203
+ });
204
+ });
205
+
206
+ it('usa null quando não há user_agent nem ip', async () => {
207
+ const { service, pagination } = makeService();
208
+ pagination.paginate.mockResolvedValue(
209
+ paginateResult([{ id: 2, user_agent: null, ip: null }]),
210
+ );
211
+
212
+ const result = await service.list({});
213
+
214
+ expect(result.data[0]).toMatchObject({
215
+ browser_name: null,
216
+ os_name: null,
217
+ country: null,
218
+ city: null,
219
+ });
220
+ expect(mockUAParser).not.toHaveBeenCalled();
221
+ expect(mockLookup).not.toHaveBeenCalled();
222
+ });
223
+ });
224
+
225
+ describe('clearOld', () => {
226
+ it('executa o DELETE em lote via $executeRaw', async () => {
227
+ const { service, prisma } = makeService();
228
+ prisma.$executeRaw.mockResolvedValue(1);
229
+
230
+ await service.clearOld(30, 500);
231
+
232
+ expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+ import { AiPromptBuilderService } from './ai-prompt-builder.service';
3
+
4
+ const makeService = () => {
5
+ const instructionService = {
6
+ getLayer: jest.fn(),
7
+ getAgentInstruction: jest.fn(),
8
+ };
9
+ const service = new AiPromptBuilderService(instructionService as any);
10
+ return { service, instructionService };
11
+ };
12
+
13
+ describe('AiPromptBuilderService', () => {
14
+ afterEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
18
+ describe('build', () => {
19
+ it('inclui apenas a camada system quando é a única disponível', async () => {
20
+ const { service, instructionService } = makeService();
21
+ instructionService.getLayer.mockImplementation(async (layer: string) =>
22
+ layer === 'system' ? 'SYS' : null,
23
+ );
24
+
25
+ const result = await service.build({});
26
+
27
+ expect(result.layers).toEqual(['SYS']);
28
+ expect(result.layerNames).toEqual(['system']);
29
+ expect(result.systemPrompt).toBe('SYS');
30
+ });
31
+
32
+ it('junta system + product com o separador', async () => {
33
+ const { service, instructionService } = makeService();
34
+ instructionService.getLayer.mockImplementation(async (layer: string) => {
35
+ if (layer === 'system') return 'SYS';
36
+ if (layer === 'product') return 'PROD';
37
+ return null;
38
+ });
39
+
40
+ const result = await service.build({});
41
+
42
+ expect(result.layers).toEqual(['SYS', 'PROD']);
43
+ expect(result.layerNames).toEqual(['system', 'product']);
44
+ expect(result.systemPrompt).toBe('SYS\n\n---\n\nPROD');
45
+ });
46
+
47
+ it('inclui a camada module quando moduleSlug é fornecido e existe', async () => {
48
+ const { service, instructionService } = makeService();
49
+ instructionService.getLayer.mockImplementation(
50
+ async (layer: string, slug?: string) => {
51
+ if (layer === 'system') return 'SYS';
52
+ if (layer === 'module' && slug === 'crm') return 'MOD';
53
+ return null;
54
+ },
55
+ );
56
+
57
+ const result = await service.build({ moduleSlug: 'crm' });
58
+
59
+ expect(result.layerNames).toEqual(['system', 'module:crm']);
60
+ expect(instructionService.getLayer).toHaveBeenCalledWith('module', 'crm');
61
+ });
62
+
63
+ it('não inclui module quando moduleSlug é fornecido mas não há conteúdo', async () => {
64
+ const { service, instructionService } = makeService();
65
+ instructionService.getLayer.mockImplementation(async (layer: string) =>
66
+ layer === 'system' ? 'SYS' : null,
67
+ );
68
+
69
+ const result = await service.build({ moduleSlug: 'crm' });
70
+
71
+ expect(result.layerNames).toEqual(['system']);
72
+ });
73
+
74
+ it('inclui a camada agent quando agentSlug é fornecido e existe', async () => {
75
+ const { service, instructionService } = makeService();
76
+ instructionService.getLayer.mockImplementation(async (layer: string) =>
77
+ layer === 'system' ? 'SYS' : null,
78
+ );
79
+ instructionService.getAgentInstruction.mockResolvedValue('AGENT');
80
+
81
+ const result = await service.build({ agentSlug: 'support' });
82
+
83
+ expect(result.layerNames).toEqual(['system', 'agent:support']);
84
+ expect(result.layers).toEqual(['SYS', 'AGENT']);
85
+ expect(instructionService.getAgentInstruction).toHaveBeenCalledWith('support');
86
+ });
87
+
88
+ it('compõe as quatro camadas na ordem de prioridade', async () => {
89
+ const { service, instructionService } = makeService();
90
+ instructionService.getLayer.mockImplementation(
91
+ async (layer: string, slug?: string) => {
92
+ if (layer === 'system') return 'L1';
93
+ if (layer === 'product') return 'L2';
94
+ if (layer === 'module' && slug === 'm') return 'L3';
95
+ return null;
96
+ },
97
+ );
98
+ instructionService.getAgentInstruction.mockResolvedValue('L4');
99
+
100
+ const result = await service.build({ moduleSlug: 'm', agentSlug: 'a' });
101
+
102
+ expect(result.layers).toEqual(['L1', 'L2', 'L3', 'L4']);
103
+ expect(result.systemPrompt).toBe('L1\n\n---\n\nL2\n\n---\n\nL3\n\n---\n\nL4');
104
+ });
105
+
106
+ it('retorna prompt vazio quando nenhuma camada tem conteúdo', async () => {
107
+ const { service, instructionService } = makeService();
108
+ instructionService.getLayer.mockResolvedValue(null);
109
+
110
+ const result = await service.build({});
111
+
112
+ expect(result.layers).toEqual([]);
113
+ expect(result.layerNames).toEqual([]);
114
+ expect(result.systemPrompt).toBe('');
115
+ });
116
+ });
117
+
118
+ describe('buildRuntimeContext', () => {
119
+ it('inclui apenas o cabeçalho quando não há parâmetros', () => {
120
+ const { service } = makeService();
121
+ expect(service.buildRuntimeContext({})).toBe('[Runtime context]');
122
+ });
123
+
124
+ it('inclui campos presentes na ordem esperada', () => {
125
+ const { service } = makeService();
126
+ const out = service.buildRuntimeContext({
127
+ userId: 7,
128
+ locale: 'pt',
129
+ userRoles: ['admin', 'user'],
130
+ currentDate: '2026-07-04',
131
+ });
132
+ expect(out).toBe(
133
+ [
134
+ '[Runtime context]',
135
+ 'user_id: 7',
136
+ 'locale: pt',
137
+ 'roles: admin, user',
138
+ 'date: 2026-07-04',
139
+ ].join('\n'),
140
+ );
141
+ });
142
+
143
+ it('ignora userRoles vazio', () => {
144
+ const { service } = makeService();
145
+ const out = service.buildRuntimeContext({ userId: 1, userRoles: [] });
146
+ expect(out).toBe('[Runtime context]\nuser_id: 1');
147
+ });
148
+
149
+ it('anexa extraContext como pares chave: valor', () => {
150
+ const { service } = makeService();
151
+ const out = service.buildRuntimeContext({
152
+ userId: 1,
153
+ extraContext: { plan: 'pro', tz: 'UTC' },
154
+ });
155
+ expect(out).toContain('plan: pro');
156
+ expect(out).toContain('tz: UTC');
157
+ });
158
+ });
159
+
160
+ describe('summarizeHistory', () => {
161
+ it('retorna as mensagens intactas quando o total <= keepLast', () => {
162
+ const { service } = makeService();
163
+ const messages = [
164
+ { role: 'user', content: 'a' },
165
+ { role: 'assistant', content: 'b' },
166
+ ];
167
+ expect(service.summarizeHistory(messages, 6)).toBe(messages);
168
+ });
169
+
170
+ it('resume as mensagens antigas e mantém as recentes verbatim', () => {
171
+ const { service } = makeService();
172
+ const messages = Array.from({ length: 10 }, (_, i) => ({
173
+ role: i % 2 === 0 ? 'user' : 'assistant',
174
+ content: `msg-${i}`,
175
+ }));
176
+
177
+ const result = service.summarizeHistory(messages, 6);
178
+
179
+ // 1 mensagem de resumo + 6 recentes
180
+ expect(result).toHaveLength(7);
181
+ expect(result[0].role).toBe('user');
182
+ expect(result[0].content).toContain('[Earlier conversation summary: 4 messages]');
183
+ expect(result[0].content).toContain('First question: "msg-0"');
184
+ // As 6 últimas verbatim.
185
+ expect(result.slice(1)).toEqual(messages.slice(4));
186
+ });
187
+
188
+ it('inclui a contagem de tool_call quando há chamadas de ferramenta', () => {
189
+ const { service } = makeService();
190
+ const messages = [
191
+ { role: 'user', content: 'q' },
192
+ { role: 'tool_call', content: 't1' },
193
+ { role: 'tool_call', content: 't2' },
194
+ { role: 'assistant', content: 'r' },
195
+ { role: 'user', content: 'q2' },
196
+ { role: 'assistant', content: 'r2' },
197
+ { role: 'user', content: 'q3' },
198
+ { role: 'assistant', content: 'r3' },
199
+ ];
200
+
201
+ const result = service.summarizeHistory(messages, 2);
202
+
203
+ expect(result[0].content).toContain('Tools called: 2');
204
+ });
205
+
206
+ it('trunca a primeira pergunta em 120 caracteres', () => {
207
+ const { service } = makeService();
208
+ const longQuestion = 'x'.repeat(200);
209
+ const messages = [
210
+ { role: 'user', content: longQuestion },
211
+ ...Array.from({ length: 6 }, (_, i) => ({
212
+ role: 'assistant',
213
+ content: `r-${i}`,
214
+ })),
215
+ ];
216
+
217
+ const result = service.summarizeHistory(messages, 1);
218
+
219
+ expect(result[0].content).toContain(`"${'x'.repeat(120)}"`);
220
+ expect(result[0].content).not.toContain('x'.repeat(121));
221
+ });
222
+ });
223
+
224
+ describe('interpolate', () => {
225
+ it('substitui os placeholders {{var}} pelos valores', () => {
226
+ const { service } = makeService();
227
+ expect(
228
+ service.interpolate('Olá {{name}}, seu plano é {{plan}}', {
229
+ name: 'Ana',
230
+ plan: 'pro',
231
+ }),
232
+ ).toBe('Olá Ana, seu plano é pro');
233
+ });
234
+
235
+ it('mantém o placeholder quando a variável não existe', () => {
236
+ const { service } = makeService();
237
+ expect(service.interpolate('valor {{missing}}', {})).toBe('valor {{missing}}');
238
+ });
239
+
240
+ it('substitui múltiplas ocorrências da mesma variável', () => {
241
+ const { service } = makeService();
242
+ expect(service.interpolate('{{x}}-{{x}}', { x: 'a' })).toBe('a-a');
243
+ });
244
+
245
+ it('não altera texto sem placeholders', () => {
246
+ const { service } = makeService();
247
+ expect(service.interpolate('sem variaveis', { a: '1' })).toBe('sem variaveis');
248
+ });
249
+ });
250
+ });