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

Merge branch 'imprv/page-v5-test-code-rename' into imprv/page-model-v5-test-create

yohei0125 4 лет назад
Родитель
Сommit
c3f70b548d

+ 2 - 1
.github/workflows/ci-app.yml

@@ -98,7 +98,8 @@ jobs:
     - name: yarn test
       working-directory: ./packages/app
       run: |
-        yarn test
+        yarn test:ci --selectProjects unit server
+        yarn test:ci --selectProjects server-v5
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 

+ 20 - 1
packages/app/jest.config.js

@@ -37,7 +37,26 @@ module.exports = {
 
       rootDir: '.',
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
+      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js',
+                  '?!<rootDir>/test/integration/**/v5.*.test.ts', '?!<rootDir>/test/integration/**/v5.*.test.js'],
+
+      testEnvironment: 'node',
+      globalSetup: '<rootDir>/test/integration/global-setup.js',
+      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
+      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
+
+      // Automatically clear mock calls and instances between every test
+      clearMocks: true,
+      moduleNameMapper: MODULE_NAME_MAPPING,
+    },
+    {
+      displayName: 'server-v5',
+
+      preset: 'ts-jest/presets/js-with-ts',
+
+      rootDir: '.',
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/integration/**/v5.*.test.ts', '<rootDir>/test/integration/**/v5.*.test.js'],
 
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',

+ 1 - 0
packages/app/package.json

@@ -38,6 +38,7 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
+    "test:ci": "cross-env NODE_ENV=test jest",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "reg:run": "reg-suit run",

+ 3 - 1
packages/app/resource/locales/en_US/translation.json

@@ -979,7 +979,9 @@
   },
   "pagetree": {
     "private_legacy_pages": "Private Legacy Pages",
-    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
+    "you_cannot_move_this_page_now": "You cannot move this page now",
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

+ 3 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -971,7 +971,9 @@
   },
   "pagetree": {
     "private_legacy_pages": "待避所",
-    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
+    "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

+ 3 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -981,7 +981,9 @@
   },
   "pagetree": {
     "private_legacy_pages": "私人遗留页面",
-    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
+    "you_cannot_move_this_page_now": "你现在不能移动这个页面",
+    "something_went_wrong_with_moving_page": "移动页面时出了问题"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 27 - 6
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,7 +1,7 @@
 import React, { useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
-import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
@@ -13,8 +13,9 @@ import {
 } from '~/stores/ui';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction, usePagePresentationModal,
 } from '~/stores/modal';
+import { useSWRxPageChildren } from '~/stores/page-listing';
 
 
 import {
@@ -122,6 +123,8 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
 const GrowiContextualSubNavigation = (props) => {
+  const { t } = useTranslation();
+
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
@@ -142,6 +145,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
+  const { mutate: mutateChildren } = useSWRxPageChildren(path);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
@@ -186,9 +190,27 @@ const GrowiContextualSubNavigation = (props) => {
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    mutateChildren();
+
+    const path = pathOrPathsToDelete;
+
+    if (isCompletely) {
+      // redirect to NotFound Page
+      window.location.href = path;
+    }
+    else {
+      window.location.reload();
+    }
+  }, [mutateChildren]);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+  }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
@@ -282,7 +304,6 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
-      additionalClasses={['container-fluid']}
     />
   );
 };

+ 3 - 3
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -23,7 +23,7 @@ type CommonProps = {
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
 }
 
 type SubNavButtonsSubstanceProps= CommonProps & {
@@ -120,8 +120,8 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       path,
     };
 
-    onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, pageId, path, revisionId]);
+    onClickDeleteMenuItem(pageToDelete, pageInfo.isAbleToDeleteCompletely);
+  }, [onClickDeleteMenuItem, pageId, pageInfo.isAbleToDeleteCompletely, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;

+ 2 - 4
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -144,18 +144,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
       // force open
       setIsOpen(true);
-
-      toastSuccess('TODO: i18n Successfully moved pages.');
     }
     catch (err) {
       // display the dropped item
       displayDroppedItemByPageId(droppedPage._id);
 
       if (err.code === 'operation__blocked') {
-        toastWarning('TODO: i18n You cannot move this page now.');
+        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
       }
       else {
-        toastError('TODO: i18n Something went wrong with moving page.');
+        toastError(t('pagetree.something_went_wrong_with_moving_page'));
       }
     }
   };

