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

Merge branch 'master' into support/refactor-findPageAndMetaDataByViewer

Yuki Takei 4 лет назад
Родитель
Сommit
68074c2560

+ 2 - 0
packages/app/src/client/base.jsx

@@ -12,6 +12,7 @@ import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PageAccessoriesModal from '../components/PageAccessoriesModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
@@ -52,6 +53,7 @@ const componentMappings = {
   'page-presentation-modal': <PagePresentationModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
+  'page-put-back-modal': <PutbackPageModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

+ 19 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -20,6 +20,7 @@ export const MenuItemType = {
   DUPLICATE: 'duplicate',
   RENAME: 'rename',
   DELETE: 'delete',
+  REVERT: 'revert',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -36,6 +37,7 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
@@ -52,7 +54,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const {
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
 
@@ -81,6 +83,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const revertItemClickedHandler = useCallback(async() => {
+    if (onClickRevertMenuItem == null) {
+      return;
+    }
+    await onClickRevertMenuItem(pageId);
+  }, [onClickRevertMenuItem]);
+
+
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {
@@ -141,6 +151,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        {/* Revert */}
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
+          <DropdownItem onClick={revertItemClickedHandler}>
+            <i className="icon-fw  icon-action-undo"></i>
+            {t('modal_putback.label.Put Back Page')}
+          </DropdownItem>
+        ) }
+
         { AdditionalMenuItems && (
           <>
             { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }

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

@@ -7,11 +7,11 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PutbackPageModal from '../PutbackPageModal';
+
 import EmptyTrashModal from '../EmptyTrashModal';
 
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/modal';
+import { usePageDeleteModal, usePutBackPageMOdal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
 const TrashPageAlert = (props) => {
@@ -30,7 +30,6 @@ const TrashPageAlert = (props) => {
 
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
-  const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
 
   useEffect(() => {
@@ -40,6 +39,7 @@ const TrashPageAlert = (props) => {
   }, [pageInfo]);
 
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageMOdal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -50,11 +50,7 @@ const TrashPageAlert = (props) => {
   }
 
   function openPutbackPageModalHandler() {
-    setIsPutbackPageModalShown(true);
-  }
-
-  function closePutbackPageModalHandler() {
-    setIsPutbackPageModalShown(false);
+    openPutBackPageModal(pageId, path);
   }
 
   const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -120,12 +116,6 @@ const TrashPageAlert = (props) => {
           isOpen={isEmptyTrashModalShown}
           onClose={closeEmptyTrashModalHandler}
         />
-        <PutbackPageModal
-          isOpen={isPutbackPageModalShown}
-          onClose={closePutbackPageModalHandler}
-          pageId={pageId}
-          path={path}
-        />
       </>
     );
   }

+ 10 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -12,7 +12,9 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { usePageRenameModal, usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageMOdal,
+} from '~/stores/modal';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
@@ -64,6 +66,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageMOdal();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -101,6 +104,11 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDeleteModal([{ pageId, revisionId: revisionId as string, path }]);
   }, [openDeleteModal, pageData]);
 
+  const revertMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, path } = pageData;
+    openPutBackPageModal(pageId, path);
+  }, [openPutBackPageModal, pageData]);
+
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
@@ -168,6 +176,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                  onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
             </div>

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -290,7 +290,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         appContainer={appContainer}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE]}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT]}
         // Components
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}

+ 7 - 9
packages/app/src/components/PutbackPageModal.jsx

@@ -7,15 +7,19 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
+import { usePutBackPageMOdal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const PutBackPageModal = (props) => {
   const {
-    t, isOpen, onClose, pageId, path,
+    t,
   } = props;
 
+  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageMOdal();
+  const { isOpened, pageId, path } = pageDataToRevert;
+
   const [errs, setErrs] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
@@ -50,8 +54,8 @@ const PutBackPageModal = (props) => {
   }
 
   return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+    <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closePutBackPageModal} className="bg-info text-light">
         <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('modal_putback.label.Put Back Page') }
       </ModalHeader>
       <ModalBody>
