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

feat(suggest-path): add ancestor path traversal to grant resolver

Enhance resolveParentGrant to traverse upward through ancestor paths
when the direct parent page doesn't exist, enabling grant resolution
for newly generated paths (sibling pattern in Phase 2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 месяц назад
Родитель
Сommit
894ed62e37

+ 1 - 1
.kiro/specs/suggest-path/tasks.md

@@ -29,7 +29,7 @@
 
 
 ## Phase 2 — Revised
 ## Phase 2 — Revised
 
 
-- [ ] 2. (P) Enhance grant resolver for ancestor path traversal
+- [x] 2. (P) Enhance grant resolver for ancestor path traversal
   - Enhance the existing grant resolution to support paths that may not yet exist in GROWI, as required by the sibling pattern where new directory names are generated
   - Enhance the existing grant resolution to support paths that may not yet exist in GROWI, as required by the sibling pattern where new directory names are generated
   - When the direct parent page exists, return its grant value as the upper bound for child page permissions
   - When the direct parent page exists, return its grant value as the upper bound for child page permissions
   - When the direct parent page is not found, traverse upward through ancestor paths to find the nearest existing page's grant
   - When the direct parent page is not found, traverse upward through ancestor paths to find the nearest existing page's grant

+ 75 - 2
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.spec.ts

@@ -56,9 +56,82 @@ describe('resolveParentGrant', () => {
     });
     });
   });
   });
 
 
-  describe('when parent page does not exist', () => {
+  describe('ancestor path traversal', () => {
+    it('should find ancestor grant when direct parent does not exist', async () => {
+      // /tech-notes/React/state-management → null, /tech-notes/React → found
+      mocks.findOneMock.mockImplementation((query: { path: string }) => ({
+        lean: vi
+          .fn()
+          .mockResolvedValue(
+            query.path === '/tech-notes/React' ? { grant: GRANT_PUBLIC } : null,
+          ),
+      }));
+
+      const result = await resolveParentGrant(
+        '/tech-notes/React/state-management/',
+      );
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should traverse multiple levels to find ancestor grant', async () => {
+      // /a/b/c/d → null, /a/b/c → null, /a/b → null, /a → found
+      mocks.findOneMock.mockImplementation((query: { path: string }) => ({
+        lean: vi
+          .fn()
+          .mockResolvedValue(
+            query.path === '/a' ? { grant: GRANT_USER_GROUP } : null,
+          ),
+      }));
+
+      const result = await resolveParentGrant('/a/b/c/d/');
+      expect(result).toBe(GRANT_USER_GROUP);
+    });
+
+    it('should find root page grant when no intermediate ancestor exists', async () => {
+      // /nonexistent/deep → null, /nonexistent → null, / → found
+      mocks.findOneMock.mockImplementation((query: { path: string }) => ({
+        lean: vi
+          .fn()
+          .mockResolvedValue(
+            query.path === '/' ? { grant: GRANT_PUBLIC } : null,
+          ),
+      }));
+
+      const result = await resolveParentGrant('/nonexistent/deep/');
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should return GRANT_OWNER when no ancestor exists at any level', async () => {
+      mocks.findOneMock.mockImplementation(() => ({
+        lean: vi.fn().mockResolvedValue(null),
+      }));
+
+      const result = await resolveParentGrant('/nonexistent/deep/path/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+
+    it('should stop at direct parent when it exists without further traversal', async () => {
+      mocks.findOneMock.mockImplementation((query: { path: string }) => ({
+        lean: vi
+          .fn()
+          .mockResolvedValue(
+            query.path === '/tech-notes/React/hooks'
+              ? { grant: GRANT_USER_GROUP }
+              : { grant: GRANT_PUBLIC },
+          ),
+      }));
+
+      const result = await resolveParentGrant('/tech-notes/React/hooks/');
+      expect(result).toBe(GRANT_USER_GROUP);
+      expect(mocks.findOneMock).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('when no ancestor page exists', () => {
     it('should return GRANT_OWNER (4) as safe default', async () => {
     it('should return GRANT_OWNER (4) as safe default', async () => {
-      mocks.leanMock.mockResolvedValue(null);
+      mocks.findOneMock.mockImplementation(() => ({
+        lean: vi.fn().mockResolvedValue(null),
+      }));
 
 
       const result = await resolveParentGrant('/memo/bob/');
       const result = await resolveParentGrant('/memo/bob/');
       expect(result).toBe(GRANT_OWNER);
       expect(result).toBe(GRANT_OWNER);

+ 23 - 8
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.ts

@@ -1,15 +1,30 @@
 import { PageGrant } from '@growi/core';
 import { PageGrant } from '@growi/core';
-import mongoose from 'mongoose';
+import mongoose, { type Model } from 'mongoose';
 
 
-export const resolveParentGrant = async (dirPath: string): Promise<number> => {
-  const pagePath = dirPath.replace(/\/$/, '') || '/';
+type PageWithGrant = { grant: number };
 
 
-  const Page = mongoose.model('Page');
-  const page = await Page.findOne({ path: pagePath }).lean();
+const findGrantInAncestors = async (
+  Page: Model<PageWithGrant>,
+  path: string,
+): Promise<number | null> => {
+  const page = await Page.findOne({ path }).lean();
 
 
-  if (page == null) {
-    return PageGrant.GRANT_OWNER;
+  if (page != null) {
+    return page.grant;
   }
   }
 
 
-  return (page as { grant: number }).grant;
+  if (path === '/') {
+    return null;
+  }
+
+  const parentPath = path.slice(0, path.lastIndexOf('/')) || '/';
+  return findGrantInAncestors(Page, parentPath);
+};
+
+export const resolveParentGrant = async (dirPath: string): Promise<number> => {
+  const Page = mongoose.model('Page');
+  const pagePath = dirPath.replace(/\/$/, '') || '/';
+
+  const grant = await findGrantInAncestors(Page, pagePath);
+  return grant ?? PageGrant.GRANT_OWNER;
 };
 };