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

Merge branch 'master' into imprv/88785-refactoring-page-rename-modal

kaori 4 лет назад
Родитель
Сommit
92e735c335

+ 123 - 129
.github/workflows/ci-app.yml

@@ -9,7 +9,6 @@ on:
       - support/prepare-v**
 
 jobs:
-
   lint:
     runs-on: ubuntu-latest
 
@@ -18,46 +17,44 @@ jobs:
         node-version: [16.x]
 
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: lerna run lint for plugins
-      run: |
-        yarn lerna run lint --scope @growi/plugin-*
-    - name: lerna run lint for app
-      run: |
-        yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
+      - uses: actions/checkout@v2
+
+      - uses: actions/setup-node@v2
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v2
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: lerna run lint for plugins
+        run: |
+          yarn lerna run lint --scope @growi/plugin-*
+      - name: lerna run lint for app
+        run: |
+          yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   test:
     runs-on: ubuntu-latest
@@ -70,56 +67,53 @@ jobs:
       mongodb:
         image: mongo:4.4
         ports:
-        - 27017/tcp
+          - 27017/tcp
 
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: yarn test
-      working-directory: ./packages/app
-      run: |
-        yarn test:ci --selectProjects unit server
-        yarn test:ci --selectProjects server-v5
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
-
-    - name: Upload coverage report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Coverage Report
-        path: packages/app/coverage
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
+      - uses: actions/checkout@v2
+
+      - uses: actions/setup-node@v2
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v2
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: yarn test
+        working-directory: ./packages/app
+        run: |
+          yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+
+      - name: Upload coverage report as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: Coverage Report
+          path: packages/app/coverage
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   launch-dev:
     runs-on: ubuntu-latest
@@ -132,45 +126,45 @@ jobs:
       mongodb:
         image: mongo:4.4
         ports:
-        - 27017/tcp
+          - 27017/tcp
 
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: yarn dev:ci
-      working-directory: ./packages/app
-      run: |
-        cp config/ci/.env.local.for-ci .env.development.local
-        yarn dev:ci
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+      - uses: actions/checkout@v2
+
+      - uses: actions/setup-node@v2
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v2
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: yarn dev:ci
+        working-directory: ./packages/app
+        run: |
+          cp config/ci/.env.local.for-ci .env.development.local
+          yarn dev:ci
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 3 - 3
packages/app/jest.config.js

@@ -37,9 +37,9 @@ module.exports = {
 
       rootDir: '.',
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js',
-                  '?!<rootDir>/test/integration/**/v5.*.test.ts', '?!<rootDir>/test/integration/**/v5.*.test.js'],
-
+      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
+      // https://regex101.com/r/jTaxYS/1
+      modulePathIgnorePatterns: ['<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s'],
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalTeardown: '<rootDir>/test/integration/global-teardown.js',

