Przeglądaj źródła

test(suggest-path): add comprehensive Phase 2 integration verification

Expand integration tests to cover the complete revised Phase 2 pipeline:
- informationType presence on search, absence on memo/category
- Path proposal patterns (parent, subdirectory, sibling with hierarchy check)
- Graceful degradation for all failure combinations
- Description type verification (fixed/AI-generated/mechanical)
- Valid PageGrant values and per-path grant resolution

Refactor mocking to control each pipeline component independently
via testState (retrieveSearchCandidates, generateCategorySuggestion).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 miesiąc temu
rodzic
commit
05569c87dd

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

@@ -78,7 +78,7 @@
   - Include unit tests for: top-level segment extraction, description generation, grant resolution, and empty result handling
   - _Requirements: 4.1, 4.2, 4.3, 4.4_
 
-- [ ] 7. Phase 2 revised orchestration and integration
+- [x] 7. Phase 2 revised orchestration and integration
 - [x] 7.1 Rewrite orchestration for revised Phase 2 pipeline
   - Rewrite the orchestration function to implement the revised Phase 2 pipeline: always generate memo suggestion first as guaranteed fallback, then invoke content analysis (1st AI call), pass keywords to search candidate retrieval, pass candidates to candidate evaluation (2nd AI call), and run category generation in parallel with the search-evaluate pipeline
   - After candidate evaluation returns, resolve grant for each proposed path via grant resolver
@@ -91,7 +91,7 @@
   - Include unit tests for: full pipeline success with all suggestion types, partial failures at each stage with correct degradation, informationType mapping to PathSuggestion, dependency injection, and parallel execution of category vs search-evaluate pipeline
   - _Requirements: 1.1, 1.2, 1.3, 3.3, 3.4, 5.3, 5.5, 8.3, 9.2, 11.4, 13.1, 13.2, 13.3_
 
-- [ ] 7.2 Phase 2 integration verification
+- [x] 7.2 Phase 2 integration verification
   - Verify the complete revised flow end-to-end: content body → content analysis (keywords + informationType) → search candidate retrieval (with score threshold) → candidate evaluation (path proposals + descriptions) → grant resolution → unified response with all suggestion types
   - Verify informationType field is present in search-based suggestions and absent in memo and category suggestions
   - Verify path proposal patterns work correctly: parent directory, subdirectory, and sibling with generated new paths at the correct hierarchy level

+ 501 - 126
apps/app/src/server/routes/apiv3/ai-tools/suggest-path-integration.spec.ts

@@ -11,18 +11,36 @@ const testState = vi.hoisted(() => ({
   aiEnabled: true,
   openaiServiceType: 'openai' as string | null,
   disableUserPages: false,
-  // Phase 2 controls
+  // Phase 2 - content analysis
   contentAnalysis: null as {
     keywords: string[];
     informationType: 'flow' | 'stock';
   } | null,
   contentAnalysisError: null as Error | null,
+  // Phase 2 - search candidates
+  searchCandidates: [] as Array<{
+    pagePath: string;
+    snippet: string;
+    score: number;
+  }>,
+  searchCandidatesError: null as Error | null,
+  // Phase 2 - candidate evaluation
   evaluatedSuggestions: [] as Array<{
     path: string;
     label: string;
     description: string;
   }>,
   evaluateCandidatesError: null as Error | null,
+  // Phase 2 - category
+  categorySuggestion: null as {
+    type: string;
+    path: string;
+    label: string;
+    description: string;
+    grant: number;
+  } | null,
+  categorySuggestionError: null as Error | null,
+  // Phase 2 - grant
   parentGrant: 1,
 }));
 
@@ -97,6 +115,16 @@ vi.mock('./analyze-content', () => ({
   }),
 }));
 