@@ -88,12 +92,6 @@ const PutBackPageModal = (props) => {
 
 PutBackPageModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 
 

+ 1 - 0
packages/app/src/interfaces/page.ts

@@ -41,6 +41,7 @@ export type IPageInfo = {
   isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
+  isRevertible: boolean,
 }
 
 export type IPageInfoForEntity = IPageInfo & {

+ 1 - 1
packages/app/src/server/models/page.ts

@@ -707,7 +707,7 @@ export default (crowi: Crowi): any => {
     // Delete PageRedirect if exists
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     try {
-      await PageRedirect.deleteOne({ from: path });
+      await PageRedirect.deleteOne({ fromPath: path });
       logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
     }
     catch (err) {

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

@@ -649,7 +649,7 @@ module.exports = (crowi) => {
 
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
-    const isEmptyAndNotRecursively = page?.isEmpty && isRecursively;
+    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     if (page == null || isEmptyAndNotRecursively) {
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');

+ 36 - 12
packages/app/src/server/service/page.ts

@@ -242,6 +242,7 @@ class PageService {
           isMovable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
+          isRevertible: false,
         },
       };
     }
@@ -703,6 +704,11 @@ class PageService {
    * Duplicate
    */
   async duplicate(page, newPagePath, user, isRecursively) {
+    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
+    if (page == null || isEmptyAndNotRecursively) {
+      throw new Error('Cannot find or duplicate the empty page');
+    }
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
@@ -1124,7 +1130,14 @@ class PageService {
       }, { new: true });
       await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
 
-      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+      try {
+        await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+      }
+      catch (err) {
+        if (err.code !== 11000) {
+          throw err;
+        }
+      }
 
       this.pageEvent.emit('delete', page, user);
       this.pageEvent.emit('create', deletedPage, user);
@@ -1181,7 +1194,14 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
 
-    await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    try {
+      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw err;
+      }
+    }
 
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('create', deletedPage, user);
@@ -1364,14 +1384,14 @@ class PageService {
 
     logger.debug('Deleting completely', paths);
 
+    await this.deleteCompletelyOperation(ids, paths);
+
     // replace with an empty page
     const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
     if (shouldReplace) {
       await Page.replaceTargetWithPage(page);
     }
 
-    await this.deleteCompletelyOperation(ids, paths);
-
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
     }
@@ -1386,19 +1406,21 @@ class PageService {
     // TODO: resume
     if (isRecursively) {
       // no await for deleteCompletelyDescendantsWithStream
-      (async() => {
-        const deletedDescendantCount = await this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
-
-        // update descendantCount of ancestors'
-        if (page.parent != null) {
-          await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
-        }
-      })();
+      this.resumableDeleteCompletelyDescendants(page, user, options, shouldUseV4Process);
     }
 
     return;
   }
 
+  async resumableDeleteCompletelyDescendants(page, user, options, shouldUseV4Process) {
+    const deletedDescendantCount = await this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
+
+    // update descendantCount of ancestors'
+    if (page.parent != null) {
+      await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
+    }
+  }
+
   private async deleteCompletelyV4(page, user, options = {}, isRecursively = false, preventEmitting = false) {
     const ids = [page._id];
     const paths = [page.path];
@@ -1737,6 +1759,7 @@ class PageService {
         isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
+        isRevertible: false,
       };
     }
 
@@ -1752,6 +1775,7 @@ class PageService {
       isMovable,
       isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
+      isRevertible: isTrashPage(page.path),
     };
 
   }

+ 1 - 0
packages/app/src/server/views/layout/layout.html

@@ -110,6 +110,7 @@
 <div id="page-presentation-modal"></div>
 <div id="page-accessories-modal"></div>
 <div id="descendants-page-list-modal"></div>
+<div id="page-put-back-modal"></div>
 
 {% include '../modal/shortcuts.html' %}
 

+ 25 - 0
packages/app/src/stores/modal.tsx

@@ -150,6 +150,31 @@ export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<Rena
   };
 };
 
+type PutBackPageModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  path?: string,
+}
+
+type PutBackPageModalUtils = {
+  open(pageId: string, path: string): Promise<PutBackPageModalStatus | undefined>
+  close():Promise<PutBackPageModalStatus | undefined>
+}
+
+export const usePutBackPageMOdal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
+  const initialData = { isOpened: false, pageId: '', path: '' };
+  const swrResponse = useStaticSWR<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, path: string) => swrResponse.mutate({
+      isOpened: true, pageId, path,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+
 /*
 * PagePresentationModal
 */

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

@@ -0,0 +1,98 @@
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('Page', () => {
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let xssSpy;
+
+  let rootPage;
+  let dummyUser1;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    jest.restoreAllMocks();
+    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');
+
+    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: '/' });
+
+    const createPageId1 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: createPageId1,
+        path: '/v5_empty_create_4',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/v5_empty_create_4/v5_create_5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: createPageId1,
+      },
+    ]);
+
+  });
+  describe('create', () => {
+
+    test('Should create single page', async() => {
+      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      expect(page).toBeTruthy();
+      expect(page.parent).toStrictEqual(rootPage._id);
+    });
+
+    test('Should create empty-child and non-empty grandchild', async() => {
+      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const childPage = await Page.findOne({ path: '/v5_empty_create2' });
+
+      expect(childPage.isEmpty).toBe(true);
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+    });
+
+    test('Should create on empty page', async() => {
+      const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
+      expect(beforeCreatePage.isEmpty).toBe(true);
+
+      const childPage = await Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const grandchildPage = await Page.findOne({ parent: childPage._id });
+
+      expect(childPage).toBeTruthy();
+      expect(childPage.isEmpty).toBe(false);
+      expect(childPage.revision.body).toBe('body');
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+    });
+
+  });
+});

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

