Browse Source

Merge pull request #5831 from weseek/feat/testcode-for-resume-rename-operation

feat Testcode for resume rename operation
Yohei Shiina 3 years ago
parent
commit
83a256f508

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

@@ -7,9 +7,8 @@
 
 
 import 'tsconfig-paths/register';
 import 'tsconfig-paths/register';
 
 
-import mongoose from 'mongoose';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
 
 
 // check env
 // check env
 if (process.env.NODE_ENV !== 'test') {
 if (process.env.NODE_ENV !== 'test') {
@@ -27,12 +26,93 @@ module.exports = async() => {
   // init DB
   // init DB
   const pageCollection = mongoose.connection.collection('pages');
   const pageCollection = mongoose.connection.collection('pages');
   const userCollection = mongoose.connection.collection('users');
   const userCollection = mongoose.connection.collection('users');
+  const userGroupCollection = mongoose.connection.collection('usergroups');
+  const userGroupRelationsCollection = mongoose.connection.collection('usergrouprelations');
 
 
   // create global user & rootPage
   // create global user & rootPage
   const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
   const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
+  const gGroupUserId1 = new mongoose.Types.ObjectId();
+  const gGroupUserId2 = new mongoose.Types.ObjectId();
+  const gGroupUserId3 = new mongoose.Types.ObjectId();
+
   await userCollection.insertMany([
   await userCollection.insertMany([
     { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
     { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
     { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
     { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
+    {
+      _id: gGroupUserId1, name: 'gGroupUser1', username: 'gGroupUser1', email: 'gGroupUser1@example.com',
+    },
+    {
+      _id: gGroupUserId2, name: 'gGroupUser2', username: 'gGroupUser2', email: 'gGroupUser2@example.com',
+    },
+    {
+      _id: gGroupUserId3, name: 'gGroupUser3', username: 'gGroupUser3', email: 'gGroupUser3@example.com',
+    },
+  ]);
+  const gGroupIdIsolate = new mongoose.Types.ObjectId();
+  const gGroupIdA = new mongoose.Types.ObjectId();
+  const gGroupIdB = new mongoose.Types.ObjectId();
+  const gGroupIdC = new mongoose.Types.ObjectId();
+  await userGroupCollection.insertMany([
+    {
+      _id: gGroupIdIsolate,
+      name: 'globalGroupIsolate',
+    },
+    {
+      _id: gGroupIdA,
+      name: 'globalGroupA',
+    },
+    {
+      _id: gGroupIdB,
+      name: 'globalGroupB',
+      parent: gGroupIdA,
+    },
+    {
+      _id: gGroupIdC,
+      name: 'globalGroupC',
+      parent: gGroupIdB,
+    },
+  ]);
+  await userGroupRelationsCollection.insertMany([
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdC,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
   ]);
   ]);
   await pageCollection.insertMany([{
   await pageCollection.insertMany([{
     path: '/',
     path: '/',

+ 861 - 0
packages/app/test/integration/service/v5.page.test.ts

@@ -0,0 +1,861 @@
+import { addSeconds } from 'date-fns';
+import mongoose from 'mongoose';
+
+import { PageActionStage, PageActionType } from '../../../src/server/models/page-operation';
+import { getInstance } from '../setup-crowi';
+
+
+describe('Test page service methods', () => {
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let UserGroup;
+  let UserGroupRelation;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let PageOperation;
+  let xssSpy;
+
+  let rootPage;
+
+  let dummyUser1;
+  let dummyUser2;
+  let globalGroupUser1;
+  let globalGroupUser2;
+  let globalGroupUser3;
+  let globalGroupIsolate;
+  let globalGroupA;
+  let globalGroupB;
+  let globalGroupC;
+
+  let pageOpId1;
+  let pageOpId2;
+  let pageOpId3;
+  let pageOpId4;
+  let pageOpId5;
+  let pageOpId6;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+    PageRedirect = mongoose.model('PageRedirect');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    PageOperation = mongoose.model('PageOperation');
+
+    /*
+     * Common
+     */
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    // ***********************************************************************************************************
+    // * Do NOT change properties of globally used documents. Otherwise, it might cause some errors in other tests
+    // ***********************************************************************************************************
+    // users
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+    globalGroupUser1 = await User.findOne({ username: 'gGroupUser1' });
+    globalGroupUser2 = await User.findOne({ username: 'gGroupUser2' });
+    globalGroupUser3 = await User.findOne({ username: 'gGroupUser3' });
+    // groups
+    globalGroupIsolate = await UserGroup.findOne({ name: 'globalGroupIsolate' });
+    globalGroupA = await UserGroup.findOne({ name: 'globalGroupA' });
+    globalGroupB = await UserGroup.findOne({ name: 'globalGroupB' });
+    globalGroupC = await UserGroup.findOne({ name: 'globalGroupC' });
+    // page
+    rootPage = await Page.findOne({ path: '/' });
+
+
+    /**
+     * pages
+     */
+    const pageId0 = new mongoose.Types.ObjectId();
+    const pageId1 = new mongoose.Types.ObjectId();
+    const pageId2 = new mongoose.Types.ObjectId();
+    const pageId3 = new mongoose.Types.ObjectId();
+    const pageId4 = new mongoose.Types.ObjectId();
+    const pageId5 = new mongoose.Types.ObjectId();
+    const pageId6 = new mongoose.Types.ObjectId();
+    const pageId7 = new mongoose.Types.ObjectId();
+    const pageId8 = new mongoose.Types.ObjectId();
+    const pageId9 = new mongoose.Types.ObjectId();
+    const pageId10 = new mongoose.Types.ObjectId();
+    const pageId11 = new mongoose.Types.ObjectId();
+    const pageId12 = new mongoose.Types.ObjectId();
+    const pageId13 = new mongoose.Types.ObjectId();
+    const pageId14 = new mongoose.Types.ObjectId();
+    const pageId15 = new mongoose.Types.ObjectId();
+    const pageId16 = new mongoose.Types.ObjectId();
+    const pageId17 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageId0,
+        path: '/resume_rename_0',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId1,
+        path: '/resume_rename_0/resume_rename_1',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId2,
+        path: '/resume_rename_1/resume_rename_2',
+        parent: pageId1,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId3,
+        path: '/resume_rename_1/resume_rename_2/resume_rename_3',
+        parent: pageId2,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId4,
+        path: '/resume_rename_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId5,
+        path: '/resume_rename_4/resume_rename_5',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId6,
+        path: '/resume_rename_5/resume_rename_6',
+        parent: pageId5,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId7,
+        path: '/resume_rename_7',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId8,
+        path: '/resume_rename_8',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId9,
+        path: '/resume_rename_8/resume_rename_9',
+        parent: pageId8,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_9/resume_rename_10',
+        parent: pageId9,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId10,
+        path: '/resume_rename_11',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 3,
+        isEmpty: false,
+      },
+      {
+        _id: pageId11,
+        path: '/resume_rename_11/resume_rename_12',
+        parent: pageId10,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 3,
+        isEmpty: false,
+      },
+      {
+        _id: pageId12,
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13',
+        parent: pageId11,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14',
+        parent: pageId12,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId13,
+        path: '/resume_rename_15',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId14,
+        path: '/resume_rename_15/resume_rename_16',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId15,
+        path: '/resume_rename_15/resume_rename_17',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId16,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18',
+        parent: pageId15,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId17,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19',
+        parent: pageId16,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+    ]);
+
+    /**
+     * PageOperation
+     */
+    pageOpId1 = new mongoose.Types.ObjectId();
+    pageOpId2 = new mongoose.Types.ObjectId();
+    pageOpId3 = new mongoose.Types.ObjectId();
+    pageOpId4 = new mongoose.Types.ObjectId();
+    pageOpId5 = new mongoose.Types.ObjectId();
+    pageOpId6 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId1 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId2 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId3 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId4 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
+
+    await PageOperation.insertMany([
+      {
+        _id: pageOpId1,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_1',
+        toPath: '/resume_rename_0/resume_rename_1',
+        page: {
+          _id: pageId1,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_1',
+          revision: pageOpRevisionId1,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
+      {
+        _id: pageOpId2,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_5',
+        toPath: '/resume_rename_4/resume_rename_5',
+        page: {
+          _id: pageId5,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_5',
+          revision: pageOpRevisionId2,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId3,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_7',
+        // toPath NOT exist
+        page: {
+          _id: pageId7,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_7',
+          revision: pageOpRevisionId3,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId4,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_9',
+        toPath: '/resume_rename_8/resume_rename_9',
+        page: {
+          _id: pageId9,
+          parent: rootPage._id,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_9',
+          revision: pageOpRevisionId4,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
+      {
+        _id: pageOpId5,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_11/resume_rename_13',
+        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
+        page: {
+          _id: pageId12,
+          parent: pageId10,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_11/resume_rename_13',
+          revision: pageOpRevisionId5,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId6,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
+        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
+        page: {
+          _id: pageId16,
+          parent: pageId14,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
+          revision: pageOpRevisionId6,
+          status: 'published',
+          grant: Page.GRANT_PUBLIC,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+    ]);
+  });
+
+  describe('restart renameOperation', () => {
+    const resumeRenameSubOperation = async(page) => {
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(page);
+
+      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+
+      mockedRenameSubOperation.mockRestore();
+      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+    };
+
+    test('it should successfully restart rename operation', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_0'; // out of renaming scope
+      const _path1 = '/resume_rename_0/resume_rename_1'; // renamed already
+      const _path2 = '/resume_rename_1/resume_rename_2'; // not renamed yet
+      const _path3 = '/resume_rename_1/resume_rename_2/resume_rename_3'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_0';
+      const path1 = '/resume_rename_0/resume_rename_1';
+      const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
+      const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(2);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_1';
+      const toPath = '/resume_rename_0/resume_rename_1';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId1, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      // check paths after renaming
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      expect(page0.descendantCount).toBe(3);
+      expect(page1.descendantCount).toBe(2);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+    });
+    test('it should successfully restart rename operation when unprocessableExpiryDate is null', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_8'; // out of renaming scope
+      const _path1 = '/resume_rename_8/resume_rename_9'; // renamed already
+      const _path2 = '/resume_rename_9/resume_rename_10'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_8';
+      const path1 = '/resume_rename_8/resume_rename_9';
+      const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(1);
+      expect(_page2.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_9';
+      const toPath = '/resume_rename_8/resume_rename_9';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId4, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      // check paths after renaming
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // others
+      expect(page1.parent).toStrictEqual(page0._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page0.descendantCount).toBe(2);
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(0);
+    });
+
+    test('it should fail and throw error if PageOperation is not found', async() => {
+      // create dummy page operation data not stored in DB
+      const notExistPageOp = {
+        _id: new mongoose.Types.ObjectId(),
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/FROM_NOT_EXIST',
+        toPath: 'TO_NOT_EXIST',
+        page: {
+          _id: new mongoose.Types.ObjectId(),
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/NOT_EXIST_PAGE',
+          revision: new mongoose.Types.ObjectId(),
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: false,
+        },
+        unprocessableExpiryDate: new Date(),
+      };
+
+      await expect(resumeRenameSubOperation(notExistPageOp))
+        .rejects.toThrow(new Error('There is nothing to be processed right now'));
+    });
+
+    test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
+      // path before renaming
+      const _path0 = '/resume_rename_4'; // out of renaming scope
+      const _path1 = '/resume_rename_4/resume_rename_5'; // renamed already
+      const _path2 = '/resume_rename_5/resume_rename_6'; // not renamed yet
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      // page operation
+      const fromPath = '/resume_rename_5';
+      const toPath = '/resume_rename_4/resume_rename_5';
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
+      // The number 15 seconds has no meaning other than placing time in the furue.
+      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+
+      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+
+      // cleanup
+      await PageOperation.findByIdAndDelete(pageOperation._id);
+    });
+
+    test('Missing property(toPath) for PageOperation should throw error', async() => {
+      // page
+      const _path1 = '/resume_rename_7';
+      const _page1 = await Page.findOne({ path: _path1 });
+      expect(_page1).toBeTruthy();
+
+      // page operation
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId3, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      const promise = resumeRenameSubOperation(_page1);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+
+      // cleanup
+      await PageOperation.findByIdAndDelete(pageOperation._id);
+    });
+
+    test(`it should succeed but 2 extra descendantCount should be added
+    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_11'; // out of renaming scope
+      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
+      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
+      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
+
+      // paths after renaming
+      const path0 = '/resume_rename_11';
+      const path1 = '/resume_rename_11/resume_rename_12';
+      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
+      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(3);
+      expect(_page1.descendantCount).toBe(3);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_11/resume_rename_13';
+      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page2);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // 2 extra descendants should be added to page1
+      expect(page0.descendantCount).toBe(3);
+      expect(page1.descendantCount).toBe(4); // originally 2
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+    });
+
+    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
+    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_15'; // out of renaming scope
+      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
+      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
+      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
+      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
+
+      // paths after renaming
+      const path0 = '/resume_rename_15';
+      const path1 = '/resume_rename_15/resume_rename_16';
+      const path2 = '/resume_rename_15/resume_rename_17';
+      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
+      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      const _page3 = await Page.findOne({ path: _path3 });
+      const _page4 = await Page.findOne({ path: _path4 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page4).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(2);
+      expect(_page1.descendantCount).toBe(0);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(1);
+      expect(_page4.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
+      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page3);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      const page3 = await Page.findById(_page3._id);
+      const page4 = await Page.findById(_page4._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page3.path).toBe(path3);
+      expect(page4.path).toBe(path4);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // 2 extra descendants should be subtracted from page1
+      expect(page0.descendantCount).toBe(2);
+      expect(page1.descendantCount).toBe(-2); // originally 0
+      expect(page2.descendantCount).toBe(2); // originally 0
+      expect(page3.descendantCount).toBe(1);
+      expect(page4.descendantCount).toBe(0);
+    });
+  });
+});