/**
* Unit tests for lib/issue-manager.js
*
* Tests for labels and utility functions
*/
import test from 'brittle'
import { IssueManager } from '../../lib/issue-manager.js'
import { MockRegistry, sampleLabels, sampleIssues } from '../helpers/fixtures.js'
// ========================================
// Static methods tests
// ========================================
test('IssueManager.DEFAULT_LABELS - has expected labels', async (t) => {
const defaults = IssueManager.DEFAULT_LABELS
t.ok(Array.isArray(defaults))
t.ok(defaults.length >= 5)
// Check required labels exist
const names = defaults.map(l => l.name)
t.ok(names.includes('bug'))
t.ok(names.includes('enhancement'))
t.ok(names.includes('documentation'))
})
test('IssueManager.DEFAULT_LABELS - all have required fields', async (t) => {
for (const label of IssueManager.DEFAULT_LABELS) {
t.ok(label.name, `Label should have name`)
t.ok(label.color, `Label ${label.name} should have color`)
t.ok(label.color.startsWith('#'), `Label ${label.name} color should be hex`)
t.ok(typeof label.description === 'string', `Label ${label.name} should have description`)
}
})
test('IssueManager.getLabelStyles - calculates text color for dark backgrounds', async (t) => {
const styles = IssueManager.getLabelStyles('#000000')
t.is(styles.background, '#000000')
t.is(styles.foreground, '#ffffff') // White text on black
})
test('IssueManager.getLabelStyles - calculates text color for light backgrounds', async (t) => {
const styles = IssueManager.getLabelStyles('#ffffff')
t.is(styles.background, '#ffffff')
t.is(styles.foreground, '#000000') // Black text on white
})
test('IssueManager.getLabelStyles - handles various colors', async (t) => {
// Red (dark enough for white text)
const red = IssueManager.getLabelStyles('#d73a4a')
t.is(red.foreground, '#ffffff')
// Light blue (light enough for black text)
const lightBlue = IssueManager.getLabelStyles('#a2eeef')
t.is(lightBlue.foreground, '#000000')
})
test('IssueManager.getLabelStyles - handles invalid color', async (t) => {
const styles = IssueManager.getLabelStyles(null)
t.is(styles.background, '#ccc')
t.is(styles.foreground, '#000')
})
// ========================================
// Label CRUD tests with MockRegistry
// ========================================
// Create a minimal mock IssueManager for testing labels
class TestableIssueManager {
constructor() {
this.registry = new MockRegistry()
}
async createLabel(repoName, labelData) {
const { name, color, description = '' } = labelData
const normalizedName = name.toLowerCase().replace(/\s+/g, '-')
const existing = await this.getLabel(repoName, normalizedName)
if (existing) {
throw new Error(`Label "${normalizedName}" already exists`)
}
const label = {
name: normalizedName,
displayName: name,
color: color.startsWith('#') ? color : `#${color}`,
description,
createdAt: Date.now()
}
await this.registry.put(`label:${repoName}:${normalizedName}`, label)
return label
}
async getLabel(repoName, labelName) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const entry = await this.registry.get(`label:${repoName}:${normalizedName}`)
return entry ? entry.value : null
}
async getLabels(repoName) {
const labels = []
for await (const entry of this.registry.createReadStream({
gt: `label:${repoName}:`,
lt: `label:${repoName}:\xff`
})) {
labels.push(entry.value)
}
return labels.sort((a, b) => a.name.localeCompare(b.name))
}
async deleteLabel(repoName, labelName) {
const normalizedName = labelName.toLowerCase().replace(/\s+/g, '-')
const label = await this.getLabel(repoName, normalizedName)
if (!label) {
throw new Error(`Label "${labelName}" not found`)
}
await this.registry.del(`label:${repoName}:${normalizedName}`)
return true
}
}
test('createLabel - creates a new label', async (t) => {
const manager = new TestableIssueManager()
const label = await manager.createLabel('test-repo', {
name: 'Bug',
color: '#d73a4a',
description: 'Something is broken'
})
t.is(label.name, 'bug') // Normalized
t.is(label.displayName, 'Bug')
t.is(label.color, '#d73a4a')
t.is(label.description, 'Something is broken')
t.ok(label.createdAt)
})
test('createLabel - normalizes label name', async (t) => {
const manager = new TestableIssueManager()
const label = await manager.createLabel('test-repo', {
name: 'Help Wanted',
color: '#008672'
})
t.is(label.name, 'help-wanted')
t.is(label.displayName, 'Help Wanted')
})
test('createLabel - adds # to color if missing', async (t) => {
const manager = new TestableIssueManager()
const label = await manager.createLabel('test-repo', {
name: 'test',
color: 'd73a4a' // No #
})
t.is(label.color, '#d73a4a')
})
test('createLabel - throws on duplicate', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', { name: 'bug', color: '#ff0000' })
try {
await manager.createLabel('test-repo', { name: 'bug', color: '#00ff00' })
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('already exists'))
}
})
test('getLabel - returns label by name', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', {
name: 'enhancement',
color: '#a2eeef'
})
const label = await manager.getLabel('test-repo', 'enhancement')
t.is(label.name, 'enhancement')
t.is(label.color, '#a2eeef')
})
test('getLabel - returns null for non-existent label', async (t) => {
const manager = new TestableIssueManager()
const label = await manager.getLabel('test-repo', 'nonexistent')
t.is(label, null)
})
test('getLabel - is case insensitive', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', { name: 'Bug', color: '#ff0000' })
const label = await manager.getLabel('test-repo', 'BUG')
t.is(label.name, 'bug')
})
test('getLabels - returns all labels for repo', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', { name: 'bug', color: '#ff0000' })
await manager.createLabel('test-repo', { name: 'enhancement', color: '#00ff00' })
await manager.createLabel('test-repo', { name: 'docs', color: '#0000ff' })
const labels = await manager.getLabels('test-repo')
t.is(labels.length, 3)
})
test('getLabels - returns empty array for repo with no labels', async (t) => {
const manager = new TestableIssueManager()
const labels = await manager.getLabels('empty-repo')
t.alike(labels, [])
})
test('getLabels - sorts alphabetically', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', { name: 'zebra', color: '#ff0000' })
await manager.createLabel('test-repo', { name: 'alpha', color: '#00ff00' })
await manager.createLabel('test-repo', { name: 'middle', color: '#0000ff' })
const labels = await manager.getLabels('test-repo')
t.is(labels[0].name, 'alpha')
t.is(labels[1].name, 'middle')
t.is(labels[2].name, 'zebra')
})
test('getLabels - isolates labels by repo', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('repo-a', { name: 'bug', color: '#ff0000' })
await manager.createLabel('repo-b', { name: 'feature', color: '#00ff00' })
const labelsA = await manager.getLabels('repo-a')
const labelsB = await manager.getLabels('repo-b')
t.is(labelsA.length, 1)
t.is(labelsA[0].name, 'bug')
t.is(labelsB.length, 1)
t.is(labelsB[0].name, 'feature')
})
test('deleteLabel - removes label', async (t) => {
const manager = new TestableIssueManager()
await manager.createLabel('test-repo', { name: 'bug', color: '#ff0000' })
const result = await manager.deleteLabel('test-repo', 'bug')
t.is(result, true)
const label = await manager.getLabel('test-repo', 'bug')
t.is(label, null)
})
test('deleteLabel - throws for non-existent label', async (t) => {
const manager = new TestableIssueManager()
try {
await manager.deleteLabel('test-repo', 'nonexistent')
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('not found'))
}
})
// ========================================
// parseMarkdown tests (from IssueManager instance)
// ========================================
// We can test parseMarkdown by creating a minimal version
function parseMarkdown(markdown, images = {}) {
if (!markdown) return ''
let html = markdown
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Images
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, hash) => {
const dataUrl = images[hash]
if (dataUrl) {
return `<img src="${dataUrl}" alt="${alt || 'Image'}" />`
}
return match
})
return html
}
test('parseMarkdown - handles empty input', async (t) => {
t.is(parseMarkdown(''), '')
t.is(parseMarkdown(null), '')
})
test('parseMarkdown - escapes HTML', async (t) => {
const result = parseMarkdown('<script>alert("xss")</script>')
t.ok(result.includes('<script>'))
t.not(result.includes('<script>'))
})
test('parseMarkdown - handles bold text', async (t) => {
t.ok(parseMarkdown('**bold**').includes('<strong>bold</strong>'))
t.ok(parseMarkdown('__bold__').includes('<strong>bold</strong>'))
})
test('parseMarkdown - handles italic text', async (t) => {
t.ok(parseMarkdown('*italic*').includes('<em>italic</em>'))
t.ok(parseMarkdown('_italic_').includes('<em>italic</em>'))
})
test('parseMarkdown - handles inline code', async (t) => {
t.ok(parseMarkdown('`code`').includes('<code>code</code>'))
})
test('parseMarkdown - handles image references', async (t) => {
const images = { 'abc123': 'data:image/png;base64,xxx' }
const result = parseMarkdown('', images)
t.ok(result.includes('<img'))
t.ok(result.includes('data:image/png'))
})
test('parseMarkdown - preserves unmatched image syntax', async (t) => {
const result = parseMarkdown('', {})
t.ok(result.includes(''))
})