@@ -24,6 +24,9 @@ describe('PageService page operations with only public pages', () => {
 
   let rootPage;
 
+  /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expectAllToBeTruthy"] }] */
+  // https://github.com/jest-community/eslint-plugin-jest/blob/v24.3.5/docs/rules/expect-expect.md#assertfunctionnames
+
   // pass unless the data is one of [false, 0, '', null, undefined, NaN]
   const expectAllToBeTruthy = (dataList) => {
     dataList.forEach((data) => {
@@ -231,6 +234,273 @@ describe('PageService page operations with only public pages', () => {
       },
     ]);
 
+    /*
+     * Duplicate
+     */
+    // page ids
+    const pageIdForDuplicate1 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate2 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate3 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate4 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate5 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate6 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate7 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate8 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate9 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate10 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate11 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate12 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate13 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate14 = new mongoose.Types.ObjectId();
+    const pageIdForDuplicate15 = new mongoose.Types.ObjectId();
+
+    // revision ids
+    const revisionIdForDuplicate1 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate2 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate3 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate4 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate5 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate6 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate7 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate8 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate9 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate10 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate11 = new mongoose.Types.ObjectId();
+    const revisionIdForDuplicate12 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdForDuplicate1,
+        path: '/v5_PageForDuplicate1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate1,
+      },
+      {
+        _id: pageIdForDuplicate2,
+        path: '/v5_PageForDuplicate2',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate3,
+        path: '/v5_PageForDuplicate2/v5_ChildForDuplicate2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate2,
+        revision: revisionIdForDuplicate2,
+      },
+      {
+        _id: pageIdForDuplicate4,
+        path: '/v5_PageForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate3,
+      },
+      {
+        _id: pageIdForDuplicate5,
+        path: '/v5_PageForDuplicate3/v5_Child_1_ForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate4,
+        revision: revisionIdForDuplicate4,
+      },
+      {
+        _id: pageIdForDuplicate6,
+        path: '/v5_PageForDuplicate3/v5_Child_2_ForDuplicate3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate4,
+        revision: revisionIdForDuplicate5,
+      },
+      {
+        _id: pageIdForDuplicate7,
+        path: '/v5_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate6,
+      },
+      {
+        _id: pageIdForDuplicate8,
+        path: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdForDuplicate7,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate9,
+        path: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate8,
+        revision: revisionIdForDuplicate7,
+      },
+      {
+        _id: pageIdForDuplicate10,
+        path: '/v5_PageForDuplicate5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate8,
+      },
+      {
+        _id: pageIdForDuplicate11,
+        path: '/v5_PageForDuplicate6',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdForDuplicate9,
+      },
+      {
+        _id: pageIdForDuplicate13,
+        path: '/v5_empty_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDuplicate14,
+        path: '/v5_empty_PageForDuplicate7/v5_child_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate13,
+        revision: revisionIdForDuplicate11,
+      },
+      {
+        _id: pageIdForDuplicate15,
+        path: '/v5_empty_PageForDuplicate7/v5_child_PageForDuplicate7/v5_grandchild_PageForDuplicate7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDuplicate14,
+        revision: revisionIdForDuplicate12,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: revisionIdForDuplicate1,
+        body: 'body1',
+        format: 'markdown',
+        pageId: pageIdForDuplicate1,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate2,
+        body: 'body3',
+        format: 'markdown',
+        pageId: pageIdForDuplicate3,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate3,
+        body: 'parent_page_body4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate4,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate4,
+        body: 'revision_id_4_child_page_body',
+        format: 'markdown',
+        pageId: pageIdForDuplicate5,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate5,
+        body: 'revision_id_5_child_page_body',
+        format: 'markdown',
+        pageId: pageIdForDuplicate6,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate6,
+        body: '/v5_PageForDuplicate4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate7,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate7,
+        body: '/v5_PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4',
+        format: 'markdown',
+        pageId: pageIdForDuplicate9,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate8,
+        body: '/v5_PageForDuplicate5',
+        format: 'markdown',
+        pageId: pageIdForDuplicate10,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate9,
+        body: '/v5_PageForDuplicate6',
+        format: 'markdown',
+        pageId: pageIdForDuplicate11,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate10,
+        body: '/v5_PageForDuplicate6',
+        format: 'comment',
+        pageId: pageIdForDuplicate12,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate11,
+        body: '/v5_child_PageForDuplicate7',
+        format: 'markdown',
+        pageId: pageIdForDuplicate14,
+        author: dummyUser1,
+      },
+      {
+        _id: revisionIdForDuplicate12,
+        body: '/v5_grandchild_PageForDuplicate7',
+        format: 'markdown',
+        pageId: pageIdForDuplicate15,
+        author: dummyUser1,
+      },
+    ]);
+    const tagForDuplicate1 = new mongoose.Types.ObjectId();
+    const tagForDuplicate2 = new mongoose.Types.ObjectId();
+
+    await Tag.insertMany([
+      { _id: tagForDuplicate1, name: 'duplicate_Tag1' },
+      { _id: tagForDuplicate2, name: 'duplicate_Tag2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      { relatedPage: pageIdForDuplicate10, relatedTag: tagForDuplicate1 },
+      { relatedPage: pageIdForDuplicate10._id, relatedTag: tagForDuplicate2 },
+    ]);
+
+    await Comment.insertMany([
+      {
+        commentPosition: -1,
+        isMarkdown: true,
+        page: pageIdForDuplicate11,
+        creator: dummyUser1._id,
+        revision: revisionIdForDuplicate10,
+        comment: 'this is comment',
+      },
+    ]);
+
     /**
      * Delete
      */
@@ -321,6 +591,183 @@ describe('PageService page operations with only public pages', () => {
       { relatedPage: pageIdForDelete3, relatedTag: tagIdForDelete1 },
       { relatedPage: pageIdForDelete3, relatedTag: tagIdForDelete2 },
     ]);
+
+    /**
+     * Delete completely
+     */
+    const pageIdForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely3 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely4 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely5 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely6 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely7 = new mongoose.Types.ObjectId();
+    const pageIdForDeleteCompletely8 = new mongoose.Types.ObjectId();
+
+    const revisionIdForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely3 = new mongoose.Types.ObjectId();
+    const revisionIdForDeleteCompletely4 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdForDeleteCompletely1,
+        path: '/v5_PageForDeleteCompletely1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely2,
+        path: '/v5_PageForDeleteCompletely2',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely3,
+        path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdForDeleteCompletely2,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForDeleteCompletely4,
+        path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely3,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely5,
+        path: '/trash/v5_PageForDeleteCompletely5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdForDeleteCompletely6,
+        path: '/v5_PageForDeleteCompletely6',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely7,
+        path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely6,
+        status: Page.STATUS_PUBLISHED,
+      },
+      {
+        _id: pageIdForDeleteCompletely8,
+        path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForDeleteCompletely7,
+        status: Page.STATUS_PUBLISHED,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: revisionIdForDeleteCompletely1,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely2,
+        body: 'pageIdForDeleteCompletely2',
+      },
+      {
+        _id: revisionIdForDeleteCompletely2,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely4,
+        body: 'pageIdForDeleteCompletely4',
+      },
+      {
+        _id: revisionIdForDeleteCompletely3,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely5,
+        body: 'pageIdForDeleteCompletely5',
+      },
+      {
+        _id: revisionIdForDeleteCompletely4,
+        format: 'markdown',
+        pageId: pageIdForDeleteCompletely2,
+        body: 'comment_pageIdForDeleteCompletely3',
+      },
+    ]);
+
+    const tagForDeleteCompletely1 = new mongoose.Types.ObjectId();
+    const tagForDeleteCompletely2 = new mongoose.Types.ObjectId();
+    await Tag.insertMany([
+      { name: 'TagForDeleteCompletely1' },
+      { name: 'TagForDeleteCompletely2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      { relatedPage: pageIdForDeleteCompletely2, relatedTag: tagForDeleteCompletely1 },
+      { relatedPage: pageIdForDeleteCompletely4, relatedTag: tagForDeleteCompletely2 },
+    ]);
+
+    await Bookmark.insertMany([
+      {
+        page: pageIdForDeleteCompletely2,
+        user: dummyUser1._id,
+      },
+      {
+        page: pageIdForDeleteCompletely2,
+        user: dummyUser2._id,
+      },
+    ]);
+
+    await Comment.insertMany([
+      {
+        commentPosition: -1,
+        isMarkdown: true,
+        page: pageIdForDeleteCompletely2,
+        creator: dummyUser1._id,
+        revision: revisionIdForDeleteCompletely4,
+        comment: 'comment_ForDeleteCompletely4',
+      },
+    ]);
+
+    await PageRedirect.insertMany([
+      {
+        fromPath: '/from/v5_PageForDeleteCompletely2',
+        toPath: '/v5_PageForDeleteCompletely2',
+      },
+      {
+        fromPath: '/from/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+        toPath: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4',
+      },
+    ]);
+
+    await ShareLink.insertMany([
+      {
+        relatedPage: pageIdForDeleteCompletely2,
+        expiredAt: null,
+        description: 'sharlink_v5PageForDeleteCompletely2',
+      },
+      {
+        relatedPage: pageIdForDeleteCompletely4,
+        expiredAt: null,
+        description: 'sharlink_v5PageForDeleteCompletely4',
+      },
+    ]);
+
   });
 
   describe('Rename', () => {
@@ -487,6 +934,170 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
   });
