@hed-hog/core 0.0.373 → 0.0.375

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 (109) hide show
  1. package/README.md +954 -873
  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 +42 -0
  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/e2e/core.spec.ts.ejs +28 -0
  57. package/hedhog/frontend/messages/en.json +35 -0
  58. package/hedhog/frontend/messages/pt.json +35 -0
  59. package/hedhog/table/user_account.yaml +1 -0
  60. package/package.json +3 -3
  61. package/src/access-log/access-log.service.spec.ts +235 -0
  62. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  63. package/src/auth/auth.service.spec.ts +891 -0
  64. package/src/auth/guards/auth.guard.spec.ts +177 -0
  65. package/src/challenge/challenge.service.spec.ts +334 -0
  66. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  67. package/src/file/image-optimization.service.spec.ts +283 -0
  68. package/src/file/provider/provider.factory.spec.ts +111 -0
  69. package/src/install/install.service.spec.ts +139 -0
  70. package/src/integration/README.md +2 -0
  71. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  72. package/src/integration/services/inbox.service.spec.ts +183 -0
  73. package/src/integration/services/integration-link.service.spec.ts +217 -0
  74. package/src/integration/services/outbox.service.spec.ts +290 -0
  75. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  76. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  77. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  78. package/src/integration-profile/integration-profile.utils.ts +14 -1
  79. package/src/locale/locale.service.spec.ts +78 -0
  80. package/src/mail/mail.service.spec.ts +257 -0
  81. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  82. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  83. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  84. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  85. package/src/mcp/mcp-server.service.spec.ts +187 -0
  86. package/src/menu/menu.service.spec.ts +76 -0
  87. package/src/notification/notification.service.spec.ts +297 -0
  88. package/src/oauth/oauth.controller.ts +53 -10
  89. package/src/oauth/oauth.module.ts +4 -0
  90. package/src/oauth/oauth.service.spec.ts +332 -0
  91. package/src/oauth/oauth.service.ts +156 -1
  92. package/src/oauth/providers/apple.provider.ts +175 -0
  93. package/src/oauth/providers/facebook.provider.ts +3 -2
  94. package/src/oauth/providers/google.provider.ts +3 -2
  95. package/src/oauth/providers/linkedin.provider.ts +96 -0
  96. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  97. package/src/oauth/providers/microsoft.provider.ts +3 -2
  98. package/src/pdf/pdf.service.ts +20 -2
  99. package/src/role/guards/role.guard.spec.ts +157 -0
  100. package/src/role/role.service.spec.ts +179 -0
  101. package/src/session/session.service.spec.ts +376 -0
  102. package/src/setting/setting.service.spec.ts +316 -0
  103. package/src/setting/setting.service.ts +4 -1
  104. package/src/task/task.service.spec.ts +47 -0
  105. package/src/token/token.service.spec.ts +351 -0
  106. package/src/user/user.service.spec.ts +256 -0
  107. package/src/utils/locale-context.spec.ts +22 -0
  108. package/src/utils/webauthn-origin.spec.ts +73 -0
  109. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -0,0 +1,376 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from '@jest/globals';
