فهرست منبع

create abstract class CronService and refactor QuestionnaireCronService

Futa Arai 1 سال پیش
والد
کامیت
529befcb98

+ 1 - 0
apps/app/package.json

@@ -234,6 +234,7 @@
     "@types/archiver": "^6.0.2",
     "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
+    "@types/node-cron": "^3.0.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",

+ 26 - 26
apps/app/test/integration/service/questionnaire-cron.test.ts → apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts

@@ -2,28 +2,30 @@
 import axios from 'axios';
 import mongoose from 'mongoose';
 
-import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
-import { IQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/questionnaire-answer';
-import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
-import ProactiveQuestionnaireAnswer from '../../../src/features/questionnaire/server/models/proactive-questionnaire-answer';
-import QuestionnaireAnswer from '../../../src/features/questionnaire/server/models/questionnaire-answer';
-import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
-import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
-import { getInstance } from '../setup-crowi';
+import { configManager } from '~/server/service/config-manager';
 
-const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
-  axios,
-  'get',
-);
+import type { IProactiveQuestionnaireAnswer } from '../../interfaces/proactive-questionnaire-answer';
+import type { IQuestionnaireAnswer } from '../../interfaces/questionnaire-answer';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
 
-const spyAxiosPost = jest.spyOn<typeof axios, 'post'>(
-  axios,
-  'post',
-);
+import questionnaireCronService from './questionnaire-cron';
 
