From d03c90a1d75aee2f005eaf06d5c67eb317d2c8df Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 2 Oct 2025 20:02:37 +0000 Subject: [PATCH] test: Add Feedback service tests (605 lines, 33 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created comprehensive test suite for user feedback management service: - Create feedback with automatic sentiment detection - Priority calculation based on type and sentiment - Feedback CRUD operations (get by ID, user history, admin list) - Status management (new/triaged/in progress/resolved/closed) - Admin workflow (assignment, internal notes, resolution) - Feature request upvoting system - Feedback statistics (by type/status/priority, resolution time, response rate) - Trending feature requests (sorted by upvotes) - Sentiment analysis (positive/negative/neutral detection) - Advanced filtering (type, status, priority, category, platform) - Pagination support - Analytics integration Tests cover: - Sentiment detection (positive, negative, very_positive, very_negative, neutral) - Priority assignment (HIGH for bugs/performance, MEDIUM/LOW for features) - All feedback types (bug report, feature request, general, performance, ui/ux) - All statuses (new, triaged, in progress, resolved, closed) - Admin operations (assign, update status, add notes) - Upvoting restricted to feature requests - Statistics calculation with empty data handling - Error scenarios (not found, invalid operations) Total: 605 lines, 33 test cases Coverage: Feedback management, sentiment analysis, admin workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../modules/feedback/feedback.service.spec.ts | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 maternal-app/maternal-app-backend/src/modules/feedback/feedback.service.spec.ts diff --git a/maternal-app/maternal-app-backend/src/modules/feedback/feedback.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/feedback/feedback.service.spec.ts new file mode 100644 index 0000000..ce02714 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/feedback/feedback.service.spec.ts @@ -0,0 +1,605 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, In } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { FeedbackService, FeedbackFilters } from './feedback.service'; +import { + Feedback, + FeedbackType, + FeedbackStatus, + FeedbackPriority, +} from './feedback.entity'; +import { CreateFeedbackDto } from './dto/create-feedback.dto'; +import { + AnalyticsService, + AnalyticsEvent, +} from '../../common/services/analytics.service'; + +describe('FeedbackService', () => { + let service: FeedbackService; + let feedbackRepository: Repository; + let analyticsService: AnalyticsService; + + const mockFeedback = { + id: 'fb_123', + userId: 'user_123', + type: FeedbackType.BUG_REPORT, + title: 'App crashes', + message: 'The app crashes when I try to log feeding', + category: 'tracking', + page: '/track/feeding', + platform: 'web', + appVersion: '1.0.0', + sentiment: 'negative', + priority: FeedbackPriority.HIGH, + status: FeedbackStatus.NEW, + upvotes: 0, + contactAllowed: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockFeedbackRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + }; + + const mockAnalyticsService = { + trackEvent: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeedbackService, + { + provide: getRepositoryToken(Feedback), + useValue: mockFeedbackRepository, + }, + { + provide: AnalyticsService, + useValue: mockAnalyticsService, + }, + ], + }).compile(); + + service = module.get(FeedbackService); + feedbackRepository = module.get>( + getRepositoryToken(Feedback), + ); + analyticsService = module.get(AnalyticsService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createFeedback', () => { + it('should create feedback with sentiment detection', async () => { + const createDto: CreateFeedbackDto = { + type: FeedbackType.BUG_REPORT, + message: 'App crashes and has terrible bugs', + title: 'Crashes', + category: 'tracking', + page: '/track', + platform: 'web', + }; + + mockFeedbackRepository.create.mockReturnValue(mockFeedback); + mockFeedbackRepository.save.mockResolvedValue(mockFeedback); + + const result = await service.createFeedback('user_123', createDto); + + expect(feedbackRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_123', + type: FeedbackType.BUG_REPORT, + sentiment: 'negative', + priority: FeedbackPriority.HIGH, + status: FeedbackStatus.NEW, + }), + ); + expect(feedbackRepository.save).toHaveBeenCalled(); + expect(analyticsService.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + userId: 'user_123', + }), + ); + expect(result).toEqual(mockFeedback); + }); + + it('should assign HIGH priority to critical bugs', async () => { + const createDto: CreateFeedbackDto = { + type: FeedbackType.BUG_REPORT, + message: 'Crash error awful', + category: 'tracking', + }; + + mockFeedbackRepository.create.mockImplementation((data) => ({ + ...mockFeedback, + ...data, + })); + mockFeedbackRepository.save.mockResolvedValue(mockFeedback); + + await service.createFeedback('user_123', createDto); + + expect(feedbackRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority: FeedbackPriority.HIGH, + }), + ); + }); + + it('should assign HIGH priority to performance issues', async () => { + const createDto: CreateFeedbackDto = { + type: FeedbackType.PERFORMANCE_ISSUE, + message: 'App is very slow', + category: 'general', + }; + + mockFeedbackRepository.create.mockImplementation((data) => ({ + ...mockFeedback, + ...data, + })); + mockFeedbackRepository.save.mockResolvedValue(mockFeedback); + + await service.createFeedback('user_123', createDto); + + expect(feedbackRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority: FeedbackPriority.HIGH, + }), + ); + }); + + it('should assign MEDIUM/LOW priority to feature requests', async () => { + const createDto: CreateFeedbackDto = { + type: FeedbackType.FEATURE_REQUEST, + message: 'Please add dark mode', + category: 'ui', + }; + + mockFeedbackRepository.create.mockImplementation((data) => ({ + ...mockFeedback, + ...data, + })); + mockFeedbackRepository.save.mockResolvedValue(mockFeedback); + + await service.createFeedback('user_123', createDto); + + const createCall = mockFeedbackRepository.create.mock.calls[0][0]; + expect([FeedbackPriority.MEDIUM, FeedbackPriority.LOW]).toContain( + createCall.priority, + ); + }); + + it('should handle save failures', async () => { + mockFeedbackRepository.save.mockRejectedValue( + new Error('Database error'), + ); + + const createDto: CreateFeedbackDto = { + type: FeedbackType.BUG_REPORT, + message: 'Test', + category: 'test', + }; + + await expect( + service.createFeedback('user_123', createDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getFeedback', () => { + it('should get feedback by ID', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(mockFeedback); + + const result = await service.getFeedback('fb_123'); + + expect(feedbackRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'fb_123' }, + }); + expect(result).toEqual(mockFeedback); + }); + + it('should throw NotFoundException if not found', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(null); + + await expect(service.getFeedback('fb_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getUserFeedback', () => { + it('should get all feedback for a user', async () => { + mockFeedbackRepository.find.mockResolvedValue([mockFeedback]); + + const result = await service.getUserFeedback('user_123'); + + expect(feedbackRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual([mockFeedback]); + }); + + it('should filter by type', async () => { + mockFeedbackRepository.find.mockResolvedValue([]); + + const filters: FeedbackFilters = { + type: FeedbackType.FEATURE_REQUEST, + }; + + await service.getUserFeedback('user_123', filters); + + expect(feedbackRepository.find).toHaveBeenCalledWith({ + where: { + userId: 'user_123', + type: FeedbackType.FEATURE_REQUEST, + }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should filter by status', async () => { + mockFeedbackRepository.find.mockResolvedValue([]); + + const filters: FeedbackFilters = { + status: FeedbackStatus.RESOLVED, + }; + + await service.getUserFeedback('user_123', filters); + + expect(feedbackRepository.find).toHaveBeenCalledWith({ + where: { + userId: 'user_123', + status: FeedbackStatus.RESOLVED, + }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should filter by category', async () => { + mockFeedbackRepository.find.mockResolvedValue([]); + + const filters: FeedbackFilters = { + category: 'tracking', + }; + + await service.getUserFeedback('user_123', filters); + + expect(feedbackRepository.find).toHaveBeenCalledWith({ + where: { + userId: 'user_123', + category: 'tracking', + }, + order: { createdAt: 'DESC' }, + }); + }); + }); + + describe('getAllFeedback', () => { + it('should get paginated feedback', async () => { + mockFeedbackRepository.findAndCount.mockResolvedValue([ + [mockFeedback], + 10, + ]); + + const result = await service.getAllFeedback({}, 2, 5); + + expect(feedbackRepository.findAndCount).toHaveBeenCalledWith({ + where: {}, + order: { priority: 'DESC', createdAt: 'DESC' }, + skip: 5, + take: 5, + }); + expect(result.feedback).toEqual([mockFeedback]); + expect(result.total).toBe(10); + }); + + it('should apply all filters', async () => { + mockFeedbackRepository.findAndCount.mockResolvedValue([[], 0]); + + const filters: FeedbackFilters = { + type: FeedbackType.BUG_REPORT, + status: FeedbackStatus.NEW, + priority: FeedbackPriority.HIGH, + category: 'tracking', + platform: 'mobile', + }; + + await service.getAllFeedback(filters, 1, 10); + + expect(feedbackRepository.findAndCount).toHaveBeenCalledWith({ + where: { + type: FeedbackType.BUG_REPORT, + status: FeedbackStatus.NEW, + priority: FeedbackPriority.HIGH, + category: 'tracking', + platform: 'mobile', + }, + order: { priority: 'DESC', createdAt: 'DESC' }, + skip: 0, + take: 10, + }); + }); + }); + + describe('updateStatus', () => { + it('should update feedback status', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(mockFeedback); + const updatedFeedback = { ...mockFeedback, status: FeedbackStatus.TRIAGED }; + mockFeedbackRepository.save.mockResolvedValue(updatedFeedback); + + const result = await service.updateStatus( + 'fb_123', + FeedbackStatus.TRIAGED, + 'admin_123', + ); + + expect(feedbackRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: FeedbackStatus.TRIAGED, + }), + ); + expect(result.status).toBe(FeedbackStatus.TRIAGED); + }); + + it('should set resolution details when resolving', async () => { + const feedbackToResolve = { ...mockFeedback }; + mockFeedbackRepository.findOne.mockResolvedValue(feedbackToResolve); + mockFeedbackRepository.save.mockResolvedValue({ + ...feedbackToResolve, + status: FeedbackStatus.RESOLVED, + resolvedAt: new Date(), + resolvedBy: 'admin_123', + resolution: 'Fixed in v1.1.0', + }); + + await service.updateStatus( + 'fb_123', + FeedbackStatus.RESOLVED, + 'admin_123', + 'Fixed in v1.1.0', + ); + + expect(feedbackRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: FeedbackStatus.RESOLVED, + resolvedAt: expect.any(Date), + resolvedBy: 'admin_123', + resolution: 'Fixed in v1.1.0', + }), + ); + }); + + it('should throw error if feedback not found', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateStatus('fb_123', FeedbackStatus.RESOLVED, 'admin_123'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('assignFeedback', () => { + it('should assign feedback to team member', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(mockFeedback); + mockFeedbackRepository.save.mockResolvedValue({ + ...mockFeedback, + assignedTo: 'dev_123', + status: FeedbackStatus.TRIAGED, + }); + + const result = await service.assignFeedback('fb_123', 'dev_123'); + + expect(feedbackRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + assignedTo: 'dev_123', + status: FeedbackStatus.TRIAGED, + }), + ); + expect(result.assignedTo).toBe('dev_123'); + }); + }); + + describe('addInternalNotes', () => { + it('should add internal notes', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(mockFeedback); + mockFeedbackRepository.save.mockResolvedValue({ + ...mockFeedback, + internalNotes: 'Investigating this issue', + }); + + const result = await service.addInternalNotes( + 'fb_123', + 'Investigating this issue', + ); + + expect(feedbackRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + internalNotes: 'Investigating this issue', + }), + ); + expect(result.internalNotes).toBe('Investigating this issue'); + }); + }); + + describe('upvoteFeedback', () => { + it('should upvote feature request', async () => { + const featureRequest = { + ...mockFeedback, + type: FeedbackType.FEATURE_REQUEST, + upvotes: 5, + }; + mockFeedbackRepository.findOne.mockResolvedValue(featureRequest); + mockFeedbackRepository.save.mockResolvedValue({ + ...featureRequest, + upvotes: 6, + }); + + const result = await service.upvoteFeedback('fb_123', 'user_456'); + + expect(feedbackRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + upvotes: 6, + }), + ); + expect(analyticsService.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEATURE_UPVOTED, + userId: 'user_456', + }), + ); + expect(result.upvotes).toBe(6); + }); + + it('should throw error if not a feature request', async () => { + mockFeedbackRepository.findOne.mockResolvedValue(mockFeedback); + + await expect( + service.upvoteFeedback('fb_123', 'user_456'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getFeedbackStats', () => { + it('should calculate feedback statistics', async () => { + const feedbacks = [ + { ...mockFeedback, type: FeedbackType.BUG_REPORT, status: FeedbackStatus.NEW, priority: FeedbackPriority.HIGH }, + { ...mockFeedback, type: FeedbackType.FEATURE_REQUEST, status: FeedbackStatus.TRIAGED, priority: FeedbackPriority.LOW }, + { ...mockFeedback, type: FeedbackType.BUG_REPORT, status: FeedbackStatus.RESOLVED, priority: FeedbackPriority.MEDIUM, resolvedAt: new Date() }, + ]; + mockFeedbackRepository.find.mockResolvedValue(feedbacks); + + const result = await service.getFeedbackStats(); + + expect(result.total).toBe(3); + expect(result.byType[FeedbackType.BUG_REPORT]).toBe(2); + expect(result.byType[FeedbackType.FEATURE_REQUEST]).toBe(1); + expect(result.byStatus[FeedbackStatus.NEW]).toBe(1); + expect(result.byStatus[FeedbackStatus.RESOLVED]).toBe(1); + expect(result.byPriority[FeedbackPriority.HIGH]).toBe(1); + expect(result.responseRate).toBeGreaterThan(0); + }); + + it('should handle empty feedback list', async () => { + mockFeedbackRepository.find.mockResolvedValue([]); + + const result = await service.getFeedbackStats(); + + expect(result.total).toBe(0); + expect(result.averageResolutionTime).toBe(0); + expect(result.responseRate).toBe(0); + }); + }); + + describe('getTrendingFeatureRequests', () => { + it('should get feature requests by upvotes', async () => { + const trending = [ + { ...mockFeedback, type: FeedbackType.FEATURE_REQUEST, upvotes: 50 }, + { ...mockFeedback, type: FeedbackType.FEATURE_REQUEST, upvotes: 30 }, + ]; + mockFeedbackRepository.find.mockResolvedValue(trending); + + const result = await service.getTrendingFeatureRequests(10); + + expect(feedbackRepository.find).toHaveBeenCalledWith({ + where: { + type: FeedbackType.FEATURE_REQUEST, + status: expect.any(Object), + }, + order: { upvotes: 'DESC', createdAt: 'DESC' }, + take: 10, + }); + expect(result).toEqual(trending); + }); + }); + + describe('detectSentiment', () => { + it('should detect negative sentiment', () => { + const sentiment = (service as any).detectSentiment( + 'This is a terrible bug that crashes the app', + ); + + expect(sentiment).toMatch(/negative/); + }); + + it('should detect very negative sentiment', () => { + const sentiment = (service as any).detectSentiment( + 'Awful terrible broken crash bug error', + ); + + expect(sentiment).toBe('very_negative'); + }); + + it('should detect positive sentiment', () => { + const sentiment = (service as any).detectSentiment( + 'I love this feature, it is great!', + ); + + expect(sentiment).toMatch(/positive/); + }); + + it('should detect very positive sentiment', () => { + const sentiment = (service as any).detectSentiment( + 'Amazing excellent wonderful fantastic love', + ); + + expect(sentiment).toBe('very_positive'); + }); + + it('should detect neutral sentiment', () => { + const sentiment = (service as any).detectSentiment( + 'The app works as expected', + ); + + expect(sentiment).toBe('neutral'); + }); + }); + + describe('calculatePriority', () => { + it('should assign HIGH priority to negative bug reports', () => { + const priority = (service as any).calculatePriority( + FeedbackType.BUG_REPORT, + 'negative', + ); + + expect(priority).toBe(FeedbackPriority.HIGH); + }); + + it('should assign HIGH priority to performance issues', () => { + const priority = (service as any).calculatePriority( + FeedbackType.PERFORMANCE_ISSUE, + 'neutral', + ); + + expect(priority).toBe(FeedbackPriority.HIGH); + }); + + it('should assign MEDIUM priority to positive feature requests', () => { + const priority = (service as any).calculatePriority( + FeedbackType.FEATURE_REQUEST, + 'very_positive', + ); + + expect(priority).toBe(FeedbackPriority.MEDIUM); + }); + + it('should assign LOW priority to neutral feature requests', () => { + const priority = (service as any).calculatePriority( + FeedbackType.FEATURE_REQUEST, + 'neutral', + ); + + expect(priority).toBe(FeedbackPriority.LOW); + }); + }); +});