Quellcode durchsuchen

test(suggest-path): add Phase 1 integration tests and mark Phase 1 complete

Verify the complete request-response cycle for the suggest-path
endpoint via supertest: authentication enforcement, input validation,
AI service gating, and response structure correctness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t vor 1 Monat
Ursprung
Commit
770f484cb3

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

@@ -2,7 +2,7 @@
 
 
 ## Phase 1 (MVP)
 ## Phase 1 (MVP)
 
 
-- [ ] 1. Phase 1 MVP — Shared types and memo path suggestion
+- [x] 1. Phase 1 MVP — Shared types and memo path suggestion
 - [x] 1.1 Define suggestion types and implement memo path generation
 - [x] 1.1 Define suggestion types and implement memo path generation
   - Define the suggestion response types used across both phases: suggestion type discriminator, individual suggestion structure with type/path/label/description/grant fields, and the response wrapper
   - Define the suggestion response types used across both phases: suggestion type discriminator, individual suggestion structure with type/path/label/description/grant fields, and the response wrapper
   - Implement memo path generation: when user pages are enabled (default), generate path under the user's home directory with owner-only grant; when user pages are disabled, generate path under an alternative namespace with hardcoded owner-only grant (actual parent grant resolution deferred to Phase 2 task 2)
   - Implement memo path generation: when user pages are enabled (default), generate path under the user's home directory with owner-only grant; when user pages are disabled, generate path under an alternative namespace with hardcoded owner-only grant (actual parent grant resolution deferred to Phase 2 task 2)
@@ -19,7 +19,7 @@
   - Register the new namespace route in the central API router
   - Register the new namespace route in the central API router
   - _Requirements: 1.1, 1.4, 8.1, 8.2, 8.3, 9.1, 9.2_
   - _Requirements: 1.1, 1.4, 8.1, 8.2, 8.3, 9.1, 9.2_
 
 
-- [ ] 1.3 Phase 1 integration verification
+- [x] 1.3 Phase 1 integration verification
   - Verify the complete request-response cycle for the memo suggestion endpoint with valid authentication
   - Verify the complete request-response cycle for the memo suggestion endpoint with valid authentication
   - Verify authentication enforcement: unauthenticated requests receive appropriate error responses
   - Verify authentication enforcement: unauthenticated requests receive appropriate error responses
   - Verify input validation: requests with missing or empty body field receive validation errors
   - Verify input validation: requests with missing or empty body field receive validation errors

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

@@ -0,0 +1,176 @@
+import type { NextFunction, Request, Response } from 'express';
+import express from 'express';
+import request from 'supertest';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+// Mutable test state — controls mock behavior per test
+const testState = vi.hoisted(() => ({
+  authenticateUser: true,
+  aiEnabled: true,
+  openaiServiceType: 'openai' as string | null,
+  disableUserPages: false,
+}));
+
+const mockUser = {
+  _id: 'user123',
+  username: 'alice',
+  status: 2, // STATUS_ACTIVE
+};
+
+// Mock access token parser — always passthrough
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser:
+    () => (_req: Request, _res: Response, next: NextFunction) =>
+      next(),
+}));
+
+// Mock login required — conditional authentication based on testState
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: () => (req: Request, res: Response, next: NextFunction) => {
+    if (!testState.authenticateUser) {
+      return res.sendStatus(403);
+    }
+    Object.assign(req, { user: mockUser });
+    next();
+  },
+}));
+
+// Mock config manager — certifyAiService and generateMemoSuggestion read from this
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: (key: string) => {
+      switch (key) {
+        case 'app:aiEnabled':
+          return testState.aiEnabled;
+        case 'openai:serviceType':
+          return testState.openaiServiceType;
+        case 'security:disableUserPages':
+          return testState.disableUserPages;
+        default:
+          return undefined;
+      }
+    },
+  },
+}));
+
+describe('POST /suggest-path — Phase 1 integration', () => {
+  let app: express.Application;
+
+  beforeEach(async () => {
+    // Reset test state to defaults
+    testState.authenticateUser = true;
+    testState.aiEnabled = true;
+    testState.openaiServiceType = 'openai';
+    testState.disableUserPages = false;
+
+    // Setup express app with ApiV3Response methods
+    app = express();
+    app.use(express.json());
+    app.use((_req: Request, res: Response, next: NextFunction) => {
+      const apiRes = res as ApiV3Response;
+      apiRes.apiv3 = function (obj = {}, status = 200) {
+        this.status(status).json(obj);
+      };
+      apiRes.apiv3Err = function (_err, status = 400) {
+        const errors = Array.isArray(_err) ? _err : [_err];
+        this.status(status).json({ errors });
+      };
+      next();
+    });
+
+    // Import and mount the handler factory with real middleware chain
+    const { suggestPathHandlersFactory } = await import('./suggest-path');
+    const mockCrowi = {} 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);
+
+      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);
+
+      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);
+    });
+  });
+
+  describe('authentication enforcement', () => {
+    it('should return 403 when user is not authenticated', async () => {
+      testState.authenticateUser = false;
+
+      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);
+    });
+
+    it('should return 400 when body field is empty string', async () => {
+      await request(app).post('/suggest-path').send({ body: '' }).expect(400);
+    });
+  });
+
+  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);
+    });
+  });
+});