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

Merge branch 'imprv/refacter-recursively' into imprv/create-duplicateDescendants-test

zahmis 5 лет назад
Родитель
Сommit
660116772e

+ 1 - 1
CHANGES.md

@@ -2,7 +2,7 @@
 
 ## v4.2.8-RC
 
-* 
+* Fix: Fixed the display of updtedAt and createdAt being reversed 
 
 ## v4.2.7
 

+ 5 - 7
README.md

@@ -11,9 +11,6 @@
 <p align="center">
   <a href="https://docs.growi.org">Documentation</a> / <a href="https://demo.growi.org">Demo</a>
 </p>
-<p align="center">
-  <a href="https://heroku.com/deploy"><img src="https://www.herokucdn.com/deploy/button.png"></a>
-</p>
 
 
 GROWI
@@ -33,8 +30,8 @@ Table Of Contents
 
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
-    - [Heroku](#heroku)
     - [docker-compose](#docker-compose)
+    - [Helm (Experimental)](#helm)
     - [On-premise](#on-premise)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)
@@ -61,14 +58,15 @@ Features
 Quick Start for Production
 ===========================
 
-### Heroku
-
-- [GROWI Docs: Launch on Heroku](https://docs.growi.org/en/admin-guide/getting-started/heroku.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/heroku.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/heroku.html))
 
 ### docker-compose
 
 - [GROWI Docs: Launch with docker-compose](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/docker-compose.html))
 
+### Helm (Experimental)
+
+- [GROWI Helm Chart](https://github.com/weseek/helm-charts/tree/master/charts/growi)
+
 ### On-premise
 
 **[Migration Guide from Crowi](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html) ([en](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html)/[ja](https://docs.growi.org/ja/admin-guide/migration-guide/from-crowi-onpremise.html))** is here.

+ 6 - 5
src/client/js/components/Navbar/AuthorInfo.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-
 import { userPageRoot } from '@commons/util/path-utils';
+import { format } from 'date-fns';
 
 import UserPicture from '../User/UserPicture';
 
+const formatType = 'yyyy/MM/dd HH:mm';
 const AuthorInfo = (props) => {
   const {
     mode, user, date, locate,
@@ -14,14 +15,14 @@ const AuthorInfo = (props) => {
     ? 'Created by'
     : 'Updated by';
   const infoLabelForFooter = mode === 'create'
-    ? 'Last revision posted at'
-    : 'Created at';
+    ? 'Created at'
+    : 'Last revision posted at';
   const userLabel = user != null
     ? <a href={userPageRoot(user)}>{user.name}</a>
     : <i>Unknown</i>;
 
   if (locate === 'footer') {
-    return <p>{infoLabelForFooter} {date} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+    return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
   }
 
   return (
@@ -31,7 +32,7 @@ const AuthorInfo = (props) => {
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date">{date}</div>
+        <div className="text-muted text-date">{format(new Date(date), formatType)}</div>
       </div>
     </div>
   );

+ 1 - 1
src/server/crowi/index.js

@@ -147,7 +147,7 @@ Crowi.prototype.initForTest = async function() {
     // this.setupSlack(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
-    // this.setupAttachmentService(),
+    this.setupAttachmentService(),
     this.setUpAcl(),
     // this.setUpCustomize(),
     // this.setUpRestQiitaAPI(),

+ 6 - 0
src/server/service/config-loader.js

@@ -392,6 +392,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: 7788,
   },
+  GROWI_CLOUD_URI: {
+    ns:      'crowi',
+    key:     'app:growiCloudUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
 };
 
 class ConfigLoader {

+ 24 - 14
src/server/service/page.js

@@ -95,9 +95,12 @@ class PageService {
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
     const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
     const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
 
     pages.forEach((page) => {
       const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+
       if (updateMetadata) {
         unorderedBulkOp.find({ _id: page._id }).update([{ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: { $toDate: Date.now() } } }]);
       }
@@ -106,7 +109,10 @@ class PageService {
       }
       if (createRedirectPage) {
         createRediectPageBulkOp.insert({
-          path: page.path, body: `redirect ${newPagePath}`, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
+          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
+        });
+        createRediectRevisionBulkOp.insert({
+          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
         });
       }
       revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
@@ -118,6 +124,7 @@ class PageService {
       // Execute after unorderedBulkOp to prevent duplication
       if (createRedirectPage) {
         await createRediectPageBulkOp.execute();
+        await createRediectRevisionBulkOp.execute();
       }
     }
     catch (err) {
@@ -405,7 +412,7 @@ class PageService {
     await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
     const deletedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
-        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: new Date(),
+        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
     }, { new: true });
     const body = `redirect ${newPath}`;
@@ -425,35 +432,40 @@ class PageService {
 
     const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
     const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
     const newPagesForRedirect = [];
 
     pages.forEach((page) => {
       const newPath = Page.getDeletedPageName(page.path);
+      const revisionId = new mongoose.Types.ObjectId();
       const body = `redirect ${newPath}`;
 
       deletePageBulkOp.find({ _id: page._id }).update({
         $set: {
-          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: new Date(),
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
         },
       });
       updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
+      createRediectRevisionBulkOp.insert({
+        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
+      });
 
       newPagesForRedirect.push({
         path: page.path,
-        body,
         creator: user._id,
         grant: page.grant,
         grantedGroup: page.grantedGroup,
         grantedUsers: page.grantedUsers,
         lastUpdateUser: user._id,
         redirectTo: newPath,
-        revision: null,
+        revision: revisionId,
       });
     });
 
     try {
       await deletePageBulkOp.execute();
       await updateRevisionListOp.execute();
+      await createRediectRevisionBulkOp.execute();
       await Page.insertMany(newPagesForRedirect, { ordered: false });
     }
     catch (err) {
@@ -516,9 +528,8 @@ class PageService {
 
     await this.deleteCompletelyOperation(ids, paths);
 
-    if (socketClientId != null) {
-      this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
-    }
+    this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
+
     return;
   }
 
@@ -535,9 +546,8 @@ class PageService {
       this.deleteCompletelyDescendantsWithStream(page, user, options);
     }
 
-    if (socketClientId != null) {
-      this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
-    }
+    this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
+
     return;
   }
 
@@ -584,7 +594,7 @@ class PageService {
       .pipe(writeStream);
   }
 
-  async revertDeletedPages(pages, user) {
+  async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
     const pageCollection = mongoose.connection.collection('pages');
     const revisionCollection = mongoose.connection.collection('revisions');
@@ -682,14 +692,14 @@ class PageService {
       .lean()
       .cursor();
 
-    const revertDeletedPages = this.revertDeletedPages.bind(this);
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     let count = 0;
     const writeStream = new Writable({
       objectMode: true,
       async write(batch, encoding, callback) {
         try {
           count += batch.length;
-          revertDeletedPages(batch, user);
+          revertDeletedDescendants(batch, user);
           logger.debug(`Reverting pages progressing: (count=${count})`);
         }
         catch (err) {

+ 354 - 26
src/test/service/page.test.js

@@ -13,15 +13,25 @@ let parentForRename2;
 let parentForRename3;
 let parentForRename4;
 
+let childForRename1;
+let childForRename2;
+let childForRename3;
+
 let parentForDuplicate;
-let parentForDelete;
+
+let parentForDelete1;
+let parentForDelete2;
+
+let childForDelete;
+
 let parentForDeleteCompletely;
-let parentForRevert;
 
-let childForRename;
+let parentForRevert1;
+let parentForRevert2;
+
 let childForDuplicate;
-let childForDelete;
 let childForDeleteCompletely;
+
 let childForRevert;
 
 describe('PageService', () => {
@@ -32,6 +42,9 @@ describe('PageService', () => {
   let User;
   let Tag;
   let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
   let xssSpy;
 
   beforeAll(async(done) => {
@@ -42,6 +55,9 @@ describe('PageService', () => {
     Revision = mongoose.model('Revision');
     Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
 
     await User.insertMany([
       { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
@@ -82,6 +98,18 @@ describe('PageService', () => {
         creator: testUser1,
         lastUpdateUser: testUser1,
       },
+      {
+        path: '/parentForRename2/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename3/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
       {
         path: '/parentForDuplicate',
         grant: Page.GRANT_PUBLIC,
@@ -96,7 +124,13 @@ describe('PageService', () => {
         lastUpdateUser: testUser1,
       },
       {
-        path: '/parentForDelete',
+        path: '/parentForDelete1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDelete2',
         grant: Page.GRANT_PUBLIC,
         creator: testUser1,
         lastUpdateUser: testUser1,
@@ -120,13 +154,22 @@ describe('PageService', () => {
         lastUpdateUser: testUser1,
       },
       {
-        path: '/parentForRevert',
+        path: '/trash/parentForRevert1',
+        status: Page.STATUS_DELETED,
         grant: Page.GRANT_PUBLIC,
         creator: testUser1,
         lastUpdateUser: testUser1,
       },
       {
-        path: '/parentForRevert/child',
+        path: '/trash/parentForRevert2',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert/child',
+        status: Page.STATUS_DELETED,
         grant: Page.GRANT_PUBLIC,
         creator: testUser1,
         lastUpdateUser: testUser1,
@@ -137,16 +180,24 @@ describe('PageService', () => {
     parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
     parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
     parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
+
     parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
-    parentForDelete = await Page.findOne({ path: '/parentForDelete' });
+
+    parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
+    parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
+
     parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
-    parentForRevert = await Page.findOne({ path: '/parentForRevert' });
+    parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
+    parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
+
+    childForRename1 = await Page.findOne({ path: '/parentForRename1/child' });
+    childForRename2 = await Page.findOne({ path: '/parentForRename2/child' });
+    childForRename3 = await Page.findOne({ path: '/parentForRename3/child' });
 
-    childForRename = await Page.findOne({ path: '/parentForRename1/child' });
     childForDuplicate = await Page.findOne({ path: '/parentForDuplicate/child' });
     childForDelete = await Page.findOne({ path: '/parentForDelete/child' });
     childForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely/child' });
-    childForRevert = await Page.findOne({ path: '/parentForRevert/child' });
+    childForRevert = await Page.findOne({ path: '/trash/parentForRevert/child' });
 
 
     await Tag.insertMany([
@@ -274,8 +325,69 @@ describe('PageService', () => {
 
     });
 
-    test('renameDescendants()', () => {
-      expect(3).toBe(3);
+    test('renameDescendants without options', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename1', 'i');
+      const newPagePathPrefix = '/renamed1';
+
+      await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed1/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
+
+      expect(resultPage.path).toBe('/renamed1/child');
+      expect(resultPage.updatedAt).toEqual(childForRename1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with updateMetadata option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename2', 'i');
+      const newPagePathPrefix = '/renamed2';
+
+      await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed2/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
+
+      expect(resultPage.path).toBe('/renamed2/child');
+      expect(resultPage.updatedAt).toEqual(dateToUse);
+      expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with createRedirectPage option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename3', 'i');
+      const newPagePathPrefix = '/renamed3';
+
+      await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed3/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
+
+      expect(resultPage.path).toBe('/renamed3/child');
+      expect(resultPage.updatedAt).toEqual(childForRename3.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
     });
   });
 
@@ -340,38 +452,254 @@ describe('PageService', () => {
       expect([insertedPage]).toHaveLength(1);
     });
 
+
     test('duplicateTags()', async() => {
-      expect(3).toBe(3);
+      const pageIdMapping = {
+        [parentForDuplicate._id]: '60110bdd85339d7dc732dddd',
+      };
+      const duplicateTagsReturn = await crowi.pageService.duplicateTags(pageIdMapping);
+      const parentoForDuplicateTag = await PageTagRelation.findOne({ relatedPage: parentForDuplicate });
+
+      expect(duplicateTagsReturn).toHaveLength(1);
+      expect(duplicateTagsReturn[0].relatedTag).toEqual(parentoForDuplicateTag.relatedTag);
     });
   });
 
   describe('delete page', () => {
-    test('deletePage()', () => {
-      expect(3).toBe(3);
+    let getDeletedPageNameSpy;
+    let pageEventSpy;
+    let deleteDescendantsWithStreamSpy;
+    const dateToUse = new Date('2000-01-01');
+    const socketClientId = null;
+
+    beforeEach(async(done) => {
+      jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
+      getDeletedPageNameSpy = jest.spyOn(Page, 'getDeletedPageName');
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('delete page without options', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete1' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete1');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete1');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
     });
 
-    test('deleteDescendants()', () => {
-      expect(3).toBe(3);
+    test('delete page with isRecursively', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete2' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete2');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete2');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+    });
+
+
+    test('deleteDescendants', async() => {
+      await crowi.pageService.deleteDescendants([childForDelete], testUser2);
+      const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete/child' });
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete/child');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(childForDelete.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
     });
   });
 
   describe('delete page completely', () => {
-    test('deleteCompletely()', () => {
-      expect(3).toBe(3);
+    let pageEventSpy;
+    let deleteCompletelyOperationSpy;
+    let deleteCompletelyDescendantsWithStreamSpy;
+    const socketClientId = null;
+
+    let deleteManyBookmarkSpy;
+    let deleteManyCommentSpy;
+    let deleteManyPageTagRelationSpy;
+    let deleteManyShareLinkSpy;
+    let deleteManyRevisionSpy;
+    let deleteManyPageSpy;
+    let removeAllAttachmentsSpy;
+
+    beforeEach(async(done) => {
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
+      deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
+
+      deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
+      deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
+      deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
+      deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
+      deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
+      deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
+      removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
+      done();
+    });
+    test('deleteCompletelyOperation', async() => {
+      await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
+
+      expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ path: { $in: [parentForDeleteCompletely.path] } });
+      expect(deleteManyPageSpy).toHaveBeenCalledWith({
+        $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
+              { path: { $in: [] } },
+              { _id: { $in: [parentForDeleteCompletely._id] } }],
+      });
+      expect(removeAllAttachmentsSpy).toHaveBeenCalled();
     });
 
-    test('deleteMultipleCompletely()', () => {
-      expect(3).toBe(3);
+    test('delete completely without options', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+    });
+
+
+    test('delete completely with isRecursively', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
     });
   });
 
   describe('revert page', () => {
-    test('revertDeletedPage()', () => {
-      expect(3).toBe(3);
+    let getRevertDeletedPageNameSpy;
+    let findByPathSpy;
+    let findSpy;
+    let deleteCompletelySpy;
+    let revertDeletedDescendantsWithStreamSpy;
+
+    beforeEach(async(done) => {
+      getRevertDeletedPageNameSpy = jest.spyOn(Page, 'getRevertDeletedPageName');
+      deleteCompletelySpy = jest.spyOn(crowi.pageService, 'deleteCompletely').mockImplementation();
+      revertDeletedDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'revertDeletedDescendantsWithStream').mockImplementation();
+      done();
     });
 
-    test('revertDeletedPages()', () => {
-      expect(3).toBe(3);
+    test('revert deleted page when the redirect from page exists', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return { redirectTo: '/trash/parentForRevert1' };
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert1');
+      expect(deleteCompletelySpy).toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert1');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted page when the redirect from page does not exist', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return null;
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');
+      expect(deleteCompletelySpy).not.toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert2');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted descendants', async() => {
+
+      findSpy = jest.spyOn(Page, 'find').mockImplementation(() => {
+        return [{ path: '/parentForRevert/child', redirectTo: '/trash/parentForRevert/child' }];
+      });
+
+      await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
+      const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
+      const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
+      const revrtedFromPageRevision = await Revision.findOne({ path: '/trash/parentForRevert/child' });
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
+      expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });
+
+      expect(resultPage.path).toBe('/parentForRevert/child');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+
+      expect(revrtedFromPage).toBeNull();
+      expect(revrtedFromPageRevision).toBeNull();
     });
   });