Yuki Takei 6 miesięcy temu
rodzic
commit
0535640e09

+ 302 - 11
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -2,16 +2,35 @@ import type { IPage, IUser } from '@growi/core/dist/interfaces';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
 
 
+import { PageActionType } from '~/interfaces/page-operation';
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
+import type { IPageOperation } from '~/server/models/page-operation';
 
 
 import { pageListingService } from './page-listing';
 import { pageListingService } from './page-listing';
 
 
 describe('page-listing store integration tests', () => {
 describe('page-listing store integration tests', () => {
   let Page: PageModel;
   let Page: PageModel;
   let User: Model<IUser>;
   let User: Model<IUser>;
+  let PageOperation: Model<IPageOperation>;
   let testUser: HydratedDocument<IUser>;
   let testUser: HydratedDocument<IUser>;
   let rootPage: HydratedDocument<IPage>;
   let rootPage: HydratedDocument<IPage>;
 
 
+  // Helper function to validate IPageForTreeItem type structure
+  const validatePageForTreeItem = (page: any): void => {
+    expect(page).toBeDefined();
+    expect(page._id).toBeDefined();
+    expect(typeof page.path).toBe('string');
+    expect(page.grant).toBeDefined();
+    expect(typeof page.isEmpty).toBe('boolean');
+    expect(typeof page.descendantCount).toBe('number');
+    expect(page.createdAt).toBeDefined();
+    expect(page.updatedAt).toBeDefined();
+    // processData is optional
+    if (page.processData !== undefined) {
+      expect(page.processData).toBeInstanceOf(Object);
+    }
+  };
+
   beforeAll(async() => {
   beforeAll(async() => {
     // setup models
     // setup models
     const setupPage = (await import('~/server/models/page')).default;
     const setupPage = (await import('~/server/models/page')).default;
@@ -22,12 +41,14 @@ describe('page-listing store integration tests', () => {
     // get models
     // get models
     Page = mongoose.model<IPage, PageModel>('Page');
     Page = mongoose.model<IPage, PageModel>('Page');
     User = mongoose.model<IUser>('User');
     User = mongoose.model<IUser>('User');
+    PageOperation = (await import('~/server/models/page-operation')).default;
   });
   });
 
 
   beforeEach(async() => {
   beforeEach(async() => {
     // Clean up database
     // Clean up database
     await Page.deleteMany({});
     await Page.deleteMany({});
     await User.deleteMany({});
     await User.deleteMany({});
+    await PageOperation.deleteMany({});
 
 
     // Create test user
     // Create test user
     testUser = await User.create({
     testUser = await User.create({
@@ -75,25 +96,295 @@ describe('page-listing store integration tests', () => {
       }
       }
     });
     });
 
 
-    test('should return proper page structure', async() => {
+    test('should return proper page structure that matches IPageForTreeItem type', async() => {
       const rootPageResult = await pageListingService.findRootByViewer(testUser);
       const rootPageResult = await pageListingService.findRootByViewer(testUser);
 
 
-      // Verify required fields are present
-      expect(rootPageResult._id).toBeDefined();
-      expect(rootPageResult.path).toBeDefined();
-      expect(rootPageResult.grant).toBeDefined();
-      expect(typeof rootPageResult.isEmpty).toBe('boolean');
-      expect(typeof rootPageResult.descendantCount).toBe('number');
-      expect(rootPageResult.createdAt).toBeDefined();
-      expect(rootPageResult.updatedAt).toBeDefined();
+      // Use helper function to validate type structure
+      validatePageForTreeItem(rootPageResult);
+
+      // Additional type-specific validations
+      expect(typeof rootPageResult._id).toBe('object'); // ObjectId
+      expect(rootPageResult.path).toBe('/');
+      expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
+      expect(rootPageResult.parent).toBeNull(); // Root page has no parent
     });
     });
 
 
-    test('should work without user (guest access)', async() => {
+    test('should work without user (guest access) and return type-safe result', async() => {
       const rootPageResult = await pageListingService.findRootByViewer();
       const rootPageResult = await pageListingService.findRootByViewer();
 
 
-      expect(rootPageResult).toBeDefined();
+      validatePageForTreeItem(rootPageResult);
       expect(rootPageResult.path).toBe('/');
       expect(rootPageResult.path).toBe('/');
       expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
       expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
     });
     });
   });
   });
+
+  describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
+    let childPage1: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create child pages
+      childPage1 = await Page.create({
+        path: '/child1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 1,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/child2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create grandchild page
+      await Page.create({
+        path: '/child1/grandchild',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: childPage1._id,
+      });
+
+      // Update root page descendant count
+      await Page.updateOne(
+        { _id: rootPage._id },
+        { descendantCount: 2 },
+      );
+    });
+
+    test('should find children by parent path and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+        expect(['/child1', '/child2']).toContain(child.path);
+      });
+    });
+
+    test('should find children by parent ID and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+      });
+    });
+
+    test('should handle nested children correctly', async() => {
+      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+
+      expect(nestedChildren).toHaveLength(1);
+      const grandChild = nestedChildren[0];
+      validatePageForTreeItem(grandChild);
+      expect(grandChild.path).toBe('/child1/grandchild');
+      expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
+    });
+
+    test('should return empty array when no children exist', async() => {
+      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+
+      expect(noChildren).toHaveLength(0);
+      expect(Array.isArray(noChildren)).toBe(true);
+    });
+
+    test('should work without user (guest access)', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+      });
+    });
+
+    test('should sort children by path in ascending order', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      expect(children[0].path).toBe('/child1');
+      expect(children[1].path).toBe('/child2');
+    });
+  });
+
+  describe('pageListingService processData injection', () => {
+    let operatingPage: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create a page that will have operations
+      operatingPage = await Page.create({
+        path: '/operating-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create a PageOperation for this page
+      await PageOperation.create({
+        actionType: PageActionType.Rename,
+        page: operatingPage._id,
+        user: testUser._id,
+        fromPath: '/operating-page',
+        toPath: '/renamed-operating-page',
+        options: {},
+        actionsOnFinishingProcess: [],
+      });
+    });
+
+    test('should inject processData for pages with operations', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // Find the operating page in results
+      const operatingResult = children.find(child => child.path === '/operating-page');
+      expect(operatingResult).toBeDefined();
+
+      // Validate type structure
+      if (operatingResult) {
+        validatePageForTreeItem(operatingResult);
+
+        // Check that processData was injected
+        expect(operatingResult.processData).toBeDefined();
+        expect(operatingResult.processData).toBeInstanceOf(Object);
+      }
+    });
+
+    test('should set processData to undefined for pages without operations', async() => {
+      // Create another page without operations
+      await Page.create({
+        path: '/normal-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const normalPage = children.find(child => child.path === '/normal-page');
+
+      expect(normalPage).toBeDefined();
+      if (normalPage) {
+        validatePageForTreeItem(normalPage);
+        expect(normalPage.processData).toBeUndefined();
+      }
+    });
+
+    test('should maintain type safety with mixed processData scenarios', async() => {
+      // Create pages with and without operations
+      await Page.create({
+        path: '/mixed-test-1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/mixed-test-2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // All results should be type-safe regardless of processData presence
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+
+        // processData should be either undefined or a valid object
+        if (child.processData !== undefined) {
+          expect(child.processData).toBeInstanceOf(Object);
+        }
+      });
+    });
+  });
+
+  describe('PageQueryBuilder exec() type safety tests', () => {
+    test('findRootByViewer should return object with correct _id type', async() => {
+      const result = await pageListingService.findRootByViewer(testUser);
+
+      // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
+      expect(result._id).toBeDefined();
+      expect(result._id.toString).toBeDefined();
+      expect(typeof result._id.toString()).toBe('string');
+      expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
+    });
+
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+      // Create test child page first
+      await Page.create({
+        path: '/test-child',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(Array.isArray(results)).toBe(true);
+      results.forEach((result) => {
+        // Validate _id behavior from exec() any return type
+        expect(result._id).toBeDefined();
+        expect(result._id.toString).toBeDefined();
+        expect(typeof result._id.toString()).toBe('string');
+        expect(result._id.toString().length).toBe(24);
+
+        // Validate parent _id behavior
+        if (result.parent) {
+          expect(result.parent.toString).toBeDefined();
+          expect(typeof result.parent.toString()).toBe('string');
+          expect(result.parent.toString().length).toBe(24);
+        }
+      });
+    });
+
+    test('should validate all required IPageForTreeItem fields are present and correctly typed', async() => {
+      const result = await pageListingService.findRootByViewer(testUser);
+
+      // These tests ensure that the 'any' return from exec() contains expected structure
+      const requiredFields = ['_id', 'path', 'grant', 'isEmpty', 'descendantCount', 'createdAt', 'updatedAt'];
+
+      requiredFields.forEach((field) => {
+        expect(result[field]).toBeDefined();
+      });
+
+      // Type-specific validations
+      expect(typeof result.path).toBe('string');
+      expect(typeof result.isEmpty).toBe('boolean');
+      expect(typeof result.descendantCount).toBe('number');
+      expect(result.createdAt instanceof Date || typeof result.createdAt === 'string').toBe(true);
+      expect(result.updatedAt instanceof Date || typeof result.updatedAt === 'string').toBe(true);
+    });
+  });
 });
 });