@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,96 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { McpRegistryService } from './mcp-registry.service';
3
+ import { MCP_TOOL_METADATA } from './decorators/mcp-tool.decorator';
4
+
5
+ class ToolProvider {
6
+ myTool() {
7
+ return 'result';
8
+ }
9
+ notATool() {
10
+ return 'nope';
11
+ }
12
+ }
13
+
14
+ class DuplicateProvider {
15
+ alsoMyTool() {
16
+ return 'dup';
17
+ }
18
+ }
19
+
20
+ const makeReflector = (defs: Array<[Function, any]>) => {
21
+ const map = new Map(defs);
22
+ return {
23
+ get: jest.fn((_key: any, target: any) => map.get(target)),
24
+ };
25
+ };
26
+
27
+ describe('McpRegistryService', () => {
28
+ afterEach(() => jest.clearAllMocks());
29
+
30
+ it('registra tools decoradas e ignora métodos sem metadata', () => {
31
+ const instance = new ToolProvider();
32
+ const reflector = makeReflector([
33
+ [ToolProvider.prototype.myTool, { name: 'my_tool', description: 'd' }],
34
+ ]);
35
+ const discovery = { getProviders: () => [{ instance }] };
36
+ const service = new McpRegistryService(discovery as any, reflector as any);
37
+
38
+ service.onModuleInit();
39
+
40
+ expect(service.has('my_tool')).toBe(true);
41
+ expect(service.getAll()).toHaveLength(1);
42
+ expect(reflector.get).toHaveBeenCalledWith(
43
+ MCP_TOOL_METADATA,
44
+ ToolProvider.prototype.myTool,
45
+ );
46
+ });
47
+
48
+ it('deduplica tools com mesmo nome (mantém a primeira)', () => {
49
+ const first = new ToolProvider();
50
+ const dup = new DuplicateProvider();
51
+ const reflector = makeReflector([
52
+ [ToolProvider.prototype.myTool, { name: 'my_tool', description: 'first' }],
53
+ [DuplicateProvider.prototype.alsoMyTool, { name: 'my_tool', description: 'dup' }],
54
+ ]);
55
+ const discovery = { getProviders: () => [{ instance: first }, { instance: dup }] };
56
+ const service = new McpRegistryService(discovery as any, reflector as any);
57
+
58
+ service.onModuleInit();
59
+
60
+ expect(service.getAll()).toHaveLength(1);
61
+ expect(service.get('my_tool')?.definition.description).toBe('first');
62
+ });
63
+
64
+ it('ignora providers sem instância', () => {
65
+ const reflector = makeReflector([]);
66
+ const discovery = {
67
+ getProviders: () => [{ instance: null }, { instance: undefined }],
68
+ };
69
+ const service = new McpRegistryService(discovery as any, reflector as any);
70
+ service.onModuleInit();
71
+ expect(service.getAll()).toHaveLength(0);
72
+ });
73
+
74
+ it('handler executa o método vinculado à instância', async () => {
75
+ const instance = new ToolProvider();
76
+ const reflector = makeReflector([
77
+ [ToolProvider.prototype.myTool, { name: 'my_tool', description: 'd' }],
78
+ ]);
79
+ const discovery = { getProviders: () => [{ instance }] };
80
+ const service = new McpRegistryService(discovery as any, reflector as any);
81
+ service.onModuleInit();
82
+
83
+ const entry = service.get('my_tool');
84
+ expect(entry!.handler({}, {} as any)).toBe('result');
85
+ });
86
+
87
+ it('get retorna undefined para tool inexistente', () => {
88
+ const service = new McpRegistryService(
89
+ { getProviders: () => [] } as any,
90
+ makeReflector([]) as any,
91
+ );
92
+ service.onModuleInit();
93
+ expect(service.get('missing')).toBeUndefined();
94
+ expect(service.has('missing')).toBe(false);
95
+ });
96
+ });
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+
3
+ const setRequestHandlerMock = jest.fn();
4
+ const connectMock = jest.fn<any>().mockResolvedValue(undefined);
5
+ const handleRequestMock = jest.fn<any>().mockResolvedValue(undefined);
6
+
7
+ jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
8
+ Server: jest.fn().mockImplementation(function (this: any) {
9
+ this.setRequestHandler = setRequestHandlerMock;
10
+ this.connect = connectMock;
11
+ }),
12
+ }));
13
+
14
+ jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
15
+ StreamableHTTPServerTransport: jest.fn().mockImplementation(function (this: any) {
16
+ this.handleRequest = handleRequestMock;
17
+ }),
18
+ }));
19
+
20
+ import { McpServerService } from './mcp-server.service';
21
+ import {
22
+ CallToolRequestSchema,
23
+ ListToolsRequestSchema,
24
+ } from '@modelcontextprotocol/sdk/types.js';
25
+ import { McpContext } from './types/mcp-context.type';
26
+
27
+ const makeDeps = () => {
28
+ const registry = {
29
+ getAll: jest.fn(),
30
+ get: jest.fn(),
31
+ };
32
+ const audit = {
33
+ record: jest.fn<any>().mockResolvedValue(undefined),
34
+ };
35
+ return { registry, audit };
36
+ };
37
+
38
+ const makeService = (deps = makeDeps()) => ({
39
+ service: new McpServerService(deps.registry as any, deps.audit as any),
40
+ deps,
41
+ });
42
+
43
+ const getRegisteredHandler = (schema: any) => {
44
+ const call = setRequestHandlerMock.mock.calls.find((c: any) => c[0] === schema);
45
+ return call?.[1];
46
+ };
47
+
48
+ const context: McpContext = {
49
+ userId: 1,
50
+ sessionId: 'sess-1',
51
+ locale: 'pt-BR',
52
+ toolName: '',
53
+ };
54
+
55
+ describe('McpServerService', () => {
56
+ afterEach(() => jest.clearAllMocks());
57
+
58
+ describe('buildServer / ListTools handler', () => {
59
+ it('lista as ferramentas registradas mapeando definition para o schema MCP', async () => {
60
+ const { service, deps } = makeService();
61
+ deps.registry.getAll.mockReturnValue([
62
+ {
63
+ definition: { name: 'tool-a', description: 'desc a', inputSchema: { type: 'object', properties: { x: {} } } },
64
+ handler: jest.fn(),
65
+ },
66
+ {
67
+ definition: { name: 'tool-b', description: 'desc b' },
68
+ handler: jest.fn(),
69
+ },
70
+ ]);
71
+
72
+ (service as any).buildServer(context);
73
+ const listHandler = getRegisteredHandler(ListToolsRequestSchema);
74
+ const result = await listHandler();
75
+
76
+ expect(result.tools).toEqual([
77
+ { name: 'tool-a', description: 'desc a', inputSchema: { type: 'object', properties: { x: {} } } },
78
+ { name: 'tool-b', description: 'desc b', inputSchema: { type: 'object', properties: {} } },
79
+ ]);
80
+ });
81
+ });
82
+
83
+ describe('buildServer / CallTool handler', () => {
84
+ it('retorna erro quando a ferramenta não é encontrada e não audita', async () => {
85
+ const { service, deps } = makeService();
86
+ deps.registry.getAll.mockReturnValue([]);
87
+ deps.registry.get.mockReturnValue(undefined);
88
+
89
+ (service as any).buildServer(context);
90
+ const callHandler = getRegisteredHandler(CallToolRequestSchema);
91
+
92
+ const result = await callHandler({
93
+ params: { name: 'missing-tool', arguments: {} },
94
+ });
95
+
96
+ expect(result).toEqual({
97
+ isError: true,
98
+ content: [{ type: 'text', text: `Tool 'missing-tool' not found` }],
99
+ });
100
+ expect(deps.audit.record).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('executa a ferramenta com sucesso, audita success:true e retorna o resultado serializado', async () => {
104
+ const { service, deps } = makeService();
105
+ const handler = jest.fn<any>().mockResolvedValue({ ok: true, value: 42 });
106
+ deps.registry.getAll.mockReturnValue([]);
107
+ deps.registry.get.mockReturnValue({
108
+ definition: { name: 'tool-a' },
109
+ handler,
110
+ });
111
+
112
+ (service as any).buildServer(context);
113
+ const callHandler = getRegisteredHandler(CallToolRequestSchema);
114
+
115
+ const result = await callHandler({
116
+ params: { name: 'tool-a', arguments: { a: 1 } },
117
+ });
118
+
119
+ expect(handler).toHaveBeenCalledWith({ a: 1 }, { ...context, toolName: 'tool-a' });
120
+ expect(result).toEqual({
121
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, value: 42 }) }],
122
+ });
123
+ expect(deps.audit.record).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ userId: context.userId,
126
+ toolName: 'tool-a',
127
+ input: { a: 1 },
128
+ success: true,
129
+ }),
130
+ );
131
+ });
132
+
133
+ it('trata argumentos ausentes como objeto vazio', async () => {
134
+ const { service, deps } = makeService();
135
+ const handler = jest.fn<any>().mockResolvedValue({});
136
+ deps.registry.getAll.mockReturnValue([]);
137
+ deps.registry.get.mockReturnValue({ definition: { name: 'tool-a' }, handler });
138
+
139
+ (service as any).buildServer(context);
140
+ const callHandler = getRegisteredHandler(CallToolRequestSchema);
141
+
142
+ await callHandler({ params: { name: 'tool-a' } });
143
+
144
+ expect(handler).toHaveBeenCalledWith({}, expect.anything());
145
+ });
146
+
147
+ it('captura exceção do handler, audita success:false com o erro e retorna isError', async () => {
148
+ const { service, deps } = makeService();
149
+ const handler = jest.fn<any>().mockRejectedValue(new Error('boom'));
150
+ deps.registry.getAll.mockReturnValue([]);
151
+ deps.registry.get.mockReturnValue({ definition: { name: 'tool-a' }, handler });
152
+
153
+ (service as any).buildServer(context);
154
+ const callHandler = getRegisteredHandler(CallToolRequestSchema);
155
+
156
+ const result = await callHandler({
157
+ params: { name: 'tool-a', arguments: {} },
158
+ });
159
+
160
+ expect(result).toEqual({
161
+ isError: true,
162
+ content: [{ type: 'text', text: 'boom' }],
163
+ });
164
+ expect(deps.audit.record).toHaveBeenCalledWith(
165
+ expect.objectContaining({
166
+ toolName: 'tool-a',
167
+ success: false,
168
+ error: 'boom',
169
+ }),
170
+ );
171
+ });
172
+ });
173
+
174
+ describe('handleRequest', () => {
175
+ it('conecta o servidor e delega ao transport HTTP', async () => {
176
+ const { service, deps } = makeService();
177
+ deps.registry.getAll.mockReturnValue([]);
178
+ const req = { body: { hello: 'world' } } as any;
179
+ const res = {} as any;
180
+
181
+ await service.handleRequest(req, res, context);
182
+
183
+ expect(connectMock).toHaveBeenCalledTimes(1);
184
+ expect(handleRequestMock).toHaveBeenCalledWith(req, res, req.body);
185
+ });
186
+ });
187
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { BadRequestException } from '@nestjs/common';
3
+ import { MenuService } from './menu.service';
4
+
5
+ const makeService = (prisma: any) =>
6
+ new MenuService(prisma as any, {} as any);
7
+
8
+ describe('MenuService.ensureValidParent', () => {
9
+ let prisma: any;
10
+ let service: MenuService;
11
+
12
+ beforeEach(() => {
13
+ prisma = {
14
+ menu: { findUnique: jest.fn() },
15
+ };
16
+ service = makeService(prisma);
17
+ });
18
+
19
+ afterEach(() => {
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ const call = (
24
+ locale: string,
25
+ currentMenuId: number | null,
26
+ parentId?: number | null,
27
+ ) => (service as any).ensureValidParent(locale, currentMenuId, parentId);
28
+
29
+ it('retorna sem consultar quando parentId é null/undefined', async () => {
30
+ await expect(call('en', 5, null)).resolves.toBeUndefined();
31
+ await expect(call('en', 5, undefined)).resolves.toBeUndefined();
32
+ expect(prisma.menu.findUnique).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it('lança BadRequestException quando parent não existe', async () => {
36
+ prisma.menu.findUnique.mockResolvedValue(null);
37
+ await expect(call('en', 5, 99)).rejects.toThrow(BadRequestException);
38
+ });
39
+
40
+ it('lança quando parentId é igual ao próprio menu', async () => {
41
+ prisma.menu.findUnique.mockResolvedValue({ id: 5, menu_id: null });
42
+ await expect(call('en', 5, 5)).rejects.toThrow(BadRequestException);
43
+ });
44
+
45
+ it('retorna quando currentMenuId é null (criação) e parent existe', async () => {
46
+ prisma.menu.findUnique.mockResolvedValue({ id: 10, menu_id: null });
47
+ await expect(call('en', null, 10)).resolves.toBeUndefined();
48
+ // apenas a busca inicial do parent
49
+ expect(prisma.menu.findUnique).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('aceita mover para um parent de raiz distinto (sem ciclo)', async () => {
53
+ // parent 10 tem pai 20, e 20 é raiz. currentMenu é 5, não aparece na cadeia.
54
+ prisma.menu.findUnique
55
+ .mockResolvedValueOnce({ id: 10, menu_id: 20 }) // parent inicial
56
+ .mockResolvedValueOnce({ id: 20, menu_id: null }); // sobe até raiz
57
+ await expect(call('en', 5, 10)).resolves.toBeUndefined();
58
+ });
59
+
60
+ it('detecta ciclo quando o parent é descendente do menu atual', async () => {
61
+ // currentMenu = 5; parent = 10 cujo ancestral é 5 → mover 5 para dentro de 10 formaria ciclo
62
+ prisma.menu.findUnique
63
+ .mockResolvedValueOnce({ id: 10, menu_id: 5 }) // parent inicial, pai é o próprio menu 5
64
+ .mockResolvedValueOnce({ id: 5, menu_id: null });
65
+ await expect(call('en', 5, 10)).rejects.toThrow(BadRequestException);
66
+ });
67
+
68
+ it('detecta ciclo mais profundo na cadeia de ancestrais', async () => {
69
+ // 5 é o atual; cadeia: parent 10 -> 11 -> 5 (ciclo)
70
+ prisma.menu.findUnique
71
+ .mockResolvedValueOnce({ id: 10, menu_id: 11 })
72
+ .mockResolvedValueOnce({ id: 11, menu_id: 5 })
73
+ .mockResolvedValueOnce({ id: 5, menu_id: null });
74
+ await expect(call('en', 5, 10)).rejects.toThrow(BadRequestException);
75
+ });
76
+ });
@@ -0,0 +1,297 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import {
3
+ BadRequestException,
4
+ ForbiddenException,
5
+ NotFoundException,
6
+ } from '@nestjs/common';
7
+ import { NotificationService } from './notification.service';
8
+
9
+ const makeDeps = () => {
10
+ const prisma = {
11
+ notification: {
12
+ count: jest.fn(),
13
+ findUnique: jest.fn(),
14
+ update: jest.fn(),
15
+ updateMany: jest.fn(),
16
+ delete: jest.fn(),
17
+ deleteMany: jest.fn(),
18
+ create: jest.fn(),
19
+ },
20
+ $executeRaw: jest.fn().mockResolvedValue(0),
21
+ $executeRawUnsafe: jest.fn().mockResolvedValue(0),
22
+ };
23
+ const pagination = { paginate: jest.fn().mockResolvedValue({ data: [] }) };
24
+ const eventEmitter = { emitAsync: jest.fn().mockResolvedValue([]) };
25
+ return { prisma, pagination, eventEmitter };
26
+ };
27
+
28
+ const makeService = (deps = makeDeps()) => ({
29
+ service: new NotificationService(
30
+ deps.prisma as any,
31
+ deps.pagination as any,
32
+ deps.eventEmitter as any,
33
+ ),
34
+ deps,
35
+ });
36
+
37
+ describe('NotificationService', () => {
38
+ afterEach(() => {
39
+ jest.clearAllMocks();
40
+ jest.useRealTimers();
41
+ });
42
+
43
+ describe('isActiveAsyncNotification', () => {
44
+ let service: NotificationService;
45
+ beforeEach(() => {
46
+ service = makeService().service;
47
+ });
48
+ const call = (n: any) => (service as any).isActiveAsyncNotification(n);
49
+
50
+ it('true quando progresso em andamento e não finalizado', () => {
51
+ expect(call({ progress: 50, finished_at: null })).toBe(true);
52
+ });
53
+ it('false quando progress null', () => {
54
+ expect(call({ progress: null, finished_at: null })).toBe(false);
55
+ });
56
+ it('false quando progress >= 100', () => {
57
+ expect(call({ progress: 100, finished_at: null })).toBe(false);
58
+ });
59
+ it('false quando já finalizado', () => {
60
+ expect(call({ progress: 50, finished_at: new Date() })).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('assertOwnership', () => {
65
+ it('lança NotFound quando não existe', async () => {
66
+ const { service, deps } = makeService();
67
+ deps.prisma.notification.findUnique.mockResolvedValue(null);
68
+ await expect((service as any).assertOwnership(1, 5)).rejects.toThrow(
69
+ NotFoundException,
70
+ );
71
+ });
72
+ it('lança Forbidden quando dono diverge', async () => {
73
+ const { service, deps } = makeService();
74
+ deps.prisma.notification.findUnique.mockResolvedValue({ user_id: 2 });
75
+ await expect((service as any).assertOwnership(1, 5)).rejects.toThrow(
76
+ ForbiddenException,
77
+ );
78
+ });
79
+ it('retorna notificação do dono', async () => {
80
+ const { service, deps } = makeService();
81
+ deps.prisma.notification.findUnique.mockResolvedValue({ user_id: 1 });
82
+ await expect((service as any).assertOwnership(1, 5)).resolves.toMatchObject(
83
+ { user_id: 1 },
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('unreadCount', () => {
89
+ it('retorna contagem de não lidas', async () => {
90
+ const { service, deps } = makeService();
91
+ deps.prisma.notification.count.mockResolvedValue(3);
92
+ await expect(service.unreadCount(1)).resolves.toEqual({ count: 3 });
93
+ });
94
+ });
95
+
96
+ describe('markRead', () => {
97
+ it('remove quando auto_remove e não ativa', async () => {
98
+ const { service, deps } = makeService();
99
+ deps.prisma.notification.findUnique.mockResolvedValue({
100
+ user_id: 1,
101
+ auto_remove: true,
102
+ progress: null,
103
+ finished_at: null,
104
+ });
105
+ deps.prisma.notification.delete.mockResolvedValue({ id: 5 });
106
+ await service.markRead(1, 5);
107
+ expect(deps.prisma.notification.delete).toHaveBeenCalledWith({
108
+ where: { id: 5 },
109
+ });
110
+ });
111
+
112
+ it('rejeita remover async ativa auto_remove', async () => {
113
+ const { service, deps } = makeService();
114
+ deps.prisma.notification.findUnique.mockResolvedValue({
115
+ user_id: 1,
116
+ auto_remove: true,
117
+ progress: 40,
118
+ finished_at: null,
119
+ });
120
+ await expect(service.markRead(1, 5)).rejects.toThrow(BadRequestException);
121
+ });
122
+
123
+ it('marca como lida quando não auto_remove', async () => {
124
+ const { service, deps } = makeService();
125
+ deps.prisma.notification.findUnique.mockResolvedValue({
126
+ user_id: 1,
127
+ auto_remove: false,
128
+ progress: null,
129
+ finished_at: null,
130
+ });
131
+ deps.prisma.notification.update.mockResolvedValue({ id: 5, status: 'read' });
132
+ await service.markRead(1, 5);
133
+ expect(deps.prisma.notification.update).toHaveBeenCalledWith({
134
+ where: { id: 5 },
135
+ data: { status: 'read' },
136
+ });
137
+ });
138
+ });
139
+
140
+ describe('delete', () => {
141
+ it('rejeita async ativa', async () => {
142
+ const { service, deps } = makeService();
143
+ deps.prisma.notification.findUnique.mockResolvedValue({
144
+ user_id: 1,
145
+ progress: 40,
146
+ finished_at: null,
147
+ });
148
+ await expect(service.delete(1, 5)).rejects.toThrow(BadRequestException);
149
+ });
150
+
151
+ it('remove notificação normal', async () => {
152
+ const { service, deps } = makeService();
153
+ deps.prisma.notification.findUnique.mockResolvedValue({
154
+ user_id: 1,
155
+ progress: null,
156
+ finished_at: null,
157
+ });
158
+ deps.prisma.notification.delete.mockResolvedValue({ id: 5 });
159
+ await expect(service.delete(1, 5)).resolves.toEqual({ id: 5 });
160
+ });
161
+ });
162
+
163
+ describe('create', () => {
164
+ it('aplica defaults (info/url/auto_remove false)', async () => {
165
+ const { service, deps } = makeService();
166
+ deps.prisma.notification.create.mockResolvedValue({ id: 9 });
167
+ await service.create({ user_id: 1, title: 't', body: 'b' } as any);
168
+ const arg = deps.prisma.notification.create.mock.calls[0][0];
169
+ expect(arg.data.type).toBe('info');
170
+ expect(arg.data.action_type).toBe('url');
171
+ expect(arg.data.auto_remove).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe('updateProgress', () => {
176
+ it('finaliza com sucesso quando progress >= 100', async () => {
177
+ jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
178
+ const { service, deps } = makeService();
179
+ deps.prisma.notification.findUnique.mockResolvedValue({ user_id: 1 });
180
+ deps.prisma.notification.update.mockResolvedValue({ id: 5 });
181
+ await service.updateProgress(1, 5, { progress: 100 } as any);
182
+ const data = deps.prisma.notification.update.mock.calls[0][0].data;
183
+ expect(data.type).toBe('success');
184
+ expect(data.finished_at).toBeInstanceOf(Date);
185
+ });
186
+
187
+ it('marca erro quando finaliza com success=false', async () => {
188
+ const { service, deps } = makeService();
189
+ deps.prisma.notification.findUnique.mockResolvedValue({ user_id: 1 });
190
+ deps.prisma.notification.update.mockResolvedValue({ id: 5 });
191
+ await service.updateProgress(1, 5, { progress: 100, success: false } as any);
192
+ expect(deps.prisma.notification.update.mock.calls[0][0].data.type).toBe(
193
+ 'error',
194
+ );
195
+ });
196
+
197
+ it('não finaliza quando progress < 100', async () => {
198
+ const { service, deps } = makeService();
199
+ deps.prisma.notification.findUnique.mockResolvedValue({ user_id: 1 });
200
+ deps.prisma.notification.update.mockResolvedValue({ id: 5 });
201
+ await service.updateProgress(1, 5, { progress: 30 } as any);
202
+ const data = deps.prisma.notification.update.mock.calls[0][0].data;
203
+ expect(data.type).toBeUndefined();
204
+ expect(data.finished_at).toBeUndefined();
205
+ });
206
+ });
207
+
208
+ describe('markAllRead / deleteAll', () => {
209
+ it('markAllRead limpa auto_remove e marca restantes como lidas', async () => {
210
+ const { service, deps } = makeService();
211
+ deps.prisma.notification.deleteMany.mockResolvedValue({ count: 1 });
212
+ deps.prisma.notification.updateMany.mockResolvedValue({ count: 2 });
213
+ await expect(service.markAllRead(1)).resolves.toEqual({ count: 2 });
214
+ });
215
+
216
+ it('deleteAll remove concluídas', async () => {
217
+ const { service, deps } = makeService();
218
+ deps.prisma.notification.deleteMany.mockResolvedValue({ count: 4 });
219
+ await expect(service.deleteAll(1)).resolves.toEqual({ count: 4 });
220
+ });
221
+ });
222
+
223
+ describe('broadcastNotificationEvent', () => {
224
+ it('engole erro do $executeRaw', async () => {
225
+ const { service, deps } = makeService();
226
+ deps.prisma.$executeRaw.mockRejectedValue(new Error('down'));
227
+ await expect(
228
+ (service as any).broadcastNotificationEvent(1, { type: 'notification.updated' }),
229
+ ).resolves.toBeUndefined();
230
+ });
231
+
232
+ it('emite evento local para o usuário', async () => {
233
+ const { service } = makeService();
234
+ const received: any[] = [];
235
+ (service as any).notificationEvents.on('notification:7', (e: any) =>
236
+ received.push(e),
237
+ );
238
+ await (service as any).broadcastNotificationEvent(7, {
239
+ type: 'notification.created',
240
+ });
241
+ expect(received).toHaveLength(1);
242
+ });
243
+ });
244
+
245
+ describe('handleClusterNotification', () => {
246
+ it('ignora canal diferente', () => {
247
+ const { service } = makeService();
248
+ const spy = jest.spyOn(service as any, 'emitLocalNotificationEvent');
249
+ (service as any).handleClusterNotification({ channel: 'other', payload: '{}' });
250
+ expect(spy).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it('ignora mensagem da própria instância', () => {
254
+ const { service } = makeService();
255
+ const instanceId = (service as any).instanceId;
256
+ const spy = jest.spyOn(service as any, 'emitLocalNotificationEvent');
257
+ (service as any).handleClusterNotification({
258
+ channel: 'notification_events',
259
+ payload: JSON.stringify({ originInstanceId: instanceId, userId: 1 }),
260
+ });
261
+ expect(spy).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it('reemite evento de outra instância', () => {
265
+ const { service } = makeService();
266
+ const spy = jest.spyOn(service as any, 'emitLocalNotificationEvent');
267
+ (service as any).handleClusterNotification({
268
+ channel: 'notification_events',
269
+ payload: JSON.stringify({
270
+ originInstanceId: 'other',
271
+ userId: 3,
272
+ type: 'notification.updated',
273
+ }),
274
+ });
275
+ expect(spy).toHaveBeenCalledWith(3, expect.objectContaining({
276
+ type: 'notification.updated',
277
+ }));
278
+ });
279
+
280
+ it('ignora payload inválido', () => {
281
+ const { service } = makeService();
282
+ const spy = jest.spyOn(service as any, 'emitLocalNotificationEvent');
283
+ (service as any).handleClusterNotification({
284
+ channel: 'notification_events',
285
+ payload: 'not-json',
286
+ });
287
+ expect(spy).not.toHaveBeenCalled();
288
+ });
289
+ });
290
+
291
+ describe('getEventKey', () => {
292
+ it('formata chave por usuário', () => {
293
+ const { service } = makeService();
294
+ expect((service as any).getEventKey(42)).toBe('notification:42');
295
+ });
296
+ });
297
+ });