+
+  describe('Duplicate', () => {
+
+    const duplicate = async(page, newPagePath, user, isRecursively) => {
+      // mock return value
+      const mockedResumableDuplicateDescendants = jest.spyOn(crowi.pageService, 'resumableDuplicateDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+
+      // retrieve the arguments passed when calling method resumableDuplicateDescendants inside duplicate method
+      const argsForResumableDuplicateDescendants = mockedResumableDuplicateDescendants.mock.calls[0];
+
+      // restores the original implementation
+      mockedResumableDuplicateDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // duplicate descendants
+      if (isRecursively) {
+        await crowi.pageService.resumableDuplicateDescendants(...argsForResumableDuplicateDescendants);
+      }
+
+      return duplicatedPage;
+    };
+
+    test('Should duplicate single page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDuplicate1' });
+      expectAllToBeTruthy([page]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate1';
+      const duplicatedPage = await duplicate(page, newPagePath, dummyUser1, false);
+
+      const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
+      const baseRevision = await Revision.findOne({ pageId: page._id });
+
+      // new path
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedPage._id).not.toStrictEqual(page._id);
+      expect(duplicatedPage.revision).toStrictEqual(duplicatedRevision._id);
+      expect(duplicatedRevision.body).toEqual(baseRevision.body);
+    });
+
+    test('Should NOT duplicate single empty page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDuplicate2' });
+      expectAllToBeTruthy([page]);
+
+      let isThrown;
+      let duplicatedPage;
+      try {
+        const newPagePath = '/duplicatedv5PageForDuplicate2';
+        duplicatedPage = await duplicate(page, newPagePath, dummyUser1, false);
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      expect(duplicatedPage).toBeUndefined();
+      expect(isThrown).toBe(true);
+    });
+
+    test('Should duplicate multiple pages', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate3' });
+      const revision = await Revision.findOne({ pageId: basePage._id });
+      const childPage1 = await Page.findOne({ path: '/v5_PageForDuplicate3/v5_Child_1_ForDuplicate3' }).populate({ path: 'revision', model: 'Revision' });
+      const childPage2 = await Page.findOne({ path: '/v5_PageForDuplicate3/v5_Child_2_ForDuplicate3' }).populate({ path: 'revision', model: 'Revision' });
+      const revisionForChild1 = childPage1.revision;
+      const revisionForChild2 = childPage2.revision;
+      expectAllToBeTruthy([basePage, revision, childPage1, childPage2, revisionForChild1, revisionForChild2]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate3';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChildPage1 = await Page.findOne({ parent: duplicatedPage._id, path: '/duplicatedv5PageForDuplicate3/v5_Child_1_ForDuplicate3' })
+        .populate({ path: 'revision', model: 'Revision' });
+      const duplicatedChildPage2 = await Page.findOne({ parent: duplicatedPage._id, path: '/duplicatedv5PageForDuplicate3/v5_Child_2_ForDuplicate3' })
+        .populate({ path: 'revision', model: 'Revision' });
+      const revisionForDuplicatedPage = await Revision.findOne({ pageId: duplicatedPage._id });
+      const revisionBodyForDupChild1 = duplicatedChildPage1.revision;
+      const revisionBodyForDupChild2 = duplicatedChildPage2.revision;
+
+      expectAllToBeTruthy([duplicatedPage, duplicatedChildPage1, duplicatedChildPage2,
+                           revisionForDuplicatedPage, revisionBodyForDupChild1, revisionBodyForDupChild2]);
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedChildPage1.path).toBe('/duplicatedv5PageForDuplicate3/v5_Child_1_ForDuplicate3');
+      expect(duplicatedChildPage2.path).toBe('/duplicatedv5PageForDuplicate3/v5_Child_2_ForDuplicate3');
+
+    });
+
+    test('Should duplicate multiple pages with empty child in it', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate4' });
+      const baseChild = await Page.findOne({ parent: basePage._id, isEmpty: true });
+      const baseGrandchild = await Page.findOne({ parent: baseChild._id });
+      expectAllToBeTruthy([basePage, baseChild, baseGrandchild]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate4';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChild = await Page.findOne({ parent: duplicatedPage._id });
+      const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expectAllToBeTruthy([duplicatedPage, duplicatedGrandchild]);
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedChild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4');
+      expect(duplicatedGrandchild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4');
+      expect(duplicatedChild.isEmpty).toBe(true);
+      expect(duplicatedGrandchild.parent).toStrictEqual(duplicatedChild._id);
+      expect(duplicatedChild.parent).toStrictEqual(duplicatedPage._id);
+
+    });
+
+    test('Should duplicate tags', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate5' });
+      const tag1 = await Tag.findOne({ name: 'duplicate_Tag1' });
+      const tag2 = await Tag.findOne({ name:  'duplicate_Tag2' });
+      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
+      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      expectAllToBeTruthy([basePage, tag1, tag2, basePageTagRelation1, basePageTagRelation2]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate5';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
+      const duplicatedTagRelations = await PageTagRelation.find({ relatedPage: duplicatedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedTagRelations.length).toBeGreaterThanOrEqual(2);
+    });
+
+    test('Should NOT duplicate comments', async() => {
+      const basePage = await Page.findOne({ path: '/v5_PageForDuplicate6' });
+      const basePageComments = await Comment.find({ page: basePage._id });
+      expectAllToBeTruthy([basePage, ...basePageComments]);
+
+      const newPagePath = '/duplicatedv5PageForDuplicate6';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
+      const duplicatedComments = await Comment.find({ page: duplicatedPage._id });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(basePageComments.length).not.toBe(duplicatedComments.length);
+    });
+
+    test('Should duplicate empty page with descendants', async() => {
+      const basePage = await Page.findOne({ path: '/v5_empty_PageForDuplicate7' });
+      const basePageChild = await Page.findOne({ parent: basePage._id }).populate({ path: 'revision', model: 'Revision' });
+      const basePageGrandhild = await Page.findOne({ parent: basePageChild._id }).populate({ path: 'revision', model: 'Revision' });
+      expectAllToBeTruthy([basePage, basePageChild, basePageGrandhild, basePageChild.revision, basePageGrandhild.revision]);
+
+      const newPagePath = '/duplicatedv5EmptyPageForDuplicate7';
+      const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
+      const duplicatedChild = await Page.findOne({ parent: duplicatedPage._id }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id }).populate({ path: 'revision', model: 'Revision' });
+
+      expectAllToBeTruthy([duplicatedPage, duplicatedChild, duplicatedGrandchild, duplicatedChild.revision, duplicatedGrandchild.revision]);
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage.path).toBe(newPagePath);
+      expect(duplicatedPage.isEmpty).toBe(true);
+      expect(duplicatedChild.revision.body).toBe(basePageChild.revision.body);
+      expect(duplicatedGrandchild.revision.body).toBe(basePageGrandhild.revision.body);
+      expect(duplicatedChild.path).toBe('/duplicatedv5EmptyPageForDuplicate7/v5_child_PageForDuplicate7');
+      expect(duplicatedGrandchild.path).toBe('/duplicatedv5EmptyPageForDuplicate7/v5_child_PageForDuplicate7/v5_grandchild_PageForDuplicate7');
+      expect(duplicatedGrandchild.parent).toStrictEqual(duplicatedChild._id);
+      expect(duplicatedChild.parent).toStrictEqual(duplicatedPage._id);
+    });
+  });
   describe('Delete', () => {
     const deletePage = async(page, user, options, isRecursively) => {
       const mockedResumableDeleteDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteDescendants').mockReturnValue(null);
@@ -602,6 +1213,124 @@ describe('PageService page operations with only public pages', () => {
     });
   });
 
+  describe('Delete completely', () => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+      const mockedResumableDeleteCompletelyDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteCompletelyDescendants').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+
+      const argsForResumableDeleteDescendants = mockedResumableDeleteCompletelyDescendants.mock.calls[0];
+
+      mockedResumableDeleteCompletelyDescendants.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      if (isRecursively) {
+        await crowi.pageService.resumableDeleteCompletelyDescendants(...argsForResumableDeleteDescendants);
+      }
+
+      return;
+    };
+
+    test('Should NOT completely delete root page', async() => {
+      expectAllToBeTruthy([rootPage]);
+      let isThrown;
+      try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
+      catch (err) { isThrown = true }
+      const page = await Page.findOne({ path: '/' });
+      expect(page).toBeTruthy();
+      expect(isThrown).toBe(true);
+    });
+    test('Should completely delete single page', async() => {
+      const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
+      expectAllToBeTruthy([page]);
+
+      await deleteCompletely(page, dummyUser1, {}, false);
+      const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
+
+      expect(deletedPage).toBeNull();
+    });
+    test('Should completely delete multiple pages', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2' });
+      const childPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3' });
+      const grandchildPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely2/v5_PageForDeleteCompletely3/v5_PageForDeleteCompletely4' });
+      const tag1 = await Tag.findOne({ name: 'TagForDeleteCompletely1' });
+      const tag2 = await Tag.findOne({ name: 'TagForDeleteCompletely2' });
+      const pageTagRelation1 = await PageTagRelation.findOne({ relatedPage: parentPage._id });
+      const pageTagRelation2 = await PageTagRelation.findOne({ relatedPage: grandchildPage._id });
+      const bookmark = await Bookmark.findOne({ page: parentPage._id });
+      const comment = await Comment.findOne({ page: parentPage._id });
+      const pageRedirect1 = await PageRedirect.findOne({ toPath: parentPage.path });
+      const pageRedirect2 = await PageRedirect.findOne({ toPath: grandchildPage.path });
+      const shareLink1 = await ShareLink.findOne({ relatedPage: parentPage._id });
+      const shareLink2 = await ShareLink.findOne({ relatedPage: grandchildPage._id });
+
+      expectAllToBeTruthy(
+        [parentPage, childPage, grandchildPage, tag1, tag2,
+         pageTagRelation1, pageTagRelation2, bookmark, comment,
+         pageRedirect1, pageRedirect2, shareLink1, shareLink2],
+      );
+
+      await deleteCompletely(parentPage, dummyUser1, {}, true);
+      const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
+      const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
+      const tags = await Tag.find({ _id: { $in: [tag1._id, tag2._id] } });
+      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
+      const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
+      const deletedComments = await Comment.find({ _id: comment._id });
+      const deletedPageRedirects = await PageRedirect.find({ _id: { $in: [pageRedirect1._id, pageRedirect2._id] } });
+      const deletedShareLinks = await ShareLink.find({ _id: { $in: [shareLink1._id, shareLink2._id] } });
+
+      // page should be null
+      expect(deletedPages.length).toBe(0);
+      // revision should be null
+      expect(deletedRevisions.length).toBe(0);
+      // tag should be Truthy
+      expectAllToBeTruthy(tags);
+      // pageTagRelation should be null
+      expect(deletedPageTagRelations.length).toBe(0);
+      // bookmark should be null
+      expect(deletedBookmarks.length).toBe(0);
+      // comment should be null
+      expect(deletedComments.length).toBe(0);
+      // pageRedirect should be null
+      expect(deletedPageRedirects.length).toBe(0);
+      // sharelink should be null
+      expect(deletedShareLinks.length).toBe(0);
+    });
+    test('Should completely delete trashed page', async() => {
+      const page = await Page.findOne({ path: '/trash/v5_PageForDeleteCompletely5' });
+      const revision = await Revision.findOne({ pageId: page._id });
+      expectAllToBeTruthy([page, revision]);
+
+      await deleteCompletely(page, dummyUser1, {}, false);
+      const deltedPage = await Page.findOne({ _id: page._id });
+      const deltedRevision = await Revision.findOne({ _id: revision._id });
+
+      expect(deltedPage).toBeNull();
+      expect(deltedRevision).toBeNull();
+    });
+    test('Should completely deleting page in the middle results in having an empty page', async() => {
+      const parentPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
+      const childPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
+      const grandchildPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
+      expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
+
+      await deleteCompletely(childPage, dummyUser1, {}, false);
+      const parentPageAfterDelete = await Page.findOne({ path: parentPage.path });
+      const childPageAfterDelete = await Page.findOne({ path: childPage.path });
+      const grandchildPageAfterDelete = await Page.findOne({ path: grandchildPage.path });
+      const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
+
+      expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
+      expect(childPageAfterDelete._id).not.toStrictEqual(childPage._id);
+      expect(childPageAfterDelete.isEmpty).toBe(true);
+      expect(childPageAfterDelete.parent).toStrictEqual(parentPage._id);
+      expect(childOfDeletedPage._id).toStrictEqual(grandchildPage._id);
+
+    });
+  });
+
 });
 
 describe('PageService page operations with non-public pages', () => {