@stamhoofd/backend 2.79.7 → 2.80.0

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/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20.12
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load();
2
+ backendEnv.load({ service: 'api' });
3
3
 
4
4
  import { Column, Database, Migration } from '@simonbackx/simple-database';
5
5
  import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.79.7",
3
+ "version": "2.80.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -35,17 +35,17 @@
35
35
  "@bwip-js/node": "^4.5.1",
36
36
  "@mollie/api-client": "3.7.0",
37
37
  "@simonbackx/simple-database": "1.29.1",
38
- "@simonbackx/simple-encoding": "2.21.0",
38
+ "@simonbackx/simple-encoding": "2.22.0",
39
39
  "@simonbackx/simple-endpoints": "1.19.1",
40
40
  "@simonbackx/simple-logging": "^1.0.1",
41
- "@stamhoofd/backend-i18n": "2.79.7",
42
- "@stamhoofd/backend-middleware": "2.79.7",
43
- "@stamhoofd/email": "2.79.7",
44
- "@stamhoofd/models": "2.79.7",
45
- "@stamhoofd/queues": "2.79.7",
46
- "@stamhoofd/sql": "2.79.7",
47
- "@stamhoofd/structures": "2.79.7",
48
- "@stamhoofd/utility": "2.79.7",
41
+ "@stamhoofd/backend-i18n": "2.80.0",
42
+ "@stamhoofd/backend-middleware": "2.80.0",
43
+ "@stamhoofd/email": "2.80.0",
44
+ "@stamhoofd/models": "2.80.0",
45
+ "@stamhoofd/queues": "2.80.0",
46
+ "@stamhoofd/sql": "2.80.0",
47
+ "@stamhoofd/structures": "2.80.0",
48
+ "@stamhoofd/utility": "2.80.0",
49
49
  "archiver": "^7.0.1",
50
50
  "aws-sdk": "^2.885.0",
51
51
  "axios": "1.6.8",
@@ -65,5 +65,5 @@
65
65
  "publishConfig": {
66
66
  "access": "public"
67
67
  },
68
- "gitHead": "d8675cbc91c2fcdcc6bbd3b3925ccd8063b15522"
68
+ "gitHead": "252faf1dbd8ebdd8469810e9c51e510d93f21fc2"
69
69
  }
@@ -1,9 +1,9 @@
1
1
  import { Model, ModelEvent } from '@simonbackx/simple-database';
2
2
  import { AuditLog } from '@stamhoofd/models';
3
+ import { ObjectDiffer } from '@stamhoofd/object-differ';
3
4
  import { AuditLogPatchItem, AuditLogPatchItemType, AuditLogReplacement, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
4
5
  import { ContextInstance } from '../helpers/Context';
5
6
  import { AuditLogService } from '../services/AuditLogService';
6
- import { diffUnknown } from '../services/diff';
7
7
 
8
8
  export type ModelEventLogOptions<D> = {
9
9
  type: AuditLogType;
@@ -180,7 +180,7 @@ export class ModelLogger<ModelType extends typeof Model, M extends InstanceType<
180
180
  }));
181
181
  }
