BMAD-METHOD/test/unit/crowdsource/feedback-manager.test.js

1093 lines
34 KiB
JavaScript

/**
* Tests for FeedbackManager - Generic feedback operations for PRD/Epic crowdsourcing
*
* Tests cover:
* - Constants and type definitions
* - Feedback creation with proper label generation
* - Feedback querying with various filters
* - Grouping by section and type
* - Conflict detection
* - Status updates and issue closing
* - Statistics generation
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
FeedbackManager,
FEEDBACK_TYPES,
FEEDBACK_STATUS,
PRIORITY_LEVELS
} from '../../../src/modules/bmm/lib/crowdsource/feedback-manager.js';
// Create a testable subclass that allows injecting mock implementations
class TestableFeedbackManager extends FeedbackManager {
constructor(githubConfig, mocks = {}) {
super(githubConfig);
this.mocks = mocks;
}
async _createIssue(params) {
if (this.mocks.createIssue) {
return this.mocks.createIssue(params);
}
throw new Error('Mock not provided for _createIssue');
}
async _getIssue(issueNumber) {
if (this.mocks.getIssue) {
return this.mocks.getIssue(issueNumber);
}
throw new Error('Mock not provided for _getIssue');
}
async _updateIssue(issueNumber, updates) {
if (this.mocks.updateIssue) {
return this.mocks.updateIssue(issueNumber, updates);
}
throw new Error('Mock not provided for _updateIssue');
}
async _closeIssue(issueNumber, reason) {
if (this.mocks.closeIssue) {
return this.mocks.closeIssue(issueNumber, reason);
}
throw new Error('Mock not provided for _closeIssue');
}
async _addComment(issueNumber, body) {
if (this.mocks.addComment) {
return this.mocks.addComment(issueNumber, body);
}
throw new Error('Mock not provided for _addComment');
}
async _searchIssues(query) {
if (this.mocks.searchIssues) {
return this.mocks.searchIssues(query);
}
throw new Error('Mock not provided for _searchIssues');
}
}
describe('FeedbackManager', () => {
// ============ Constants Tests ============
describe('FEEDBACK_TYPES', () => {
it('should define all standard feedback types', () => {
const expectedTypes = [
'clarification',
'concern',
'suggestion',
'addition',
'priority'
];
for (const type of expectedTypes) {
expect(FEEDBACK_TYPES[type]).toBeDefined();
expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/);
expect(FEEDBACK_TYPES[type].emoji).toBeTruthy();
expect(FEEDBACK_TYPES[type].description).toBeTruthy();
}
});
it('should define epic-specific feedback types', () => {
const epicTypes = ['scope', 'dependency', 'technical_risk', 'story_split'];
for (const type of epicTypes) {
expect(FEEDBACK_TYPES[type]).toBeDefined();
expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/);
}
});
it('should have correct label formats', () => {
expect(FEEDBACK_TYPES.clarification.label).toBe('feedback-type:clarification');
expect(FEEDBACK_TYPES.concern.label).toBe('feedback-type:concern');
expect(FEEDBACK_TYPES.technical_risk.label).toBe('feedback-type:technical-risk');
expect(FEEDBACK_TYPES.story_split.label).toBe('feedback-type:story-split');
});
it('should have descriptive emojis for visual identification', () => {
expect(FEEDBACK_TYPES.clarification.emoji).toBe('📋');
expect(FEEDBACK_TYPES.concern.emoji).toBe('⚠️');
expect(FEEDBACK_TYPES.suggestion.emoji).toBe('💡');
expect(FEEDBACK_TYPES.scope.emoji).toBe('📐');
});
});
describe('FEEDBACK_STATUS', () => {
it('should define all status values', () => {
expect(FEEDBACK_STATUS.new).toBe('feedback-status:new');
expect(FEEDBACK_STATUS.reviewed).toBe('feedback-status:reviewed');
expect(FEEDBACK_STATUS.incorporated).toBe('feedback-status:incorporated');
expect(FEEDBACK_STATUS.deferred).toBe('feedback-status:deferred');
});
});
describe('PRIORITY_LEVELS', () => {
it('should define all priority levels', () => {
expect(PRIORITY_LEVELS.high).toBe('priority:high');
expect(PRIORITY_LEVELS.medium).toBe('priority:medium');
expect(PRIORITY_LEVELS.low).toBe('priority:low');
});
});
// ============ Constructor Tests ============
describe('constructor', () => {
it('should initialize with github config', () => {
const manager = new FeedbackManager({
owner: 'test-org',
repo: 'test-repo'
});
expect(manager.owner).toBe('test-org');
expect(manager.repo).toBe('test-repo');
});
});
// ============ createFeedback Tests ============
describe('createFeedback', () => {
let manager;
let mockCreateIssue;
let mockAddComment;
beforeEach(() => {
mockCreateIssue = vi.fn().mockResolvedValue({
number: 42,
html_url: 'https://github.com/test-org/test-repo/issues/42'
});
mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ createIssue: mockCreateIssue, addComment: mockAddComment }
);
});
it('should create feedback with correct labels for PRD', async () => {
const result = await manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:user-auth',
documentType: 'prd',
section: 'User Stories',
feedbackType: 'clarification',
priority: 'high',
title: 'Unclear login flow',
content: 'The login flow description is ambiguous',
submittedBy: 'alice'
});
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
const createCall = mockCreateIssue.mock.calls[0][0];
expect(createCall.title).toBe('📋 Feedback: Unclear login flow');
expect(createCall.labels).toContain('type:prd-feedback');
expect(createCall.labels).toContain('prd:user-auth');
expect(createCall.labels).toContain('linked-review:100');
expect(createCall.labels).toContain('feedback-section:user-stories');
expect(createCall.labels).toContain('feedback-type:clarification');
expect(createCall.labels).toContain('feedback-status:new');
expect(createCall.labels).toContain('priority:high');
expect(result.feedbackId).toBe(42);
expect(result.status).toBe('new');
});
it('should create feedback with correct labels for Epic', async () => {
const result = await manager.createFeedback({
reviewIssueNumber: 200,
documentKey: 'epic:2',
documentType: 'epic',
section: 'Story Breakdown',
feedbackType: 'scope',
priority: 'medium',
title: 'Epic too large',
content: 'Should be split into smaller epics',
submittedBy: 'bob'
});
const createCall = mockCreateIssue.mock.calls[0][0];
expect(createCall.title).toBe('📐 Feedback: Epic too large');
expect(createCall.labels).toContain('type:epic-feedback');
expect(createCall.labels).toContain('epic:2');
expect(createCall.labels).toContain('feedback-type:scope');
});
it('should add link comment to review issue', async () => {
await manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:user-auth',
documentType: 'prd',
section: 'User Stories',
feedbackType: 'concern',
priority: 'high',
title: 'Security risk',
content: 'Missing security consideration',
submittedBy: 'security-team'
});
expect(mockAddComment).toHaveBeenCalledTimes(1);
const commentCall = mockAddComment.mock.calls[0];
expect(commentCall[0]).toBe(100); // review issue number
expect(commentCall[1]).toContain('@security-team');
expect(commentCall[1]).toContain('Security risk');
expect(commentCall[1]).toContain('#42'); // feedback issue number
});
it('should include suggested change and rationale in body when provided', async () => {
await manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:payments',
documentType: 'prd',
section: 'FR-3',
feedbackType: 'suggestion',
priority: 'medium',
title: 'Better error handling',
content: 'Need better error messages',
suggestedChange: 'Add user-friendly error codes',
rationale: 'Improves debugging for support team',
submittedBy: 'dev-lead'
});
const createCall = mockCreateIssue.mock.calls[0][0];
expect(createCall.body).toContain('## Suggested Change');
expect(createCall.body).toContain('Add user-friendly error codes');
expect(createCall.body).toContain('## Context/Rationale');
expect(createCall.body).toContain('Improves debugging for support team');
});
it('should throw error for unknown feedback type', async () => {
await expect(manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
section: 'Test',
feedbackType: 'invalid-type',
priority: 'medium',
title: 'Test',
content: 'Test',
submittedBy: 'user'
})).rejects.toThrow('Unknown feedback type: invalid-type');
});
it('should default to medium priority when invalid priority provided', async () => {
await manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
section: 'Test',
feedbackType: 'clarification',
priority: 'invalid',
title: 'Test',
content: 'Test',
submittedBy: 'user'
});
const createCall = mockCreateIssue.mock.calls[0][0];
expect(createCall.labels).toContain('priority:medium');
});
it('should normalize section name for labels', async () => {
await manager.createFeedback({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
section: 'Non Functional Requirements',
feedbackType: 'clarification',
priority: 'low',
title: 'Test',
content: 'Test',
submittedBy: 'user'
});
const createCall = mockCreateIssue.mock.calls[0][0];
expect(createCall.labels).toContain('feedback-section:non-functional-requirements');
});
});
// ============ getFeedback Tests ============
describe('getFeedback', () => {
let manager;
let mockSearchIssues;
beforeEach(() => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'https://github.com/test/repo/issues/1',
title: '📋 Feedback: Test feedback',
labels: [
{ name: 'type:prd-feedback' },
{ name: 'prd:user-auth' },
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'alice' },
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
body: 'Test body'
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
});
it('should query feedback with document key filter', async () => {
await manager.getFeedback({
documentKey: 'prd:user-auth',
documentType: 'prd'
});
expect(mockSearchIssues).toHaveBeenCalledTimes(1);
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('repo:test-org/test-repo');
expect(query).toContain('type:issue');
expect(query).toContain('is:open');
expect(query).toContain('label:type:prd-feedback');
expect(query).toContain('label:prd:user-auth');
});
it('should query feedback with review issue filter', async () => {
await manager.getFeedback({
reviewIssueNumber: 100,
documentType: 'prd'
});
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('label:linked-review:100');
});
it('should query feedback with status filter', async () => {
await manager.getFeedback({
documentType: 'prd',
status: 'incorporated'
});
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('label:feedback-status:incorporated');
});
it('should query feedback with section filter', async () => {
await manager.getFeedback({
documentType: 'epic',
section: 'Story Breakdown'
});
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('label:feedback-section:story-breakdown');
});
it('should query feedback with type filter', async () => {
await manager.getFeedback({
documentType: 'prd',
feedbackType: 'concern'
});
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('label:feedback-type:concern');
});
it('should parse feedback issues correctly', async () => {
const results = await manager.getFeedback({
documentType: 'prd',
documentKey: 'prd:user-auth'
});
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
id: 1,
url: 'https://github.com/test/repo/issues/1',
title: 'Test feedback',
section: 'user-stories',
feedbackType: 'clarification',
status: 'new',
priority: 'high',
submittedBy: 'alice'
});
});
it('should handle document key with colon', async () => {
await manager.getFeedback({
documentKey: 'prd:complex-key',
documentType: 'prd'
});
const query = mockSearchIssues.mock.calls[0][0];
expect(query).toContain('label:prd:complex-key');
});
});
// ============ getFeedbackBySection Tests ============
describe('getFeedbackBySection', () => {
let manager;
let mockSearchIssues;
beforeEach(() => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '📋 Feedback: FB1',
labels: [
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'alice' },
created_at: '2026-01-01',
updated_at: '2026-01-01'
},
{
number: 2,
html_url: 'url2',
title: '💡 Feedback: FB2',
labels: [
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'bob' },
created_at: '2026-01-01',
updated_at: '2026-01-01'
},
{
number: 3,
html_url: 'url3',
title: '⚠️ Feedback: FB3',
labels: [
{ name: 'feedback-section:fr-3' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'charlie' },
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
});
it('should group feedback by section', async () => {
const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd');
expect(Object.keys(bySection)).toHaveLength(2);
expect(bySection['user-stories']).toHaveLength(2);
expect(bySection['fr-3']).toHaveLength(1);
});
it('should preserve feedback details in grouped results', async () => {
const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd');
expect(bySection['user-stories'][0].submittedBy).toBe('alice');
expect(bySection['user-stories'][1].submittedBy).toBe('bob');
});
});
// ============ getFeedbackByType Tests ============
describe('getFeedbackByType', () => {
let manager;
let mockSearchIssues;
beforeEach(() => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '📋 Feedback: FB1',
labels: [
{ name: 'feedback-section:test' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'alice' }
},
{
number: 2,
html_url: 'url2',
title: '📋 Feedback: FB2',
labels: [
{ name: 'feedback-section:test2' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'bob' }
},
{
number: 3,
html_url: 'url3',
title: '⚠️ Feedback: FB3',
labels: [
{ name: 'feedback-section:test' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'charlie' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
});
it('should group feedback by type', async () => {
const byType = await manager.getFeedbackByType('prd:user-auth', 'prd');
expect(Object.keys(byType)).toHaveLength(2);
expect(byType['clarification']).toHaveLength(2);
expect(byType['concern']).toHaveLength(1);
});
});
// ============ detectConflicts Tests ============
describe('detectConflicts', () => {
let manager;
let mockSearchIssues;
it('should detect conflicts when multiple concerns on same section', async () => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '⚠️ Feedback: Timeout too short',
labels: [
{ name: 'feedback-section:fr-5' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'security' }
},
{
number: 2,
html_url: 'url2',
title: '⚠️ Feedback: Timeout too long',
labels: [
{ name: 'feedback-section:fr-5' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'ux-team' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:user-auth', 'prd');
expect(conflicts).toHaveLength(1);
expect(conflicts[0].section).toBe('fr-5');
expect(conflicts[0].conflictType).toBe('multiple_opinions');
expect(conflicts[0].feedbackItems).toHaveLength(2);
});
it('should detect conflicts when concern and suggestion on same section', async () => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '⚠️ Feedback: Risk',
labels: [
{ name: 'feedback-section:security' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'security' }
},
{
number: 2,
html_url: 'url2',
title: '💡 Feedback: Improvement',
labels: [
{ name: 'feedback-section:security' },
{ name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'dev' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd');
expect(conflicts).toHaveLength(1);
expect(conflicts[0].section).toBe('security');
});
it('should not detect conflicts for single feedback on section', async () => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '⚠️ Feedback: Single concern',
labels: [
{ name: 'feedback-section:fr-1' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'user1' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd');
expect(conflicts).toHaveLength(0);
});
it('should not detect conflicts for multiple clarifications (not opposing)', async () => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '📋 Feedback: Question 1',
labels: [
{ name: 'feedback-section:fr-1' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'user1' }
},
{
number: 2,
html_url: 'url2',
title: '📋 Feedback: Question 2',
labels: [
{ name: 'feedback-section:fr-1' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:low' }
],
user: { login: 'user2' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd');
expect(conflicts).toHaveLength(0);
});
});
// ============ updateFeedbackStatus Tests ============
describe('updateFeedbackStatus', () => {
let manager;
let mockGetIssue;
let mockUpdateIssue;
let mockAddComment;
let mockCloseIssue;
beforeEach(() => {
mockGetIssue = vi.fn().mockResolvedValue({
number: 42,
labels: [
{ name: 'type:prd-feedback' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
]
});
mockUpdateIssue = vi.fn().mockResolvedValue({});
mockAddComment = vi.fn().mockResolvedValue({});
mockCloseIssue = vi.fn().mockResolvedValue({});
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{
getIssue: mockGetIssue,
updateIssue: mockUpdateIssue,
addComment: mockAddComment,
closeIssue: mockCloseIssue
}
);
});
it('should update status labels correctly', async () => {
await manager.updateFeedbackStatus(42, 'reviewed');
expect(mockUpdateIssue).toHaveBeenCalledTimes(1);
const updateCall = mockUpdateIssue.mock.calls[0];
expect(updateCall[0]).toBe(42);
expect(updateCall[1].labels).toContain('feedback-status:reviewed');
expect(updateCall[1].labels).not.toContain('feedback-status:new');
expect(updateCall[1].labels).toContain('type:prd-feedback');
expect(updateCall[1].labels).toContain('priority:high');
});
it('should add resolution comment when provided', async () => {
await manager.updateFeedbackStatus(42, 'incorporated', 'Added to PRD v2');
expect(mockAddComment).toHaveBeenCalledTimes(1);
expect(mockAddComment.mock.calls[0][0]).toBe(42);
expect(mockAddComment.mock.calls[0][1]).toContain('incorporated');
expect(mockAddComment.mock.calls[0][1]).toContain('Added to PRD v2');
});
it('should close issue when status is incorporated', async () => {
await manager.updateFeedbackStatus(42, 'incorporated');
expect(mockCloseIssue).toHaveBeenCalledTimes(1);
expect(mockCloseIssue.mock.calls[0][0]).toBe(42);
expect(mockCloseIssue.mock.calls[0][1]).toBe('completed');
});
it('should close issue when status is deferred', async () => {
await manager.updateFeedbackStatus(42, 'deferred');
expect(mockCloseIssue).toHaveBeenCalledTimes(1);
expect(mockCloseIssue.mock.calls[0][0]).toBe(42);
expect(mockCloseIssue.mock.calls[0][1]).toBe('not_planned');
});
it('should not close issue for reviewed status', async () => {
await manager.updateFeedbackStatus(42, 'reviewed');
expect(mockCloseIssue).not.toHaveBeenCalled();
});
it('should throw error for unknown status', async () => {
await expect(
manager.updateFeedbackStatus(42, 'invalid-status')
).rejects.toThrow('Unknown status: invalid-status');
});
it('should return updated status info', async () => {
const result = await manager.updateFeedbackStatus(42, 'reviewed');
expect(result).toEqual({
feedbackId: 42,
status: 'reviewed'
});
});
});
// ============ getStats Tests ============
describe('getStats', () => {
let manager;
let mockSearchIssues;
beforeEach(() => {
mockSearchIssues = vi.fn().mockResolvedValue([
{
number: 1,
html_url: 'url1',
title: '📋 Feedback: FB1',
labels: [
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'alice' }
},
{
number: 2,
html_url: 'url2',
title: '⚠️ Feedback: FB2',
labels: [
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:concern' },
{ name: 'feedback-status:reviewed' },
{ name: 'priority:high' }
],
user: { login: 'bob' }
},
{
number: 3,
html_url: 'url3',
title: '💡 Feedback: FB3',
labels: [
{ name: 'feedback-section:fr-3' },
{ name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' },
{ name: 'priority:medium' }
],
user: { login: 'alice' }
}
]);
manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
});
it('should calculate total feedback count', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.total).toBe(3);
});
it('should group stats by type', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.byType).toEqual({
clarification: 1,
concern: 1,
suggestion: 1
});
});
it('should group stats by status', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.byStatus).toEqual({
new: 2,
reviewed: 1
});
});
it('should group stats by section', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.bySection).toEqual({
'user-stories': 2,
'fr-3': 1
});
});
it('should group stats by priority', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.byPriority).toEqual({
high: 2,
medium: 1
});
});
it('should count unique submitters', async () => {
const stats = await manager.getStats('prd:user-auth', 'prd');
expect(stats.submitterCount).toBe(2);
expect(stats.submitters).toContain('alice');
expect(stats.submitters).toContain('bob');
});
});
// ============ Private Method Tests ============
describe('_formatFeedbackBody', () => {
let manager;
beforeEach(() => {
manager = new FeedbackManager({ owner: 'test', repo: 'test' });
});
it('should format body with all required sections', () => {
const body = manager._formatFeedbackBody({
reviewIssueNumber: 100,
documentKey: 'prd:test',
section: 'User Stories',
feedbackType: 'clarification',
typeConfig: FEEDBACK_TYPES.clarification,
priority: 'high',
content: 'This is unclear',
submittedBy: 'alice'
});
expect(body).toContain('# 📋 Feedback: Clarification');
expect(body).toContain('**Review:** #100');
expect(body).toContain('**Document:** `prd:test`');
expect(body).toContain('**Section:** User Stories');
expect(body).toContain('**Priority:** high');
expect(body).toContain('## Feedback');
expect(body).toContain('This is unclear');
expect(body).toContain('@alice');
});
it('should include suggested change when provided', () => {
const body = manager._formatFeedbackBody({
reviewIssueNumber: 100,
documentKey: 'prd:test',
section: 'FR-1',
feedbackType: 'suggestion',
typeConfig: FEEDBACK_TYPES.suggestion,
priority: 'medium',
content: 'Could be improved',
suggestedChange: 'Use async/await pattern',
submittedBy: 'bob'
});
expect(body).toContain('## Suggested Change');
expect(body).toContain('Use async/await pattern');
});
it('should include rationale when provided', () => {
const body = manager._formatFeedbackBody({
reviewIssueNumber: 100,
documentKey: 'prd:test',
section: 'NFR-1',
feedbackType: 'concern',
typeConfig: FEEDBACK_TYPES.concern,
priority: 'high',
content: 'Security risk',
rationale: 'OWASP Top 10 vulnerability',
submittedBy: 'security'
});
expect(body).toContain('## Context/Rationale');
expect(body).toContain('OWASP Top 10 vulnerability');
});
});
describe('_parseFeedbackIssue', () => {
let manager;
beforeEach(() => {
manager = new FeedbackManager({ owner: 'test', repo: 'test' });
});
it('should parse issue into feedback object', () => {
const issue = {
number: 42,
html_url: 'https://github.com/test/repo/issues/42',
title: '📋 Feedback: Test feedback title',
labels: [
{ name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
],
user: { login: 'alice' },
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
body: 'Test body content'
};
const parsed = manager._parseFeedbackIssue(issue);
expect(parsed).toEqual({
id: 42,
url: 'https://github.com/test/repo/issues/42',
title: 'Test feedback title',
section: 'user-stories',
feedbackType: 'clarification',
status: 'new',
priority: 'high',
submittedBy: 'alice',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
body: 'Test body content'
});
});
it('should strip emoji prefix from title', () => {
const issue = {
number: 1,
html_url: 'url',
title: '⚠️ Feedback: Important concern',
labels: [],
user: null
};
const parsed = manager._parseFeedbackIssue(issue);
expect(parsed.title).toBe('Important concern');
});
it('should handle missing labels gracefully', () => {
const issue = {
number: 1,
html_url: 'url',
title: 'Feedback: Missing labels',
labels: [],
user: { login: 'user' }
};
const parsed = manager._parseFeedbackIssue(issue);
expect(parsed.section).toBeNull();
expect(parsed.feedbackType).toBeNull();
expect(parsed.status).toBeNull();
expect(parsed.priority).toBeNull();
});
});
describe('_extractLabel', () => {
let manager;
beforeEach(() => {
manager = new FeedbackManager({ owner: 'test', repo: 'test' });
});
it('should extract value from label with prefix', () => {
const labels = ['type:prd-feedback', 'feedback-type:concern', 'priority:high'];
expect(manager._extractLabel(labels, 'feedback-type:')).toBe('concern');
expect(manager._extractLabel(labels, 'priority:')).toBe('high');
});
it('should return null when label not found', () => {
const labels = ['type:prd-feedback'];
expect(manager._extractLabel(labels, 'feedback-type:')).toBeNull();
});
});
// ============ Error Handling Tests ============
describe('error handling', () => {
it('should throw when GitHub methods not implemented', async () => {
const manager = new FeedbackManager({ owner: 'test', repo: 'test' });
await expect(manager._createIssue({})).rejects.toThrow(
'_createIssue must be implemented by caller via GitHub MCP'
);
await expect(manager._getIssue(1)).rejects.toThrow(
'_getIssue must be implemented by caller via GitHub MCP'
);
await expect(manager._searchIssues('')).rejects.toThrow(
'_searchIssues must be implemented by caller via GitHub MCP'
);
});
});
});