BMAD-METHOD/test/unit/cache/cache-manager-prd-epic.test.js

700 lines
23 KiB
JavaScript

/**
* Tests for CacheManager PRD and Epic Extensions
*
* Tests cover:
* - PRD read/write operations
* - Epic read/write operations with PRD lineage
* - Status updates and filtering
* - User task queries (getPrdsNeedingAttention, getEpicsNeedingAttention)
* - Extended statistics
* - Document staleness checking
* - Atomic file operations
*
* Uses real temporary directory for testing actual file I/O operations.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
// Import the CacheManager (CommonJS module)
const { CacheManager, DOCUMENT_TYPES, CACHE_META_FILENAME } = await import(
'../../../src/modules/bmm/lib/cache/cache-manager.js'
);
describe('CacheManager PRD/Epic Extensions', () => {
let cacheManager;
let testCacheDir;
beforeEach(() => {
// Create a real temporary directory for each test
testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-cache-test-'));
cacheManager = new CacheManager({
cacheDir: testCacheDir,
stalenessThresholdMinutes: 5,
github: { owner: 'test-org', repo: 'test-repo' }
});
});
afterEach(() => {
// Clean up the temporary directory
if (testCacheDir && fs.existsSync(testCacheDir)) {
fs.rmSync(testCacheDir, { recursive: true, force: true });
}
});
// ============ DOCUMENT_TYPES Tests ============
describe('DOCUMENT_TYPES', () => {
it('should define all document types', () => {
expect(DOCUMENT_TYPES.story).toBe('story');
expect(DOCUMENT_TYPES.prd).toBe('prd');
expect(DOCUMENT_TYPES.epic).toBe('epic');
});
});
// ============ Directory Initialization Tests ============
describe('directory initialization', () => {
it('should create subdirectories for all document types', () => {
expect(fs.existsSync(path.join(testCacheDir, 'stories'))).toBe(true);
expect(fs.existsSync(path.join(testCacheDir, 'prds'))).toBe(true);
expect(fs.existsSync(path.join(testCacheDir, 'epics'))).toBe(true);
});
it('should create meta file on first access', () => {
cacheManager.loadMeta();
expect(fs.existsSync(path.join(testCacheDir, CACHE_META_FILENAME))).toBe(true);
});
});
// ============ Metadata Migration Tests ============
describe('metadata migration', () => {
it('should migrate v1 metadata to v2', () => {
// Write v1 metadata directly
const v1Meta = {
version: '1.0.0',
stories: { 'story-1': { github_issue: 10 } }
};
fs.writeFileSync(
path.join(testCacheDir, CACHE_META_FILENAME),
JSON.stringify(v1Meta),
'utf8'
);
// Create new manager to trigger migration
const manager = new CacheManager({
cacheDir: testCacheDir,
github: {}
});
const meta = manager.loadMeta();
expect(meta.version).toBe('2.0.0');
expect(meta.prds).toBeDefined();
expect(meta.epics).toBeDefined();
expect(meta.stories).toEqual({ 'story-1': { github_issue: 10 } });
});
it('should not migrate already v2 metadata', () => {
// Write v2 metadata directly
const v2Meta = {
version: '2.0.0',
prds: { 'existing-prd': { status: 'approved' } },
epics: { 'existing-epic': { status: 'draft' } },
stories: {}
};
fs.writeFileSync(
path.join(testCacheDir, CACHE_META_FILENAME),
JSON.stringify(v2Meta),
'utf8'
);
const manager = new CacheManager({
cacheDir: testCacheDir,
github: {}
});
const meta = manager.loadMeta();
expect(meta.prds['existing-prd'].status).toBe('approved');
expect(meta.epics['existing-epic'].status).toBe('draft');
});
});
// ============ PRD Methods Tests ============
describe('PRD operations', () => {
describe('getPrdPath', () => {
it('should return correct path for PRD', () => {
const prdPath = cacheManager.getPrdPath('user-auth');
expect(prdPath).toBe(path.join(testCacheDir, 'prds', 'user-auth.md'));
});
});
describe('writePrd', () => {
it('should write PRD content and update metadata', () => {
const content = '# PRD: User Authentication\n\nThis is the content.';
const prdMeta = {
review_issue: 100,
version: 1,
status: 'draft',
stakeholders: ['@alice', '@bob'],
owner: '@sarah'
};
const result = cacheManager.writePrd('user-auth', content, prdMeta);
expect(result.prdKey).toBe('user-auth');
expect(result.hash).toBeDefined();
expect(result.hash.length).toBe(64); // SHA-256 hex
// Verify file was written
const prdPath = cacheManager.getPrdPath('user-auth');
expect(fs.existsSync(prdPath)).toBe(true);
expect(fs.readFileSync(prdPath, 'utf8')).toBe(content);
// Verify metadata was updated
const meta = cacheManager.loadMeta();
expect(meta.prds['user-auth'].status).toBe('draft');
expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice', '@bob']);
});
it('should preserve existing metadata when not provided', () => {
// First write with full metadata
cacheManager.writePrd('user-auth', 'Content v1', {
review_issue: 100,
version: 2,
status: 'feedback',
stakeholders: ['@alice']
});
// Write with partial metadata
cacheManager.writePrd('user-auth', 'Content v2', { version: 3 });
// Verify metadata was merged
const meta = cacheManager.loadMeta();
expect(meta.prds['user-auth'].version).toBe(3);
expect(meta.prds['user-auth'].review_issue).toBe(100);
expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice']);
});
});
describe('readPrd', () => {
it('should return null for non-existent PRD', () => {
const result = cacheManager.readPrd('non-existent');
expect(result).toBeNull();
});
it('should return PRD content with metadata', () => {
const content = '# PRD: User Auth';
cacheManager.writePrd('user-auth', content, {
version: 1,
status: 'draft'
});
const result = cacheManager.readPrd('user-auth');
expect(result.content).toBe(content);
expect(result.meta.version).toBe(1);
expect(result.isStale).toBe(false);
});
it('should mark stale PRDs with warning', () => {
// Write PRD first
cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' });
// Manually set old timestamp
const meta = cacheManager.loadMeta();
meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z';
cacheManager.saveMeta(meta);
const result = cacheManager.readPrd('user-auth');
expect(result.isStale).toBe(true);
expect(result.warning).toContain('stale');
});
it('should ignore staleness when option set', () => {
// Write PRD first
cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' });
// Manually set old timestamp
const meta = cacheManager.loadMeta();
meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z';
cacheManager.saveMeta(meta);
const result = cacheManager.readPrd('user-auth', { ignoreStale: true });
expect(result.isStale).toBe(true);
expect(result.warning).toBeUndefined();
});
});
describe('updatePrdStatus', () => {
it('should update PRD status', () => {
cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' });
cacheManager.updatePrdStatus('user-auth', 'feedback');
const meta = cacheManager.loadMeta();
expect(meta.prds['user-auth'].status).toBe('feedback');
});
it('should throw error for non-existent PRD', () => {
expect(() => {
cacheManager.updatePrdStatus('non-existent', 'feedback');
}).toThrow('PRD not found in cache: non-existent');
});
});
describe('listCachedPrds', () => {
it('should return all cached PRD keys', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { status: 'draft' });
cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
const prds = cacheManager.listCachedPrds();
expect(prds).toContain('user-auth');
expect(prds).toContain('payments');
expect(prds).toContain('mobile');
expect(prds.length).toBe(3);
});
});
describe('getPrdsByStatus', () => {
it('should filter PRDs by status', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' });
cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
const feedbackPrds = cacheManager.getPrdsByStatus('feedback');
expect(feedbackPrds).toHaveLength(2);
expect(feedbackPrds.map(p => p.prdKey)).toContain('user-auth');
expect(feedbackPrds.map(p => p.prdKey)).toContain('mobile');
});
});
describe('getPrdsNeedingAttention', () => {
it('should find PRDs needing feedback from user', () => {
cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback',
stakeholders: ['@alice', '@bob']
});
cacheManager.writePrd('payments', '# PRD 2', {
status: 'signoff',
stakeholders: ['@alice', '@charlie']
});
cacheManager.writePrd('mobile', '# PRD 3', {
status: 'feedback',
stakeholders: ['@charlie']
});
const tasks = cacheManager.getPrdsNeedingAttention('alice');
expect(tasks.pendingFeedback).toHaveLength(1);
expect(tasks.pendingFeedback[0].prdKey).toBe('user-auth');
expect(tasks.pendingSignoff).toHaveLength(1);
expect(tasks.pendingSignoff[0].prdKey).toBe('payments');
});
it('should handle @ prefix in username', () => {
cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback',
stakeholders: ['alice', 'bob']
});
const tasks = cacheManager.getPrdsNeedingAttention('@alice');
expect(tasks.pendingFeedback).toHaveLength(1);
});
});
describe('deletePrd', () => {
it('should delete PRD file and metadata', () => {
cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' });
const prdPath = cacheManager.getPrdPath('user-auth');
expect(fs.existsSync(prdPath)).toBe(true);
cacheManager.deletePrd('user-auth');
expect(fs.existsSync(prdPath)).toBe(false);
expect(cacheManager.loadMeta().prds['user-auth']).toBeUndefined();
});
});
});
// ============ Epic Methods Tests ============
describe('Epic operations', () => {
describe('getEpicPath', () => {
it('should return correct path for Epic', () => {
const epicPath = cacheManager.getEpicPath('2');
expect(epicPath).toBe(path.join(testCacheDir, 'epics', 'epic-2.md'));
});
});
describe('writeEpic', () => {
it('should write Epic content with PRD lineage', () => {
const content = '# Epic 2: Core Authentication';
const epicMeta = {
github_issue: 50,
prd_key: 'user-auth',
version: 1,
status: 'draft',
stories: ['2-1-login', '2-2-logout']
};
const result = cacheManager.writeEpic('2', content, epicMeta);
expect(result.epicKey).toBe('2');
expect(result.hash).toBeDefined();
expect(result.hash.length).toBe(64);
// Verify file was written
const epicPath = cacheManager.getEpicPath('2');
expect(fs.existsSync(epicPath)).toBe(true);
expect(fs.readFileSync(epicPath, 'utf8')).toBe(content);
});
it('should track PRD lineage in metadata', () => {
cacheManager.writeEpic('2', 'Epic content', {
prd_key: 'user-auth',
status: 'draft'
});
const meta = cacheManager.loadMeta();
expect(meta.epics['2'].prd_key).toBe('user-auth');
});
});
describe('readEpic', () => {
it('should return null for non-existent Epic', () => {
const result = cacheManager.readEpic('999');
expect(result).toBeNull();
});
it('should return Epic content with metadata', () => {
const content = '# Epic 2: Auth';
cacheManager.writeEpic('2', content, {
prd_key: 'user-auth',
version: 1,
status: 'draft'
});
const result = cacheManager.readEpic('2');
expect(result.content).toBe(content);
expect(result.meta.prd_key).toBe('user-auth');
expect(result.isStale).toBe(false);
});
});
describe('updateEpicStatus', () => {
it('should update Epic status', () => {
cacheManager.writeEpic('2', '# Epic', { status: 'draft' });
cacheManager.updateEpicStatus('2', 'feedback');
const meta = cacheManager.loadMeta();
expect(meta.epics['2'].status).toBe('feedback');
});
it('should throw error for non-existent Epic', () => {
expect(() => {
cacheManager.updateEpicStatus('999', 'feedback');
}).toThrow('Epic not found in cache: 999');
});
});
describe('listCachedEpics', () => {
it('should return all cached Epic keys', () => {
cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' });
cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' });
cacheManager.writeEpic('3', '# Epic 3', { status: 'feedback' });
const epics = cacheManager.listCachedEpics();
expect(epics).toContain('1');
expect(epics).toContain('2');
expect(epics).toContain('3');
expect(epics.length).toBe(3);
});
});
describe('getEpicsByPrd', () => {
it('should filter Epics by source PRD', () => {
cacheManager.writeEpic('1', '# Epic 1', { prd_key: 'user-auth', status: 'approved' });
cacheManager.writeEpic('2', '# Epic 2', { prd_key: 'user-auth', status: 'draft' });
cacheManager.writeEpic('3', '# Epic 3', { prd_key: 'payments', status: 'draft' });
const authEpics = cacheManager.getEpicsByPrd('user-auth');
expect(authEpics).toHaveLength(2);
expect(authEpics.map(e => e.epicKey)).toContain('1');
expect(authEpics.map(e => e.epicKey)).toContain('2');
});
});
describe('getEpicsNeedingAttention', () => {
it('should find Epics needing feedback from user', () => {
cacheManager.writeEpic('1', '# Epic 1', {
status: 'feedback',
stakeholders: ['@alice', '@bob']
});
cacheManager.writeEpic('2', '# Epic 2', {
status: 'draft',
stakeholders: ['@alice']
});
cacheManager.writeEpic('3', '# Epic 3', {
status: 'feedback',
stakeholders: ['@charlie']
});
const tasks = cacheManager.getEpicsNeedingAttention('alice');
expect(tasks.pendingFeedback).toHaveLength(1);
expect(tasks.pendingFeedback[0].epicKey).toBe('1');
});
});
describe('deleteEpic', () => {
it('should delete Epic file and metadata', () => {
cacheManager.writeEpic('2', '# Epic', { status: 'draft' });
const epicPath = cacheManager.getEpicPath('2');
expect(fs.existsSync(epicPath)).toBe(true);
cacheManager.deleteEpic('2');
expect(fs.existsSync(epicPath)).toBe(false);
expect(cacheManager.loadMeta().epics['2']).toBeUndefined();
});
});
});
// ============ Unified Task Query Tests ============
describe('getMyTasks', () => {
it('should return combined PRD and Epic tasks', () => {
cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback',
stakeholders: ['@alice']
});
cacheManager.writePrd('payments', '# PRD 2', {
status: 'signoff',
stakeholders: ['@alice']
});
cacheManager.writeEpic('2', '# Epic 2', {
status: 'feedback',
stakeholders: ['@alice']
});
const tasks = cacheManager.getMyTasks('alice');
expect(tasks.prds.pendingFeedback).toHaveLength(1);
expect(tasks.prds.pendingSignoff).toHaveLength(1);
expect(tasks.epics.pendingFeedback).toHaveLength(1);
});
it('should return empty arrays when user has no tasks', () => {
cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback',
stakeholders: ['@bob']
});
const tasks = cacheManager.getMyTasks('alice');
expect(tasks.prds.pendingFeedback).toHaveLength(0);
expect(tasks.prds.pendingSignoff).toHaveLength(0);
expect(tasks.epics.pendingFeedback).toHaveLength(0);
});
});
// ============ Extended Statistics Tests ============
describe('getExtendedStats', () => {
it('should return comprehensive statistics', () => {
cacheManager.writeStory('2-1-login', '# Story', { github_issue: 10 });
cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' });
cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' });
cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' });
cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' });
cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' });
const stats = cacheManager.getExtendedStats();
expect(stats.story_count).toBe(1);
expect(stats.prd_count).toBe(3);
expect(stats.prds_by_status).toEqual({
feedback: 2,
approved: 1
});
expect(stats.epic_count).toBe(2);
expect(stats.epics_by_status).toEqual({
approved: 1,
draft: 1
});
expect(stats.prd_size_kb).toBeGreaterThanOrEqual(0);
expect(stats.epic_size_kb).toBeGreaterThanOrEqual(0);
});
});
// ============ Document Staleness Tests ============
describe('_isDocumentStale', () => {
it('should return true for missing metadata', () => {
expect(cacheManager._isDocumentStale(null)).toBe(true);
expect(cacheManager._isDocumentStale({})).toBe(true);
});
it('should return true for old cache timestamp', () => {
const oldMeta = {
cache_timestamp: '2020-01-01T00:00:00Z'
};
expect(cacheManager._isDocumentStale(oldMeta)).toBe(true);
});
it('should return false for recent cache timestamp', () => {
const recentMeta = {
cache_timestamp: new Date().toISOString()
};
expect(cacheManager._isDocumentStale(recentMeta)).toBe(false);
});
});
// ============ Atomic Write Tests ============
describe('atomic writes', () => {
it('should write PRD atomically (no temp files left)', () => {
cacheManager.writePrd('user-auth', '# Content', { status: 'draft' });
const prdPath = cacheManager.getPrdPath('user-auth');
const tempPath = `${prdPath}.tmp`;
expect(fs.existsSync(prdPath)).toBe(true);
expect(fs.existsSync(tempPath)).toBe(false);
});
it('should write Epic atomically (no temp files left)', () => {
cacheManager.writeEpic('2', '# Content', { status: 'draft' });
const epicPath = cacheManager.getEpicPath('2');
const tempPath = `${epicPath}.tmp`;
expect(fs.existsSync(epicPath)).toBe(true);
expect(fs.existsSync(tempPath)).toBe(false);
});
it('should save metadata atomically (no temp files left)', () => {
cacheManager.writePrd('user-auth', '# Content', { status: 'draft' });
const metaPath = path.join(testCacheDir, CACHE_META_FILENAME);
const tempPath = `${metaPath}.tmp`;
expect(fs.existsSync(metaPath)).toBe(true);
expect(fs.existsSync(tempPath)).toBe(false);
});
});
// ============ Edge Cases ============
describe('edge cases', () => {
it('should handle empty stakeholder arrays', () => {
cacheManager.writePrd('user-auth', '# PRD', {
status: 'feedback',
stakeholders: []
});
const tasks = cacheManager.getPrdsNeedingAttention('alice');
expect(tasks.pendingFeedback).toHaveLength(0);
});
it('should handle missing stakeholders property', () => {
cacheManager.writePrd('user-auth', '# PRD', { status: 'feedback' });
const tasks = cacheManager.getPrdsNeedingAttention('alice');
expect(tasks.pendingFeedback).toHaveLength(0);
});
it('should handle PRDs with no status', () => {
cacheManager.writePrd('user-auth', '# PRD', { version: 1 });
// Status defaults to 'draft' in writePrd
const feedbackPrds = cacheManager.getPrdsByStatus('feedback');
expect(feedbackPrds).toHaveLength(0);
const draftPrds = cacheManager.getPrdsByStatus('draft');
expect(draftPrds).toHaveLength(1);
});
it('should handle special characters in content', () => {
const content = '# PRD: Auth\n\n## Special chars: "quotes", <tags>, & ampersands';
cacheManager.writePrd('user-auth', content, { status: 'draft' });
const result = cacheManager.readPrd('user-auth');
expect(result.content).toBe(content);
});
it('should handle unicode content', () => {
const content = '# PRD: 认证系统\n\nUnicode: 日本語, 한국어, emoji 🚀';
cacheManager.writePrd('unicode-prd', content, { status: 'draft' });
const result = cacheManager.readPrd('unicode-prd');
expect(result.content).toBe(content);
});
it('should handle concurrent writes to different PRDs', () => {
// Write multiple PRDs in sequence (simulating concurrent writes)
for (let i = 0; i < 10; i++) {
cacheManager.writePrd(`prd-${i}`, `# PRD ${i}`, { status: 'draft' });
}
const prds = cacheManager.listCachedPrds();
expect(prds.length).toBe(10);
// Verify all are readable
for (let i = 0; i < 10; i++) {
const result = cacheManager.readPrd(`prd-${i}`);
expect(result.content).toBe(`# PRD ${i}`);
}
});
});
// ============ Content Hash Tests ============
describe('content hashing', () => {
it('should generate consistent hash for same content', () => {
const content = '# PRD Content';
const result1 = cacheManager.writePrd('prd-1', content, { status: 'draft' });
const result2 = cacheManager.writePrd('prd-2', content, { status: 'draft' });
expect(result1.hash).toBe(result2.hash);
});
it('should generate different hash for different content', () => {
const result1 = cacheManager.writePrd('prd-1', '# Content A', { status: 'draft' });
const result2 = cacheManager.writePrd('prd-2', '# Content B', { status: 'draft' });
expect(result1.hash).not.toBe(result2.hash);
});
it('should detect content changes via hasContentChanged (story method)', () => {
cacheManager.writeStory('story-1', '# Original', { github_issue: 10 });
expect(cacheManager.hasContentChanged('story-1', '# Original')).toBe(false);
expect(cacheManager.hasContentChanged('story-1', '# Modified')).toBe(true);
});
});
});