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

test(ai-tools): add Phase 2 integration tests for suggest-path

Extend integration test suite to verify the complete Phase 2 flow:
keyword extraction → parallel search/category suggestion → unified
response. Covers graceful degradation and response structure validation.

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

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

@@ -62,7 +62,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, 5.2, 6.4, 6.5_
 
-- [ ] 5. Phase 2 orchestration and integration
+- [x] 5. Phase 2 orchestration and integration
 - [x] 5.1 Wire suggestion generators into unified orchestration with graceful degradation
   - Implement the orchestration function that invokes all suggestion generators: memo (always), then keyword extraction followed by search and category generators in parallel
   - On keyword extraction or search service failure, fall back to memo-only response while logging the error
@@ -71,7 +71,7 @@
   - Include unit tests for successful multi-suggestion response, partial failures with graceful degradation, and complete Phase 2 failure falling back to memo only
   - _Requirements: 5.3, 6.1, 9.2_
 
-- [ ] 5.2 Phase 2 integration verification
+- [x] 5.2 Phase 2 integration verification
   - Verify the complete flow: content body to keyword extraction to parallel search and category suggestions to unified response with all suggestion types
   - Verify graceful degradation: when search returns no results, those suggestion types are omitted; when keyword extraction fails, memo-only response is returned
   - Verify response structure across all suggestion types: correct fields, descriptions, grant values, and trailing slashes

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

@@ -11,6 +11,10 @@ const testState = vi.hoisted(() => ({
   aiEnabled: true,
   openaiServiceType: 'openai' as string | null,
   disableUserPages: false,
+  // Phase 2 controls
+  extractedKeywords: [] as string[],
+  extractKeywordsError: null as Error | null,
+  parentGrant: 1,
 }));
 
 const mockUser = {
@@ -71,13 +75,26 @@ vi.mock(
   }),
 );
 
-// Mock extractKeywords — return empty array so Phase 2 falls back to memo-only
+// Mock extractKeywords — configurable per test via testState
 vi.mock('./extract-keywords', () => ({
-  extractKeywords: vi.fn().mockResolvedValue([]),
+  extractKeywords: vi.fn().mockImplementation(() => {
+    if (testState.extractKeywordsError != null) {
+      return Promise.reject(testState.extractKeywordsError);
+    }
+    return Promise.resolve(testState.extractedKeywords);
+  }),
+}));
+
+// Mock resolveParentGrant — returns configurable grant value via testState
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: vi.fn().mockImplementation(() => {
+    return Promise.resolve(testState.parentGrant);
+  }),
 }));
 
