@app-connect/core 1.7.35 → 1.7.36
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.
- package/connector/mock.js +9 -4
- package/connector/proxy/engine.js +3 -2
- package/connector/proxy/index.js +2 -2
- package/docs/architecture.md +1 -0
- package/docs/handlers.md +1 -1
- package/docs/models.md +2 -0
- package/docs/routes.md +1 -1
- package/handlers/disposition.js +3 -2
- package/handlers/log.js +16 -6
- package/handlers/plugin.js +13 -6
- package/index.js +10 -4
- package/lib/callLogLookup.js +82 -9
- package/lib/migrateCallLogsSchema.js +128 -7
- package/models/callLogModel.js +6 -0
- package/package.json +1 -1
- package/releaseNotes.json +16 -0
- package/test/connector/developerPortal.test.js +166 -0
- package/test/connector/mock.test.js +131 -0
- package/test/connector/proxy/engine.test.js +85 -0
- package/test/connector/proxy/index.test.js +246 -0
- package/test/connector/proxy/sample.json +1 -0
- package/test/handlers/admin.test.js +344 -0
- package/test/handlers/appointment.test.js +260 -0
- package/test/handlers/calldown.test.js +310 -0
- package/test/handlers/disposition.test.js +396 -0
- package/test/handlers/log.test.js +324 -1
- package/test/handlers/managedOAuth.test.js +262 -0
- package/test/handlers/plugin.test.js +305 -0
- package/test/handlers/user.test.js +381 -0
- package/test/index.test.js +102 -0
- package/test/lib/analytics.test.js +146 -0
- package/test/lib/authSession.test.js +173 -0
- package/test/lib/encode.test.js +59 -0
- package/test/lib/errorHandler.test.js +246 -0
- package/test/lib/generalErrorMessage.test.js +82 -0
- package/test/lib/s3ErrorLogReport.test.js +187 -0
- package/test/mcp/mcpHandlerMore.test.js +384 -0
- package/test/mcp/tools/appointmentTools.test.js +362 -0
- package/test/models/callDownListModel.test.js +125 -0
- package/test/models/dynamo/lockSchema.test.js +37 -0
- package/test/models/dynamo/noteCacheSchema.test.js +45 -0
- package/test/models/llmSessionModel.test.js +91 -0
- package/test/models/models.test.js +92 -0
- package/test/routes/calldownRoutes.test.js +224 -0
- package/test/routes/coreRouterBroadRoutes.test.js +855 -0
- package/test/routes/dispositionRoutes.test.js +192 -0
- package/test/routes/managedAuthRoutes.test.js +151 -0
- package/test/routes/pluginRoutes.test.js +262 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
jest.mock('../../lib/logger', () => ({
|
|
2
|
+
error: jest.fn(),
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
const logger = require('../../lib/logger');
|
|
6
|
+
const {
|
|
7
|
+
asyncHandler,
|
|
8
|
+
errorMiddleware,
|
|
9
|
+
getOperationErrorMessage,
|
|
10
|
+
handleApiError,
|
|
11
|
+
handleDatabaseError,
|
|
12
|
+
} = require('../../lib/errorHandler');
|
|
13
|
+
|
|
14
|
+
describe('errorHandler', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('handleApiError', () => {
|
|
20
|
+
test('maps rate limit responses to the platform rate limit message', () => {
|
|
21
|
+
const error = new Error('too many requests');
|
|
22
|
+
error.response = {
|
|
23
|
+
status: 429,
|
|
24
|
+
data: { message: 'rate limited' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const result = handleApiError(error, 'Clio', 'createCallLog', { accountId: 'account-1' });
|
|
28
|
+
|
|
29
|
+
expect(logger.error).toHaveBeenCalledWith('createCallLog failed for platform Clio', expect.objectContaining({
|
|
30
|
+
platform: 'Clio',
|
|
31
|
+
operation: 'createCallLog',
|
|
32
|
+
statusCode: 429,
|
|
33
|
+
accountId: 'account-1',
|
|
34
|
+
}));
|
|
35
|
+
expect(result).toMatchObject({
|
|
36
|
+
successful: false,
|
|
37
|
+
extraDataTracking: { statusCode: 429 },
|
|
38
|
+
});
|
|
39
|
+
expect(result.returnMessage.message).toBe('Rate limit exceeded');
|
|
40
|
+
expect(result.returnMessage.details[0].items[0].text).toContain('Clio');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('maps 400-409 responses to authorization messages', () => {
|
|
44
|
+
const error = new Error('unauthorized');
|
|
45
|
+
error.response = {
|
|
46
|
+
status: 401,
|
|
47
|
+
data: { message: 'unauthorized' },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = handleApiError(error, 'Bullhorn', 'findContact');
|
|
51
|
+
|
|
52
|
+
expect(result.successful).toBe(false);
|
|
53
|
+
expect(result.extraDataTracking).toEqual({ statusCode: 401 });
|
|
54
|
+
expect(result.returnMessage.message).toBe('Authorization error');
|
|
55
|
+
expect(result.returnMessage.details[0].items[0].text).toContain('Bullhorn');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('maps non-auth provider errors to operation-specific messages', () => {
|
|
59
|
+
const error = new Error('provider unavailable');
|
|
60
|
+
error.response = {
|
|
61
|
+
status: 500,
|
|
62
|
+
data: { message: 'unavailable' },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = handleApiError(error, 'Salesforce', 'updateCallLog');
|
|
66
|
+
|
|
67
|
+
expect(result).toEqual({
|
|
68
|
+
successful: false,
|
|
69
|
+
returnMessage: {
|
|
70
|
+
message: 'Error updating call log',
|
|
71
|
+
messageType: 'warning',
|
|
72
|
+
details: [
|
|
73
|
+
{
|
|
74
|
+
title: 'Details',
|
|
75
|
+
items: [
|
|
76
|
+
{
|
|
77
|
+
id: 1,
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: 'Please check if the log entity still exists on Salesforce and your account has permission to EDIT logs.',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
ttl: 5000,
|
|
85
|
+
},
|
|
86
|
+
extraDataTracking: {
|
|
87
|
+
statusCode: 500,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('uses unknown status when the error has no response status', () => {
|
|
93
|
+
const result = handleApiError(new Error('network down'), 'Pipedrive', 'createContact');
|
|
94
|
+
|
|
95
|
+
expect(result.extraDataTracking).toEqual({ statusCode: 'unknown' });
|
|
96
|
+
expect(result.returnMessage.message).toBe('Error creating contact');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('getOperationErrorMessage', () => {
|
|
101
|
+
test('returns configured appointment messages with platform interpolation', () => {
|
|
102
|
+
const result = getOperationErrorMessage('updateAppointment', 'Google');
|
|
103
|
+
|
|
104
|
+
expect(result.message).toBe('Error updating appointment');
|
|
105
|
+
expect(result.details[0].items[0].text).toContain('exists on Google');
|
|
106
|
+
expect(result.details[0].items[0].id).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('returns a generic message for unknown operations', () => {
|
|
110
|
+
const result = getOperationErrorMessage('syncCustomObject', 'CRM');
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({
|
|
113
|
+
message: 'Error performing syncCustomObject',
|
|
114
|
+
messageType: 'warning',
|
|
115
|
+
details: [
|
|
116
|
+
{
|
|
117
|
+
title: 'Details',
|
|
118
|
+
items: [
|
|
119
|
+
{
|
|
120
|
+
id: 1,
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: 'Please check if your account has the necessary permissions.',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
ttl: 5000,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('handleDatabaseError', () => {
|
|
133
|
+
test('logs context and returns the database warning response', () => {
|
|
134
|
+
const error = new Error('connection lost');
|
|
135
|
+
|
|
136
|
+
const result = handleDatabaseError(error, 'saveUser', { userId: 'user-1' });
|
|
137
|
+
|
|
138
|
+
expect(logger.error).toHaveBeenCalledWith('Database operation failed: saveUser', expect.objectContaining({
|
|
139
|
+
operation: 'saveUser',
|
|
140
|
+
errorMessage: 'connection lost',
|
|
141
|
+
userId: 'user-1',
|
|
142
|
+
}));
|
|
143
|
+
expect(result).toEqual({
|
|
144
|
+
successful: false,
|
|
145
|
+
returnMessage: {
|
|
146
|
+
message: 'Database operation failed',
|
|
147
|
+
messageType: 'warning',
|
|
148
|
+
ttl: 5000,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('asyncHandler', () => {
|
|
155
|
+
test('passes through successful async handlers', async () => {
|
|
156
|
+
const req = {};
|
|
157
|
+
const res = {};
|
|
158
|
+
const next = jest.fn();
|
|
159
|
+
const handler = jest.fn().mockResolvedValue('done');
|
|
160
|
+
|
|
161
|
+
asyncHandler(handler)(req, res, next);
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
|
|
164
|
+
expect(handler).toHaveBeenCalledWith(req, res, next);
|
|
165
|
+
expect(next).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('forwards rejected async handlers to next', async () => {
|
|
169
|
+
const req = {};
|
|
170
|
+
const res = {};
|
|
171
|
+
const next = jest.fn();
|
|
172
|
+
const error = new Error('failed');
|
|
173
|
+
|
|
174
|
+
asyncHandler(jest.fn().mockRejectedValue(error))(req, res, next);
|
|
175
|
+
await Promise.resolve();
|
|
176
|
+
|
|
177
|
+
expect(next).toHaveBeenCalledWith(error);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('errorMiddleware', () => {
|
|
182
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('responds with development error details and request status code', () => {
|
|
189
|
+
process.env.NODE_ENV = 'test';
|
|
190
|
+
const err = new Error('visible error');
|
|
191
|
+
err.statusCode = 418;
|
|
192
|
+
const req = {
|
|
193
|
+
platform: 'Clio',
|
|
194
|
+
method: 'POST',
|
|
195
|
+
path: '/test',
|
|
196
|
+
route: { path: '/test' },
|
|
197
|
+
correlationId: 'correlation-1',
|
|
198
|
+
};
|
|
199
|
+
const res = {
|
|
200
|
+
status: jest.fn().mockReturnThis(),
|
|
201
|
+
json: jest.fn(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
errorMiddleware(err, req, res, jest.fn());
|
|
205
|
+
|
|
206
|
+
expect(logger.error).toHaveBeenCalledWith('Request failed', expect.objectContaining({
|
|
207
|
+
platform: 'Clio',
|
|
208
|
+
operation: '/test',
|
|
209
|
+
method: 'POST',
|
|
210
|
+
path: '/test',
|
|
211
|
+
statusCode: 418,
|
|
212
|
+
correlationId: 'correlation-1',
|
|
213
|
+
}));
|
|
214
|
+
expect(res.status).toHaveBeenCalledWith(418);
|
|
215
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
216
|
+
successful: false,
|
|
217
|
+
returnMessage: {
|
|
218
|
+
message: 'visible error',
|
|
219
|
+
messageType: 'error',
|
|
220
|
+
ttl: 5000,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('hides internal error details in production', () => {
|
|
226
|
+
process.env.NODE_ENV = 'production';
|
|
227
|
+
const res = {
|
|
228
|
+
status: jest.fn().mockReturnThis(),
|
|
229
|
+
json: jest.fn(),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
errorMiddleware(new Error('secret detail'), { query: { platform: 'CRM' } }, res, jest.fn());
|
|
233
|
+
|
|
234
|
+
expect(res.status).toHaveBeenCalledWith(500);
|
|
235
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
236
|
+
successful: false,
|
|
237
|
+
returnMessage: {
|
|
238
|
+
message: 'An internal error occurred',
|
|
239
|
+
messageType: 'error',
|
|
240
|
+
ttl: 5000,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const {
|
|
2
|
+
authorizationErrorMessage,
|
|
3
|
+
rateLimitErrorMessage,
|
|
4
|
+
} = require('../../lib/generalErrorMessage');
|
|
5
|
+
|
|
6
|
+
describe('generalErrorMessage', () => {
|
|
7
|
+
describe('rateLimitErrorMessage', () => {
|
|
8
|
+
test('builds the standard rate limit warning message', () => {
|
|
9
|
+
const result = rateLimitErrorMessage({ platform: 'Clio' });
|
|
10
|
+
|
|
11
|
+
expect(result).toEqual({
|
|
12
|
+
message: 'Rate limit exceeded',
|
|
13
|
+
messageType: 'warning',
|
|
14
|
+
details: [
|
|
15
|
+
{
|
|
16
|
+
title: 'Details',
|
|
17
|
+
items: [
|
|
18
|
+
{
|
|
19
|
+
id: '1',
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: 'You have exceeded the maximum number of requests allowed by Clio. Please try again in the next minute. If the problem persists please contact support.',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
ttl: 5000,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('keeps current interpolation behavior for empty platform input', () => {
|
|
31
|
+
const result = rateLimitErrorMessage({ platform: '' });
|
|
32
|
+
|
|
33
|
+
expect(result.details[0].items[0].text).toContain('allowed by .');
|
|
34
|
+
expect(result.ttl).toBe(5000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('keeps current interpolation behavior for missing platform input', () => {
|
|
38
|
+
const result = rateLimitErrorMessage({});
|
|
39
|
+
|
|
40
|
+
expect(result.details[0].items[0].text).toContain('allowed by undefined.');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('authorizationErrorMessage', () => {
|
|
45
|
+
test('builds the standard authorization warning message', () => {
|
|
46
|
+
const result = authorizationErrorMessage({ platform: 'Bullhorn' });
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual({
|
|
49
|
+
message: 'Authorization error',
|
|
50
|
+
messageType: 'warning',
|
|
51
|
+
details: [
|
|
52
|
+
{
|
|
53
|
+
title: 'Details',
|
|
54
|
+
items: [
|
|
55
|
+
{
|
|
56
|
+
id: '1',
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: "It seems like there's something wrong with your authorization of Bullhorn. Please Logout and then Connect your Bullhorn account within this extension.",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
ttl: 5000,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('keeps current interpolation behavior for empty platform input', () => {
|
|
68
|
+
const result = authorizationErrorMessage({ platform: '' });
|
|
69
|
+
|
|
70
|
+
expect(result.details[0].items[0].text).toContain('authorization of .');
|
|
71
|
+
expect(result.details[0].items[0].text).toContain('Connect your account');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('keeps current interpolation behavior for missing platform input', () => {
|
|
75
|
+
const result = authorizationErrorMessage({});
|
|
76
|
+
|
|
77
|
+
expect(result.details[0].items[0].text).toContain('authorization of undefined.');
|
|
78
|
+
expect(result.details[0].items[0].text).toContain('Connect your undefined account');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const mockS3Send = jest.fn();
|
|
2
|
+
const mockGetSignedUrl = jest.fn();
|
|
3
|
+
const mockShortIdGenerate = jest.fn();
|
|
4
|
+
|
|
5
|
+
jest.mock('@aws-sdk/client-s3', () => {
|
|
6
|
+
class HeadBucketCommand {
|
|
7
|
+
constructor(input) {
|
|
8
|
+
this.input = input;
|
|
9
|
+
this.commandName = 'HeadBucketCommand';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class CreateBucketCommand {
|
|
14
|
+
constructor(input) {
|
|
15
|
+
this.input = input;
|
|
16
|
+
this.commandName = 'CreateBucketCommand';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class PutObjectCommand {
|
|
21
|
+
constructor(input) {
|
|
22
|
+
this.input = input;
|
|
23
|
+
this.commandName = 'PutObjectCommand';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class GetObjectCommand {
|
|
28
|
+
constructor(input) {
|
|
29
|
+
this.input = input;
|
|
30
|
+
this.commandName = 'GetObjectCommand';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
S3Client: jest.fn(() => ({
|
|
36
|
+
send: mockS3Send,
|
|
37
|
+
})),
|
|
38
|
+
HeadBucketCommand,
|
|
39
|
+
CreateBucketCommand,
|
|
40
|
+
PutObjectCommand,
|
|
41
|
+
GetObjectCommand,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
jest.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
46
|
+
getSignedUrl: mockGetSignedUrl,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
jest.mock('shortid', () => ({
|
|
50
|
+
generate: mockShortIdGenerate,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
54
|
+
const {
|
|
55
|
+
CreateBucketCommand,
|
|
56
|
+
HeadBucketCommand,
|
|
57
|
+
PutObjectCommand,
|
|
58
|
+
} = require('@aws-sdk/client-s3');
|
|
59
|
+
const {
|
|
60
|
+
ensureBucketExists,
|
|
61
|
+
getUploadUrl,
|
|
62
|
+
} = require('../../lib/s3ErrorLogReport');
|
|
63
|
+
|
|
64
|
+
describe('s3ErrorLogReport', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
jest.clearAllMocks();
|
|
67
|
+
mockShortIdGenerate.mockReturnValue('report-123');
|
|
68
|
+
mockGetSignedUrl.mockResolvedValue('https://signed-upload.example.com/report');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
jest.useRealTimers();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('ensureBucketExists', () => {
|
|
76
|
+
test('checks bucket existence and does not create when the bucket exists', async () => {
|
|
77
|
+
mockS3Send.mockResolvedValueOnce({});
|
|
78
|
+
|
|
79
|
+
await ensureBucketExists();
|
|
80
|
+
|
|
81
|
+
expect(mockS3Send).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
|
83
|
+
expect(mockS3Send.mock.calls[0][0].input).toEqual({
|
|
84
|
+
Bucket: 'error-reports-bucket',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('creates the bucket when the local bucket is missing by name', async () => {
|
|
89
|
+
const notFound = new Error('missing');
|
|
90
|
+
notFound.name = 'NotFound';
|
|
91
|
+
mockS3Send
|
|
92
|
+
.mockRejectedValueOnce(notFound)
|
|
93
|
+
.mockResolvedValueOnce({});
|
|
94
|
+
|
|
95
|
+
await ensureBucketExists();
|
|
96
|
+
|
|
97
|
+
expect(mockS3Send).toHaveBeenCalledTimes(2);
|
|
98
|
+
expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
|
99
|
+
expect(mockS3Send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
|
|
100
|
+
expect(mockS3Send.mock.calls[1][0].input).toEqual({
|
|
101
|
+
Bucket: 'error-reports-bucket',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('creates the bucket when the local bucket is missing by 404 metadata', async () => {
|
|
106
|
+
const notFound = new Error('missing');
|
|
107
|
+
notFound.$metadata = { httpStatusCode: 404 };
|
|
108
|
+
mockS3Send
|
|
109
|
+
.mockRejectedValueOnce(notFound)
|
|
110
|
+
.mockResolvedValueOnce({});
|
|
111
|
+
|
|
112
|
+
await ensureBucketExists();
|
|
113
|
+
|
|
114
|
+
expect(mockS3Send).toHaveBeenCalledTimes(2);
|
|
115
|
+
expect(mockS3Send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('keeps current behavior of swallowing non-404 bucket check errors', async () => {
|
|
119
|
+
const accessDenied = new Error('access denied');
|
|
120
|
+
accessDenied.name = 'AccessDenied';
|
|
121
|
+
accessDenied.$metadata = { httpStatusCode: 403 };
|
|
122
|
+
mockS3Send.mockRejectedValueOnce(accessDenied);
|
|
123
|
+
|
|
124
|
+
await expect(ensureBucketExists()).resolves.toBeUndefined();
|
|
125
|
+
|
|
126
|
+
expect(mockS3Send).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('getUploadUrl', () => {
|
|
132
|
+
test('checks the local bucket, composes metadata and key, then requests a presigned URL', async () => {
|
|
133
|
+
jest.useFakeTimers().setSystemTime(new Date('2026-07-02T09:20:00.000Z'));
|
|
134
|
+
mockS3Send.mockResolvedValueOnce({});
|
|
135
|
+
|
|
136
|
+
const uploadUrl = await getUploadUrl({
|
|
137
|
+
userId: 'user-1',
|
|
138
|
+
platform: 'clio',
|
|
139
|
+
metadata: {
|
|
140
|
+
source: 'test-suite',
|
|
141
|
+
severity: 'warning',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(uploadUrl).toBe('https://signed-upload.example.com/report');
|
|
146
|
+
expect(mockS3Send).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
|
148
|
+
expect(getSignedUrl).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(getSignedUrl.mock.calls[0][0]).toEqual(expect.objectContaining({
|
|
150
|
+
send: mockS3Send,
|
|
151
|
+
}));
|
|
152
|
+
expect(getSignedUrl.mock.calls[0][1]).toBeInstanceOf(PutObjectCommand);
|
|
153
|
+
expect(getSignedUrl.mock.calls[0][1].input).toEqual({
|
|
154
|
+
Bucket: 'error-reports-bucket',
|
|
155
|
+
Key: 'error-reports/2026-07-02/user-1-report-123.json',
|
|
156
|
+
ContentType: 'application/json',
|
|
157
|
+
Metadata: {
|
|
158
|
+
'user-id': 'user-1',
|
|
159
|
+
platform: 'clio',
|
|
160
|
+
source: 'test-suite',
|
|
161
|
+
severity: 'warning',
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(getSignedUrl.mock.calls[0][2]).toEqual({
|
|
165
|
+
expiresIn: 300,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('uses default metadata when no metadata object is provided', async () => {
|
|
170
|
+
jest.useFakeTimers().setSystemTime(new Date('2026-07-03T01:02:03.000Z'));
|
|
171
|
+
mockS3Send.mockResolvedValueOnce({});
|
|
172
|
+
|
|
173
|
+
await getUploadUrl({
|
|
174
|
+
userId: 'user-2',
|
|
175
|
+
platform: 'bullhorn',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(getSignedUrl.mock.calls[0][1].input).toMatchObject({
|
|
179
|
+
Key: 'error-reports/2026-07-03/user-2-report-123.json',
|
|
180
|
+
Metadata: {
|
|
181
|
+
'user-id': 'user-2',
|
|
182
|
+
platform: 'bullhorn',
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|