+ 5 - 1
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -20,7 +20,11 @@ defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    await mongoose.connect(getMongoUri(), mongoOptions);
+    // connect only if disconnected
+    // see: https://mongoosejs.com/docs/api/connection.html#connection_Connection-readyState
+    if (mongoose.connection.readyState === 0) {
+      await mongoose.connect(getMongoUri(), mongoOptions);
+    }
 
     const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
 

+ 1 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -485,7 +485,7 @@ module.exports = (crowi) => {
 
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
-      // if page found, cannot cannot rename to that path
+      // if page found, cannot rename to that path
       return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
     }
 

+ 6 - 0
packages/app/src/server/service/page.ts

@@ -314,6 +314,12 @@ class PageService {
   async renamePage(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
 
+    const isExist = await Page.count({ path: newPagePath }) > 0;
+    if (isExist) {
+      // if page found, cannot rename to that path
+      throw new Error('the path already exists');
+    }
+
     if (isTopPage(page.path)) {
       throw Error('It is forbidden to rename the top page');
     }

+ 12 - 8
packages/app/test/integration/global-setup.js

@@ -16,22 +16,26 @@ if (process.env.NODE_ENV !== 'test') {
   throw new Error('\'process.env.NODE_ENV\' must be \'test\'');
 }
 
-
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-// const { getInstance } = require('./setup-crowi');
-
 module.exports = async() => {
   initMongooseGlobalSettings();
 
-  await mongoose.connect(getMongoUri(), mongoOptions);
+  mongoose.connect(getMongoUri(), mongoOptions);
 
   // drop database
   await mongoose.connection.dropDatabase();
 
   // init DB
-  // const crowi = await getInstance();
-  // const appService = crowi.appService;
-  // await appService.initDB();
+  const pageCollection = mongoose.connection.collection('pages');
+  const userCollection = mongoose.connection.collection('users');
+
+  // create global user & rootPage
+  const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
+  await pageCollection.insertMany([{
+    path: '/',
+    grant: 1,
+    creator: globalUser,
+    lastUpdateUser: globalUser,
+  }]);
 
   await mongoose.disconnect();
 };

+ 1 - 3
packages/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts

@@ -1,6 +1,5 @@
 import mongoose from 'mongoose';
 import { Collection } from 'mongodb';
-import { getMongoUri, mongoOptions } from '@growi/core';
 
 const migrate = require('~/migrations/20210913153942-migrate-slack-app-integration-schema');
 
@@ -9,8 +8,7 @@ describe('migrate-slack-app-integration-schema', () => {
   let collection: Collection;
 
   beforeAll(async() => {
-    await mongoose.connect(getMongoUri(), mongoOptions);
-    collection = mongoose.connection.db.collection('slackappintegrations');
+    collection = mongoose.connection.collection('slackappintegrations');
 
     await collection.insertMany([
       {

+ 10 - 6
packages/app/test/integration/service/page-grant.test.js

@@ -109,13 +109,17 @@ describe('PageGrantService', () => {
     ]);
 
     // Root page (Depth: 0)
-    await Page.insertMany([
-      {
-        path: '/',
-        grant: Page.GRANT_PUBLIC,
-      },
-    ]);
     rootPage = await Page.findOne({ path: '/' });
+    if (rootPage == null) {
+      const pages = await Page.insertMany([
+        {
+          path: '/',
+          grant: Page.GRANT_PUBLIC,
+        },
+      ]);
+      rootPage = pages[0];
+    }
+
 
     // Empty pages (Depth: 1)
     await Page.insertMany([

+ 8 - 1
packages/app/test/integration/service/v5-migration.test.js → packages/app/test/integration/service/v5.migration.test.js

@@ -16,6 +16,8 @@ describe('V5 page migration', () => {
     Page = mongoose.model('Page');
     User = mongoose.model('User');
 
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
     await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
     testUser1 = await User.findOne({ username: 'testUser1' });
   });
@@ -26,7 +28,7 @@ describe('V5 page migration', () => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      const pages = await Page.insertMany([
+      let pages = await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -57,6 +59,11 @@ describe('V5 page migration', () => {
         },
       ]);
 
+      if (!await Page.exists({ path: '/' })) {
+        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+        pages = [...additionalPages, ...pages];
+      }
+
       const pageIds = pages.map(page => page._id);
       // migrate
       await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);

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

@@ -0,0 +1,393 @@
+/* eslint-disable no-unused-vars */
+import { advanceTo } from 'jest-date-mock';
+
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('PageService page operations with only public pages', () => {
+
+  let dummyUser1;
+  let dummyUser2;
+
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let xssSpy;
+
+  let rootPage;
+
+  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
+  const expectAllToBeTruthy = (dataList) => {
+    dataList.forEach((data) => {
+      expect(data).toBeTruthy();
+    });
+  };
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    User = mongoose.model('User');
+    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');
+
+    /*
+     * 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);
+
+    rootPage = await Page.findOne({ path: '/' });
+    if (rootPage == null) {
+      const pages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+      rootPage = pages[0];
+    }
+
+    /*
+     * Rename
+     */
+    const pageIdForRename1 = new mongoose.Types.ObjectId();
+    const pageIdForRename2 = new mongoose.Types.ObjectId();
+    const pageIdForRename3 = new mongoose.Types.ObjectId();
+    const pageIdForRename4 = new mongoose.Types.ObjectId();
+    const pageIdForRename5 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename7 = new mongoose.Types.ObjectId();
+    const pageIdForRename8 = new mongoose.Types.ObjectId();
+    const pageIdForRename9 = new mongoose.Types.ObjectId();
+    const pageIdForRename10 = new mongoose.Types.ObjectId();
+    const pageIdForRename11 = new mongoose.Types.ObjectId();
+    const pageIdForRename12 = new mongoose.Types.ObjectId();
+    const pageIdForRename13 = new mongoose.Types.ObjectId();
+    const pageIdForRename14 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename16 = new mongoose.Types.ObjectId();
+
+    // Create Pages
+    await Page.insertMany([
+      // parents
+      {
+        _id: pageIdForRename1,
+        path: '/v5_ParentForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename2,
+        path: '/v5_ParentForRename2',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        // id not needed for this data
+        path: '/v5_ParentForRename2/dummyChild1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename2,
+      },
+      {
+        _id: pageIdForRename3,
+        path: '/v5_ParentForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename4,
+        path: '/v5_ParentForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename5,
+        path: '/v5_ParentForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename7,
+        path: '/v5_ParentForRename7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename8,
+        path: '/v5_ParentForRename8',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename9,
+        path: '/v5_ParentForRename9',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      // children
+      {
+        _id: pageIdForRename10,
+        path: '/v5_ChildForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename11,
+        path: '/v5_ChildForRename2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename12,
+        path: '/v5_ChildForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        updatedAt: new Date('2021'),
+      },
+      {
+        _id: pageIdForRename13,
+        path: '/v5_ChildForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename14,
+        path: '/v5_ChildForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename16,
+        path: '/v5_ChildForRename7',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      // Grandchild
+      {
+        path: '/v5_ChildForRename5/v5_GrandchildForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename14,
+        updatedAt: new Date('2021'),
+      },
+      {
+        path: '/v5_ChildForRename7/v5_GrandchildForRename7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename16,
+      },
+    ]);
+
+  });
+
+  describe('Rename', () => {
+
+    const renamePage = async(page, newPagePath, user, options) => {
+    // mock return value
+      const mockedResumableRenameDescendants = jest.spyOn(crowi.pageService, 'resumableRenameDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+
+      // retrieve the arguments passed when calling method resumableRenameDescendants inside renamePage method
+      const argsForResumableRenameDescendants = mockedResumableRenameDescendants.mock.calls[0];
+
+      // restores the original implementation
+      mockedResumableRenameDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // rename descendants
+      await crowi.pageService.resumableRenameDescendants(...argsForResumableRenameDescendants);
+
+      return renamedPage;
+    };
+
+    test('Should NOT rename top page', async() => {
+      expectAllToBeTruthy([rootPage]);
+      let isThrown = false;
+      try {
+        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should rename/move to under non-empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename1' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename1' });
+      expectAllToBeTruthy([childPage, parentPage]);
+
+      const newPath = '/v5_ParentForRename1/renamedChildForRename1';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+
+    });
+
+    test('Should rename/move to under empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename2' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename2' });
+      expectAllToBeTruthy([childPage, parentPage]);
+      expect(parentPage.isEmpty).toBe(true);
+
+      const newPath = '/v5_ParentForRename2/renamedChildForRename2';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(parentPage.isEmpty).toBe(true);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+    });
+
+    test('Should rename/move with option updateMetadata: true', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename3' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename3' });
+      expectAllToBeTruthy([childPage, parentPage]);
+      expect(childPage.lastUpdateUser).toStrictEqual(dummyUser1._id);
+
+      const newPath = '/v5_ParentForRename3/renamedChildForRename3';
+      const oldUdpateAt = childPage.updatedAt;
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      expect(renamedPage.lastUpdateUser).toStrictEqual(dummyUser2._id);
+      expect(renamedPage.updatedAt.getFullYear()).toBeGreaterThan(oldUdpateAt.getFullYear());
+    });
+
+    // ****************** TODO ******************
+    // uncomment the next test when working on 88097
+    // ******************************************
+    // test('Should move with option createRedirectPage: true', async() => {
+    // const parentPage = await Page.findOne({ path: '/v5_ParentForRename4' });
+    // const childPage = await Page.findOne({ path: '/v5_ChildForRename4' });
+    // expectAllToBeTruthy([parentPage, childPage]);
+
+    //   // rename target page
+    //   const newPath = '/v5_ParentForRename4/renamedChildForRename4';
+    //   const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true });
+    //   const pageRedirect = await PageRedirect.find({ fromPath: childPage.path, toPath: renamedPage.path });
+
+    // expect(xssSpy).toHaveBeenCalled();
+    //   expect(renamedPage.path).toBe(newPath);
+    //   expect(renamedPage.parent).toStrictEqual(parentPage._id);
+    //   expect(pageRedirect.length).toBeGreaterThan(0);
+    // });
+
+    test('Should rename/move with descendants', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename5' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename5' });
+
+      expectAllToBeTruthy([parentPage, childPage]);
+
+      const newPath = '/v5_ParentForRename5/renamedChildForRename5';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      // find child of renamed page
+      const grandchild = await Page.findOne({ parent: renamedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      // grandchild's parent should be the renamed page
+      expect(grandchild.parent).toStrictEqual(renamedPage._id);
+      expect(grandchild.path).toBe('/v5_ParentForRename5/renamedChildForRename5/v5_GrandchildForRename5');
+    });
+
+    test('Should rename/move empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_ParentForRename7' });
+      const childPage = await Page.findOne({ path: '/v5_ChildForRename7' });
+
+      expectAllToBeTruthy([parentPage, childPage]);
+      expect(childPage.isEmpty).toBe(true);
+
+      const newPath = '/v5_ParentForRename7/renamedChildForRename7';
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const grandchild = await Page.findOne({ parent: renamedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(renamedPage.path).toBe(newPath);
+      expect(renamedPage.isEmpty).toBe(true);
+      expect(renamedPage.parent).toStrictEqual(parentPage._id);
+      // grandchild's parent should be renamed page
+      expect(grandchild.parent).toStrictEqual(renamedPage._id);
+      expect(grandchild.path).toBe('/v5_ParentForRename7/renamedChildForRename7/v5_GrandchildForRename7');
+    });
+    test('Should NOT rename/move with existing path', async() => {
+      const page = await Page.findOne({ path: '/v5_ParentForRename8' });
+      expectAllToBeTruthy([page]);
+
+      const newPath = '/v5_ParentForRename9';
+      let isThrown;
+      try {
+        await renamePage(page, newPath, dummyUser1, {});
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(isThrown).toBe(true);
+    });
+  });
+});
+
+describe('PageService page operations with non-public pages', () => {
+  // TODO: write test code
+});