9
+ import {
10
+ BadRequestException,
11
+ UnauthorizedException,
12
+ } from '@nestjs/common';
13
+ import { SessionService } from './session.service';
14
+
15
+ const makeDeps = () => {
16
+ const prisma = {
17
+ user_session: {
18
+ create: jest.fn(),
19
+ findFirst: jest.fn(),
20
+ findUnique: jest.fn(),
21
+ findMany: jest.fn(),
22
+ updateMany: jest.fn(),
23
+ update: jest.fn(),
24
+ },
25
+ user: { findUnique: jest.fn() },
26
+ $transaction: jest.fn(),
27
+ };
28
+ const security = {
29
+ hashWithPepper: jest.fn((v: string) => `hash(${v})`),
30
+ };
31
+ const setting = {
32
+ getSettingValues: jest.fn().mockResolvedValue({}),
33
+ };
34
+ const token = {
35
+ createOpaqueToken: jest.fn().mockResolvedValue('opaque'),
36
+ removeRefreshTokenCookie: jest.fn().mockResolvedValue(undefined),
37
+ };
38
+ const http = { get: jest.fn() };
39
+ const user = { registerUserActivity: jest.fn().mockResolvedValue(undefined) };
40
+ const paginationService = { paginatePrismaModel: jest.fn() };
41
+ return { prisma, security, setting, token, http, user, paginationService };
42
+ };
43
+
44
+ const makeService = (deps = makeDeps()) => ({
45
+ service: new SessionService(
46
+ deps.prisma as any,
47
+ deps.security as any,
48
+ deps.setting as any,
49
+ deps.token as any,
50
+ deps.http as any,
51
+ deps.user as any,
52
+ deps.paginationService as any,
53
+ ),
54
+ deps,
55
+ });
56
+
57
+ describe('SessionService', () => {
58
+ afterEach(() => {
59
+ jest.clearAllMocks();
60
+ jest.useRealTimers();
61
+ });
62
+
63
+ describe('create - validações', () => {
64
+ it('lança BadRequest sem userId', async () => {
65
+ const { service } = makeService();
66
+ await expect(
67
+ service.create('en', 0 as any, '1.2.3.4', 'ua'),
68
+ ).rejects.toThrow(BadRequestException);
69
+ });
70
+
71
+ it('lança BadRequest sem ipAddress', async () => {
72
+ const { service } = makeService();
73
+ await expect(service.create('en', 1, '', 'ua')).rejects.toThrow(
74
+ BadRequestException,
75
+ );
76
+ });
77
+
78
+ it('lança BadRequest sem userAgent', async () => {
79
+ const { service } = makeService();
80
+ await expect(service.create('en', 1, '1.2.3.4', '')).rejects.toThrow(
81
+ BadRequestException,
82
+ );
83
+ });
84
+
85
+ it('cria sessão com expiração default (43200 min) quando setting ausente', async () => {
86
+ jest.useFakeTimers().setSystemTime(new Date('2026-01-15T12:00:00Z'));
87
+ const { service, deps } = makeService();
88
+ deps.setting.getSettingValues.mockResolvedValue({});
89
+ deps.prisma.user_session.create.mockResolvedValue({ id: 1 });
90
+
91
+ const result = await service.create('en', 5, '1.2.3.4', 'ua', 'br-1');
92
+
93
+ expect(result.token).toBe('opaque');
94
+ const createArg = deps.prisma.user_session.create.mock.calls[0][0];
95
+ expect(createArg.data.hash).toBe('hash(opaque)');
96
+ expect(createArg.data.browser_id).toBe('br-1');
97
+ const expected = new Date(
98
+ Date.parse('2026-01-15T12:00:00Z') + 43200 * 60 * 1000,
99
+ );
100
+ expect(createArg.data.expires_at.getTime()).toBe(expected.getTime());
101
+ });
102
+
103
+ it('aciona enforceSessionLimit quando max-concurrent-sessions > 0', async () => {
104
+ const { service, deps } = makeService();
105
+ deps.setting.getSettingValues.mockResolvedValue({
106
+ 'max-concurrent-sessions': 2,
107
+ });
108
+ deps.prisma.user_session.findMany.mockResolvedValue([]);
109
+ deps.prisma.user_session.create.mockResolvedValue({ id: 1 });
110
+
111
+ await service.create('en', 5, '1.2.3.4', 'ua');
112
+
113
+ expect(deps.prisma.user_session.findMany).toHaveBeenCalled();
114
+ });
115
+ });
116
+
117
+ describe('enforceSessionLimit', () => {
118
+ const run = (
119
+ deps: ReturnType<typeof makeDeps>,
120
+ service: SessionService,
121
+ sessions: Array<{ id: number; browser_id: string | null }>,
122
+ maxSessions: number,
123
+ isRotation: boolean,
124
+ currentBrowserId?: string | null,
125
+ ) => {
126
+ deps.prisma.user_session.findMany.mockResolvedValue(sessions);
127
+ return (service as any).enforceSessionLimit(
128
+ 7,
129
+ maxSessions,
130
+ isRotation,
131
+ currentBrowserId,
132
+ );
133
+ };
134
+
135
+ it('não revoga quando abaixo do limite', async () => {
136
+ const { service, deps } = makeService();
137
+ await run(
138
+ deps,
139
+ service,
140
+ [{ id: 1, browser_id: 'a' }],
141
+ 3,
142
+ false,
143
+ 'b',
144
+ );
145
+ expect(deps.prisma.user_session.updateMany).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('agrupa sessões do mesmo browser em um único slot', async () => {
149
+ const { service, deps } = makeService();
150
+ await run(
151
+ deps,
152
+ service,
153
+ [
154
+ { id: 1, browser_id: 'a' },
155
+ { id: 2, browser_id: 'a' },
156
+ ],
157
+ 1,
158
+ false,
159
+ 'a', // browser já tem slot -> isNewSlot false, currentSlotCount=1, revoke=1-1+0=0
160
+ );
161
+ expect(deps.prisma.user_session.updateMany).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('cada browser_id null conta como slot próprio', async () => {
165
+ const { service, deps } = makeService();
166
+ await run(
167
+ deps,
168
+ service,
169
+ [
170
+ { id: 1, browser_id: null },
171
+ { id: 2, browser_id: null },
172
+ ],
173
+ 1,
174
+ false,
175
+ null, // novo slot: currentSlotCount=2, revoke=2-1+1=2 -> revoga ambos
176
+ );
177
+ expect(deps.prisma.user_session.updateMany).toHaveBeenCalledWith(
178
+ expect.objectContaining({
179
+ where: { id: { in: [1, 2] } },
180
+ }),
181
+ );
182
+ });
183
+
184
+ it('revoga o slot mais antigo quando novo browser excede o limite', async () => {
185
+ const { service, deps } = makeService();
186
+ await run(
187
+ deps,
188
+ service,
189
+ [
190
+ { id: 1, browser_id: 'old' },
191
+ { id: 2, browser_id: 'mid' },
192
+ ],
193
+ 2,
194
+ false,
195
+ 'new', // currentSlotCount=2, isNewSlot true, revoke=2-2+1=1 -> revoga slot 0 (id 1)
196
+ );
197
+ expect(deps.prisma.user_session.updateMany).toHaveBeenCalledWith(
198
+ expect.objectContaining({ where: { id: { in: [1] } } }),
199
+ );
200
+ });
201
+
202
+ it('rotação não abre novo slot', async () => {
203
+ const { service, deps } = makeService();
204
+ await run(
205
+ deps,
206
+ service,
207
+ [
208
+ { id: 1, browser_id: 'a' },
209
+ { id: 2, browser_id: 'b' },
210
+ ],
211
+ 2,
212
+ true, // rotation: isNewSlot false, revoke=2-2+0=0
213
+ 'c',
214
+ );
215
+ expect(deps.prisma.user_session.updateMany).not.toHaveBeenCalled();
216
+ });
217
+ });
218
+
219
+ describe('refresh', () => {
220
+ it('lança Unauthorized sem refreshToken', async () => {
221
+ const { service } = makeService();
222
+ await expect(
223
+ service.refresh('en', '', '1.2.3.4', 'ua'),
224
+ ).rejects.toThrow(UnauthorizedException);
225
+ });
226
+
227
+ it('lança Unauthorized quando hash não encontrado', async () => {
228
+ const { service, deps } = makeService();
229
+ deps.prisma.user_session.findFirst.mockResolvedValue(null);
230
+ await expect(
231
+ service.refresh('en', 'tok', '1.2.3.4', 'ua'),
232
+ ).rejects.toThrow(UnauthorizedException);
233
+ });
234
+
235
+ it('lança Unauthorized quando sessão revogada', async () => {
236
+ const { service, deps } = makeService();
237
+ deps.prisma.user_session.findFirst.mockResolvedValue({
238
+ id: 1,
239
+ user_id: 5,
240
+ revoked_at: new Date(),
241
+ expires_at: new Date(Date.now() + 100000),
242
+ });
243
+ await expect(
244
+ service.refresh('en', 'tok', '1.2.3.4', 'ua'),
245
+ ).rejects.toThrow(UnauthorizedException);
246
+ });
247
+
248
+ it('lança Unauthorized quando sessão expirada', async () => {
249
+ const { service, deps } = makeService();
250
+ deps.prisma.user_session.findFirst.mockResolvedValue({
251
+ id: 1,
252
+ user_id: 5,
253
+ revoked_at: null,
254
+ expires_at: new Date(Date.now() - 1000),
255
+ });
256
+ await expect(
257
+ service.refresh('en', 'tok', '1.2.3.4', 'ua'),
258
+ ).rejects.toThrow(UnauthorizedException);
259
+ });
260
+
261
+ it('lança Unauthorized em corrida (updateResult.count === 0)', async () => {
262
+ const { service, deps } = makeService();
263
+ deps.prisma.user_session.findFirst.mockResolvedValue({
264
+ id: 1,
265
+ user_id: 5,
266
+ ip_address: '9.9.9.9',
267
+ user_agent: 'old-ua',
268
+ revoked_at: null,
269
+ expires_at: new Date(Date.now() + 100000),
270
+ browser_id: 'br',
271
+ });
272
+ const tx = {
273
+ user_session: {
274
+ updateMany: jest.fn().mockResolvedValue({ count: 0 }),
275
+ create: jest.fn().mockResolvedValue({ id: 2 }),
276
+ },
277
+ };
278
+ deps.prisma.$transaction.mockImplementation(async (cb: any) => cb(tx));
279
+
280
+ await expect(
281
+ service.refresh('en', 'tok', '1.2.3.4', 'ua'),
282
+ ).rejects.toThrow(UnauthorizedException);
283
+ });
284
+
285
+ it('rotaciona sessão e usa fallback de ip/ua quando vazios', async () => {
286
+ const { service, deps } = makeService();
287
+ deps.prisma.user_session.findFirst.mockResolvedValue({
288
+ id: 1,
289
+ user_id: 5,
290
+ ip_address: '9.9.9.9',
291
+ user_agent: 'old-ua',
292
+ revoked_at: null,
293
+ expires_at: new Date(Date.now() + 100000),
294
+ browser_id: 'br',
295
+ });
296
+ const tx = {
297
+ user_session: {
298
+ updateMany: jest.fn().mockResolvedValue({ count: 1 }),
299
+ create: jest.fn().mockResolvedValue({ id: 2 }),
300
+ },
301
+ };
302
+ deps.prisma.$transaction.mockImplementation(async (cb: any) => cb(tx));
303
+
304
+ const result = await service.refresh('en', 'tok', ' ', '');
305
+
306
+ expect(result.token).toBe('opaque');
307
+ expect(result.session).toEqual({ id: 2 });
308
+ const createArg = tx.user_session.create.mock.calls[0][0];
309
+ expect(createArg.data.ip_address).toBe('9.9.9.9');
310
+ expect(createArg.data.user_agent).toBe('old-ua');
311
+ expect(createArg.data.browser_id).toBe('br');
312
+ });
313
+ });
314
+
315
+ describe('getUserSessions - paginação', () => {
316
+ it('lança BadRequest quando usuário não existe', async () => {
317
+ const { service, deps } = makeService();
318
+ deps.prisma.user.findUnique.mockResolvedValue(null);
319
+ await expect(
320
+ service.getUserSessions({ page: 1, pageSize: 10 } as any, 1, 'en'),
321
+ ).rejects.toThrow(BadRequestException);
322
+ });
323
+
324
+ it('calcula lastPage e trata IP localhost', async () => {
325
+ const { service, deps } = makeService();
326
+ deps.prisma.user.findUnique.mockResolvedValue({ id: 1 });
327
+ deps.paginationService.paginatePrismaModel.mockResolvedValue({
328
+ data: [{ id: 1, ip_address: '127.0.0.1' }],
329
+ total: 25,
330
+ page: 1,
331
+ pageSize: 10,
332
+ });
333
+
334
+ const result = await service.getUserSessions(
335
+ { page: 1, pageSize: 10 } as any,
336
+ 1,
337
+ 'en',
338
+ );
339
+
340
+ expect(result.lastPage).toBe(3); // ceil(25/10)
341
+ expect(result.total).toBe(25);
342
+ expect(result.data[0].location).toEqual({
343
+ ip: '127.0.0.1',
344
+ country: 'Localhost',
345
+ region: '',
346
+ city: '',
347
+ });
348
+ });
349
+ });
350
+
351
+ describe('revokeUserSession', () => {
352
+ it('lança NotFound quando sessão não pertence ao usuário', async () => {
353
+ const { service, deps } = makeService();
354
+ deps.prisma.user_session.findFirst.mockResolvedValue(null);
355
+ await expect(
356
+ service.revokeUserSession(1, 99, 'en'),
357
+ ).rejects.toThrow('Session not found or does not belong to user');
358
+ });
359
+
360
+ it('revoga sessão existente e registra atividade', async () => {
361
+ const { service, deps } = makeService();
362
+ deps.prisma.user_session.findFirst.mockResolvedValue({ id: 5 });
363
+ deps.prisma.user_session.update.mockResolvedValue({ id: 5 });
364
+
365
+ await service.revokeUserSession(1, 5, 'en');
366
+
367
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(
368
+ 1,
369
+ 'revokeSession',
370
+ );
371
+ expect(deps.prisma.user_session.update).toHaveBeenCalledWith(
372
+ expect.objectContaining({ where: { id: 5 } }),
373
+ );
374
+ });
375
+ });
376
+ });
@@ -0,0 +1,316 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from '@jest/globals';
9
+ import { BadRequestException } from '@nestjs/common';
10
+ import { SettingService } from './setting.service';
11
+
12
+ const makeDeps = () => {
13
+ const prisma = {
14
+ setting: {
15
+ findMany: jest.fn(),
16
+ findFirst: jest.fn(),
17
+ findUnique: jest.fn(),
18
+ },
19
+ setting_user: { findMany: jest.fn() },
20
+ user: { findUnique: jest.fn() },
21
+ $executeRawUnsafe: jest.fn().mockResolvedValue(undefined),
22
+ $queryRawUnsafe: jest.fn().mockResolvedValue([]),
23
+ $transaction: jest.fn(),
24
+ };
25
+ const pagination = { paginate: jest.fn() };
26
+ const locale = { getEnables: jest.fn().mockResolvedValue([]) };
27
+ const integrationApi = { publishEvents: jest.fn().mockResolvedValue(undefined) };
28
+ return { prisma, pagination, locale, integrationApi };
29
+ };
30
+
31
+ const makeService = (deps = makeDeps()) => ({
32
+ service: new SettingService(
33
+ deps.prisma as any,
34
+ deps.pagination as any,
35
+ deps.locale as any,
36
+ deps.integrationApi as any,
37
+ ),
38
+ deps,
39
+ });
40
+
41
+ describe('SettingService', () => {
42
+ afterEach(() => {
43
+ jest.clearAllMocks();
44
+ jest.useRealTimers();
45
+ });
46
+
47
+ describe('setValueFormattedByType', () => {
48
+ let service: SettingService;
49
+ beforeEach(() => {
50
+ service = makeService().service;
51
+ });
52
+
53
+ it('coage boolean para "true"/"false"', () => {
54
+ expect(service.setValueFormattedByType('boolean', true)).toBe('true');
55
+ expect(service.setValueFormattedByType('boolean', 'true')).toBe('true');
56
+ expect(service.setValueFormattedByType('boolean', false)).toBe('false');
57
+ expect(service.setValueFormattedByType('boolean', 'no')).toBe('false');
58
+ });
59
+
60
+ it('number: vazio/null vira ""', () => {
61
+ expect(service.setValueFormattedByType('number', '')).toBe('');
62
+ expect(service.setValueFormattedByType('number', null)).toBe('');
63
+ expect(service.setValueFormattedByType('number', undefined)).toBe('');
64
+ expect(service.setValueFormattedByType('number', ' ')).toBe('');
65
+ });
66
+
67
+ it('number: converte valor válido e lança em inválido', () => {
68
+ expect(service.setValueFormattedByType('number', '42')).toBe('42');
69
+ expect(service.setValueFormattedByType('number', 3.5)).toBe('3.5');
70
+ expect(() => service.setValueFormattedByType('number', 'abc')).toThrow(
71
+ BadRequestException,
72
+ );
73
+ });
74
+
75
+ it('array/json: serializa objeto, mantém string', () => {
76
+ expect(service.setValueFormattedByType('json', { a: 1 })).toBe('{"a":1}');
77
+ expect(service.setValueFormattedByType('array', [1, 2])).toBe('[1,2]');
78
+ expect(service.setValueFormattedByType('json', '{"x":1}')).toBe('{"x":1}');
79
+ });
80
+
81
+ it('default: retorna valor sem alteração', () => {
82
+ expect(service.setValueFormattedByType('string', 'hello')).toBe('hello');
83
+ });
84
+ });
85
+
86
+ describe('getValueFormattedByType', () => {
87
+ let service: SettingService;
88
+ beforeEach(() => {
89
+ service = makeService().service;
90
+ });
91
+
92
+ it('espelha regras de boolean/number/json', () => {
93
+ expect(service.getValueFormattedByType('boolean', 'true')).toBe('true');
94
+ expect(service.getValueFormattedByType('number', '7')).toBe('7');
95
+ expect(service.getValueFormattedByType('number', '')).toBe('');
96
+ expect(service.getValueFormattedByType('json', { b: 2 })).toBe('{"b":2}');
97
+ expect(service.getValueFormattedByType('other', 'x')).toBe('x');
98
+ expect(() => service.getValueFormattedByType('number', 'NaNzz')).toThrow(
99
+ BadRequestException,
100
+ );
101
+ });
102
+ });
103
+
104
+ describe('parseVersionValue', () => {
105
+ it('trata number/bigint/string/desconhecido', () => {
106
+ const { service } = makeService();
107
+ const p = (v: unknown) => (service as any).parseVersionValue(v);
108
+ expect(p(5)).toBe(5);
109
+ expect(p(BigInt(9))).toBe(9);
110
+ expect(p('12')).toBe(12);
111
+ expect(p(null)).toBe(0);
112
+ expect(p({})).toBe(0);
113
+ });
114
+ });
115
+
116
+ describe('normalizeValueForComparison', () => {
117
+ let service: SettingService;
118
+ beforeEach(() => {
119
+ service = makeService().service;
120
+ });
121
+ const call = (v: any) => (service as any).normalizeValueForComparison(v);
122
+
123
+ it('undefined vira sentinela, null permanece null', () => {
124
+ expect(call(undefined)).toBe('__undefined__');
125
+ expect(call(null)).toBeNull();
126
+ });
127
+
128
+ it('faz trim de strings', () => {
129
+ expect(call(' hi ')).toBe('hi');
130
+ });
131
+
132
+ it('serializa objeto e cai em String em circular', () => {
133
+ expect(call({ a: 1 })).toBe('{"a":1}');
134
+ const circular: any = {};
135
+ circular.self = circular;
136
+ expect(call(circular)).toBe('[object Object]');
137
+ });
138
+ });
139
+
140
+ describe('isValueDefined', () => {
141
+ let service: SettingService;
142
+ beforeEach(() => {
143
+ service = makeService().service;
144
+ });
145
+ const call = (v: any) => (service as any).isValueDefined(v);
146
+
147
+ it('false para undefined/null/vazio', () => {
148
+ expect(call(undefined)).toBe(false);
149
+ expect(call(null)).toBe(false);
150
+ expect(call(' ')).toBe(false);
151
+ expect(call([])).toBe(false);
152
+ });
153
+
154
+ it('true para conteúdo', () => {
155
+ expect(call('x')).toBe(true);
156
+ expect(call([1])).toBe(true);
157
+ expect(call(0)).toBe(true);
158
+ expect(call(true)).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('setCache / clearCache', () => {
163
+ it('mescla e limpa o cache', () => {
164
+ const { service } = makeService();
165
+ service.setCache({ a: 1 });
166
+ service.setCache({ b: 2 });
167
+ expect((service as any).cachedSettings).toEqual({ a: 1, b: 2 });
168
+ service.clearCache();
169
+ expect((service as any).cachedSettings).toEqual({});
170
+ });
171
+ });
172
+
173
+ describe('getSettingValues', () => {
174
+ it('coage valores por tipo', async () => {
175
+ const { service, deps } = makeService();
176
+ deps.prisma.setting.findMany.mockResolvedValue([
177
+ { id: 1, slug: 'flag', type: 'boolean', value: 'true', user_override: false },
178
+ { id: 2, slug: 'count', type: 'number', value: '10', user_override: false },
179
+ { id: 3, slug: 'list', type: 'json', value: '{"a":1}', user_override: false },
180
+ { id: 4, slug: 'name', type: 'string', value: 'hh', user_override: false },
181
+ ]);
182
+
183
+ const result = await service.getSettingValues(['flag', 'count', 'list', 'name']);
184
+
185
+ expect(result).toEqual({
186
+ flag: true,
187
+ count: 10,
188
+ list: { a: 1 },
189
+ name: 'hh',
190
+ });
191
+ });
192
+
193
+ it('json inválido cai em string bruta', async () => {
194
+ const { service, deps } = makeService();
195
+ deps.prisma.setting.findMany.mockResolvedValue([
196
+ { id: 1, slug: 'bad', type: 'json', value: 'not-json', user_override: false },
197
+ ]);
198
+ const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
199
+
200
+ const result = await service.getSettingValues('bad');
201
+
202
+ expect(result.bad).toBe('not-json');
203
+ errSpy.mockRestore();
204
+ });
205
+
206
+ it('serve do cache sem reconsultar quando slug já cacheado', async () => {
207
+ const { service, deps } = makeService();
208
+ deps.prisma.setting.findMany.mockResolvedValue([
209
+ { id: 1, slug: 'flag', type: 'boolean', value: 'true', user_override: false },
210
+ ]);
211
+
212
+ await service.getSettingValues('flag');
213
+ const second = await service.getSettingValues('flag');
214
+
215
+ expect(second).toEqual({ flag: true });
216
+ expect(deps.prisma.setting.findMany).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ it('aplica override do usuário sem cachear', async () => {
220
+ const { service, deps } = makeService();
221
+ deps.prisma.setting.findMany.mockResolvedValue([
222
+ { id: 7, slug: 'theme', type: 'string', value: 'light', user_override: true },
223
+ ]);
224
+ deps.prisma.setting_user.findMany.mockResolvedValue([
225
+ { setting_id: 7, value: 'dark' },
226
+ ]);
227
+
228
+ const result = await service.getSettingValues('theme', 99);
229
+
230
+ expect(result.theme).toBe('dark');
231
+ expect(deps.prisma.setting_user.findMany).toHaveBeenCalledWith(
232
+ expect.objectContaining({
233
+ where: expect.objectContaining({ user_id: 99 }),
234
+ }),
235
+ );
236
+ });
237
+ });
238
+
239
+ describe('getEffectiveSettings', () => {
240
+ it('coage overrides do usuário conforme tipo global', async () => {
241
+ const { service } = makeService();
242
+ jest.spyOn(service, 'getSystemSettings').mockResolvedValue({
243
+ locales: [],
244
+ setting: {
245
+ flag: true,
246
+ count: 5,
247
+ obj: { a: 1 },
248
+ name: 'x',
249
+ },
250
+ });
251
+ jest.spyOn(service, 'getUserSettings').mockResolvedValue([
252
+ { setting: { slug: 'flag' }, value: 'false' },
253
+ { setting: { slug: 'count' }, value: '9' },
254
+ { setting: { slug: 'count' }, value: null },
255
+ { setting: { slug: 'obj' }, value: '{"b":2}' },
256
+ { setting: { slug: 'name' }, value: 'y' },
257
+ { setting: { slug: null }, value: 'ignored' },
258
+ ] as any);
259
+
260
+ const { setting } = await service.getEffectiveSettings(1);
261
+
262
+ expect(setting.flag).toBe(false);
263
+ expect(setting.count).toBe(9);
264
+ expect(setting.obj).toEqual({ b: 2 });
265
+ expect(setting.name).toBe('y');
266
+ });
267
+
268
+ it('mantém valor global quando número override é NaN', async () => {
269
+ const { service } = makeService();
270
+ jest.spyOn(service, 'getSystemSettings').mockResolvedValue({
271
+ locales: [],
272
+ setting: { count: 5 },
273
+ });
274
+ jest.spyOn(service, 'getUserSettings').mockResolvedValue([
275
+ { setting: { slug: 'count' }, value: 'abc' },
276
+ ] as any);
277
+
278
+ const { setting } = await service.getEffectiveSettings(1);
279
+ expect(setting.count).toBe('abc');
280
+ });
281
+ });
282
+
283
+ describe('emitSettingChangedEvents', () => {
284
+ it('não publica quando valor não muda', async () => {
285
+ const { service, deps } = makeService();
286
+ await (service as any).emitSettingChangedEvents([
287
+ { slug: 's', oldValue: 'a', newValue: 'a', source: 'test' },
288
+ ]);
289
+ expect(deps.integrationApi.publishEvents).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it('publica evento quando valor muda', async () => {
293
+ const { service, deps } = makeService();
294
+ await (service as any).emitSettingChangedEvents([
295
+ { settingId: 3, slug: 's', type: 'string', oldValue: 'a', newValue: 'b', source: 'test' },
296
+ ]);
297
+ expect(deps.integrationApi.publishEvents).toHaveBeenCalledWith([
298
+ expect.objectContaining({
299
+ eventName: 'core.setting.changed',
300
+ aggregateId: '3',
301
+ payload: expect.objectContaining({ slug: 's', hasValue: true }),
302
+ }),
303
+ ]);
304
+ });
305
+
306
+ it('engole erro de publishEvents', async () => {
307
+ const { service, deps } = makeService();
308
+ deps.integrationApi.publishEvents.mockRejectedValue(new Error('boom'));
309
+ await expect(
310
+ (service as any).emitSettingChangedEvents([
311
+ { slug: 's', oldValue: 'a', newValue: 'b', source: 'test' },
312
+ ]),
313
+ ).resolves.toBeUndefined();
314
+ });
315
+ });
316
+ });