-describe('QuestionnaireCronService', () => {
-  let crowi;
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
 
+describe('QuestionnaireCronService', () => {
   const mockResponse = {
     data: {
       questionnaireOrders: [
@@ -137,13 +139,11 @@ describe('QuestionnaireCronService', () => {
   };
 
   beforeAll(async() => {
-    crowi = await getInstance();
-    const User = crowi.model('User');
+    await configManager.loadConfigs();
     await User.create({
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
       email: 'questionnaireCronTestUser@example.com',
-      password: 'usertestpass',
       createdAt: '2020-01-01',
     });
   });
@@ -325,19 +325,19 @@ describe('QuestionnaireCronService', () => {
       validProactiveQuestionnaireAnswer,
     ]);
 
-    crowi.setupCron();
+    questionnaireCronService.startCron('0 22 * * *');
 
-    spyAxiosGet.mockResolvedValue(mockResponse);
-    spyAxiosPost.mockResolvedValue({ data: { result: 'success' } });
+    vi.spyOn(axios, 'get').mockResolvedValue(mockResponse);
+    vi.spyOn(axios, 'post').mockResolvedValue({ data: { result: 'success' } });
   });
 
   afterAll(() => {
-    crowi.questionnaireCronService.stopCron(); // jest will not finish until cronjob stops
+    questionnaireCronService.stopCron(); // vitest will not finish until cronjob stops
   });
 
   test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
     // testing the cronjob from schedule has untrivial overhead, so test job execution in place
-    await crowi.questionnaireCronService.executeJob();
+    await questionnaireCronService.executeJob();
 
     const savedOrders = await QuestionnaireOrder.find()
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')

+ 15 - 40
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -1,19 +1,17 @@
 import axiosRetry from 'axios-retry';
 
-import loggerFactory from '~/utils/logger';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
 import { getRandomIntInRange } from '~/utils/rand';
 
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire-order';
 
-const logger = loggerFactory('growi:service:questionnaire-cron');
-
 const axios = require('axios').default;
-const nodeCron = require('node-cron');
 
 axiosRetry(axios, { retries: 3 });
 
@@ -24,36 +22,19 @@ axiosRetry(axios, { retries: 3 });
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
  */
-class QuestionnaireCronService {
-
-  crowi: any;
-
-  cronJob: any;
-
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
+class QuestionnaireCronService extends CronService {
 
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
 
-  startCron(): void {
-    const cronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
-    const maxHoursUntilRequest = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
-
+  override startCron(cronSchedule: string): void {
+    const maxHoursUntilRequest = configManager.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
     const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
 
-    this.cronJob?.stop();
-    this.cronJob = this.generateCronJob(cronSchedule, maxSecondsUntilRequest);
-    this.cronJob.start();
+    super.startCron(cronSchedule, this.getPreExecute(maxSecondsUntilRequest));
   }
 
-  stopCron(): void {
-    this.cronJob.stop();
-  }
-
-  async executeJob(): Promise<void> {
-    const questionnaireServerOrigin = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+  override async executeJob(): Promise<void> {
+    const questionnaireServerOrigin = configManager.getConfig('crowi', 'app:questionnaireServerOrigin');
 
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
@@ -100,22 +81,16 @@ class QuestionnaireCronService {
     await changeSkippedAnswerStatusToNotAnswered();
   }
 
-  private generateCronJob(cronSchedule: string, maxSecondsUntilRequest: number) {
-    return nodeCron.schedule(cronSchedule, async() => {
+  private getPreExecute(maxSecondsUntilRequest: number): () => Promise<void> {
+    return async() => {
       // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
       const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
       await this.sleep(secToSleep * 1000);
-
-      try {
-        this.executeJob();
-      }
-      catch (e) {
-        logger.error(e);
-      }
-
-    });
+    };
   }
 
 }
 
-export default QuestionnaireCronService;
+const questionnaireCronService = new QuestionnaireCronService();
+
+export default questionnaireCronService;

+ 3 - 4
apps/app/src/server/crowi/index.js

@@ -16,7 +16,7 @@ import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/
 import PageBulkExportJob from '~/features/page-bulk-export/server/models/page-bulk-export-job';
 import instanciatePageBulkExportService, { pageBulkExportService } from '~/features/page-bulk-export/server/service/page-bulk-export';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
-import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
+import questionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
@@ -100,7 +100,6 @@ class Crowi {
     this.activityService = null;
     this.commentService = null;
     this.questionnaireService = null;
-    this.questionnaireCronService = null;
 
     this.tokens = null;
 
@@ -329,8 +328,8 @@ Crowi.prototype.setupModels = async function() {
 };
 
 Crowi.prototype.setupCron = function() {
-  this.questionnaireCronService = new QuestionnaireCronService(this);
-  this.questionnaireCronService.startCron();
+  const questionnaireCronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
+  questionnaireCronService.startCron(questionnaireCronSchedule);
 };
 
 Crowi.prototype.setupQuestionnaireService = function() {

+ 38 - 0
apps/app/src/server/service/cron.ts

@@ -0,0 +1,38 @@
+import type { ScheduledTask } from 'node-cron';
+import nodeCron from 'node-cron';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:cron');
+
+abstract class CronService {
+
+  cronJob: ScheduledTask;
+
+  startCron(cronSchedule: string, preExecute?: () => void): void {
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob(cronSchedule, preExecute);
+    this.cronJob.start();
+  }
+
+  stopCron(): void {
+    this.cronJob.stop();
+  }
+
+  abstract executeJob(): Promise<void>;
+
+  protected generateCronJob(cronSchedule: string, preExecute?: () => void): ScheduledTask {
+    return nodeCron.schedule(cronSchedule, async() => {
+      await preExecute?.();
+      try {
+        this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+    });
+  }
+
+}
+
+export default CronService;

+ 15 - 35
yarn.lock

@@ -4188,13 +4188,6 @@
   dependencies:
     tslib "2.1.0"
 
-"@types/archiver@^6.0.2":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.2.tgz#0daf8c83359cbde69de1e4b33dcade6a48a929e2"
-  integrity sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==
-  dependencies:
-    "@types/readdir-glob" "*"
-
 "@tybys/wasm-util@^0.9.0":
   version "0.9.0"
   resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
@@ -4202,6 +4195,13 @@
   dependencies:
     tslib "^2.4.0"
 
+"@types/archiver@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.2.tgz#0daf8c83359cbde69de1e4b33dcade6a48a929e2"
+  integrity sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==
+  dependencies:
+    "@types/readdir-glob" "*"
+
 "@types/argparse@1.0.38":
   version "1.0.38"
   resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
@@ -4534,6 +4534,11 @@
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
 
+"@types/node-cron@^3.0.2":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344"
+  integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==
+
 "@types/node-fetch@^2.5.0":
   version "2.6.8"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.8.tgz#9a2993583975849c2e1f360b6ca2f11755b2c504"
@@ -17269,7 +17274,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17287,15 +17292,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17379,7 +17375,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17393,13 +17389,6 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19221,7 +19210,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19239,15 +19228,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"