Просмотр исходного кода

Merge pull request #7752 from weseek/support/refactor-config-manager

support: Refactor ConfigManager
Yuki Takei 2 лет назад
Родитель
Сommit
61042df8fa

+ 1 - 1
.eslintrc.js

@@ -14,7 +14,7 @@ module.exports = {
       {
       {
         pathGroups: [
         pathGroups: [
           {
           {
-            pattern: 'vitest',
+            pattern: '(vitest|vitest-mock-extended)',
             group: 'builtin',
             group: 'builtin',
             position: 'before',
             position: 'before',
           },
           },

+ 2 - 2
apps/app/src/server/crowi/index.js

@@ -26,7 +26,7 @@ import UserGroup from '../models/user-group';
 import AclService from '../service/acl';
 import AclService from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
-import ConfigManager from '../service/config-manager';
+import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageService from '../service/page';
@@ -274,7 +274,7 @@ Crowi.prototype.setupSessionConfig = async function() {
 };
 };
 
 
 Crowi.prototype.setupConfigManager = async function() {
 Crowi.prototype.setupConfigManager = async function() {
-  this.configManager = new ConfigManager();
+  this.configManager = configManagerSingletonInstance;
   return this.configManager.loadConfigs();
   return this.configManager.loadConfigs();
 };
 };
 
 

+ 60 - 0
apps/app/src/server/service/config-manager.spec.ts

@@ -0,0 +1,60 @@
+import {
+  vi,
+  beforeAll,
+  describe, expect, test,
+} from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import ConfigModel from '../models/config';
+
+import { configManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+
+describe('ConfigManager test', () => {
+
+  const s2sMessagingServiceMock = mock<S2sMessagingService>();
+
+  beforeAll(async() => {
+    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+  });
+
+
+  describe('updateConfigsInTheSameNamespace()', () => {
+
+    test.concurrent('invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
+
+      // when
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
+
+      // then
+      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test.concurrent('does not invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
+
+      // when
+      const dummyConfig = { dummyKey: 'dummyValue' };
+      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
+
+      // then
+      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
+    });
+
+  });
+
+
+});

+ 75 - 85
apps/app/src/server/service/config-manager.ts

@@ -6,8 +6,8 @@ import ConfigModel from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 
 
 import ConfigLoader, { ConfigObject } from './config-loader';
 import ConfigLoader, { ConfigObject } from './config-loader';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:ConfigManager');
 const logger = loggerFactory('growi:service:ConfigManager');
 
 
@@ -36,7 +36,17 @@ const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
   'gcs:uploadNamespace',
   'gcs:uploadNamespace',
 ];
 ];
 
 
-export default class ConfigManager implements S2sMessageHandlable {
+
+export interface ConfigManager {
+  loadConfigs(): Promise<void>,
+  getConfig(namespace: string, key: string): any,
+  getConfigFromDB(namespace: string, key: string): any,
+  getConfigFromEnvVars(namespace: string, key: string): any,
+  updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage?: boolean): Promise<void>
+  removeConfigsInTheSameNamespace(namespace: string, configKeys: string[], withoutPublishingS2sMessage?: boolean): Promise<void>
+}
+
+class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
 
 
   private configLoader: ConfigLoader = new ConfigLoader();
   private configLoader: ConfigLoader = new ConfigLoader();
 
 
@@ -48,6 +58,16 @@ export default class ConfigManager implements S2sMessageHandlable {
 
 
   private lastLoadedAt?: Date;
   private lastLoadedAt?: Date;
 
 
+  private get isInitialized() {
+    return this.lastLoadedAt != null;
+  }
+
+  private validateInitialized() {
+    if (!this.isInitialized) {
+      throw new Error('The config data has not loaded yet.');
+    }
+  }
+
   /**
   /**
    * load configs from the database and the environment variables
    * load configs from the database and the environment variables
    */
    */
@@ -61,68 +81,10 @@ export default class ConfigManager implements S2sMessageHandlable {
     this.lastLoadedAt = new Date();
     this.lastLoadedAt = new Date();
   }
   }
 
 
-  /**
-   * Set S2sMessagingServiceDelegator instance
-   * @param s2sMessagingService
-   */
-  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
-    this.s2sMessagingService = s2sMessagingService;
-  }
-
-  /**
-   * get a config specified by namespace & key
-   *
-   * Basically, this searches a specified config from configs loaded from the database at first
-   * and then from configs loaded from the environment variables.
-   *
-   * In some case, this search method changes.
-   *
-   * the followings are the meanings of each special return value.
-   * - null:      a specified config is not set.
-   * - undefined: a specified config does not exist.
-   */
-  getConfig(namespace, key) {
-    let value;
-
-    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
-      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
-    }
-    else {
-      value = this.defaultSearch(namespace, key);
-    }
-
-    logger.debug(key, value);
-    return value;
-  }
-
-  /**
-   * get a config specified by namespace and regular expression
-   */
-  getConfigByRegExp(namespace, regexp) {
-    const result = {};
-
-    for (const key of this.configKeys) {
-      if (regexp.test(key)) {
-        result[key] = this.getConfig(namespace, key);
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * get a config specified by namespace and prefix
-   */
-  getConfigByPrefix(namespace, prefix) {
-    const regexp = new RegExp(`^${prefix}`);
-
-    return this.getConfigByRegExp(namespace, regexp);
-  }
-
   /**
   /**
    * generate an array of config keys from this.configObject
    * generate an array of config keys from this.configObject
    */
    */
-  getConfigKeys() {
+  private getConfigKeys() {
     // type: fromDB, fromEnvVars
     // type: fromDB, fromEnvVars
     const types = Object.keys(this.configObject);
     const types = Object.keys(this.configObject);
     let namespaces: string[] = [];
     let namespaces: string[] = [];
@@ -152,16 +114,46 @@ export default class ConfigManager implements S2sMessageHandlable {
     return keys;
     return keys;
   }
   }
 
 
-  reloadConfigKeys() {
+  private reloadConfigKeys() {
     this.configKeys = this.getConfigKeys();
     this.configKeys = this.getConfigKeys();
   }
   }
 
 
+
+  /**
+   * get a config specified by namespace & key
+   *
+   * Basically, this searches a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables.
+   *
+   * In some case, this search method changes.
+   *
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
+   */
+  getConfig(namespace, key) {
+    this.validateInitialized();
+
+    let value;
+
+    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
+      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      value = this.defaultSearch(namespace, key);
+    }
+
+    logger.debug(key, value);
+    return value;
+  }
+
   /**
   /**
    * get a config specified by namespace & key from configs loaded from the database
    * get a config specified by namespace & key from configs loaded from the database
    *
    *
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
    */
   getConfigFromDB(namespace, key) {
   getConfigFromDB(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromDBConfigs(namespace, key);
     return this.searchOnlyFromDBConfigs(namespace, key);
   }
   }
 
 
@@ -171,6 +163,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
    */
   getConfigFromEnvVars(namespace, key) {
   getConfigFromEnvVars(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
   }
   }
 
 
@@ -192,7 +185,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    *  );
    *  );
    * ```
    * ```
    */
    */
-  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage?) {
+  async updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage = false): Promise<void> {
     const queries: any[] = [];
     const queries: any[] = [];
     for (const key of Object.keys(configs)) {
     for (const key of Object.keys(configs)) {
       queries.push({
       queries.push({
@@ -235,7 +228,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * return whether the specified namespace/key should be retrieved only from env vars
    * return whether the specified namespace/key should be retrieved only from env vars
    */
    */
-  shouldSearchedFromEnvVarsOnly(namespace, key) {
+  private shouldSearchedFromEnvVarsOnly(namespace, key) {
     return (namespace === 'crowi' && (
     return (namespace === 'crowi' && (
       // siteUrl
       // siteUrl
       (
       (
@@ -273,7 +266,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * search a specified config from configs loaded from the database at first
    * search a specified config from configs loaded from the database at first
    * and then from configs loaded from the environment variables
    * and then from configs loaded from the environment variables
    */
    */
-  defaultSearch(namespace, key) {
+  private defaultSearch(namespace, key) {
     // does not exist neither in db nor in env vars
     // does not exist neither in db nor in env vars
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
       logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
       logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
@@ -309,7 +302,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * search a specified config from configs loaded from the database
    * search a specified config from configs loaded from the database
    */
    */
-  searchOnlyFromDBConfigs(namespace, key) {
+  private searchOnlyFromDBConfigs(namespace, key) {
     if (!this.configExistsInDB(namespace, key)) {
     if (!this.configExistsInDB(namespace, key)) {
       return undefined;
       return undefined;
     }
     }
@@ -320,7 +313,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * search a specified config from configs loaded from the environment variables
    * search a specified config from configs loaded from the environment variables
    */
    */
-  searchOnlyFromEnvVarConfigs(namespace, key) {
+  private searchOnlyFromEnvVarConfigs(namespace, key) {
     if (!this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInEnvVars(namespace, key)) {
       return undefined;
       return undefined;
     }
     }
@@ -331,7 +324,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * check whether a specified config exists in configs loaded from the database
    * check whether a specified config exists in configs loaded from the database
    */
    */
-  configExistsInDB(namespace, key) {
+  private configExistsInDB(namespace, key) {
     if (this.configObject.fromDB[namespace] === undefined) {
     if (this.configObject.fromDB[namespace] === undefined) {
       return false;
       return false;
     }
     }
@@ -342,7 +335,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * check whether a specified config exists in configs loaded from the environment variables
    * check whether a specified config exists in configs loaded from the environment variables
    */
    */
-  configExistsInEnvVars(namespace, key) {
+  private configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
       return false;
       return false;
     }
     }
@@ -350,10 +343,18 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
   }
 
 
-  convertInsertValue(value) {
+  private convertInsertValue(value) {
     return JSON.stringify(value === '' ? null : value);
     return JSON.stringify(value === '' ? null : value);
   }
   }
 
 
+  /**
+   * Set S2sMessagingServiceDelegator instance
+   * @param s2sMessagingService
+   */
+  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
+    this.s2sMessagingService = s2sMessagingService;
+  }
+
   async publishUpdateMessage() {
   async publishUpdateMessage() {
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
 
 
@@ -385,18 +386,7 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.loadConfigs();
     return this.loadConfigs();
   }
   }
 
 
-  /**
-   * Returns file upload total limit in bytes.
-   * Reference to previous implementation is
-   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
-   * @returns file upload total limit in bytes
-   */
-  getFileUploadTotalLimit(): number {
-    const fileUploadTotalLimit = this.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
-      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
-      ? this.getConfig('crowi', 'gridfs:totalLimit') ?? this.getConfig('crowi', 'app:fileUploadTotalLimit')
-      : this.getConfig('crowi', 'app:fileUploadTotalLimit');
-    return fileUploadTotalLimit;
-  }
-
 }
 }
+
+// export the singleton instance
+export const configManager = new ConfigManagerImpl();

+ 2 - 2
apps/app/src/server/service/customize.ts

@@ -7,9 +7,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import type { IPluginService } from './plugin';
 import type { IPluginService } from './plugin';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 
 
 const logger = loggerFactory('growi:service:CustomizeService');
 const logger = loggerFactory('growi:service:CustomizeService');

+ 4 - 3
apps/app/src/server/service/file-uploader-switch.ts

@@ -1,9 +1,10 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 
 

+ 47 - 12
apps/app/src/server/service/file-uploader/aws.ts

@@ -13,6 +13,10 @@ import urljoin from 'url-join';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
@@ -37,10 +41,41 @@ type AwsConfig = {
   forcePathStyle?: boolean
   forcePathStyle?: boolean
 }
 }
 
 
+// TODO: rewrite this module to be a type-safe implementation
+class AwsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AwsFileUploader(crowi);
 
 
   const getAwsConfig = (): AwsConfig => {
   const getAwsConfig = (): AwsConfig => {
     return {
     return {
@@ -100,7 +135,7 @@ module.exports = (crowi) => {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
   };
 
 
-  lib.respond = async function(res, attachment) {
+  (lib as any).respond = async function(res, attachment) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -134,12 +169,12 @@ module.exports = (crowi) => {
 
 
   };
   };
 
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
+    return (lib as any).deleteFileByFilePath(filePath);
   };
   };
 
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -157,7 +192,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectsCommand(totalParams));
     return s3.send(new DeleteObjectsCommand(totalParams));
   };
   };
 
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  (lib as any).deleteFileByFilePath = async function(filePath) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -179,7 +214,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
     return s3.send(new DeleteObjectCommand(params));
   };
   };
 
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -216,7 +251,7 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
     return s3.send(new PutObjectCommand(params));
   };
   };
 
 
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -249,7 +284,7 @@ module.exports = (crowi) => {
     return stream;
     return stream;
   };
   };
 
 
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
@@ -258,7 +293,7 @@ module.exports = (crowi) => {
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }

+ 151 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -0,0 +1,151 @@
+import { randomUUID } from 'crypto';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+const logger = loggerFactory('growi:service:fileUploader');
+
+
+export type SaveFileParam = {
+  filePath: string,
+  contentType: string,
+  data,
+}
+
+export type CheckLimitResult = {
+  isUploadable: boolean,
+  errorMessage?: string,
+}
+
+export interface FileUploader {
+  getIsUploadable(): boolean,
+  isWritable(): Promise<boolean>,
+  getIsReadable(): boolean,
+  isValidUploadSettings(): boolean,
+  getFileUploadEnabled(): boolean,
+  saveFile(param: SaveFileParam): Promise<any>,
+  deleteFiles(): void,
+  getFileUploadTotalLimit(): number,
+  getTotalFileSize(): Promise<number>,
+  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
+  canRespond(): boolean
+  respond(res: Response, attachment: Response): void,
+}
+
+export abstract class AbstractFileUploader implements FileUploader {
+
+  private crowi;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  getIsUploadable() {
+    return !configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
+  }
+
+  /**
+   * Returns whether write opration to the storage is permitted
+   * @returns Whether write opration to the storage is permitted
+   */
+  async isWritable() {
+    const filePath = `${randomUUID()}.growi`;
+    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
+
+    try {
+      await this.saveFile({
+        filePath,
+        contentType: 'text/plain',
+        data,
+      });
+      // TODO: delete tmp file in background
+
+      return true;
+    }
+    catch (err) {
+      logger.error(err);
+      return false;
+    }
+  }
+
+  // File reading is possible even if uploading is disabled
+  getIsReadable() {
+    return this.isValidUploadSettings();
+  }
+
+  abstract isValidUploadSettings(): boolean;
+
+  getFileUploadEnabled() {
+    if (!this.getIsUploadable()) {
+      return false;
+    }
+
+    return !!configManager.getConfig('crowi', 'app:fileUpload');
+  }
+
+  abstract saveFile(param: SaveFileParam);
+
+  abstract deleteFiles();
+
+  /**
+   * Returns file upload total limit in bytes.
+   * Reference to previous implementation is
+   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   * @returns file upload total limit in bytes
+   */
+  getFileUploadTotalLimit() {
+    const fileUploadTotalLimit = configManager.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
+      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
+      ? configManager.getConfig('crowi', 'gridfs:totalLimit') ?? configManager.getConfig('crowi', 'app:fileUploadTotalLimit')
+      : configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return fileUploadTotalLimit;
+  }
+
+  /**
+   * Get total file size
+   * @returns Total file size
+   */
+  async getTotalFileSize() {
+    const Attachment = this.crowi.model('Attachment');
+
+    // Get attachment total file size
+    const res = await Attachment.aggregate().group({
+      _id: null,
+      total: { $sum: '$fileSize' },
+    });
+
+    // res is [] if not using
+    return res.length === 0 ? 0 : res[0].total;
+  }
+
+  /**
+   * Check files size limits for all uploaders
+   *
+   */
+  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult> {
+    if (uploadFileSize > maxFileSize) {
+      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
+    }
+
+    const usingFilesSize = await this.getTotalFileSize();
+    if (usingFilesSize + uploadFileSize > totalLimit) {
+      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
+    }
+
+    return { isUploadable: true };
+  }
+
+  /**
+   * Checks if Uploader can respond to the HTTP request.
+   */
+  canRespond(): boolean {
+    return false;
+  }
+
+  /**
+   * Respond to the HTTP request.
+   */
+  abstract respond(res: Response, attachment: Response): void;
+
+}

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderAws');
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
 const { Storage } = require('@google-cloud/storage');
 const { Storage } = require('@google-cloud/storage');
@@ -9,9 +11,8 @@ let _instance;
 
 
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
 
   function getGcsBucket() {
   function getGcsBucket() {
     return configManager.getConfig('crowi', 'gcs:bucket');
     return configManager.getConfig('crowi', 'gcs:bucket');

+ 49 - 12
apps/app/src/server/service/file-uploader/gridfs.js → apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,16 +1,53 @@
 import { Readable } from 'stream';
 import { Readable } from 'stream';
+import util from 'util';
+
+import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
-const util = require('util');
 
 
-const mongoose = require('mongoose');
+
+// TODO: rewrite this module to be a type-safe implementation
+class GridfsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new GridfsFileUploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
   const COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
 
@@ -33,7 +70,7 @@ module.exports = function(crowi) {
     return true;
     return true;
   };
   };
 
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
     let filenameValue = attachment.fileName;
 
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -49,7 +86,7 @@ module.exports = function(crowi) {
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
   };
 
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     const filenameValues = attachments.map((attachment) => {
     const filenameValues = attachments.map((attachment) => {
       return attachment.fileName;
       return attachment.fileName;
     });
     });
@@ -87,13 +124,13 @@ module.exports = function(crowi) {
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    */
    */
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = configManager.getFileUploadTotalLimit();
+    const totalLimit = lib.getFileUploadTotalLimit();
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
   };
 
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
 
     return AttachmentFile.promisifiedWrite(
     return AttachmentFile.promisifiedWrite(
@@ -125,7 +162,7 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    * @return {stream.Readable} readable stream
    */
    */
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     let filenameValue = attachment.fileName;
     let filenameValue = attachment.fileName;
 
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -145,7 +182,7 @@ module.exports = function(crowi) {
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     const attachmentFiles = await AttachmentFile.find();
     const attachmentFiles = await AttachmentFile.find();
     return attachmentFiles.map(({ filename: name, length: size }) => ({
     return attachmentFiles.map(({ filename: name, length: size }) => ({
       name, size,
       name, size,

+ 3 - 2
apps/app/src/server/service/file-uploader/local.js

@@ -2,6 +2,8 @@ import { Readable } from 'stream';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
 
 const fs = require('fs');
 const fs = require('fs');
@@ -13,9 +15,8 @@ const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
 
   function getFilePathOnStorage(attachment) {
   function getFilePathOnStorage(attachment) {

+ 3 - 2
apps/app/src/server/service/file-uploader/none.js

@@ -1,9 +1,10 @@
 // crowi-fileupload-none
 // crowi-fileupload-none
 
 
+const { AbstractFileUploader } = require('./file-uploader');
+
 module.exports = function(crowi) {
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
   const debug = require('debug')('growi:service:fileUploaderNone');
-  const Uploader = require('./uploader');
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
 
   lib.getIsUploadable = function() {
   lib.getIsUploadable = function() {
     return false;
     return false;

+ 0 - 123
apps/app/src/server/service/file-uploader/uploader.js

@@ -1,123 +0,0 @@
-import { randomUUID } from 'crypto';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:fileUploader');
-
-// file uploader virtual class
-// 各アップローダーで共通のメソッドはここで定義する
-
-class Uploader {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.configManager = crowi.configManager;
-  }
-
-  getIsUploadable() {
-    return !this.configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
-  }
-
-  /**
-   * Returns whether write opration to the storage is permitted
-   * @returns Whether write opration to the storage is permitted
-   */
-  async isWritable() {
-    const filePath = `${randomUUID()}.growi`;
-    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
-
-    try {
-      await this.saveFile({
-        filePath,
-        contentType: 'text/plain',
-        data,
-      });
-      // TODO: delete tmp file in background
-
-      return true;
-    }
-    catch (err) {
-      logger.error(err);
-      return false;
-    }
-  }
-
-  // File reading is possible even if uploading is disabled
-  getIsReadable() {
-    return this.isValidUploadSettings();
-  }
-
-  isValidUploadSettings() {
-    throw new Error('Implement this');
-  }
-
-  getFileUploadEnabled() {
-    if (!this.getIsUploadable()) {
-      return false;
-    }
-
-    return !!this.configManager.getConfig('crowi', 'app:fileUpload');
-  }
-
-  deleteFiles() {
-    throw new Error('Implemnt this');
-  }
-
-  /**
-   * Get total file size
-   * @returns Total file size
-   */
-  async getTotalFileSize() {
-    const Attachment = this.crowi.model('Attachment');
-
-    // Get attachment total file size
-    const res = await Attachment.aggregate().group({
-      _id: null,
-      total: { $sum: '$fileSize' },
-    });
-
-    // res is [] if not using
-    return res.length === 0 ? 0 : res[0].total;
-  }
-
-  /**
-   * Check files size limits for all uploaders
-   *
-   * @param {*} uploadFileSize
-   * @param {*} maxFileSize
-   * @param {*} totalLimit
-   * @returns
-   * @memberof Uploader
-   */
-  async doCheckLimit(uploadFileSize, maxFileSize, totalLimit) {
-    if (uploadFileSize > maxFileSize) {
-      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
-    }
-
-    const usingFilesSize = await this.getTotalFileSize();
-    if (usingFilesSize + uploadFileSize > totalLimit) {
-      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
-    }
-
-    return { isUploadable: true };
-  }
-
-  /**
-   * Checks if Uploader can respond to the HTTP request.
-   */
-  canRespond() {
-    return false;
-  }
-
-  /**
-   * Respond to the HTTP request.
-   * @param {Response} res
-   * @param {Response} attachment
-   */
-  respond(res, attachment) {
-    throw new Error('Implement this');
-  }
-
-}
-
-module.exports = Uploader;

+ 1 - 1
apps/app/src/server/service/g2g-transfer.ts

@@ -535,7 +535,7 @@ export class G2GTransferReceiverService implements Receiver {
     const { version, configManager, fileUploadService } = this.crowi;
     const { version, configManager, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
-    const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
+    const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
     const isWritable = await fileUploadService.isWritable();
 
 
     const attachmentInfo = {
     const attachmentInfo = {

+ 3 - 3
apps/app/src/server/service/installer.ts

@@ -6,13 +6,13 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { IPage } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
+import type { IPage } from '~/interfaces/page';
+import type { IUser } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateConfigsForInstalling } from '../models/config';
 import { generateConfigsForInstalling } from '../models/config';
 
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import SearchService from './search';
 import SearchService from './search';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');

+ 6 - 6
apps/app/src/server/service/slack-integration.ts

@@ -5,20 +5,20 @@ import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder'
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
-import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { IncomingWebhookSendArguments } from '@slack/webhook';
+import { type ChatPostMessageArguments, WebClient } from '@slack/web-api';
+import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { EventActionsPermission } from '../interfaces/slack-integration/events';
+import type { EventActionsPermission } from '../interfaces/slack-integration/events';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 
 
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');

+ 0 - 52
apps/app/test/integration/service/config-manager.test.js

@@ -1,52 +0,0 @@
-import ConfigModel from '~/server/models/config';
-
-const { getInstance } = require('../setup-crowi');
-
-describe('ConfigManager test', () => {
-  let crowi;
-  let configManager;
-
-  beforeEach(async() => {
-    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
-
-    crowi = await getInstance();
-    configManager = crowi.configManager;
-  });
-
-
-  describe('updateConfigsInTheSameNamespace()', () => {
-
-    beforeEach(async() => {
-      configManager.s2sMessagingService = {};
-    });
-
-    test('invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
-
-      const dummyConfig = { dummyKey: 'dummyValue' };
-      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
-
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
-      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
-      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
-    });
-
-    test('does not invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
-
-      const dummyConfig = { dummyKey: 'dummyValue' };
-      await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
-
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
-      expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
-      expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
-    });
-
-  });
-
-
-});

+ 2 - 1
package.json

@@ -104,7 +104,8 @@
     "vite": "^4.3.8",
     "vite": "^4.3.8",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vite-tsconfig-paths": "^4.2.0",
-    "vitest": "^0.31.1"
+    "vitest": "^0.31.1",
+    "vitest-mock-extended": "^1.1.3"
   },
   },
   "engines": {
   "engines": {
     "node": "^16 || ^18",
     "node": "^16 || ^18",

+ 12 - 0
yarn.lock

@@ -17012,6 +17012,11 @@ ts-essentials@9.3.0:
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.3.0.tgz#7e639c1a76b1805c3c60d6e1b5178da2e70aea02"
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.3.0.tgz#7e639c1a76b1805c3c60d6e1b5178da2e70aea02"
   integrity sha512-XeiCboEyBG8UqXZtXl59bWEi4ZgOqRsogFDI6WDGIF1LmzbYiAkIwjkXN6zZWWl4re/lsOqMlYfe8KA0XiiEPw==
   integrity sha512-XeiCboEyBG8UqXZtXl59bWEi4ZgOqRsogFDI6WDGIF1LmzbYiAkIwjkXN6zZWWl4re/lsOqMlYfe8KA0XiiEPw==
 
 
+ts-essentials@^9.3.1:
+  version "9.3.2"
+  resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.3.2.tgz#5f4ae6d24e20d042a033316c0592dbb51d1b273f"
+  integrity sha512-JxKJzuWqH1MmH4ZFHtJzGEhkfN3QvVR3C3w+4BIoWeoY68UVVoA2Np/Bca9z0IPSErVCWhv439aT0We4Dks8kQ==
+
 ts-morph@17.0.1:
 ts-morph@17.0.1:
   version "17.0.1"
   version "17.0.1"
   resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-17.0.1.tgz#d85df4fcf9a1fcda1b331d52c00655f381c932d1"
   resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-17.0.1.tgz#d85df4fcf9a1fcda1b331d52c00655f381c932d1"
@@ -17781,6 +17786,13 @@ vite-tsconfig-paths@^4.2.0:
   optionalDependencies:
   optionalDependencies:
     fsevents "~2.3.2"
     fsevents "~2.3.2"
 
 
+vitest-mock-extended@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/vitest-mock-extended/-/vitest-mock-extended-1.1.3.tgz#1647b554c390e6673c4f14036086e366117c8f47"
+  integrity sha512-MiaKYZbTg+fjozKnCpoTTva0BnlSNYyk4jiPuM2xVhg4aou112QIrALdH3/ZKK6qfXWh0A17gFIjWJjylOlXxg==
+  dependencies:
+    ts-essentials "^9.3.1"
+
 vitest@^0.31.1:
 vitest@^0.31.1:
   version "0.31.1"
   version "0.31.1"
   resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.31.1.tgz#e3d1b68a44e76e24f142c1156fe9772ef603e52c"
   resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.31.1.tgz#e3d1b68a44e76e24f142c1156fe9772ef603e52c"