182
182
  else {
183
- log.patchList.push(...diffUnknown(
183
+ log.patchList.push(...ObjectDiffer.diff(
184
184
  key in oldModel ? oldModel[key] : undefined,
185
185
  key in event.model ? event.model[key] : undefined,
186
186
  AuditLogReplacement.key(renamedKey),
@@ -27,13 +27,17 @@ async function saveLog({ email, organization, type, subType, subject, response,
27
27
  type: AuditLogReplacementType.EmailAddress,
28
28
  })],
29
29
  ['subType', AuditLogReplacement.key(subType || 'unknown')],
30
- ['response', AuditLogReplacement.longText(response)],
31
30
  ['subject', AuditLogReplacement.string(subject)],
32
31
  ['sender', AuditLogReplacement.create({
33
32
  value: sender,
34
33
  type: AuditLogReplacementType.EmailAddress,
35
34
  })],
36
35
  ]);
36
+
37
+ if (response) {
38
+ log.replacements.set('response', AuditLogReplacement.longText(response));
39
+ }
40
+
37
41
  // Check if we already logged this bounce
38
42
  const existing = await AuditLog.select().where('externalId', log.externalId).first(false);
39
43
  if (existing) {
@@ -44,140 +48,180 @@ async function saveLog({ email, organization, type, subType, subject, response,
44
48
  await log.save();
45
49
  }
46
50
 
47
- async function checkBounces() {
48
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
49
- return;
50
- }
51
-
52
- console.log('[AWS BOUNCES] Checking bounces from AWS SQS');
53
- const sqs = new AWS.SQS();
54
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue', MaxNumberOfMessages: 10 }).promise();
55
- if (messages.Messages) {
56
- for (const message of messages.Messages) {
57
- console.log('[AWS BOUNCES] Received bounce message');
58
- console.log('[AWS BOUNCES]', message);
59
-
60
- if (message.ReceiptHandle) {
61
- if (STAMHOOFD.environment === 'production') {
62
- await sqs.deleteMessage({
63
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue',
64
- ReceiptHandle: message.ReceiptHandle,
65
- }).promise();
66
- console.log('[AWS BOUNCES] Deleted from queue');
51
+ async function handleBounce(message: any) {
52
+ if (message.bounce) {
53
+ const b = message.bounce;
54
+ // Block all receivers that generate a permanent bounce
55
+ const type = b.bounceType;
56
+ const subtype = b.bounceSubType;
57
+
58
+ const source = message.mail.source;
59
+
60
+ // try to find organization that is responsible for this e-mail address
61
+
62
+ for (const recipient of b.bouncedRecipients) {
63
+ const email = recipient.emailAddress;
64
+
65
+ if (
66
+ type === 'Permanent'
67
+ || (
68
+ recipient.diagnosticCode && (
69
+ (recipient.diagnosticCode as string).toLowerCase().includes('invalid domain')
70
+ || (recipient.diagnosticCode as string).toLowerCase().includes('unable to lookup dns')
71
+ )
72
+ )
73
+ ) {
74
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
75
+ if (organization) {
76
+ const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
77
+ emailAddress.hardBounce = true;
78
+ await emailAddress.save();
79
+ }
80
+ else {
81
+ console.error('[AWS BOUNCES] Unknown organization for email address ' + source);
67
82
  }
68
- }
69
-
70
- try {
71
- if (message.Body) {
72
- // decode the JSON value
73
- const bounce = JSON.parse(message.Body);
74
83
 
75
- if (bounce.Message) {
76
- const message = JSON.parse(bounce.Message);
84
+ await saveLog({
85
+ id: b.feedbackId,
86
+ email,
87
+ organization,
88
+ type: AuditLogType.EmailAddressHardBounced,
89
+ subType: subtype || 'unknown',
90
+ sender: source,
91
+ response: recipient.diagnosticCode || '',
92
+ subject: message.mail.commonHeaders?.subject || '',
93
+ });
94
+ }
95
+ else if (
96
+ type === 'Transient'
97
+ ) {
98
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
99
+ await saveLog({
100
+ id: b.feedbackId,
101
+ email,
102
+ organization,
103
+ type: AuditLogType.EmailAddressSoftBounced,
104
+ subType: subtype || 'unknown',
105
+ sender: source,
106
+ response: recipient.diagnosticCode || '',
107
+ subject: message.mail.commonHeaders?.subject || '',
108
+ });
109
+ }
110
+ }
111
+ console.log('[AWS BOUNCES] For domain ' + source);
112
+ }
113
+ else {
114
+ console.log("[AWS BOUNCES] 'bounce' field missing in bounce message");
115
+ }
116
+ }
77
117
 
78
- if (message.bounce) {
79
- const b = message.bounce;
80
- // Block all receivers that generate a permanent bounce
81
- const type = b.bounceType;
82
- const subtype = b.bounceSubType;
83
-
84
- const source = message.mail.source;
85
-
86
- // try to find organization that is responsible for this e-mail address
87
-
88
- for (const recipient of b.bouncedRecipients) {
89
- const email = recipient.emailAddress;
90
-
91
- if (
92
- type === 'Permanent'
93
- || (
94
- recipient.diagnosticCode && (
95
- (recipient.diagnosticCode as string).toLowerCase().includes('invalid domain')
96
- || (recipient.diagnosticCode as string).toLowerCase().includes('unable to lookup dns')
97
- )
98
- )
99
- ) {
100
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
101
- if (organization) {
102
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
103
- emailAddress.hardBounce = true;
104
- await emailAddress.save();
105
- }
106
- else {
107
- console.error('[AWS BOUNCES] Unknown organization for email address ' + source);
108
- }
109
-
110
- await saveLog({
111
- id: b.feedbackId,
112
- email,
113
- organization,
114
- type: AuditLogType.EmailAddressHardBounced,
115
- subType: subtype || 'unknown',
116
- sender: source,
117
- response: b.diagnosticCode || '',
118
- subject: message.mail.commonHeaders?.subject || '',
119
- });
120
- }
121
- else if (
122
- type === 'Transient'
123
- ) {
124
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
125
- await saveLog({
126
- id: b.feedbackId,
127
- email,
128
- organization,
129
- type: AuditLogType.EmailAddressSoftBounced,
130
- subType: subtype || 'unknown',
131
- sender: source,
132
- response: b.diagnosticCode || '',
133
- subject: message.mail.commonHeaders?.subject || '',
134
- });
135
- }
136
- }
137
- console.log('[AWS BOUNCES] For domain ' + source);
138
- }
139
- else {
140
- console.log("[AWS BOUNCES] 'bounce' field missing in bounce message");
141
- }
118
+ async function handleComplaint(message: any) {
119
+ if (message.complaint) {
120
+ const b = message.complaint;
121
+ const source = message.mail.source;
122
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
123
+
124
+ const type: 'abuse' | 'auth-failure' | 'fraud' | 'not-spam' | 'other' | 'virus' = b.complaintFeedbackType;
125
+
126
+ if (organization) {
127
+ for (const recipient of b.complainedRecipients) {
128
+ const email = recipient.emailAddress;
129
+ const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
130
+ emailAddress.markedAsSpam = type !== 'not-spam';
131
+ await emailAddress.save();
132
+
133
+ if (type !== 'not-spam') {
134
+ if (type === 'virus' || type === 'fraud') {
135
+ await saveLog({
136
+ id: b.feedbackId,
137
+ email: source,
138
+ organization,
139
+ type: AuditLogType.EmailAddressFraudComplaint,
140
+ subType: type || 'unknown',
141
+ sender: source,
142
+ response: recipient.diagnosticCode || '',
143
+ subject: message.mail.commonHeaders?.subject || '',
144
+ });
142
145
  }
143
146
  else {
144
- console.log("[AWS BOUNCES] 'Message' field missing in bounce message");
147
+ await saveLog({
148
+ id: b.feedbackId,
149
+ email: source,
150
+ organization,
151
+ type: AuditLogType.EmailAddressMarkedAsSpam,
152
+ subType: type || 'unknown',
153
+ sender: source,
154
+ response: recipient.diagnosticCode || '',
155
+ subject: message.mail.commonHeaders?.subject || '',
156
+ });
145
157
  }
146
158
  }
147
- else {
148
- console.log('[AWS BOUNCES] Message Body missing in bounce');
149
- }
150
159
  }
151
- catch (e) {
152
- console.log('[AWS BOUNCES] Bounce message processing failed:');
153
- console.error('[AWS BOUNCES] Bounce message processing failed:');
154
- console.error('[AWS BOUNCES]', e);
160
+ }
161
+ else {
162
+ console.error('[AWS COMPLAINTS] Unknown organization for email address ' + source);
163
+ }
164
+
165
+ if (type === 'virus' || type === 'fraud') {
166
+ console.error('[AWS COMPLAINTS] Received virus / fraud complaint!');
167
+ console.error('[AWS COMPLAINTS]', message.complaint);
168
+ if (STAMHOOFD.environment !== 'development') {
169
+ Email.sendWebmaster({
170
+ subject: 'Received a ' + type + ' email notification',
171
+ text: 'We received a ' + type + ' notification for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n',
172
+ });
155
173
  }
156
174
  }
157
175
  }
176
+ else {
177
+ console.log('[AWS COMPLAINTS] Missing complaint field');
178
+ }
158
179
  }
159
180
 
160
- async function checkReplies() {
161
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
162
- return;
181
+ async function handleForward(message: any) {
182
+ if (message.mail && message.content && message.receipt) {
183
+ const content = message.content;
184
+ const receipt = message.receipt as {
185
+ recipients: string[];
186
+ spamVerdict: { status: 'PASS' | string };
187
+ virusVerdict: { status: 'PASS' | string };
188
+ spfVerdict: { status: 'PASS' | string };
189
+ dkimVerdict: { status: 'PASS' | string };
190
+ dmarcVerdict: { status: 'PASS' | string };
191
+ };
192
+
193
+ const options = await ForwardHandler.handle(content, receipt);
194
+ if (options) {
195
+ if (STAMHOOFD.environment !== 'development') {
196
+ Email.send(options);
197
+ }
198
+ }
199
+ }
200
+ else {
201
+ console.log('[AWS FORWARDING] Missing mail, content or receipt field');
163
202
  }
203
+ }
164
204
 
165
- console.log('Checking replies from AWS SQS');
205
+ async function readFromQueue(queueUrl: string) {
206
+ console.log('[AWS Queue] Checking ' + queueUrl);
166
207
  const sqs = new AWS.SQS();
167
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding', MaxNumberOfMessages: 10 }).promise();
208
+ const messages = await sqs.receiveMessage({ QueueUrl: queueUrl, MaxNumberOfMessages: 10 }).promise();
209
+ let didProcess = 0;
168
210
  if (messages.Messages) {
169
211
  for (const message of messages.Messages) {
170
- console.log('Received message from forwarding queue');
212
+ console.log('[AWS Queue] Received message');
213
+ console.log('[AWS Queue]', message);
171
214
 
172
215
  if (message.ReceiptHandle) {
173
- if (STAMHOOFD.environment === 'production') {
216
+ if (STAMHOOFD.environment !== 'development') {
174
217
  await sqs.deleteMessage({
175
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding',
218
+ QueueUrl: queueUrl,
176
219
  ReceiptHandle: message.ReceiptHandle,
177
220
  }).promise();
178
- console.log('Deleted from queue');
221
+ console.log('[AWS Queue] Deleted from queue');
179
222
  }
180
223
  }
224
+ didProcess += 1;
181
225
 
182
226
  try {
183
227
  if (message.Body) {
@@ -187,138 +231,82 @@ async function checkReplies() {
187
231
  if (bounce.Message) {
188
232
  const message = JSON.parse(bounce.Message);
189
233
 
234
+ // Docs: https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-contents.html
235
+ if (message.bounce) {
236
+ await handleBounce(message);
237
+ }
238
+ else if (message.complaint) {
239
+ await handleComplaint(message);
240
+ }
190
241
  // Read message content
191
- if (message.mail && message.content && message.receipt) {
192
- const content = message.content;
193
- const receipt = message.receipt as {
194
- recipients: string[];
195
- spamVerdict: { status: 'PASS' | string };
196
- virusVerdict: { status: 'PASS' | string };
197
- spfVerdict: { status: 'PASS' | string };
198
- dkimVerdict: { status: 'PASS' | string };
199
- dmarcVerdict: { status: 'PASS' | string };
200
- };
201
-
202
- const options = await ForwardHandler.handle(content, receipt);
203
- if (options) {
204
- if (STAMHOOFD.environment === 'production') {
205
- Email.send(options);
206
- }
207
- }
242
+ // https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-contents.html
243
+ else if (message.mail && message.content && message.receipt) {
244
+ await handleForward(message);
245
+ }
246
+ else {
247
+ console.log('[AWS Queue] Unsupported message');
208
248
  }
209
249
  }
250
+ else {
251
+ console.log("[AWS Queue] 'Message' field missing");
252
+ }
253
+ }
254
+ else {
255
+ console.log('[AWS Queue] Message Body missing in bounce');
210
256
  }
211
257
  }
212
258
  catch (e) {
213
- console.error(e);
259
+ console.log('[AWS Queue] Message processing failed:');
260
+ console.log('[AWS Queue]', e);
261
+
262
+ console.error('[AWS Queue] Message processing failed:');
263
+ console.error('[AWS Queue]', e);
214
264
  }
215
265
  }
216
266
  }
267
+
268
+ if (didProcess) {
269
+ console.log(`[AWS Queue] Processed ${didProcess} message(s) from queue`);
270
+ }
271
+ else {
272
+ console.log(`[AWS Queue] No message to process from queue`);
273
+ }
274
+ return didProcess;
217
275
  }
218
276
 
219
- async function checkComplaints() {
220
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
277
+ async function readAllFromQueue(queueUrl: string) {
278
+ let readCount = 0;
279
+ while (readCount < 20) {
280
+ const didProcess = await readFromQueue(queueUrl);
281
+ if (!didProcess) {
282
+ break;
283
+ }
284
+ readCount += didProcess;
285
+ }
286
+ console.log(`[AWS Queue] Finished processing all messages from queue (${readCount} messages processed)`);
287
+ return true;
288
+ }
289
+
290
+ async function checkBounces() {
291
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_BOUNCE_QUEUE_URL) {
221
292
  return;
222
293
  }
223
294
 
224
- console.log('[AWS COMPLAINTS] Checking complaints from AWS SQS');
225
- const sqs = new AWS.SQS();
226
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue', MaxNumberOfMessages: 10 }).promise();
227
- if (messages.Messages) {
228
- for (const message of messages.Messages) {
229
- console.log('[AWS COMPLAINTS] Received complaint message');
230
- console.log('[AWS COMPLAINTS]', message);
295
+ await readAllFromQueue(STAMHOOFD.AWS_BOUNCE_QUEUE_URL);
296
+ }
231
297
 
232
- if (message.ReceiptHandle) {
233
- if (STAMHOOFD.environment === 'production') {
234
- await sqs.deleteMessage({
235
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue',
236
- ReceiptHandle: message.ReceiptHandle,
237
- }).promise();
238
- console.log('[AWS COMPLAINTS] Deleted from queue');
239
- }
240
- }
298
+ async function checkReplies() {
299
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_FORWARDING_QUEUE_URL) {
300
+ return;
301
+ }
241
302
 
242
- try {
243
- if (message.Body) {
244
- // decode the JSON value
245
- const complaint = JSON.parse(message.Body);
246
- console.log('[AWS COMPLAINTS]', complaint);
247
-
248
- if (complaint.Message) {
249
- const message = JSON.parse(complaint.Message);
250
-
251
- if (message.complaint) {
252
- const b = message.complaint;
253
- const source = message.mail.source;
254
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
255
-
256
- const type: 'abuse' | 'auth-failure' | 'fraud' | 'not-spam' | 'other' | 'virus' = b.complaintFeedbackType;
257
-
258
- if (organization) {
259
- for (const recipient of b.complainedRecipients) {
260
- const email = recipient.emailAddress;
261
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
262
- emailAddress.markedAsSpam = type !== 'not-spam';
263
- await emailAddress.save();
264
-
265
- if (type !== 'not-spam') {
266
- if (type === 'virus' || type === 'fraud') {
267
- await saveLog({
268
- id: b.feedbackId,
269
- email: source,
270
- organization,
271
- type: AuditLogType.EmailAddressFraudComplaint,
272
- subType: type || 'unknown',
273
- sender: source,
274
- response: b.diagnosticCode || '',
275
- subject: message.mail.commonHeaders?.subject || '',
276
- });
277
- }
278
- else {
279
- await saveLog({
280
- id: b.feedbackId,
281
- email: source,
282
- organization,
283
- type: AuditLogType.EmailAddressMarkedAsSpam,
284
- subType: type || 'unknown',
285
- sender: source,
286
- response: b.diagnosticCode || '',
287
- subject: message.mail.commonHeaders?.subject || '',
288
- });
289
- }
290
- }
291
- }
292
- }
293
- else {
294
- console.error('[AWS COMPLAINTS] Unknown organization for email address ' + source);
295
- }
296
-
297
- if (type === 'virus' || type === 'fraud') {
298
- console.error('[AWS COMPLAINTS] Received virus / fraud complaint!');
299
- console.error('[AWS COMPLAINTS]', complaint);
300
- if (STAMHOOFD.environment === 'production') {
301
- Email.sendWebmaster({
302
- subject: 'Received a ' + type + ' email notification',
303
- text: 'We received a ' + type + ' notification for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n',
304
- });
305
- }
306
- }
307
- }
308
- else {
309
- console.log('[AWS COMPLAINTS] Missing complaint field');
310
- }
311
- }
312
- else {
313
- console.log('[AWS COMPLAINTS] Missing message field in complaint');
314
- }
315
- }
316
- }
317
- catch (e) {
318
- console.log('[AWS COMPLAINTS] Complain message processing failed:');
319
- console.error('[AWS COMPLAINTS] Complain message processing failed:');
320
- console.error('[AWS COMPLAINTS]', e);
321
- }
322
- }
303
+ await readAllFromQueue(STAMHOOFD.AWS_FORWARDING_QUEUE_URL);
304
+ }
305
+
306
+ async function checkComplaints() {
307
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_COMPLAINTS_QUEUE_URL) {
308
+ return;
323
309
  }
310
+
311
+ await readAllFromQueue(STAMHOOFD.AWS_COMPLAINTS_QUEUE_URL);
324
312
  }
@@ -4,7 +4,7 @@ import { clearExcelCacheHelper } from './clearExcelCache';
4
4
 
5
5
  const testPath = '/Users/user/project/backend/app/api/.cache';
6
6
  jest.mock('fs/promises');
7
- const fsMock = jest.mocked(fs, true);
7
+ const fsMock = jest.mocked(fs, { shallow: true });
8
8
 
9
9
  describe('clearExcelCacheHelper', () => {
10
10
  it('should only run between 3 and 6 AM', async () => {