@hed-hog/core 0.0.373 → 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 +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/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 +3 -3
  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,183 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { InboxService } from './inbox.service';
3
+ import { InboxEventStatus } from '../types';
4
+
5
+ const makeDeps = () => {
6
+ const prisma = {
7
+ inbox_event: {
8
+ findFirst: jest.fn(),
9
+ findUnique: jest.fn(),
10
+ create: jest.fn(),
11
+ update: jest.fn(),
12
+ count: jest.fn(),
13
+ },
14
+ };
15
+ return { prisma };
16
+ };
17
+
18
+ const makeService = (deps = makeDeps()) => ({
19
+ service: new InboxService(deps.prisma as any),
20
+ deps,
21
+ });
22
+
23
+ const record = (over: any = {}) => ({
24
+ id: 1,
25
+ outbox_event_id: 10,
26
+ consumer_name: 'crm',
27
+ status: InboxEventStatus.RECEIVED,
28
+ attempt_count: 0,
29
+ last_error: null,
30
+ processed_at: null,
31
+ created_at: new Date('2026-01-01'),
32
+ updated_at: new Date('2026-01-01'),
33
+ ...over,
34
+ });
35
+
36
+ describe('InboxService', () => {
37
+ afterEach(() => jest.clearAllMocks());
38
+
39
+ describe('toDatabaseId', () => {
40
+ it('mantém number e converte string', () => {
41
+ const { service } = makeService();
42
+ expect((service as any).toDatabaseId(5)).toBe(5);
43
+ expect((service as any).toDatabaseId('12')).toBe(12);
44
+ });
45
+ });
46
+
47
+ describe('toDomainEvent', () => {
48
+ it('mapeia colunas para camelCase', () => {
49
+ const { service } = makeService();
50
+ const result = (service as any).toDomainEvent(record({ id: 3, outbox_event_id: 99 }));
51
+ expect(result).toMatchObject({
52
+ id: '3',
53
+ outboxEventId: '99',
54
+ consumerName: 'crm',
55
+ attemptCount: 0,
56
+ });
57
+ });
58
+ });
59
+
60
+ describe('toDatabaseUpdate', () => {
61
+ it('retorna undefined sem partial', () => {
62
+ const { service } = makeService();
63
+ expect((service as any).toDatabaseUpdate()).toBeUndefined();
64
+ });
65
+ it('mapeia campos parciais', () => {
66
+ const { service } = makeService();
67
+ expect(
68
+ (service as any).toDatabaseUpdate({ attemptCount: 2, lastError: 'e', processedAt: null }),
69
+ ).toEqual({ attempt_count: 2, last_error: 'e', processed_at: null });
70
+ });
71
+ });
72
+
73
+ describe('getOrCreate (idempotência)', () => {
74
+ it('retorna existente sem criar', async () => {
75
+ const { service, deps } = makeService();
76
+ deps.prisma.inbox_event.findFirst.mockResolvedValue(record({ id: 7 }));
77
+ const result = await service.getOrCreate(10, 'crm');
78
+ expect(result.id).toBe('7');
79
+ expect(deps.prisma.inbox_event.create).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('cria quando não existe com status RECEIVED', async () => {
83
+ const { service, deps } = makeService();
84
+ deps.prisma.inbox_event.findFirst.mockResolvedValue(null);
85
+ deps.prisma.inbox_event.create.mockResolvedValue(record({ id: 8 }));
86
+ await service.getOrCreate('10', 'crm');
87
+ expect(deps.prisma.inbox_event.create).toHaveBeenCalledWith({
88
+ data: expect.objectContaining({
89
+ outbox_event_id: 10,
90
+ consumer_name: 'crm',
91
+ status: InboxEventStatus.RECEIVED,
92
+ attempt_count: 0,
93
+ }),
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('markProcessed', () => {
99
+ it('define processed_at e status PROCESSED', async () => {
100
+ const { service, deps } = makeService();
101
+ deps.prisma.inbox_event.update.mockResolvedValue(record({ status: InboxEventStatus.PROCESSED }));
102
+ await service.markProcessed(1);
103
+ const arg = deps.prisma.inbox_event.update.mock.calls[0][0];
104
+ expect(arg.data.status).toBe(InboxEventStatus.PROCESSED);
105
+ expect(arg.data.processed_at).toBeInstanceOf(Date);
106
+ });
107
+ });
108
+
109
+ describe('markFailed', () => {
110
+ it('lança quando registro não existe', async () => {
111
+ const { service, deps } = makeService();
112
+ deps.prisma.inbox_event.findUnique.mockResolvedValue(null);
113
+ await expect(service.markFailed(1, 'boom')).rejects.toThrow('not found');
114
+ });
115
+
116
+ it('incrementa tentativa e registra erro', async () => {
117
+ const { service, deps } = makeService();
118
+ deps.prisma.inbox_event.findUnique.mockResolvedValue(record({ attempt_count: 2 }));
119
+ deps.prisma.inbox_event.update.mockResolvedValue(record({ status: InboxEventStatus.FAILED }));
120
+ await service.markFailed(1, 'boom');
121
+ const arg = deps.prisma.inbox_event.update.mock.calls[0][0];
122
+ expect(arg.data.status).toBe(InboxEventStatus.FAILED);
123
+ expect(arg.data.attempt_count).toBe(3);
124
+ expect(arg.data.last_error).toBe('boom');
125
+ });
126
+ });
127
+
128
+ describe('incrementAttempt', () => {
129
+ it('lança quando registro não existe', async () => {
130
+ const { service, deps } = makeService();
131
+ deps.prisma.inbox_event.findUnique.mockResolvedValue(null);
132
+ await expect(service.incrementAttempt(1)).rejects.toThrow('not found');
133
+ });
134
+
135
+ it('mantém status e incrementa tentativa', async () => {
136
+ const { service, deps } = makeService();
137
+ deps.prisma.inbox_event.findUnique.mockResolvedValue(record({ attempt_count: 5, status: InboxEventStatus.PROCESSING }));
138
+ deps.prisma.inbox_event.update.mockResolvedValue(record());
139
+ await service.incrementAttempt(1);
140
+ const arg = deps.prisma.inbox_event.update.mock.calls[0][0];
141
+ expect(arg.data.status).toBe(InboxEventStatus.PROCESSING);
142
+ expect(arg.data.attempt_count).toBe(6);
143
+ });
144
+ });
145
+
146
+ describe('isProcessed', () => {
147
+ it('true apenas quando status PROCESSED', async () => {
148
+ const { service, deps } = makeService();
149
+ deps.prisma.inbox_event.findFirst.mockResolvedValue(record({ status: InboxEventStatus.PROCESSED }));
150
+ await expect(service.isProcessed(10, 'crm')).resolves.toBe(true);
151
+ });
152
+ it('false quando outro status', async () => {
153
+ const { service, deps } = makeService();
154
+ deps.prisma.inbox_event.findFirst.mockResolvedValue(record({ status: InboxEventStatus.FAILED }));
155
+ await expect(service.isProcessed(10, 'crm')).resolves.toBe(false);
156
+ });
157
+ it('false quando não existe', async () => {
158
+ const { service, deps } = makeService();
159
+ deps.prisma.inbox_event.findFirst.mockResolvedValue(null);
160
+ await expect(service.isProcessed(10, 'crm')).resolves.toBe(false);
161
+ });
162
+ });
163
+
164
+ describe('getById / countUnprocessed', () => {
165
+ it('getById retorna null quando ausente', async () => {
166
+ const { service, deps } = makeService();
167
+ deps.prisma.inbox_event.findUnique.mockResolvedValue(null);
168
+ await expect(service.getById(1)).resolves.toBeNull();
169
+ });
170
+
171
+ it('countUnprocessed exclui PROCESSED e SKIPPED', async () => {
172
+ const { service, deps } = makeService();
173
+ deps.prisma.inbox_event.count.mockResolvedValue(4);
174
+ await service.countUnprocessed('crm');
175
+ expect(deps.prisma.inbox_event.count).toHaveBeenCalledWith({
176
+ where: {
177
+ consumer_name: 'crm',
178
+ status: { notIn: [InboxEventStatus.PROCESSED, InboxEventStatus.SKIPPED] },
179
+ },
180
+ });
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { IntegrationLinkService } from './integration-link.service';
3
+ import { LinkType } from '../types';
4
+
5
+ const makeDelegate = () => ({
6
+ create: jest.fn(),
7
+ findMany: jest.fn(),
8
+ findFirst: jest.fn(),
9
+ findUnique: jest.fn(),
10
+ delete: jest.fn(),
11
+ count: jest.fn(),
12
+ });
13
+
14
+ describe('IntegrationLinkService', () => {
15
+ afterEach(() => jest.clearAllMocks());
16
+
17
+ describe('resolvePersistence', () => {
18
+ it('prioriza integration_link (snake) do prisma', () => {
19
+ const snake = makeDelegate();
20
+ const service = new IntegrationLinkService({ integration_link: snake } as any);
21
+ const { delegate, useSnakeCase } = (service as any).resolvePersistence();
22
+ expect(delegate).toBe(snake);
23
+ expect(useSnakeCase).toBe(true);
24
+ });
25
+
26
+ it('usa integrationLink (camel) quando snake ausente', () => {
27
+ const camel = makeDelegate();
28
+ const service = new IntegrationLinkService({ integrationLink: camel } as any);
29
+ const { delegate, useSnakeCase } = (service as any).resolvePersistence();
30
+ expect(delegate).toBe(camel);
31
+ expect(useSnakeCase).toBe(false);
32
+ });
33
+
34
+ it('lança quando não há delegate', () => {
35
+ const service = new IntegrationLinkService({} as any);
36
+ expect(() => (service as any).resolvePersistence()).toThrow(
37
+ 'Integration link delegate is not available on the Prisma client.',
38
+ );
39
+ });
40
+
41
+ it('aceita client de persistência explícito', () => {
42
+ const explicit = makeDelegate();
43
+ const service = new IntegrationLinkService({} as any);
44
+ const { delegate } = (service as any).resolvePersistence({
45
+ integration_link: explicit,
46
+ });
47
+ expect(delegate).toBe(explicit);
48
+ });
49
+ });
50
+
51
+ describe('toDomainLink', () => {
52
+ let service: IntegrationLinkService;
53
+ beforeEach(() => {
54
+ service = new IntegrationLinkService({} as any);
55
+ });
56
+
57
+ it('mapeia colunas snake_case', () => {
58
+ const now = new Date('2026-01-01T00:00:00Z');
59
+ const result = (service as any).toDomainLink({
60
+ id: 5,
61
+ source_module: 'crm',
62
+ source_entity_type: 'person',
63
+ source_entity_id: '1',
64
+ target_module: 'finance',
65
+ target_entity_type: 'invoice',
66
+ target_entity_id: '2',
67
+ link_type: LinkType.CASCADE,
68
+ metadata: { k: 'v' },
69
+ created_at: now,
70
+ updated_at: now,
71
+ });
72
+ expect(result).toMatchObject({
73
+ id: '5',
74
+ sourceModule: 'crm',
75
+ targetEntityId: '2',
76
+ linkType: LinkType.CASCADE,
77
+ metadata: { k: 'v' },
78
+ });
79
+ });
80
+
81
+ it('usa defaults quando faltam campos', () => {
82
+ const result = (service as any).toDomainLink({ id: 9 });
83
+ expect(result.sourceModule).toBe('');
84
+ expect(result.linkType).toBe(LinkType.REFERENCE);
85
+ expect(result.metadata).toBeNull();
86
+ expect(result.createdAt).toBeInstanceOf(Date);
87
+ });
88
+ });
89
+
90
+ describe('toDatabaseId', () => {
91
+ let service: IntegrationLinkService;
92
+ beforeEach(() => {
93
+ service = new IntegrationLinkService({} as any);
94
+ });
95
+
96
+ it('mantém number', () => {
97
+ expect((service as any).toDatabaseId(7)).toBe(7);
98
+ });
99
+ it('converte string numérica', () => {
100
+ expect((service as any).toDatabaseId('42')).toBe(42);
101
+ });
102
+ it('mantém string não numérica', () => {
103
+ expect((service as any).toDatabaseId('abc')).toBe('abc');
104
+ });
105
+ });
106
+
107
+ describe('createLink', () => {
108
+ it('usa data snake_case', async () => {
109
+ const delegate = makeDelegate();
110
+ delegate.create.mockResolvedValue({ id: 1 });
111
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
112
+ await service.createLink({
113
+ sourceModule: 'a',
114
+ sourceEntityType: 'b',
115
+ sourceEntityId: 'c',
116
+ targetModule: 'd',
117
+ targetEntityType: 'e',
118
+ targetEntityId: 'f',
119
+ linkType: LinkType.REFERENCE,
120
+ });
121
+ expect(delegate.create).toHaveBeenCalledWith({
122
+ data: expect.objectContaining({
123
+ source_module: 'a',
124
+ target_entity_id: 'f',
125
+ link_type: LinkType.REFERENCE,
126
+ metadata: null,
127
+ }),
128
+ });
129
+ });
130
+
131
+ it('usa data camelCase', async () => {
132
+ const delegate = makeDelegate();
133
+ delegate.create.mockResolvedValue({ id: 1 });
134
+ const service = new IntegrationLinkService({ integrationLink: delegate } as any);
135
+ await service.createLink({
136
+ sourceModule: 'a',
137
+ sourceEntityType: 'b',
138
+ sourceEntityId: 'c',
139
+ targetModule: 'd',
140
+ targetEntityType: 'e',
141
+ targetEntityId: 'f',
142
+ linkType: LinkType.REFERENCE,
143
+ metadata: { x: 1 },
144
+ });
145
+ expect(delegate.create).toHaveBeenCalledWith({
146
+ data: expect.objectContaining({
147
+ sourceModule: 'a',
148
+ metadata: { x: 1 },
149
+ }),
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('consultas', () => {
155
+ it('findOutbound monta where snake', async () => {
156
+ const delegate = makeDelegate();
157
+ delegate.findMany.mockResolvedValue([]);
158
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
159
+ await service.findOutbound('m', 't', 'i');
160
+ expect(delegate.findMany).toHaveBeenCalledWith({
161
+ where: { source_module: 'm', source_entity_type: 't', source_entity_id: 'i' },
162
+ });
163
+ });
164
+
165
+ it('findInbound monta where camel', async () => {
166
+ const delegate = makeDelegate();
167
+ delegate.findMany.mockResolvedValue([]);
168
+ const service = new IntegrationLinkService({ integrationLink: delegate } as any);
169
+ await service.findInbound('m', 't', 'i');
170
+ expect(delegate.findMany).toHaveBeenCalledWith({
171
+ where: { targetModule: 'm', targetEntityType: 't', targetEntityId: 'i' },
172
+ });
173
+ });
174
+
175
+ it('findByLinkType filtra por tipo', async () => {
176
+ const delegate = makeDelegate();
177
+ delegate.findMany.mockResolvedValue([]);
178
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
179
+ await service.findByLinkType(LinkType.CASCADE);
180
+ expect(delegate.findMany).toHaveBeenCalledWith({
181
+ where: { link_type: LinkType.CASCADE },
182
+ });
183
+ });
184
+
185
+ it('getById retorna null quando ausente', async () => {
186
+ const delegate = makeDelegate();
187
+ delegate.findUnique.mockResolvedValue(null);
188
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
189
+ await expect(service.getById('1')).resolves.toBeNull();
190
+ });
191
+
192
+ it('getById mapeia para domínio', async () => {
193
+ const delegate = makeDelegate();
194
+ delegate.findUnique.mockResolvedValue({ id: 3, source_module: 'x' });
195
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
196
+ const result = await service.getById('3');
197
+ expect(result).toMatchObject({ id: '3', sourceModule: 'x' });
198
+ });
199
+
200
+ it('countFromModule delega count', async () => {
201
+ const delegate = makeDelegate();
202
+ delegate.count.mockResolvedValue(4);
203
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
204
+ await expect(service.countFromModule('crm')).resolves.toBe(4);
205
+ expect(delegate.count).toHaveBeenCalledWith({ where: { source_module: 'crm' } });
206
+ });
207
+
208
+ it('deleteLink mapeia registro removido', async () => {
209
+ const delegate = makeDelegate();
210
+ delegate.delete.mockResolvedValue({ id: 8 });
211
+ const service = new IntegrationLinkService({ integration_link: delegate } as any);
212
+ const result = await service.deleteLink('8');
213
+ expect(delegate.delete).toHaveBeenCalledWith({ where: { id: 8 } });
214
+ expect(result.id).toBe('8');
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { OutboxService } from './outbox.service';
3
+ import { OutboxEventStatus } from '../types';
4
+
5
+ const makeDeps = () => {
6
+ const prisma = {
7
+ outbox_event: {
8
+ create: jest.fn(),
9
+ update: jest.fn(),
10
+ findUnique: jest.fn(),
11
+ count: jest.fn(),
12
+ },
13
+ $transaction: jest.fn(),
14
+ };
15
+ return { prisma };
16
+ };
17
+
18
+ const makeService = (deps = makeDeps()) => ({
19
+ service: new OutboxService(deps.prisma as any),
20
+ deps,
21
+ });
22
+
23
+ const record = (over: any = {}) => ({
24
+ id: 1,
25
+ event_name: 'person.created',
26
+ source_module: 'crm',
27
+ aggregate_type: 'person',
28
+ aggregate_id: '10',
29
+ payload: { foo: 'bar' },
30
+ status: OutboxEventStatus.PENDING,
31
+ attempt_count: 0,
32
+ last_error: null,
33
+ available_at: new Date('2026-01-01'),
34
+ processed_at: null,
35
+ created_at: new Date('2026-01-01'),
36
+ updated_at: new Date('2026-01-01'),
37
+ ...over,
38
+ });
39
+
40
+ describe('OutboxService', () => {
41
+ afterEach(() => {
42
+ jest.clearAllMocks();
43
+ jest.useRealTimers();
44
+ });
45
+
46
+ describe('toDatabaseId', () => {
47
+ it('mantém number e converte string', () => {
48
+ const { service } = makeService();
49
+ expect((service as any).toDatabaseId(5)).toBe(5);
50
+ expect((service as any).toDatabaseId('12')).toBe(12);
51
+ });
52
+ });
53
+
54
+ describe('toDomainEvent', () => {
55
+ it('mapeia colunas para camelCase', () => {
56
+ const { service } = makeService();
57
+ const result = (service as any).toDomainEvent(record({ id: 3 }));
58
+ expect(result).toMatchObject({
59
+ id: '3',
60
+ eventName: 'person.created',
61
+ sourceModule: 'crm',
62
+ aggregateType: 'person',
63
+ aggregateId: '10',
64
+ payload: { foo: 'bar' },
65
+ attemptCount: 0,
66
+ });
67
+ });
68
+ });
69
+
70
+ describe('toDatabaseUpdate', () => {
71
+ it('retorna undefined sem partial', () => {
72
+ const { service } = makeService();
73
+ expect((service as any).toDatabaseUpdate()).toBeUndefined();
74
+ });
75
+
76
+ it('mapeia campos parciais', () => {
77
+ const { service } = makeService();
78
+ const availableAt = new Date('2026-02-01');
79
+ expect(
80
+ (service as any).toDatabaseUpdate({
81
+ attemptCount: 2,
82
+ lastError: 'e',
83
+ availableAt,
84
+ processedAt: null,
85
+ }),
86
+ ).toEqual({
87
+ attempt_count: 2,
88
+ last_error: 'e',
89
+ available_at: availableAt,
90
+ processed_at: null,
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('createEvent', () => {
96
+ it('cria com status PENDING e attempt_count 0 usando prisma padrão', async () => {
97
+ const { service, deps } = makeService();
98
+ deps.prisma.outbox_event.create.mockResolvedValue(record({ id: 5 }));
99
+
100
+ const result = await service.createEvent({
101
+ eventName: 'person.created',
102
+ sourceModule: 'crm',
103
+ aggregateType: 'person',
104
+ aggregateId: '10',
105
+ payload: { foo: 'bar' },
106
+ });
107
+
108
+ expect(deps.prisma.outbox_event.create).toHaveBeenCalledWith({
109
+ data: expect.objectContaining({
110
+ event_name: 'person.created',
111
+ source_module: 'crm',
112
+ aggregate_type: 'person',
113
+ aggregate_id: '10',
114
+ payload: { foo: 'bar' },
115
+ status: OutboxEventStatus.PENDING,
116
+ attempt_count: 0,
117
+ }),
118
+ });
119
+ expect(result.id).toBe('5');
120
+ });
121
+
122
+ it('usa persistenceClient alternativo (ex.: dentro de uma transação) quando fornecido', async () => {
123
+ const { service } = makeService();
124
+ const altClient = {
125
+ outbox_event: {
126
+ create: jest.fn<any>().mockResolvedValue(record({ id: 9 })),
127
+ },
128
+ };
129
+
130
+ const result = await service.createEvent(
131
+ {
132
+ eventName: 'x',
133
+ sourceModule: 'crm',
134
+ aggregateType: 'person',
135
+ aggregateId: '1',
136
+ payload: {},
137
+ },
138
+ altClient as any,
139
+ );
140
+
141
+ expect(altClient.outbox_event.create).toHaveBeenCalled();
142
+ expect(result.id).toBe('9');
143
+ });
144
+ });
145
+
146
+ describe('findPendingAndLock', () => {
147
+ it('retorna [] quando não há linhas pendentes', async () => {
148
+ const { service, deps } = makeService();
149
+ const tx = {
150
+ $queryRaw: jest.fn<any>().mockResolvedValue([]),
151
+ $executeRaw: jest.fn(),
152
+ };
153
+ deps.prisma.$transaction.mockImplementation(async (cb: any) => cb(tx));
154
+
155
+ const result = await service.findPendingAndLock(10, 5000);
156
+
157
+ expect(result).toEqual([]);
158
+ expect(tx.$executeRaw).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('bloqueia linhas encontradas e marca como PROCESSING', async () => {
162
+ const { service, deps } = makeService();
163
+ const rows = [record({ id: 1 }), record({ id: 2 })];
164
+ const tx = {
165
+ $queryRaw: jest.fn<any>().mockResolvedValue(rows),
166
+ $executeRaw: jest.fn(),
167
+ };
168
+ deps.prisma.$transaction.mockImplementation(async (cb: any) => cb(tx));
169
+
170
+ const result = await service.findPendingAndLock(10, 5000);
171
+
172
+ expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
173
+ expect(result).toHaveLength(2);
174
+ expect(result[0].status).toBe(OutboxEventStatus.PROCESSING);
175
+ expect(result[1].status).toBe(OutboxEventStatus.PROCESSING);
176
+ });
177
+ });
178
+
179
+ describe('updateStatus', () => {
180
+ it('atualiza status e campos parciais', async () => {
181
+ const { service, deps } = makeService();
182
+ deps.prisma.outbox_event.update.mockResolvedValue(
183
+ record({ status: OutboxEventStatus.PROCESSED }),
184
+ );
185
+
186
+ const result = await service.updateStatus(1, OutboxEventStatus.PROCESSED, {
187
+ attemptCount: 1,
188
+ });
189
+
190
+ expect(deps.prisma.outbox_event.update).toHaveBeenCalledWith({
191
+ where: { id: 1 },
192
+ data: expect.objectContaining({
193
+ status: OutboxEventStatus.PROCESSED,
194
+ attempt_count: 1,
195
+ }),
196
+ });
197
+ expect(result.status).toBe(OutboxEventStatus.PROCESSED);
198
+ });
199
+
200
+ it('converte eventId string para número no where', async () => {
201
+ const { service, deps } = makeService();
202
+ deps.prisma.outbox_event.update.mockResolvedValue(record());
203
+
204
+ await service.updateStatus('42', OutboxEventStatus.PENDING);
205
+
206
+ expect(deps.prisma.outbox_event.update).toHaveBeenCalledWith({
207
+ where: { id: 42 },
208
+ data: { status: OutboxEventStatus.PENDING },
209
+ });
210
+ });
211
+ });
212
+
213
+ describe('markProcessing', () => {
214
+ it('define availableAt com base no leaseMs', async () => {
215
+ jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
216
+ const { service, deps } = makeService();
217
+ deps.prisma.outbox_event.update.mockResolvedValue(
218
+ record({ status: OutboxEventStatus.PROCESSING }),
219
+ );
220
+
221
+ await service.markProcessing(1, 5000);
222
+
223
+ const arg = deps.prisma.outbox_event.update.mock.calls[0][0];
224
+ expect(arg.data.status).toBe(OutboxEventStatus.PROCESSING);
225
+ expect(arg.data.available_at).toEqual(new Date('2026-01-01T00:00:05Z'));
226
+ });
227
+ });
228
+
229
+ describe('incrementAttempt', () => {
230
+ it('lança quando registro não existe', async () => {
231
+ const { service, deps } = makeService();
232
+ deps.prisma.outbox_event.findUnique.mockResolvedValue(null);
233
+ await expect(service.incrementAttempt(1)).rejects.toThrow('not found');
234
+ });
235
+
236
+ it('incrementa attempt_count e mantém status atual, registrando erro', async () => {
237
+ const { service, deps } = makeService();
238
+ deps.prisma.outbox_event.findUnique.mockResolvedValue(
239
+ record({ attempt_count: 2, status: OutboxEventStatus.PROCESSING }),
240
+ );
241
+ deps.prisma.outbox_event.update.mockResolvedValue(record());
242
+
243
+ await service.incrementAttempt(1, 'boom');
244
+
245
+ const arg = deps.prisma.outbox_event.update.mock.calls[0][0];
246
+ expect(arg.data.status).toBe(OutboxEventStatus.PROCESSING);
247
+ expect(arg.data.attempt_count).toBe(3);
248
+ expect(arg.data.last_error).toBe('boom');
249
+ });
250
+
251
+ it('usa null como last_error quando erro não é informado', async () => {
252
+ const { service, deps } = makeService();
253
+ deps.prisma.outbox_event.findUnique.mockResolvedValue(
254
+ record({ attempt_count: 0, status: OutboxEventStatus.PENDING }),
255
+ );
256
+ deps.prisma.outbox_event.update.mockResolvedValue(record());
257
+
258
+ await service.incrementAttempt(1);
259
+
260
+ const arg = deps.prisma.outbox_event.update.mock.calls[0][0];
261
+ expect(arg.data.last_error).toBeNull();
262
+ });
263
+ });
264
+
265
+ describe('getById', () => {
266
+ it('retorna null quando ausente', async () => {
267
+ const { service, deps } = makeService();
268
+ deps.prisma.outbox_event.findUnique.mockResolvedValue(null);
269
+ await expect(service.getById(1)).resolves.toBeNull();
270
+ });
271
+
272
+ it('retorna evento mapeado quando existe', async () => {
273
+ const { service, deps } = makeService();
274
+ deps.prisma.outbox_event.findUnique.mockResolvedValue(record({ id: 4 }));
275
+ await expect(service.getById(4)).resolves.toMatchObject({ id: '4' });
276
+ });
277
+ });
278
+
279
+ describe('countByStatus', () => {
280
+ it('conta por status', async () => {
281
+ const { service, deps } = makeService();
282
+ deps.prisma.outbox_event.count.mockResolvedValue(7);
283
+
284
+ await expect(service.countByStatus(OutboxEventStatus.PENDING)).resolves.toBe(7);
285
+ expect(deps.prisma.outbox_event.count).toHaveBeenCalledWith({
286
+ where: { status: OutboxEventStatus.PENDING },
287
+ });
288
+ });
289
+ });
290
+ });