-describe('POST /suggest-path — Phase 1 integration', () => {
+describe('POST /suggest-path integration', () => {
   let app: express.Application;
+  let mockSearchKeyword: ReturnType<typeof vi.fn>;
 
   beforeEach(async () => {
     // Reset test state to defaults
@@ -85,6 +102,11 @@ describe('POST /suggest-path — Phase 1 integration', () => {
     testState.aiEnabled = true;
     testState.openaiServiceType = 'openai';
     testState.disableUserPages = false;
+    testState.extractedKeywords = [];
+    testState.extractKeywordsError = null;
+    testState.parentGrant = 1;
+
+    mockSearchKeyword = vi.fn().mockResolvedValue([{ data: [] }, undefined]);
 
     // Setup express app with ApiV3Response methods
     app = express();
@@ -104,96 +126,309 @@ describe('POST /suggest-path — Phase 1 integration', () => {
     // Import and mount the handler factory with real middleware chain
     const { suggestPathHandlersFactory } = await import('./suggest-path');
     const mockCrowi = {
-      searchService: { searchKeyword: vi.fn() },
+      searchService: { searchKeyword: mockSearchKeyword },
     } as unknown as Crowi;
     app.post('/suggest-path', suggestPathHandlersFactory(mockCrowi));
   });
 
-  describe('valid request with authentication', () => {
-    it('should return 200 with suggestions array containing one memo suggestion', async () => {
-      const response = await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content about React hooks' })
-        .expect(200);
+  describe('Phase 1 — memo-only', () => {
+    describe('valid request with authentication', () => {
+      it('should return 200 with suggestions array containing one memo suggestion', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content about React hooks' })
+          .expect(200);
 
-      expect(response.body.suggestions).toBeDefined();
-      expect(Array.isArray(response.body.suggestions)).toBe(true);
-      expect(response.body.suggestions).toHaveLength(1);
-    });
+        expect(response.body.suggestions).toBeDefined();
+        expect(Array.isArray(response.body.suggestions)).toBe(true);
+        expect(response.body.suggestions).toHaveLength(1);
+      });
 
-    it('should return memo suggestion with all required fields and correct values', async () => {
-      const response = await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(200);
+      it('should return memo suggestion with all required fields and correct values', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
 
-      const suggestion = response.body.suggestions[0];
-      expect(suggestion).toEqual({
-        type: 'memo',
-        path: '/user/alice/memo/',
-        label: 'Save as memo',
-        description: 'Save to your personal memo area',
-        grant: 4,
+        const suggestion = response.body.suggestions[0];
+        expect(suggestion).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
+
+      it('should return path with trailing slash', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
+
+        expect(response.body.suggestions[0].path).toMatch(/\/$/);
+      });
+
+      it('should return grant value of 4 (GRANT_OWNER)', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
+
+        expect(response.body.suggestions[0].grant).toBe(4);
       });
     });
 
-    it('should return path with trailing slash', async () => {
-      const response = await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(200);
+    describe('authentication enforcement', () => {
+      it('should return 403 when user is not authenticated', async () => {
+        testState.authenticateUser = false;
 
-      expect(response.body.suggestions[0].path).toMatch(/\/$/);
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
     });
 
-    it('should return grant value of 4 (GRANT_OWNER)', async () => {
-      const response = await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(200);
+    describe('input validation', () => {
+      it('should return 400 when body field is missing', async () => {
+        await request(app).post('/suggest-path').send({}).expect(400);
+      });
 
-      expect(response.body.suggestions[0].grant).toBe(4);
+      it('should return 400 when body field is empty string', async () => {
+        await request(app).post('/suggest-path').send({ body: '' }).expect(400);
+      });
     });
-  });
 
-  describe('authentication enforcement', () => {
-    it('should return 403 when user is not authenticated', async () => {
-      testState.authenticateUser = false;
+    describe('AI service gating', () => {
+      it('should return 403 when AI is not enabled', async () => {
+        testState.aiEnabled = false;
+
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
+
+      it('should return 403 when openai service type is not configured', async () => {
+        testState.openaiServiceType = null;
 
-      await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(403);
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
     });
   });
 
-  describe('input validation', () => {
-    it('should return 400 when body field is missing', async () => {
-      await request(app).post('/suggest-path').send({}).expect(400);
-    });
+  describe('Phase 2 — multi-suggestion response', () => {
+    const searchResults = [
+      { _score: 10, _source: { path: '/tech-notes/React/hooks-guide' } },
+      { _score: 8, _source: { path: '/tech-notes/React/state-management' } },
+      { _score: 5, _source: { path: '/tech-notes/React/best-practices' } },
+    ];
+
+    describe('complete flow with all suggestion types', () => {
+      it('should return memo, search, and category suggestions when keywords extracted and search results found', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks and state management' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(3);
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('search');
+        expect(response.body.suggestions[2].type).toBe('category');
+      });
+
+      it('should return correct memo suggestion alongside Phase 2 suggestions', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions[0]).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
+
+      it('should return search suggestion with parent directory path and related page titles in description', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
 
-    it('should return 400 when body field is empty string', async () => {
-      await request(app).post('/suggest-path').send({ body: '' }).expect(400);
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions[1];
+        expect(searchSuggestion.type).toBe('search');
+        expect(searchSuggestion.path).toBe('/tech-notes/React/');
+        expect(searchSuggestion.label).toBe('Save near related pages');
+        expect(searchSuggestion.description).toBe(
+          'Related pages under this directory: hooks-guide, state-management, best-practices',
+        );
+        expect(searchSuggestion.grant).toBe(1);
+      });
+
+      it('should return category suggestion with top-level segment path and category name in description', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const categorySuggestion = response.body.suggestions[2];
+        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(categorySuggestion.grant).toBe(1);
+      });
     });
-  });
 
-  describe('AI service gating', () => {
-    it('should return 403 when AI is not enabled', async () => {
-      testState.aiEnabled = false;
+    describe('response structure verification', () => {
+      it('should have trailing slashes on all suggestion paths', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        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 () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        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 () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([
+          { data: searchResults },
+          undefined,
+        ]);
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
 
-      await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(403);
+        for (const suggestion of response.body.suggestions) {
+          expect(typeof suggestion.grant).toBe('number');
+        }
+      });
     });
 
-    it('should return 403 when openai service type is not configured', async () => {
-      testState.openaiServiceType = null;
+    describe('graceful degradation', () => {
+      it('should return memo-only when keyword extraction fails', async () => {
+        testState.extractKeywordsError = new Error('AI service unavailable');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
+
+      it('should return memo-only when keyword extraction returns empty array', async () => {
+        // testState.extractedKeywords is [] by default
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
 
-      await request(app)
-        .post('/suggest-path')
-        .send({ body: 'Some page content' })
-        .expect(403);
+      it('should omit search and category suggestions when search returns no results', async () => {
+        testState.extractedKeywords = ['React', 'hooks'];
+        mockSearchKeyword.mockResolvedValue([{ data: [] }, undefined]);
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
+
+      it('should return correct memo structure even when Phase 2 degrades', async () => {
+        testState.extractKeywordsError = new Error('AI service unavailable');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions[0]).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
     });
   });
 });