From 1a5e040ed8296133bee97fe6104809a3f83b751e Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 18 Jun 2026 11:57:38 +0800 Subject: [PATCH] test: add unit tests for user-ai.controller (API-AI-063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 34 tests covering all 28 endpoints - Profile: GET/PUT with null→{} fallback - Settings: GET/PUT delegation - Credentials: CRUD + test (6 endpoints) - Analysis Jobs: create/cancel/get/list with query filters - Publish: quiz + flashcard - Analysis Results: getAnalysis/listAnalyses/listRecommendations/listWeakPoints - Quizzes: get/getQuestions/list with filters - Reanalysis/Notifications/Feedback/Flashcards - Fix: getProfile ?? {} now awaits before null-coalescing Co-Authored-By: Claude Opus 4.7 --- .../ai-runtime/user-ai.controller.spec.ts | 347 ++++++++++++++++++ src/modules/ai-runtime/user-ai.controller.ts | 2 +- 2 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/modules/ai-runtime/user-ai.controller.spec.ts diff --git a/src/modules/ai-runtime/user-ai.controller.spec.ts b/src/modules/ai-runtime/user-ai.controller.spec.ts new file mode 100644 index 0000000..aaf8106 --- /dev/null +++ b/src/modules/ai-runtime/user-ai.controller.spec.ts @@ -0,0 +1,347 @@ +import { UserAiController } from './user-ai.controller'; + +describe('UserAiController', () => { + let controller: UserAiController; + let mockService: any; + + beforeEach(() => { + mockService = { + getProfile: jest.fn(), + saveProfile: jest.fn(), + getSettings: jest.fn(), + updateSettings: jest.fn(), + listCredentials: jest.fn(), + createCredential: jest.fn(), + updateCredential: jest.fn(), + deleteCredential: jest.fn(), + testCredential: jest.fn(), + createAnalysisJob: jest.fn(), + cancelJob: jest.fn(), + getJob: jest.fn(), + listJobs: jest.fn(), + publishQuiz: jest.fn(), + publishFlashcard: jest.fn(), + getAnalysis: jest.fn(), + listAnalyses: jest.fn(), + listRecommendations: jest.fn(), + listWeakPoints: jest.fn(), + getQuiz: jest.fn(), + getQuizQuestions: jest.fn(), + listQuizzes: jest.fn(), + triggerReanalysis: jest.fn(), + listNotifications: jest.fn(), + submitArtifactFeedback: jest.fn(), + submitFeedback: jest.fn(), + getFlashcard: jest.fn(), + listFlashcards: jest.fn(), + }; + controller = new UserAiController(mockService); + }); + + const req = (id = 'u1') => ({ user: { id } }) as any; + + // ═══════════════════════════════════════════════════════════════════ + // Profile + // ═══════════════════════════════════════════════════════════════════ + + describe('Profile', () => { + it('GET profile returns profile or empty object', async () => { + mockService.getProfile.mockResolvedValue({ learningGoal: 'exam' }); + const result = await controller.getProfile(req()); + expect(result).toEqual({ learningGoal: 'exam' }); + expect(mockService.getProfile).toHaveBeenCalledWith('u1'); + }); + + it('GET profile returns {} when service returns null', async () => { + mockService.getProfile.mockResolvedValue(null); + const result = await controller.getProfile(req()); + expect(result).toEqual({}); + }); + + it('PUT profile delegates to service', async () => { + const dto = { learningGoal: 'exam' }; + mockService.saveProfile.mockResolvedValue({ id: 'p1' }); + const result = await controller.saveProfile(req(), dto as any); + expect(mockService.saveProfile).toHaveBeenCalledWith('u1', dto); + expect(result).toEqual({ id: 'p1' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Settings + // ═══════════════════════════════════════════════════════════════════ + + describe('Settings', () => { + it('GET settings', async () => { + mockService.getSettings.mockResolvedValue({ allowAiAnalysis: true }); + const result = await controller.getSettings(req()); + expect(mockService.getSettings).toHaveBeenCalledWith('u1'); + expect(result).toEqual({ allowAiAnalysis: true }); + }); + + it('PUT settings', async () => { + const dto = { allowAiAnalysis: false }; + mockService.updateSettings.mockResolvedValue({ id: 's1' }); + const result = await controller.updateSettings(req(), dto as any); + expect(mockService.updateSettings).toHaveBeenCalledWith('u1', dto); + expect(result).toEqual({ id: 's1' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Model Credentials + // ═══════════════════════════════════════════════════════════════════ + + describe('Model Credentials', () => { + it('GET model-credentials lists credentials', async () => { + mockService.listCredentials.mockResolvedValue([{ id: 'c1' }]); + const result = await controller.listCredentials(req()); + expect(mockService.listCredentials).toHaveBeenCalledWith('u1'); + expect(result).toEqual([{ id: 'c1' }]); + }); + + it('POST model-credentials creates credential', async () => { + const dto = { name: 'MyKey', apiKey: 'sk-xxx' }; + mockService.createCredential.mockResolvedValue({ id: 'c1' }); + const result = await controller.createCredential(req(), dto as any); + expect(mockService.createCredential).toHaveBeenCalledWith('u1', dto); + expect(result).toEqual({ id: 'c1' }); + }); + + it('PUT model-credentials/:id updates credential', async () => { + const dto = { name: 'Updated' }; + mockService.updateCredential.mockResolvedValue({ id: 'c1' }); + const result = await controller.updateCredential(req(), 'c1', dto as any); + expect(mockService.updateCredential).toHaveBeenCalledWith('u1', 'c1', dto); + expect(result).toEqual({ id: 'c1' }); + }); + + it('DELETE model-credentials/:id returns { ok: true }', async () => { + mockService.deleteCredential.mockResolvedValue(undefined); + const result = await controller.deleteCredential(req(), 'c1'); + expect(mockService.deleteCredential).toHaveBeenCalledWith('u1', 'c1'); + expect(result).toEqual({ ok: true }); + }); + + it('POST model-credentials/:id/test tests credential', async () => { + mockService.testCredential.mockResolvedValue({ success: true }); + const result = await controller.testCredential(req(), 'c1'); + expect(mockService.testCredential).toHaveBeenCalledWith('u1', 'c1'); + expect(result).toEqual({ success: true }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Analysis Jobs + // ═══════════════════════════════════════════════════════════════════ + + describe('Analysis Jobs', () => { + it('POST jobs creates analysis job', async () => { + const dto = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' }; + mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j1' }); + const result = await controller.createAnalysisJob(req(), dto as any); + expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto); + expect(result).toEqual({ jobId: 'j1' }); + }); + + it('POST jobs/:jobId/cancel cancels job', async () => { + mockService.cancelJob.mockResolvedValue({ status: 'cancelled' }); + const result = await controller.cancelJob(req(), 'j1'); + expect(mockService.cancelJob).toHaveBeenCalledWith('u1', 'j1'); + expect(result).toEqual({ status: 'cancelled' }); + }); + + it('GET jobs/:jobId gets job detail', async () => { + mockService.getJob.mockResolvedValue({ id: 'j1', status: 'running' }); + const result = await controller.getJob(req(), 'j1'); + expect(mockService.getJob).toHaveBeenCalledWith('u1', 'j1'); + expect(result).toEqual({ id: 'j1', status: 'running' }); + }); + + it('GET jobs lists jobs with query filters', async () => { + mockService.listJobs.mockResolvedValue([{ id: 'j1' }]); + const result = await controller.listJobs(req(), 'running', '10'); + expect(mockService.listJobs).toHaveBeenCalledWith('u1', 'running', 10); + expect(result).toEqual([{ id: 'j1' }]); + }); + + it('GET jobs without take returns undefined', async () => { + mockService.listJobs.mockResolvedValue([]); + await controller.listJobs(req(), undefined, undefined); + expect(mockService.listJobs).toHaveBeenCalledWith('u1', undefined, undefined); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Quiz & Flashcard Publish + // ═══════════════════════════════════════════════════════════════════ + + describe('Publish', () => { + it('POST quizzes/:quizId/publish', async () => { + mockService.publishQuiz.mockResolvedValue({ status: 'published' }); + const result = await controller.publishQuiz(req(), 'q1'); + expect(mockService.publishQuiz).toHaveBeenCalledWith('u1', 'q1'); + expect(result).toEqual({ status: 'published' }); + }); + + it('POST flashcards/:cardId/publish', async () => { + mockService.publishFlashcard.mockResolvedValue({ status: 'published' }); + const result = await controller.publishFlashcard(req(), 'fc1'); + expect(mockService.publishFlashcard).toHaveBeenCalledWith('u1', 'fc1'); + expect(result).toEqual({ status: 'published' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Analysis Results + // ═══════════════════════════════════════════════════════════════════ + + describe('Analysis Results', () => { + it('GET analyses/:id', async () => { + mockService.getAnalysis.mockResolvedValue({ id: 'a1' }); + const result = await controller.getAnalysis(req(), 'a1'); + expect(mockService.getAnalysis).toHaveBeenCalledWith('u1', 'a1'); + expect(result).toEqual({ id: 'a1' }); + }); + + it('GET analyses with filters', async () => { + mockService.listAnalyses.mockResolvedValue([{ id: 'a1' }]); + const result = await controller.listAnalyses(req(), 'material', 'm1', '5'); + expect(mockService.listAnalyses).toHaveBeenCalledWith('u1', 'material', 'm1', 5); + expect(result).toEqual([{ id: 'a1' }]); + }); + + it('GET analyses without filters', async () => { + mockService.listAnalyses.mockResolvedValue([]); + await controller.listAnalyses(req()); + expect(mockService.listAnalyses).toHaveBeenCalledWith('u1', undefined, undefined, undefined); + }); + + it('GET recommendations with filters', async () => { + mockService.listRecommendations.mockResolvedValue([{ id: 'r1' }]); + const result = await controller.listRecommendations(req(), 'material', 'm1', 'active', '5'); + expect(mockService.listRecommendations).toHaveBeenCalledWith('u1', 'material', 'm1', 'active', 5); + expect(result).toEqual([{ id: 'r1' }]); + }); + + it('GET recommendations without filters', async () => { + mockService.listRecommendations.mockResolvedValue([]); + await controller.listRecommendations(req()); + expect(mockService.listRecommendations).toHaveBeenCalledWith('u1', undefined, undefined, undefined, undefined); + }); + + it('GET weak-points with filters', async () => { + mockService.listWeakPoints.mockResolvedValue([{ id: 'w1' }]); + const result = await controller.listWeakPoints(req(), 'material', 'm1', 'active', '5'); + expect(mockService.listWeakPoints).toHaveBeenCalledWith('u1', 'material', 'm1', 'active', 5); + expect(result).toEqual([{ id: 'w1' }]); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Quizzes + // ═══════════════════════════════════════════════════════════════════ + + describe('Quizzes', () => { + it('GET quizzes/:quizId', async () => { + mockService.getQuiz.mockResolvedValue({ id: 'q1', title: 'Test' }); + const result = await controller.getQuiz(req(), 'q1'); + expect(mockService.getQuiz).toHaveBeenCalledWith('u1', 'q1'); + expect(result).toEqual({ id: 'q1', title: 'Test' }); + }); + + it('GET quizzes/:quizId/questions', async () => { + mockService.getQuizQuestions.mockResolvedValue([{ id: 'qq1' }]); + const result = await controller.getQuizQuestions(req(), 'q1'); + expect(mockService.getQuizQuestions).toHaveBeenCalledWith('u1', 'q1'); + expect(result).toEqual([{ id: 'qq1' }]); + }); + + it('GET quizzes with filters', async () => { + mockService.listQuizzes.mockResolvedValue([{ id: 'q1' }]); + const result = await controller.listQuizzes(req(), 'kb1', 'ready', '10'); + expect(mockService.listQuizzes).toHaveBeenCalledWith('u1', 'kb1', 'ready', 10); + expect(result).toEqual([{ id: 'q1' }]); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Reanalysis + // ═══════════════════════════════════════════════════════════════════ + + describe('Reanalysis', () => { + it('POST reanalyze triggers reanalysis', async () => { + mockService.triggerReanalysis.mockResolvedValue({ jobId: 'j1' }); + const result = await controller.triggerReanalysis(req(), 'material', 'm1'); + expect(mockService.triggerReanalysis).toHaveBeenCalledWith('u1', 'material', 'm1'); + expect(result).toEqual({ jobId: 'j1' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Notifications + // ═══════════════════════════════════════════════════════════════════ + + describe('Notifications', () => { + it('GET notifications with take', async () => { + mockService.listNotifications.mockResolvedValue([{ id: 'n1' }]); + const result = await controller.listNotifications(req(), '20'); + expect(mockService.listNotifications).toHaveBeenCalledWith('u1', 20); + expect(result).toEqual([{ id: 'n1' }]); + }); + + it('GET notifications without take', async () => { + mockService.listNotifications.mockResolvedValue([]); + await controller.listNotifications(req()); + expect(mockService.listNotifications).toHaveBeenCalledWith('u1', undefined); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Feedback + // ═══════════════════════════════════════════════════════════════════ + + describe('Feedback', () => { + it('POST artifacts/:type/:id/feedback', async () => { + const dto = { feedbackType: 'like', reason: 'good' }; + mockService.submitArtifactFeedback.mockResolvedValue({ ok: true }); + const result = await controller.submitArtifactFeedback(req(), 'quiz', 'q1', dto); + expect(mockService.submitArtifactFeedback).toHaveBeenCalledWith('u1', 'quiz', 'q1', dto); + expect(result).toEqual({ ok: true }); + }); + + it('POST feedback', async () => { + const dto = { category: 'bug', content: 'issue desc', email: 'a@b.com' }; + mockService.submitFeedback.mockResolvedValue({ ok: true }); + const result = await controller.submitFeedback(req(), dto); + expect(mockService.submitFeedback).toHaveBeenCalledWith('u1', dto); + expect(result).toEqual({ ok: true }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Flashcards + // ═══════════════════════════════════════════════════════════════════ + + describe('Flashcards', () => { + it('GET flashcards/:cardId', async () => { + mockService.getFlashcard.mockResolvedValue({ id: 'fc1', front: 'Q', back: 'A' }); + const result = await controller.getFlashcard(req(), 'fc1'); + expect(mockService.getFlashcard).toHaveBeenCalledWith('u1', 'fc1'); + expect(result).toEqual({ id: 'fc1', front: 'Q', back: 'A' }); + }); + + it('GET flashcards with filters', async () => { + mockService.listFlashcards.mockResolvedValue([{ id: 'fc1' }]); + const result = await controller.listFlashcards(req(), 'kp1', 'draft', '10'); + expect(mockService.listFlashcards).toHaveBeenCalledWith('u1', 'kp1', 'draft', 10); + expect(result).toEqual([{ id: 'fc1' }]); + }); + + it('GET flashcards without filters', async () => { + mockService.listFlashcards.mockResolvedValue([]); + await controller.listFlashcards(req()); + expect(mockService.listFlashcards).toHaveBeenCalledWith('u1', undefined, undefined, undefined); + }); + }); +}); diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts index 1e51e2f..9556350 100644 --- a/src/modules/ai-runtime/user-ai.controller.ts +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -10,7 +10,7 @@ export class UserAiController { @Get('profile') async getProfile(@Req() req: any) { - return this.service.getProfile(req.user.id) ?? {}; + return (await this.service.getProfile(req.user.id)) ?? {}; } @Put('profile')