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

Merge branch 'feat/auditlog' of https://github.com/weseek/growi into feat/97283-create-activit-by-general-user-operation

Shun Miyazawa 3 лет назад
Родитель
Сommit
5b580689db

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

@@ -576,7 +576,8 @@ module.exports = (crowi) => {
     }
 
     try {
-      await crowi.pageService.resumeRenameSubOperation(page);
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {
       logger.error(err);

+ 25 - 11
packages/app/src/server/service/page-operation.ts

@@ -8,7 +8,9 @@ import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const {
+  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
@@ -34,8 +36,10 @@ class PageOperationService {
    */
   async afterExpressServerReady(): Promise<void> {
     try {
+      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+        .sort({ createdAt: 'asc' });
       // execute rename operation
-      await this.executeAllRenameOperationBySystem();
+      await this.executeAllRenameOperationBySystem(pageOps);
     }
     catch (err) {
       logger.error(err);
@@ -45,17 +49,12 @@ class PageOperationService {
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
-  private async executeAllRenameOperationBySystem(): Promise<void> {
-    const Page = this.crowi.model('Page');
-
-    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-      .sort({ createdAt: 'asc' });
+  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
     if (pageOps.length === 0) return;
 
+    const Page = this.crowi.model('Page');
+
     for await (const pageOp of pageOps) {
-      const {
-        page, toPath, options, user,
-      } = pageOp;
 
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
@@ -64,7 +63,7 @@ class PageOperationService {
       }
 
       // rename
-      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
     }
   }
 
@@ -169,6 +168,21 @@ class PageOperationService {
     clearInterval(timerObj);
   }
 
+  /**
+   * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
+   */
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
+    const fromAncestorsPaths = collectAncestorPaths(fromPath);
+    const toAncestorsPaths = collectAncestorPaths(toPath);
+    // merge duplicate paths and return paths of ancestors
+    return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
+  }
+
+  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+    return PageOperation.findOne(filter);
+  }
+
 }
 
 export default PageOperationService;

+ 41 - 18
packages/app/src/server/service/page.ts

@@ -27,7 +27,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -533,30 +533,31 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
-
-    // findOne PageOperation
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
-    const pageOp = await PageOperation.findOne(filter);
-    if (pageOp == null) {
-      throw Error('There is nothing to be processed right now');
-    }
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
     }
+    if (pageOp.toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume rename operation(${pageOp._id})`);
+    }
 
     const {
-      page, toPath, options, user,
+      page, fromPath, toPath, options, user,
     } = pageOp;
 
-    // check property
-    if (toPath == null) {
-      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
-    }
-
-    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+  }
 
+  /**
+   * Renaming paths and fixing descendantCount of ancestors. It shoud be run synchronously.
+   * `renameSubOperation` to restart rename operation
+   * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
+   */
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+    const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
+    await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
@@ -2959,8 +2960,30 @@ class PageService {
     builder.addConditionToSortPagesByDescPath();
 
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
+  /**
+   * update descendantCount of the pages sequentially from longer path to shorter path
+   */
+  async updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void> {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
 
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToListByPathsArray(paths); // find by paths
+    builder.addConditionToSortPagesByDescPath(); // sort in DESC
+
+    const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
+
+  /**
+   * Recount descendantCount of pages one by one
+   */
+  async recountAndUpdateDescendantCountOfPages(pageCursor: QueryCursor<any>, batchSize:number): Promise<void> {
+    const Page = this.crowi.model('Page');
     const recountWriteStream = new Writable({
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
@@ -2974,8 +2997,8 @@ class PageService {
         callback();
       },
     });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
+    pageCursor
+      .pipe(createBatchStream(batchSize))
       .pipe(recountWriteStream);
 
     await streamToPromise(recountWriteStream);

+ 346 - 2
packages/app/test/integration/models/v5.page.test.js

@@ -163,6 +163,12 @@ describe('Page', () => {
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
     const pageIdUpd13 = new mongoose.Types.ObjectId();
+    const pageIdUpd14 = new mongoose.Types.ObjectId();
+    const pageIdUpd15 = new mongoose.Types.ObjectId();
+    const pageIdUpd16 = new mongoose.Types.ObjectId();
+    const pageIdUpd17 = new mongoose.Types.ObjectId();
+    const pageIdUpd18 = new mongoose.Types.ObjectId();
+    const pageIdUpd19 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -337,7 +343,138 @@ describe('Page', () => {
         descendantCount: 0,
       },
       {
-        path: '/mup24',
+        _id: pageIdUpd14,
+        path: '/mup24_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup24_pub/mup25_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd14,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup26_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd15,
+        path: '/mup27_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup27_pub/mup28_owner',
+        grant: Page.GRANT_OWNER,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd15,
+        grantedUsers: [pModelUserId1],
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd16,
+        path: '/mup29_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup29_A/mup30_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd16,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd17,
+        path: '/mup31_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup31_A/mup32_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd17,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd18,
+        path: '/mup33_C',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup33_C/mup34_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId3],
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: pageIdUpd18,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd19,
+        path: '/mup35_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup35_owner/mup36_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd19,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup40', // used this number to resolve conflict
         grant: Page.GRANT_OWNER,
         grantedUsers: [dummyUser1._id],
         creator: dummyUser1,
@@ -434,7 +571,7 @@ describe('Page', () => {
 
     describe('Changing grant to GRANT_RESTRICTED', () => {
       test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async() => {
-        const path = '/mup24';
+        const path = '/mup40';
         const _page = await Page.findOne({ path, grant: Page.GRANT_OWNER, grantedUsers: [dummyUser1._id] });
         expect(_page).toBeTruthy();
 
@@ -559,6 +696,213 @@ describe('Page', () => {
         expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
       });
     });
+    describe('Changing grant to GRANT_USER_GROUP', () => {
+      describe('update grant of a page under a page with GRANT_PUBLIC', () => {
+        test('successfully change to GRANT_USER_GROUP from GRANT_PUBLIC if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup24_pub';
+          const path2 = '/mup24_pub/mup25_pub';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC, parent: _page1._id }); // update target
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // check page2 grant and group
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const _path1 = '/mup26_awl';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
+          expect(_page1).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          expect(page1).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page1._id);
+
+          // updated page
+          expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+
+          // parent's grant check
+          const parent = await Page.findById(page1.parent);
+          expect(parent.grant).toBe(Page.GRANT_PUBLIC);
+
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_OWNER if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup27_pub';
+          const path2 = '/mup27_pub/mup28_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({
+            path: path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          }); // update target
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // grant check
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedUsers.length).toBe(0);
+        });
+      });
+      describe('update grant of a page under a page with GRANT_USER_GROUP', () => {
+        test('successfully change to GRANT_USER_GROUP if the group to set is the child or descendant of the parent page group', async() => {
+          // path
+          const _path1 = '/mup29_A';
+          const _path2 = '/mup29_A/mup30_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // out of update scope
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdB };
+
+          // First round
+          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdB)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          expect(page2.grantedUsers.length).toBe(0);
+
+          // Second round
+          // Update group to groupC which is a grandchild from pageA's point of view
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdC }; // from GRANT_USER_GROUP(groupIdB) to GRANT_USER_GROUP(groupIdC)
+          const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
+
+          expect(secondRoundUpdatedPage).toBeTruthy();
+          expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(groupIdC);
+        });
+        test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
+          // path
+          const _path1 = '/mup31_A';
+          const _path2 = '/mup31_A/mup32_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          // group
+          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          expect(_groupIsolated).toBeTruthy();
+          // group parent check
+          expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdIsolate };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdIsolate)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page1).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+        test('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async() => {
+          // path
+          const _path1 = '/mup33_C';
+          const _path2 = '/mup33_C/mup34_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // groupC
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+
+          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // this should fail because the groupC is a descendant of groupA
+          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+      describe('update grant of a page under a page with GRANT_OWNER', () => {
+        test('Fail to change from GRNAT_OWNER', async() => {
+          // path
+          const path1 = '/mup35_owner';
+          const path2 = '/mup35_owner/mup36_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1] });
+          const _page2 = await Page.findOne({ // update target
+            path: path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1.id);
+          const page2 = await Page.findById(_page2.id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+
+    });
 
   });
 });

+ 122 - 237
packages/app/test/integration/service/v5.page.test.ts

@@ -103,6 +103,9 @@ describe('Test page service methods', () => {
     const pageId15 = new mongoose.Types.ObjectId();
     const pageId16 = new mongoose.Types.ObjectId();
     const pageId17 = new mongoose.Types.ObjectId();
+    const pageId18 = new mongoose.Types.ObjectId();
+    const pageId19 = new mongoose.Types.ObjectId();
+    const pageId20 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -158,7 +161,7 @@ describe('Test page service methods', () => {
       {
         _id: pageId5,
         path: '/resume_rename_4/resume_rename_5',
-        parent: pageId0,
+        parent: pageId4,
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
@@ -303,6 +306,54 @@ describe('Test page service methods', () => {
         descendantCount: 0,
         isEmpty: false,
       },
+      {
+        _id: pageId18,
+        path: '/fix_descendantCount_1',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId19,
+        path: '/fix_descendantCount_1/fix_descendantCount_2',
+        parent: pageId18,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: true,
+      },
+      {
+        path: '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3',
+        parent: pageId19,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId20,
+        path: '/fix_descendantCount_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        path: '/fix_descendantCount_4/fix_descendantCount_5',
+        parent: pageId20,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
     ]);
 
     /**
@@ -312,14 +363,10 @@ describe('Test page service methods', () => {
     pageOpId2 = new mongoose.Types.ObjectId();
     pageOpId3 = new mongoose.Types.ObjectId();
     pageOpId4 = new mongoose.Types.ObjectId();
-    pageOpId5 = new mongoose.Types.ObjectId();
-    pageOpId6 = new mongoose.Types.ObjectId();
     const pageOpRevisionId1 = new mongoose.Types.ObjectId();
     const pageOpRevisionId2 = new mongoose.Types.ObjectId();
     const pageOpRevisionId3 = new mongoose.Types.ObjectId();
     const pageOpRevisionId4 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
 
     await PageOperation.insertMany([
       {
@@ -438,76 +485,18 @@ describe('Test page service methods', () => {
         },
         unprocessableExpiryDate: null,
       },
-      {
-        _id: pageOpId5,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_11/resume_rename_13',
-        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
-        page: {
-          _id: pageId12,
-          parent: pageId10,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_11/resume_rename_13',
-          revision: pageOpRevisionId5,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
-      {
-        _id: pageOpId6,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
-        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
-        page: {
-          _id: pageId16,
-          parent: pageId14,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
-          revision: pageOpRevisionId6,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
     ]);
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(page) => {
-      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(page);
+    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+      const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
 
-      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+      const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
-      mockedRenameSubOperation.mockRestore();
-      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+      mockedPathsAndDescendantCountOfAncestors.mockRestore();
+      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(...argsForRenameSubOperation);
     };
 
     test('it should successfully restart rename operation', async() => {
@@ -547,7 +536,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -605,7 +594,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -630,43 +619,6 @@ describe('Test page service methods', () => {
       expect(page1.descendantCount).toBe(1);
       expect(page2.descendantCount).toBe(0);
     });
-
-    test('it should fail and throw error if PageOperation is not found', async() => {
-      // create dummy page operation data not stored in DB
-      const notExistPageOp = {
-        _id: new mongoose.Types.ObjectId(),
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/FROM_NOT_EXIST',
-        toPath: 'TO_NOT_EXIST',
-        page: {
-          _id: new mongoose.Types.ObjectId(),
-          parent: rootPage._id,
-          descendantCount: 2,
-          isEmpty: false,
-          path: '/NOT_EXIST_PAGE',
-          revision: new mongoose.Types.ObjectId(),
-          status: 'published',
-          grant: 1,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: false,
-        },
-        unprocessableExpiryDate: new Date(),
-      };
-
-      await expect(resumeRenameSubOperation(notExistPageOp))
-        .rejects.toThrow(new Error('There is nothing to be processed right now'));
-    });
-
     test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
       // path before renaming
       const _path0 = '/resume_rename_4'; // out of renaming scope
@@ -683,21 +635,21 @@ describe('Test page service methods', () => {
       // page operation
       const fromPath = '/resume_rename_5';
       const toPath = '/resume_rename_4/resume_rename_5';
-      const pageOperation = await PageOperation.findOne({
+      const _pageOperation = await PageOperation.findOne({
         _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
       });
-      expect(pageOperation).toBeTruthy();
+      expect(_pageOperation).toBeTruthy();
 
       // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
       // The number 15 seconds has no meaning other than placing time in the furue.
-      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+      const pageOperation = await PageOperation.findByIdAndUpdate(_pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) }, { new: true });
+      expect(pageOperation).toBeTruthy();
 
-      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+      await expect(resumeRenameSubOperation(_page1, pageOperation)).rejects.toThrow(new Error('This page operation is currently being processed'));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
     test('Missing property(toPath) for PageOperation should throw error', async() => {
       // page
       const _path1 = '/resume_rename_7';
@@ -710,152 +662,85 @@ describe('Test page service methods', () => {
       });
       expect(pageOperation).toBeTruthy();
 
-      const promise = resumeRenameSubOperation(_page1);
-      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+      const promise = resumeRenameSubOperation(_page1, pageOperation);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume rename operation(${pageOperation._id})`));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
-    test(`it should succeed but 2 extra descendantCount should be added
-    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_11'; // out of renaming scope
-      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
-      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
-      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_11';
-      const path1 = '/resume_rename_11/resume_rename_12';
-      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
-
-      // page
-      const _page0 = await Page.findOne({ path: _path0 });
-      const _page1 = await Page.findOne({ path: _path1 });
-      const _page2 = await Page.findOne({ path: _path2 });
-      const _page3 = await Page.findOne({ path: _path3 });
-      expect(_page0).toBeTruthy();
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(3);
-      expect(_page1.descendantCount).toBe(2);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_11/resume_rename_13';
-      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page2);
-
-      // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      expect(page0).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be added to page1
-      expect(page0.descendantCount).toBe(3);
-      expect(page1.descendantCount).toBe(3); // originally 2, +1 in Main, -1 in Sub, +2 for new descendants
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
-    });
-
-    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
-    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_15'; // out of renaming scope
-      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
-      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
-      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
-      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_15';
-      const path1 = '/resume_rename_15/resume_rename_16';
-      const path2 = '/resume_rename_15/resume_rename_17';
-      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
-
+  });
+  describe('updateDescendantCountOfPagesWithPaths', () => {
+    test('should fix descendantCount of pages with one of the given paths', async() => {
+      // path
+      const _path1 = '/fix_descendantCount_1';
+      const _path2 = '/fix_descendantCount_1/fix_descendantCount_2'; // empty
+      const _path3 = '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3';
+      const _path4 = '/fix_descendantCount_4';
+      const _path5 = '/fix_descendantCount_4/fix_descendantCount_5';
       // page
-      const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
       const _page2 = await Page.findOne({ path: _path2 });
       const _page3 = await Page.findOne({ path: _path3 });
       const _page4 = await Page.findOne({ path: _path4 });
-      expect(_page0).toBeTruthy();
+      const _page5 = await Page.findOne({ path: _path5 });
+      // check existance
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
       expect(_page3).toBeTruthy();
       expect(_page4).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(2);
-      expect(_page1.descendantCount).toBe(0);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(1);
-      expect(_page4.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
-      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page3);
+      expect(_page5).toBeTruthy();
+      // check descendantCount (all broken)
+      expect(_page1.descendantCount).toBe(100);
+      expect(_page2.descendantCount).toBe(100);
+      expect(_page3.descendantCount).toBe(100);
+      expect(_page4.descendantCount).toBe(100);
+      expect(_page5.descendantCount).toBe(100);
+      // check isEmpty
+      expect(_page1.isEmpty).toBe(false);
+      expect(_page2.isEmpty).toBe(true);
+      expect(_page3.isEmpty).toBe(false);
+      expect(_page4.isEmpty).toBe(false);
+      expect(_page5.isEmpty).toBe(false);
+      // check parent
+      expect(_page1.parent).toStrictEqual(rootPage._id);
+      expect(_page2.parent).toStrictEqual(_page1._id);
+      expect(_page3.parent).toStrictEqual(_page2._id);
+      expect(_page4.parent).toStrictEqual(rootPage._id);
+      expect(_page5.parent).toStrictEqual(_page4._id);
+
+      await crowi.pageService.updateDescendantCountOfPagesWithPaths([_path1, _path2, _path3, _path4, _path5]);
 
       // page
-      const page0 = await Page.findById(_page0._id);
       const page1 = await Page.findById(_page1._id);
       const page2 = await Page.findById(_page2._id);
       const page3 = await Page.findById(_page3._id);
       const page4 = await Page.findById(_page4._id);
-      expect(page0).toBeTruthy();
+      const page5 = await Page.findById(_page5._id);
+
+      // check existance
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       expect(page3).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-      expect(page4.path).toBe(path4);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be subtracted from page1
-      expect(page0.descendantCount).toBe(2);
-      expect(page1.descendantCount).toBe(-2); // originally 0, -2 for old descendants
-      expect(page2.descendantCount).toBe(2); // originally 1, -1 in Sub, +2 for new descendants
-      expect(page3.descendantCount).toBe(1);
-      expect(page4.descendantCount).toBe(0);
+      expect(page4).toBeTruthy();
+      expect(page5).toBeTruthy();
+      // check descendantCount (all fixed)
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+      expect(page4.descendantCount).toBe(1);
+      expect(page5.descendantCount).toBe(0);
+      // check isEmpty
+      expect(page1.isEmpty).toBe(false);
+      expect(page2.isEmpty).toBe(true);
+      expect(page3.isEmpty).toBe(false);
+      expect(page4.isEmpty).toBe(false);
+      expect(page5.isEmpty).toBe(false);
+      // check parent
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page3.parent).toStrictEqual(page2._id);
+      expect(page4.parent).toStrictEqual(rootPage._id);
+      expect(page5.parent).toStrictEqual(page4._id);
     });
   });
 });