+ 2 - 4
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -30,7 +30,6 @@ const TrashPageAlert = (props) => {
 
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
-  const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -66,7 +65,6 @@ const TrashPageAlert = (props) => {
       [pageToDelete],
       {
         isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
-        forceDeleteCompletelyMode: true,
         onDeletedHandler,
       },
     );
@@ -100,7 +98,7 @@ const TrashPageAlert = (props) => {
         <button
           type="button"
           className="btn btn-danger rounded-pill btn-sm"
-          disabled={!isAbleToDeleteCompletely}
+          disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
           <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }

+ 19 - 12
packages/app/src/components/PageDeleteModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, FC } from 'react';
+import React, { useState, FC, useMemo } from 'react';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -11,6 +11,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import { isTrashPage } from '^/../core/src/utils/page-path-utils';
 
 
 const deleteIconAndKey = {
@@ -32,11 +33,24 @@ const PageDeleteModal: FC = () => {
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
   const isOpened = deleteModalData?.isOpened ?? false;
-  const forceDeleteCompletelyMode = deleteModalData?.opts?.forceDeleteCompletelyMode ?? false;
+
+  const isAbleToDeleteCompletely = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
+    }
+    return true;
+  }, [deleteModalData]);
+
+  const forceDeleteCompletelyMode = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => isTrashPage(page.path));
+    }
+    return false;
+  }, [deleteModalData]);
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(forceDeleteCompletelyMode);
-  const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
+  const deleteMode = forceDeleteCompletelyMode || isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
@@ -89,7 +103,7 @@ const PageDeleteModal: FC = () => {
     else {
       try {
         const recursively = isDeleteRecursively === true ? true : undefined;
-        const completely = isDeleteCompletely === true ? true : undefined;
+        const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
         const page = deleteModalData.pages[0];
 
@@ -136,13 +150,6 @@ const PageDeleteModal: FC = () => {
   }
 
   function renderDeleteCompletelyForm() {
-    let isAbleToDeleteCompletely = false;
-
-    // modify isAbleToDeleteCompletely value if every page allows completely deletion
-    if (deleteModalData != null && deleteModalData.pages != null) {
-      isAbleToDeleteCompletely = deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
-    }
-
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
         <input
@@ -189,7 +196,7 @@ const PageDeleteModal: FC = () => {
           {renderPagePathsToDelete()}
         </div>
         {renderDeleteRecursivelyForm()}
-        {!forceDeleteCompletelyMode && renderDeleteCompletelyForm()}
+        { !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />

+ 17 - 17
packages/app/src/server/service/page.ts

@@ -291,12 +291,10 @@ class PageService {
   private shouldUseV4ProcessForRevert(page): boolean {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    const isPageMigrated = page.parent != null;
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isRoot = isTopPage(page.path);
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
-    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated);
+    const shouldUseV4Process = !isPageRestricted && !isV5Compatible;
 
     return shouldUseV4Process;
   }
@@ -327,6 +325,7 @@ class PageService {
    * @param {User} viewer
    */
   private async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+
     const Page = this.crowi.model('Page');
     const { PageQueryBuilder } = Page;
 
@@ -1578,30 +1577,31 @@ class PageService {
       },
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
-
     if (isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
     }
 
     // TODO: resume
-    if (!isRecursively) {
+    if (isRecursively) {
       // no await for revertDeletedDescendantsWithStream
-      (async() => {
-        const revertedDescendantCount = await this.revertDeletedDescendantsWithStream(page, user, options, shouldUseV4Process);
-
-        // update descendantCount of ancestors'
-        if (page.parent != null) {
-          await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
-
-          // delete leaf empty pages
-          await this.removeLeafEmptyPages(page);
-        }
-      })();
+      this.resumableRevertDeletedDescendants(page, user, options, shouldUseV4Process);
     }
 
     return updatedPage;
   }
 
+  async resumableRevertDeletedDescendants(page, user, options, shouldUseV4Process) {
+    const revertedDescendantCount = await this.revertDeletedDescendantsWithStream(page, user, options, shouldUseV4Process);
+
+    // update descendantCount of ancestors'
+    if (page.parent != null) {
+      await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
+
+      // delete leaf empty pages
+      await this.removeLeafEmptyPages(page);
+    }
+  }
+
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -1682,7 +1682,7 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
 
-    await streamToPromise(readStream);
+    await streamToPromise(writeStream);
 
     return count;
   }

+ 1 - 2
packages/app/src/stores/modal.tsx

@@ -39,7 +39,6 @@ export type IPageForPageDeleteModal = {
 
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
-  forceDeleteCompletelyMode?: boolean,
 }
 
 export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
@@ -73,7 +72,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
     ) => swrResponse.mutate({
       isOpened: true, pages, opts,
     }),
-    close: () => swrResponse.mutate({ isOpened: false, pages: [], opts: undefined }),
+    close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
 

+ 2 - 0
packages/app/test/integration/global-setup.js

@@ -30,6 +30,8 @@ module.exports = async() => {
 
   // create global user & rootPage
   const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
+  await userCollection.insertMany([{ name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' }])[0];
+  await userCollection.insertMany([{ name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' }])[0];
   await pageCollection.insertMany([{
     path: '/',
     grant: 1,

+ 0 - 3
packages/app/test/integration/models/v5.page.test.js

@@ -34,9 +34,6 @@ describe('Page', () => {
     PageRedirect = mongoose.model('PageRedirect');
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
-    if (dummyUser1 == null) {
-      dummyUser1 = await User.create({ name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' });
-    }
 
     rootPage = await Page.findOne({ path: '/' });
 

+ 138 - 11
packages/app/test/integration/service/v5.page.test.ts

@@ -51,19 +51,9 @@ describe('PageService page operations with only public pages', () => {
     /*
      * Common
      */
-    await User.insertMany([
-      { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
-      { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
-    ]);
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
-    if (dummyUser1 == null) {
-      dummyUser1 = await User.create({ name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' });
-    }
     dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
-    if (dummyUser2 == null) {
-      dummyUser2 = await User.create({ name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' });
-    }
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
 
@@ -768,6 +758,84 @@ describe('PageService page operations with only public pages', () => {
       },
     ]);
 
+    /**
+     * Revert
+     */
+    const pageIdForRevert1 = new mongoose.Types.ObjectId();
+    const pageIdForRevert2 = new mongoose.Types.ObjectId();
+    const pageIdForRevert3 = new mongoose.Types.ObjectId();
+
+    const revisionIdForRevert1 = new mongoose.Types.ObjectId();
+    const revisionIdForRevert2 = new mongoose.Types.ObjectId();
+    const revisionIdForRevert3 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdForRevert1,
+        path: '/trash/v5_revert1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        revision: revisionIdForRevert1,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdForRevert2,
+        path: '/trash/v5_revert2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        revision: revisionIdForRevert2,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdForRevert3,
+        path: '/trash/v5_revert2/v5_revert3/v5_revert4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        revision: revisionIdForRevert3,
+        status: Page.STATUS_DELETED,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: revisionIdForRevert1,
+        pageId: pageIdForRevert1,
+        body: 'revert1',
+        format: 'comment',
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForRevert2,
+        pageId: pageIdForRevert2,
+        body: 'revert2',
+        format: 'comment',
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForRevert3,
+        pageId: pageIdForRevert3,
+        body: 'revert3',
+        format: 'comment',
+        author: dummyUser1,
+      },
+    ]);
+
+    const tagIdRevert1 = new mongoose.Types.ObjectId();
+    await Tag.insertMany([
+      { _id: tagIdRevert1, name: 'revertTag1' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      {
+        relatedPage: pageIdForRevert1,
+        relatedTag: tagIdRevert1,
+        isPageTrashed: true,
+      },
+    ]);
+
   });
 
   describe('Rename', () => {
@@ -1331,8 +1399,67 @@ describe('PageService page operations with only public pages', () => {
     });
   });
 
-});
 
+  describe('revert', () => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+      // mock return value
+      const mockedResumableRevertDeletedDescendants = jest.spyOn(crowi.pageService, 'resumableRevertDeletedDescendants').mockReturnValue(null);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+
+      const argsForResumableRevertDeletedDescendants = mockedResumableRevertDeletedDescendants.mock.calls[0];
+
+      // restores the original implementation
+      mockedResumableRevertDeletedDescendants.mockRestore();
+      if (isRecursively) {
+        await crowi.pageService.resumableRevertDeletedDescendants(...argsForResumableRevertDeletedDescendants);
+      }
+
+      return revertedPage;
+
+    };
+
+    test('revert single deleted page', async() => {
+      const deletedPage = await Page.findOne({ path: '/trash/v5_revert1', status: Page.STATUS_DELETED });
+      const revision = await Revision.findOne({ pageId: deletedPage._id });
+      const tag = await Tag.findOne({ name: 'revertTag1' });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      expectAllToBeTruthy([deletedPage, revision, tag, deletedPageTagRelation]);
+
+      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id });
+
+      expect(revertedPage.parent).toStrictEqual(rootPage._id);
+      expect(revertedPage.path).toBe('/v5_revert1');
+      expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(pageTagRelation.isPageTrashed).toBe(false);
+
+    });
+
+    test('revert multiple deleted page (has non existent page in the middle)', async() => {
+      const deletedPage1 = await Page.findOne({ path: '/trash/v5_revert2', status: Page.STATUS_DELETED });
+      const deletedPage2 = await Page.findOne({ path: '/trash/v5_revert2/v5_revert3/v5_revert4', status: Page.STATUS_DELETED });
+      const revision1 = await Revision.findOne({ pageId: deletedPage1._id });
+      const revision2 = await Revision.findOne({ pageId: deletedPage2._id });
+      expectAllToBeTruthy([deletedPage1, deletedPage2, revision1, revision2]);
+
+      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true);
+      const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
+      const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
+
+      expectAllToBeTruthy([revertedPage1, revertedPage2, newlyCreatedPage]);
+      expect(revertedPage1.parent).toStrictEqual(rootPage._id);
+      expect(revertedPage1.path).toBe('/v5_revert2');
+      expect(revertedPage2.path).toBe('/v5_revert2/v5_revert3/v5_revert4');
+      expect(newlyCreatedPage.parent).toStrictEqual(revertedPage1._id);
+      expect(revertedPage2.parent).toStrictEqual(newlyCreatedPage._id);
+      expect(revertedPage1.status).toBe(Page.STATUS_PUBLISHED);
+      expect(revertedPage2.status).toBe(Page.STATUS_PUBLISHED);
+      expect(newlyCreatedPage.status).toBe(Page.STATUS_PUBLISHED);
+
+    });
+  });
+
+});
 describe('PageService page operations with non-public pages', () => {
   // TODO: write test code
 });