Browse Source

migrate test to vitest

Yuki Takei 2 months ago
parent
commit
1b63c22473
1 changed files with 199 additions and 143 deletions
  1. 199 143
      apps/app/src/server/service/page/v5.public-page.integ.ts

+ 199 - 143
apps/app/test/integration/service/v5.public-page.test.ts → apps/app/src/server/service/page/v5.public-page.integ.ts

@@ -1,33 +1,31 @@
 import type { IPage, IRevision } from '@growi/core';
 import mongoose from 'mongoose';
 
-import type { CommentModel } from '../../../src/features/comment/server/models/comment';
-import type { IComment } from '../../../src/interfaces/comment';
-import {
-  PageActionStage,
-  PageActionType,
-} from '../../../src/interfaces/page-operation';
-import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
-import type { IShareLink } from '../../../src/interfaces/share-link';
-import type Crowi from '../../../src/server/crowi';
-import type { PageDocument, PageModel } from '../../../src/server/models/page';
+import { getInstance } from '^/test-with-vite/setup/crowi';
+
+import type { CommentModel } from '~/features/comment/server/models/comment';
+import type { IComment } from '~/interfaces/comment';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
+import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
+import type { IShareLink } from '~/interfaces/share-link';
+import type Crowi from '~/server/crowi';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import type {
   IPageOperation,
   PageOperationModel,
-} from '../../../src/server/models/page-operation';
+} from '~/server/models/page-operation';
 import type {
   IPageRedirect,
   PageRedirectModel,
-} from '../../../src/server/models/page-redirect';
-import PageTagRelation from '../../../src/server/models/page-tag-relation';
+} from '~/server/models/page-redirect';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import type {
   IRevisionDocument,
   IRevisionModel,
-} from '../../../src/server/models/revision';
-import type { ShareLinkModel } from '../../../src/server/models/share-link';
-import Tag from '../../../src/server/models/tag';
-import { generalXssFilter } from '../../../src/services/general-xss-filter';
-import { getInstance } from '../setup-crowi';
+} from '~/server/models/revision';
+import type { ShareLinkModel } from '~/server/models/share-link';
+import Tag from '~/server/models/tag';
+import { generalXssFilter } from '~/services/general-xss-filter';
 
 describe('PageService page operations with only public pages', () => {
   // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
@@ -38,15 +36,13 @@ describe('PageService page operations with only public pages', () => {
   let crowi: Crowi;
   let Page: PageModel;
   let Revision: IRevisionModel;
-  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
-  let User;
-  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
-  let Bookmark;
+  let User: UserModel;
+  let Bookmark: BookmarkModel;
   let Comment: CommentModel;
   let ShareLink: ShareLinkModel;
   let PageRedirect: PageRedirectModel;
   let PageOperation: PageOperationModel;
-  let generalXssFilterProcessSpy: jest.SpyInstance;
+  let generalXssFilterProcessSpy: ReturnType<typeof vi.spyOn>;
 
   let rootPage: PageDocument;
 
@@ -54,7 +50,7 @@ describe('PageService page operations with only public pages', () => {
   let pageOpId1: mongoose.Types.ObjectId;
 
   const create = async (path, body, user, options = {}) => {
-    const mockedCreateSubOperation = jest
+    const mockedCreateSubOperation = vi
       .spyOn(crowi.pageService, 'createSubOperation')
       .mockReturnValue(null);
 
@@ -99,19 +95,46 @@ describe('PageService page operations with only public pages', () => {
      * Common
      */
 
-    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
-    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
-
-    generalXssFilterProcessSpy = jest.spyOn(generalXssFilter, 'process');
+    // Ensure root page exists
+    const existingRootPage = await Page.findOne({ path: '/' });
+    if (existingRootPage == null) {
+      const rootPageId = new mongoose.Types.ObjectId();
+      rootPage = await Page.create({
+        _id: rootPageId,
+        path: '/',
+        grant: Page.GRANT_PUBLIC,
+      });
+    } else {
+      rootPage = existingRootPage;
+    }
 
-    rootPage = (await Page.findOne({ path: '/' }))!;
-    if (rootPage == null) {
-      const pages = await Page.insertMany([
-        { path: '/', grant: Page.GRANT_PUBLIC },
+    // Create dummy users if they don't exist
+    const existingUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    if (existingUser1 == null) {
+      await User.insertMany([
+        {
+          name: 'v5DummyUser1',
+          username: 'v5DummyUser1',
+          email: 'v5dummyuser1@example.com',
+        },
+      ]);
+    }
+    const existingUser2 = await User.findOne({ username: 'v5DummyUser2' });
+    if (existingUser2 == null) {
+      await User.insertMany([
+        {
+          name: 'v5DummyUser2',
+          username: 'v5DummyUser2',
+          email: 'v5dummyuser2@example.com',
+        },
       ]);
-      rootPage = pages[0];
     }
 
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+
+    generalXssFilterProcessSpy = vi.spyOn(generalXssFilter, 'process');
+
     /**
      * create
      * mc_ => model create
@@ -1139,8 +1162,8 @@ describe('PageService page operations with only public pages', () => {
   });
 
   describe('create', () => {
-    test('Should create single page', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create single page', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1151,8 +1174,8 @@ describe('PageService page operations with only public pages', () => {
       expect(isGrantNormalizedSpy).toBeCalledTimes(1);
     });
 
-    test('Should create empty-child and non-empty grandchild', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create empty-child and non-empty grandchild', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1173,8 +1196,8 @@ describe('PageService page operations with only public pages', () => {
       expect(isGrantNormalizedSpy).toBeCalledTimes(1);
     });
 
-    test('Should create on empty page', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create on empty page', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1203,8 +1226,8 @@ describe('PageService page operations with only public pages', () => {
   });
 
   describe('create by system', () => {
-    test('Should create single page by system', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create single page by system', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1219,8 +1242,8 @@ describe('PageService page operations with only public pages', () => {
       expect(isGrantNormalizedSpy).toBeCalledTimes(0);
     });
 
-    test('Should create empty-child and non-empty grandchild', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create empty-child and non-empty grandchild', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1242,8 +1265,8 @@ describe('PageService page operations with only public pages', () => {
       expect(isGrantNormalizedSpy).toBeCalledTimes(0);
     });
 
-    test('Should create on empty page', async () => {
-      const isGrantNormalizedSpy = jest.spyOn(
+    it('Should create on empty page', async () => {
+      const isGrantNormalizedSpy = vi.spyOn(
         crowi.pageGrantService,
         'isGrantNormalized',
       );
@@ -1271,6 +1294,24 @@ describe('PageService page operations with only public pages', () => {
   });
 
   describe('Rename', () => {
+    // Helper to wait for async operation to complete by checking PageOperation is deleted
+    const waitForPageOperationComplete = async (
+      fromPath: string,
+      maxWaitMs = 5000,
+    ) => {
+      const startTime = Date.now();
+      while (Date.now() - startTime < maxWaitMs) {
+        const op = await PageOperation.findOne({ fromPath });
+        if (op == null) {
+          return; // Operation completed
+        }
+        await new Promise((resolve) => setTimeout(resolve, 50));
+      }
+      throw new Error(
+        `PageOperation for ${fromPath} did not complete within ${maxWaitMs}ms`,
+      );
+    };
+
     const renamePage = async (
       page,
       newPagePath,
@@ -1278,10 +1319,7 @@ describe('PageService page operations with only public pages', () => {
       options,
       activityParameters?,
     ) => {
-      // mock return value
-      const mockedRenameSubOperation = jest
-        .spyOn(crowi.pageService, 'renameSubOperation')
-        .mockReturnValue(null);
+      const fromPath = page.path;
       const renamedPage = await crowi.pageService.renamePage(
         page,
         newPagePath,
@@ -1290,18 +1328,9 @@ describe('PageService page operations with only public pages', () => {
         activityParameters,
       );
 
-      // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
-      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
-
-      // restores the original implementation
-      mockedRenameSubOperation.mockRestore();
-
-      // rename descendants
-      await crowi.pageService.renameSubOperation(
-        ...(argsForRenameSubOperation as Parameters<
-          typeof crowi.pageService.renameSubOperation
-        >),
-      );
+      // Wait for the async renameSubOperation to complete
+      // renameSubOperation is called without await in production, so we need to wait for it
+      await waitForPageOperationComplete(fromPath);
 
       return renamedPage;
     };
@@ -1328,7 +1357,7 @@ describe('PageService page operations with only public pages', () => {
       });
 
       // mock return value
-      const mockedRenameSubOperation = jest
+      const mockedRenameSubOperation = vi
         .spyOn(crowi.pageService, 'renameSubOperation')
         .mockReturnValue(null);
       const renamedPage = await crowi.pageService.renameMainOperation(
@@ -1346,7 +1375,7 @@ describe('PageService page operations with only public pages', () => {
       return renamedPage;
     };
 
-    test('Should NOT rename top page', async () => {
+    it('Should NOT rename top page', async () => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
       try {
@@ -1367,7 +1396,7 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
 
-    test('Should rename/move to under non-empty page', async () => {
+    it('Should rename/move to under non-empty page', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename1' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename1' });
       expect(childPage).toBeTruthy();
@@ -1394,7 +1423,7 @@ describe('PageService page operations with only public pages', () => {
       expect(childPageBeforeRename).toBeNull();
     });
 
-    test('Should rename/move to under empty page', async () => {
+    it('Should rename/move to under empty page', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename2' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename2' });
       expect(childPage).toBeTruthy();
@@ -1423,7 +1452,7 @@ describe('PageService page operations with only public pages', () => {
       expect(childPageBeforeRename).toBeNull();
     });
 
-    test('Should rename/move with option updateMetadata: true', async () => {
+    it('Should rename/move with option updateMetadata: true', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename3' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename3' });
       expect(childPage).toBeTruthy();
@@ -1452,7 +1481,7 @@ describe('PageService page operations with only public pages', () => {
       );
     });
 
-    test('Should move with option createRedirectPage: true', async () => {
+    it('Should move with option createRedirectPage: true', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename4' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename4' });
       expect(parentPage).toBeTruthy();
@@ -1481,7 +1510,7 @@ describe('PageService page operations with only public pages', () => {
       expect(pageRedirect).toBeTruthy();
     });
 
-    test('Should rename/move with descendants', async () => {
+    it('Should rename/move with descendants', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename5' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename5' });
       const grandchild = await Page.findOne({
@@ -1525,7 +1554,7 @@ describe('PageService page operations with only public pages', () => {
       );
     });
 
-    test('Should rename/move empty page', async () => {
+    it('Should rename/move empty page', async () => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename7' });
       const childPage = await Page.findOne({
         path: '/v5_ChildForRename7',
@@ -1569,7 +1598,7 @@ describe('PageService page operations with only public pages', () => {
         '/v5_ParentForRename7/renamedChildForRename7/v5_GrandchildForRename7',
       );
     });
-    test('Should NOT rename/move with existing path', async () => {
+    it('Should NOT rename/move with existing path', async () => {
       const page = await Page.findOne({ path: '/v5_ParentForRename8' });
       expect(page).toBeTruthy();
 
@@ -1592,7 +1621,7 @@ describe('PageService page operations with only public pages', () => {
 
       expect(isThrown).toBe(true);
     });
-    test('Should rename/move to the path that exists as an empty page', async () => {
+    it('Should rename/move to the path that exists as an empty page', async () => {
       const page = await Page.findOne({ path: '/v5_ParentForRename10' });
       const pageDistination = await Page.findOne({
         path: '/v5_ParentForRename11',
@@ -1619,7 +1648,7 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPage.isEmpty).toBe(false);
       expect(renamedPage._id).toStrictEqual(page?._id);
     });
-    test('Rename non-empty page path to its descendant non-empty page path', async () => {
+    it('Rename non-empty page path to its descendant non-empty page path', async () => {
       const initialPathForPage1 = '/v5_pageForRename17';
       const initialPathForPage2 = '/v5_pageForRename17/v5_pageForRename18';
       const page1 = await Page.findOne({
@@ -1681,7 +1710,7 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageChild?.isEmpty).toBe(false);
     });
 
-    test('Rename empty page path to its descendant non-empty page path', async () => {
+    it('Rename empty page path to its descendant non-empty page path', async () => {
       const initialPathForPage1 = '/v5_pageForRename19';
       const initialPathForPage2 = '/v5_pageForRename19/v5_pageForRename20';
       const page1 = await Page.findOne({
@@ -1743,7 +1772,7 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageChild?.isEmpty).toBe(false);
     });
 
-    test('Rename the path of a non-empty page to its grandchild page path that has an empty parent', async () => {
+    it('Rename the path of a non-empty page to its grandchild page path that has an empty parent', async () => {
       const initialPathForPage1 = '/v5_pageForRename21';
       const initialPathForPage2 = '/v5_pageForRename21/v5_pageForRename22';
       const initialPathForPage3 =
@@ -1832,7 +1861,7 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageGrandchild?.isEmpty).toBe(false);
     });
 
-    test('should add 1 descendantCount to parent page in MainOperation', async () => {
+    it('should add 1 descendantCount to parent page in MainOperation', async () => {
       // paths before renaming
       const _path0 = '/v5_pageForRename24'; // out of renaming scope
       const _path1 = '/v5_pageForRename25'; // not renamed yet
@@ -1869,7 +1898,7 @@ describe('PageService page operations with only public pages', () => {
       await PageOperation.findOneAndDelete({ fromPath: _path1 });
     });
 
-    test('should subtract 1 descendantCount from a new parent page in renameSubOperation', async () => {
+    it('should subtract 1 descendantCount from a new parent page in renameSubOperation', async () => {
       // paths before renaming
       const _path0 = '/v5_pageForRename29'; // out of renaming scope
       const _path1 = '/v5_pageForRename29/v5_pageForRename30'; // already renamed
@@ -1988,11 +2017,29 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Duplicate', () => {
+    // Helper to wait for async operation to complete by checking PageOperation is deleted
+    const waitForDuplicateOperationComplete = async (
+      fromPath: string,
+      maxWaitMs = 5000,
+    ) => {
+      const startTime = Date.now();
+      while (Date.now() - startTime < maxWaitMs) {
+        const op = await PageOperation.findOne({
+          fromPath,
+          actionType: PageActionType.Duplicate,
+        });
+        if (op == null) {
+          return; // Operation completed
+        }
+        await new Promise((resolve) => setTimeout(resolve, 50));
+      }
+      throw new Error(
+        `PageOperation for duplicate ${fromPath} did not complete within ${maxWaitMs}ms`,
+      );
+    };
+
     const duplicate = async (page, newPagePath, user, isRecursively) => {
-      // mock return value
-      const mockedDuplicateRecursivelyMainOperation = jest
-        .spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation')
-        .mockReturnValue(null);
+      const fromPath = page.path;
       const duplicatedPage = await crowi.pageService.duplicate(
         page,
         newPagePath,
@@ -2001,26 +2048,15 @@ describe('PageService page operations with only public pages', () => {
         false,
       );
 
-      // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
-      const argsForDuplicateRecursivelyMainOperation =
-        mockedDuplicateRecursivelyMainOperation.mock.calls[0];
-
-      // restores the original implementation
-      mockedDuplicateRecursivelyMainOperation.mockRestore();
-
-      // duplicate descendants
+      // Wait for the async duplicateRecursivelyMainOperation to complete
       if (isRecursively) {
-        await crowi.pageService.duplicateRecursivelyMainOperation(
-          ...(argsForDuplicateRecursivelyMainOperation as Parameters<
-            typeof crowi.pageService.duplicateRecursivelyMainOperation
-          >),
-        );
+        await waitForDuplicateOperationComplete(fromPath);
       }
 
       return duplicatedPage;
     };
 
-    test('Should duplicate single page', async () => {
+    it('Should duplicate single page', async () => {
       const page = await Page.findOne({ path: '/v5_PageForDuplicate1' });
       expect(page).toBeTruthy();
 
@@ -2045,7 +2081,7 @@ describe('PageService page operations with only public pages', () => {
       expect(duplicatedRevision?.body).toEqual(baseRevision?.body);
     });
 
-    test('Should NOT duplicate single empty page', async () => {
+    it('Should NOT duplicate single empty page', async () => {
       const page = await Page.findOne({ path: '/v5_PageForDuplicate2' });
       expect(page).toBeTruthy();
 
@@ -2062,7 +2098,7 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
 
-    test('Should duplicate to the path that exists as an empty page', async () => {
+    it('Should duplicate to the path that exists as an empty page', async () => {
       const page = await Page.findOne({ path: '/v5_PageForDuplicate1' });
       expect(page).toBeTruthy();
 
@@ -2087,7 +2123,7 @@ describe('PageService page operations with only public pages', () => {
       expect(duplicatedRevision?.body).toEqual(baseRevision?.body);
     });
 
-    test('Should duplicate multiple pages', async () => {
+    it('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({
@@ -2149,7 +2185,7 @@ describe('PageService page operations with only public pages', () => {
       );
     });
 
-    test('Should duplicate multiple pages with empty child in it', async () => {
+    it('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,
@@ -2189,7 +2225,7 @@ describe('PageService page operations with only public pages', () => {
       expect(duplicatedChild?.parent).toStrictEqual(duplicatedPage?._id);
     });
 
-    test('Should duplicate tags', async () => {
+    it('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' });
@@ -2221,7 +2257,7 @@ describe('PageService page operations with only public pages', () => {
       expect(duplicatedTagRelations.length).toBeGreaterThanOrEqual(2);
     });
 
-    test('Should NOT duplicate comments', async () => {
+    it('Should NOT duplicate comments', async () => {
       const basePage = await Page.findOne({ path: '/v5_PageForDuplicate6' });
       const basePageComments = await Comment.find({ page: basePage?._id });
       expect(basePage).toBeTruthy();
@@ -2243,7 +2279,7 @@ describe('PageService page operations with only public pages', () => {
       expect(basePageComments.length).not.toBe(duplicatedComments.length);
     });
 
-    test('Should duplicate empty page with descendants', async () => {
+    it('Should duplicate empty page with descendants', async () => {
       const basePage = await Page.findOne({
         path: '/v5_empty_PageForDuplicate7',
       });
@@ -2309,6 +2345,27 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete', () => {
+    // Helper to wait for async operation to complete by checking PageOperation is deleted
+    const waitForDeleteOperationComplete = async (
+      fromPath: string,
+      maxWaitMs = 5000,
+    ) => {
+      const startTime = Date.now();
+      while (Date.now() - startTime < maxWaitMs) {
+        const op = await PageOperation.findOne({
+          fromPath,
+          actionType: PageActionType.Delete,
+        });
+        if (op == null) {
+          return; // Operation completed
+        }
+        await new Promise((resolve) => setTimeout(resolve, 50));
+      }
+      throw new Error(
+        `PageOperation for delete ${fromPath} did not complete within ${maxWaitMs}ms`,
+      );
+    };
+
     const deletePage = async (
       page,
       user,
@@ -2316,10 +2373,7 @@ describe('PageService page operations with only public pages', () => {
       isRecursively,
       activityParameters?,
     ) => {
-      const mockedDeleteRecursivelyMainOperation = jest
-        .spyOn(crowi.pageService, 'deleteRecursivelyMainOperation')
-        .mockReturnValue(null);
-
+      const fromPath = page.path;
       const deletedPage = await crowi.pageService.deletePage(
         page,
         user,
@@ -2328,23 +2382,15 @@ describe('PageService page operations with only public pages', () => {
         activityParameters,
       );
 
-      const argsForDeleteRecursivelyMainOperation =
-        mockedDeleteRecursivelyMainOperation.mock.calls[0];
-
-      mockedDeleteRecursivelyMainOperation.mockRestore();
-
+      // Wait for the async deleteRecursivelyMainOperation to complete
       if (isRecursively) {
-        await crowi.pageService.deleteRecursivelyMainOperation(
-          ...(argsForDeleteRecursivelyMainOperation as Parameters<
-            typeof crowi.pageService.deleteRecursivelyMainOperation
-          >),
-        );
+        await waitForDeleteOperationComplete(fromPath);
       }
 
       return deletedPage;
     };
 
-    test('Should NOT delete root page', async () => {
+    it('Should NOT delete root page', async () => {
       let isThrown = false;
       expect(rootPage).toBeTruthy();
       try {
@@ -2362,7 +2408,7 @@ describe('PageService page operations with only public pages', () => {
       expect(page).toBeTruthy();
     });
 
-    test('Should NOT delete trashed page', async () => {
+    it('Should NOT delete trashed page', async () => {
       const trashedPage = await Page.findOne({
         path: '/trash/v5_PageForDelete1',
       });
@@ -2384,7 +2430,7 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
 
-    test('Should NOT delete /user/hoge page', async () => {
+    it('Should NOT delete /user/hoge page', async () => {
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
       expect(dummyUser1Page).toBeTruthy();
       let isThrown = false;
@@ -2403,7 +2449,7 @@ describe('PageService page operations with only public pages', () => {
       expect(isThrown).toBe(true);
     });
 
-    test('Should delete single page', async () => {
+    it('Should delete single page', async () => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
       expect(pageToDelete).toBeTruthy();
       const deletedPage = await deletePage(
@@ -2424,7 +2470,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedPage.status).toBe(Page.STATUS_DELETED);
     });
 
-    test('Should delete multiple pages including empty child', async () => {
+    it('Should delete multiple pages including empty child', async () => {
       const parentPage = await Page.findOne({ path: '/v5_PageForDelete3' });
       const childPage = await Page.findOne({
         path: '/v5_PageForDelete3/v5_PageForDelete4',
@@ -2464,7 +2510,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedGrandchildPage?.parent).toBeNull();
     });
 
-    test('Should delete page tag relation', async () => {
+    it('Should delete page tag relation', async () => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete6' });
       const tag1 = await Tag.findOne({ name: 'TagForDelete1' });
       const tag2 = await Tag.findOne({ name: 'TagForDelete2' });
@@ -2506,6 +2552,27 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete completely', () => {
+    // Helper to wait for async operation to complete by checking PageOperation is deleted
+    const waitForDeleteCompletelyOperationComplete = async (
+      fromPath: string,
+      maxWaitMs = 5000,
+    ) => {
+      const startTime = Date.now();
+      while (Date.now() - startTime < maxWaitMs) {
+        const op = await PageOperation.findOne({
+          fromPath,
+          actionType: PageActionType.DeleteCompletely,
+        });
+        if (op == null) {
+          return; // Operation completed
+        }
+        await new Promise((resolve) => setTimeout(resolve, 50));
+      }
+      throw new Error(
+        `PageOperation for deleteCompletely ${fromPath} did not complete within ${maxWaitMs}ms`,
+      );
+    };
+
     const deleteCompletely = async (
       page,
       user,
@@ -2514,10 +2581,7 @@ describe('PageService page operations with only public pages', () => {
       preventEmitting = false,
       activityParameters?,
     ) => {
-      const mockedDeleteCompletelyRecursivelyMainOperation = jest
-        .spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation')
-        .mockReturnValue(null);
-
+      const fromPath = page.path;
       await crowi.pageService.deleteCompletely(
         page,
         user,
@@ -2527,23 +2591,15 @@ describe('PageService page operations with only public pages', () => {
         activityParameters,
       );
 
-      const argsForDeleteCompletelyRecursivelyMainOperation =
-        mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
-
-      mockedDeleteCompletelyRecursivelyMainOperation.mockRestore();
-
+      // Wait for the async deleteCompletelyRecursivelyMainOperation to complete
       if (isRecursively) {
-        await crowi.pageService.deleteCompletelyRecursivelyMainOperation(
-          ...(argsForDeleteCompletelyRecursivelyMainOperation as Parameters<
-            typeof crowi.pageService.deleteCompletelyRecursivelyMainOperation
-          >),
-        );
+        await waitForDeleteCompletelyOperationComplete(fromPath);
       }
 
       return;
     };
 
-    test('Should NOT completely delete root page', async () => {
+    it('Should NOT completely delete root page', async () => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
       try {
@@ -2558,7 +2614,7 @@ describe('PageService page operations with only public pages', () => {
       expect(page).toBeTruthy();
       expect(isThrown).toBe(true);
     });
-    test('Should completely delete single page', async () => {
+    it('Should completely delete single page', async () => {
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
       expect(page).toBeTruthy();
 
@@ -2573,7 +2629,7 @@ describe('PageService page operations with only public pages', () => {
 
       expect(deletedPage).toBeNull();
     });
-    test('Should completely delete multiple pages', async () => {
+    it('Should completely delete multiple pages', async () => {
       const parentPage = await Page.findOne({
         path: '/v5_PageForDeleteCompletely2',
       });
@@ -2659,7 +2715,7 @@ describe('PageService page operations with only public pages', () => {
       // sharelink should be null
       expect(deletedShareLinks.length).toBe(0);
     });
-    test('Should completely delete trashed page', async () => {
+    it('Should completely delete trashed page', async () => {
       const page = await Page.findOne({
         path: '/trash/v5_PageForDeleteCompletely5',
       });
@@ -2676,7 +2732,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deltedPage).toBeNull();
       expect(deltedRevision).toBeNull();
     });
-    test('Should completely deleting page in the middle results in having an empty page', async () => {
+    it('Should completely deleting page in the middle results in having an empty page', async () => {
       const parentPage = await Page.findOne({
         path: '/v5_PageForDeleteCompletely6',
       });
@@ -2726,7 +2782,7 @@ describe('PageService page operations with only public pages', () => {
       activityParameters?,
     ) => {
       // mock return value
-      const mockedRevertRecursivelyMainOperation = jest
+      const mockedRevertRecursivelyMainOperation = vi
         .spyOn(crowi.pageService, 'revertRecursivelyMainOperation')
         .mockReturnValue(null);
       const revertedPage = await crowi.pageService.revertDeletedPage(
@@ -2753,7 +2809,7 @@ describe('PageService page operations with only public pages', () => {
       return revertedPage;
     };
 
-    test('revert single deleted page', async () => {
+    it('revert single deleted page', async () => {
       const deletedPage = await Page.findOne({
         path: '/trash/v5_revert1',
         status: Page.STATUS_DELETED,
@@ -2791,7 +2847,7 @@ describe('PageService page operations with only public pages', () => {
       expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
 
-    test('revert multiple deleted page (has non existent page in the middle)', async () => {
+    it('revert multiple deleted page (has non existent page in the middle)', async () => {
       const deletedPage1 = await Page.findOne({
         path: '/trash/v5_revert2',
         status: Page.STATUS_DELETED,