+// Mock retrieveSearchCandidates — configurable per test via testState
+vi.mock('./retrieve-search-candidates', () => ({
+  retrieveSearchCandidates: vi.fn().mockImplementation(() => {
+    if (testState.searchCandidatesError != null) {
+      return Promise.reject(testState.searchCandidatesError);
+    }
+    return Promise.resolve(testState.searchCandidates);
+  }),
+}));
+
 // Mock evaluateCandidates — configurable per test via testState
 vi.mock('./evaluate-candidates', () => ({
   evaluateCandidates: vi.fn().mockImplementation(() => {
@@ -107,6 +135,16 @@ vi.mock('./evaluate-candidates', () => ({
   }),
 }));
 
+// Mock generateCategorySuggestion — configurable per test via testState
+vi.mock('./generate-category-suggestion', () => ({
+  generateCategorySuggestion: vi.fn().mockImplementation(() => {
+    if (testState.categorySuggestionError != null) {
+      return Promise.reject(testState.categorySuggestionError);
+    }
+    return Promise.resolve(testState.categorySuggestion);
+  }),
+}));
+
 // Mock resolveParentGrant — returns configurable grant value via testState
 vi.mock('./resolve-parent-grant', () => ({
   resolveParentGrant: vi.fn().mockImplementation(() => {
@@ -116,7 +154,6 @@ vi.mock('./resolve-parent-grant', () => ({
 
 describe('POST /suggest-path integration', () => {
   let app: express.Application;
-  let mockSearchKeyword: ReturnType<typeof vi.fn>;
 
   beforeEach(async () => {
     // Reset test state to defaults
@@ -126,12 +163,14 @@ describe('POST /suggest-path integration', () => {
     testState.disableUserPages = false;
     testState.contentAnalysis = null;
     testState.contentAnalysisError = null;
+    testState.searchCandidates = [];
+    testState.searchCandidatesError = null;
     testState.evaluatedSuggestions = [];
     testState.evaluateCandidatesError = null;
+    testState.categorySuggestion = null;
+    testState.categorySuggestionError = null;
     testState.parentGrant = 1;
 
-    mockSearchKeyword = vi.fn().mockResolvedValue([{ data: [] }, undefined]);
-
     // Setup express app with ApiV3Response methods
     app = express();
     app.use(express.json());
@@ -150,7 +189,7 @@ describe('POST /suggest-path integration', () => {
     // Import and mount the handler factory with real middleware chain
     const { suggestPathHandlersFactory } = await import('./suggest-path');
     const mockCrowi = {
-      searchService: { searchKeyword: mockSearchKeyword },
+      searchService: { searchKeyword: vi.fn() },
     } as unknown as Crowi;
     app.post('/suggest-path', suggestPathHandlersFactory(mockCrowi));
   });
@@ -245,26 +284,32 @@ describe('POST /suggest-path integration', () => {
     });
   });
 
-  describe('Phase 2 — multi-suggestion response', () => {
-    const searchResults = [
-      {
-        _score: 10,
-        _source: { path: '/tech-notes/React/hooks-guide' },
-        _highlight: { body: ['React hooks overview'] },
-      },
+  describe('Phase 2 — revised pipeline verification', () => {
+    // Common fixture data
+    const stockAnalysis = {
+      keywords: ['React', 'hooks'],
+      informationType: 'stock' as const,
+    };
+
+    const flowAnalysis = {
+      keywords: ['meeting', 'standup'],
+      informationType: 'flow' as const,
+    };
+
+    const searchCandidates = [
       {
-        _score: 8,
-        _source: { path: '/tech-notes/React/state-management' },
-        _highlight: { body: ['State management'] },
+        pagePath: '/tech-notes/React/hooks-guide',
+        snippet: 'React hooks overview',
+        score: 10,
       },
       {
-        _score: 5,
-        _source: { path: '/tech-notes/React/best-practices' },
-        _highlight: { body: ['Best practices'] },
+        pagePath: '/tech-notes/React/state-management',
+        snippet: 'State management',
+        score: 8,
       },
     ];
 
-    const evaluatedResults = [
+    const singleEvaluated = [
       {
         path: '/tech-notes/React/',
         label: 'Save near related pages',
@@ -273,42 +318,47 @@ describe('POST /suggest-path integration', () => {
       },
     ];
 
-    describe('complete flow with all suggestion types', () => {
-      it('should return memo, search, and category suggestions when analysis succeeds and search results found', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+    const categorySuggestionFixture = {
+      type: 'category',
+      path: '/tech-notes/',
+      label: 'Save under category',
+      description: 'Top-level category: tech-notes',
+      grant: 1,
+    };
+
+    // Helper: set up full pipeline success with optional overrides
+    const setupFullPipeline = (overrides?: {
+      analysis?: typeof stockAnalysis;
+      candidates?: typeof searchCandidates;
+      evaluated?: typeof singleEvaluated;
+      category?: typeof categorySuggestionFixture | null;
+    }) => {
+      testState.contentAnalysis = overrides?.analysis ?? stockAnalysis;
+      testState.searchCandidates = overrides?.candidates ?? searchCandidates;
+      testState.evaluatedSuggestions = overrides?.evaluated ?? singleEvaluated;
+      testState.categorySuggestion =
+        overrides?.category !== undefined
+          ? overrides.category
+          : categorySuggestionFixture;
+    };
+
+    describe('complete revised flow end-to-end', () => {
+      it('should return memo, search, and category suggestions when all succeed', async () => {
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
           .send({ body: 'Content about React hooks and state management' })
           .expect(200);
 
-        expect(response.body.suggestions.length).toBeGreaterThanOrEqual(3);
+        expect(response.body.suggestions).toHaveLength(3);
         expect(response.body.suggestions[0].type).toBe('memo');
         expect(response.body.suggestions[1].type).toBe('search');
-        // Category is the last suggestion
-        const lastSuggestion =
-          response.body.suggestions[response.body.suggestions.length - 1];
-        expect(lastSuggestion.type).toBe('category');
+        expect(response.body.suggestions[2].type).toBe('category');
       });
 
       it('should return correct memo suggestion alongside Phase 2 suggestions', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
@@ -325,15 +375,7 @@ describe('POST /suggest-path integration', () => {
       });
 
       it('should return search suggestion with AI-evaluated path and description', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
@@ -351,104 +393,234 @@ describe('POST /suggest-path integration', () => {
         expect(searchSuggestion.informationType).toBe('stock');
       });
 
-      it('should return category suggestion with top-level segment path and category name in description', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+      it('should return category suggestion with correct structure', async () => {
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
           .send({ body: 'Content about React hooks' })
           .expect(200);
 
-        const categorySuggestion =
-          response.body.suggestions[response.body.suggestions.length - 1];
-        expect(categorySuggestion.type).toBe('category');
-        expect(categorySuggestion.path).toBe('/tech-notes/');
-        expect(categorySuggestion.label).toBe('Save under category');
-        expect(categorySuggestion.description).toBe(
-          'Top-level category: tech-notes',
+        expect(response.body.suggestions[2]).toEqual(categorySuggestionFixture);
+      });
+
+      it('should return multiple search suggestions for multi-candidate evaluation', async () => {
+        const multiEvaluated = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Save near related pages',
+            description:
+              'React documentation area with existing hooks content.',
+          },
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'New section for performance',
+            description: 'New sibling alongside existing React pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: multiEvaluated });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React performance' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(4); // memo + 2 search + category
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestions).toHaveLength(2);
+        expect(searchSuggestions[0].path).toBe('/tech-notes/React/');
+        expect(searchSuggestions[1].path).toBe(
+          '/tech-notes/React/performance/',
         );
-        expect(categorySuggestion.grant).toBe(1);
+      });
+
+      it('should omit search suggestions when evaluator finds no suitable candidates', async () => {
+        setupFullPipeline({ evaluated: [] });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2); // memo + category
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
       });
     });
 
-    describe('response structure verification', () => {
-      it('should have trailing slashes on all suggestion paths', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+    describe('informationType verification', () => {
+      it('should include informationType in search-based suggestions', async () => {
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
           .send({ body: 'Content about React hooks' })
           .expect(200);
 
-        for (const suggestion of response.body.suggestions) {
-          expect(suggestion.path).toMatch(/\/$/);
-        }
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.informationType).toBe('stock');
       });
 
-      it('should include all required fields in every suggestion', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+      it('should not include informationType in memo suggestion', async () => {
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
           .send({ body: 'Content about React hooks' })
           .expect(200);
 
-        const requiredFields = [
-          'type',
-          'path',
-          'label',
-          'description',
-          'grant',
-        ];
-        for (const suggestion of response.body.suggestions) {
-          for (const field of requiredFields) {
-            expect(suggestion).toHaveProperty(field);
-          }
-        }
+        const memoSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'memo',
+        );
+        expect(memoSuggestion).not.toHaveProperty('informationType');
       });
 
-      it('should include grant values as numbers for all suggestion types', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = evaluatedResults;
-        mockSearchKeyword.mockResolvedValue([
-          { data: searchResults },
-          undefined,
-        ]);
+      it('should not include informationType in category suggestion', async () => {
+        setupFullPipeline();
 
         const response = await request(app)
           .post('/suggest-path')
           .send({ body: 'Content about React hooks' })
           .expect(200);
 
-        for (const suggestion of response.body.suggestions) {
-          expect(typeof suggestion.grant).toBe('number');
+        const categorySuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'category',
+        );
+        expect(categorySuggestion).not.toHaveProperty('informationType');
+      });
+
+      it('should map flow informationType when content is classified as flow', async () => {
+        setupFullPipeline({ analysis: flowAnalysis });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Meeting notes from standup' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.informationType).toBe('flow');
+      });
+    });
+
+    describe('path proposal patterns', () => {
+      it('should support parent directory pattern', async () => {
+        const parentPattern = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Parent directory',
+            description:
+              'Save in the parent directory of matching React pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: parentPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'React hooks content' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe('/tech-notes/React/');
+        expect(searchSuggestion.path).toMatch(/\/$/);
+      });
+
+      it('should support subdirectory pattern', async () => {
+        const subdirPattern = [
+          {
+            path: '/tech-notes/React/hooks-guide/advanced/',
+            label: 'Subdirectory of matching page',
+            description: 'Save under the hooks guide as a sub-topic.',
+          },
+        ];
+        setupFullPipeline({ evaluated: subdirPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Advanced React hooks patterns' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe(
+          '/tech-notes/React/hooks-guide/advanced/',
+        );
+      });
+
+      it('should support sibling pattern with new path at correct hierarchy level', async () => {
+        // Matching candidate was at /tech-notes/React/hooks-guide (depth 3)
+        // Sibling should also be at depth 3: /tech-notes/React/performance/
+        const siblingPattern = [
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'New section for performance',
+            description:
+              'A new sibling section alongside existing React documentation.',
+          },
+        ];
+        setupFullPipeline({ evaluated: siblingPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'React performance optimization tips' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe('/tech-notes/React/performance/');
+        // Verify hierarchy level: path has 3 segments (same depth as hooks-guide)
+        const segments = searchSuggestion.path.split('/').filter(Boolean);
+        expect(segments).toHaveLength(3);
+      });
+
+      it('should return all three patterns when evaluator produces them', async () => {
+        const allPatterns = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Parent directory',
+            description: 'Parent directory of matching pages.',
+          },
+          {
+            path: '/tech-notes/React/hooks-guide/advanced/',
+            label: 'Subdirectory',
+            description: 'Under the hooks guide.',
+          },
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'Sibling section',
+            description: 'New sibling alongside existing pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: allPatterns, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React' })
+          .expect(200);
+
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestions).toHaveLength(3);
+        expect(searchSuggestions[0].path).toBe('/tech-notes/React/');
+        expect(searchSuggestions[1].path).toBe(
+          '/tech-notes/React/hooks-guide/advanced/',
+        );
+        expect(searchSuggestions[2].path).toBe(
+          '/tech-notes/React/performance/',
+        );
+        // All paths end with trailing slash
+        for (const s of searchSuggestions) {
+          expect(s.path).toMatch(/\/$/);
         }
       });
     });
@@ -478,13 +650,59 @@ describe('POST /suggest-path integration', () => {
         expect(response.body.suggestions[0].type).toBe('memo');
       });
 
-      it('should omit search suggestions when search returns no results above threshold', async () => {
-        testState.contentAnalysis = {
-          keywords: ['React', 'hooks'],
-          informationType: 'stock',
-        };
-        testState.evaluatedSuggestions = [];
-        mockSearchKeyword.mockResolvedValue([{ data: [] }, undefined]);
+      it('should omit search suggestions when search returns empty candidates', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = [];
+        testState.categorySuggestion = categorySuggestionFixture;
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2); // memo + category
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
+      });
+
+      it('should return memo + category when candidate evaluation fails', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = searchCandidates;
+        testState.evaluateCandidatesError = new Error('AI evaluation failed');
+        testState.categorySuggestion = categorySuggestionFixture;
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2);
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
+      });
+
+      it('should return memo + search when category generation fails', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = searchCandidates;
+        testState.evaluatedSuggestions = singleEvaluated;
+        testState.categorySuggestionError = new Error(
+          'Category generation failed',
+        );
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2);
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('search');
+      });
+
+      it('should return memo-only when all Phase 2 components fail', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidatesError = new Error('Search service down');
+        testState.categorySuggestionError = new Error('Category failed');
 
         const response = await request(app)
           .post('/suggest-path')
@@ -511,6 +729,163 @@ describe('POST /suggest-path integration', () => {
           grant: 4,
         });
       });
+
+      it('should skip search pipeline entirely when content analysis fails', async () => {
+        testState.contentAnalysisError = new Error('AI service unavailable');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        // Only memo, no search or category
+        expect(response.body.suggestions).toHaveLength(1);
+        const types = response.body.suggestions.map(
+          (s: { type: string }) => s.type,
+        );
+        expect(types).not.toContain('search');
+        expect(types).not.toContain('category');
+      });
+    });
+
+    describe('response structure verification', () => {
+      it('should have trailing slashes on all suggestion paths', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        for (const suggestion of response.body.suggestions) {
+          expect(suggestion.path).toMatch(/\/$/);
+        }
+      });
+
+      it('should include all required fields in every suggestion', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const requiredFields = [
+          'type',
+          'path',
+          'label',
+          'description',
+          'grant',
+        ];
+        for (const suggestion of response.body.suggestions) {
+          for (const field of requiredFields) {
+            expect(suggestion).toHaveProperty(field);
+          }
+        }
+      });
+
+      it('should include grant values as numbers for all suggestion types', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        for (const suggestion of response.body.suggestions) {
+          expect(typeof suggestion.grant).toBe('number');
+        }
+      });
+
+      it('should have fixed description for memo type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const memo = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'memo',
+        );
+        expect(memo.description).toBe('Save to your personal memo area');
+      });
+
+      it('should have AI-generated description for search type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const search = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        // AI-generated descriptions are non-empty and contextual
+        expect(search.description).toBeTruthy();
+        expect(search.description.length).toBeGreaterThan(10);
+      });
+
+      it('should have mechanical description for category type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const category = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'category',
+        );
+        // Mechanical description follows "Top-level category: {name}" format
+        expect(category.description).toMatch(/^Top-level category: /);
+      });
+
+      it('should have valid PageGrant values for all suggestions', async () => {
+        testState.parentGrant = 4;
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const validGrants = [1, 2, 4, 5];
+        for (const suggestion of response.body.suggestions) {
+          expect(validGrants).toContain(suggestion.grant);
+        }
+      });
+
+      it('should resolve different grant values per search suggestion path', async () => {
+        const multiEvaluated = [
+          {
+            path: '/public-docs/React/',
+            label: 'Public docs',
+            description: 'Public documentation area.',
+          },
+          {
+            path: '/private-notes/React/',
+            label: 'Private notes',
+            description: 'Private notes area.',
+          },
+        ];
+        setupFullPipeline({ evaluated: multiEvaluated, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React' })
+          .expect(200);
+
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        // Both use testState.parentGrant (1) — verifies grant resolution is called per path
+        expect(searchSuggestions).toHaveLength(2);
+        for (const s of searchSuggestions) {
+          expect(typeof s.grant).toBe('number');
+        }
+      });
     });
   });
 });