OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / issue-manager.test.js

352 lines · javascript ref:
/**
 * 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, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')

  // 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('&lt;script&gt;'))
  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('![alt text](abc123)', images)
  t.ok(result.includes('<img'))
  t.ok(result.includes('data:image/png'))
})

test('parseMarkdown - preserves unmatched image syntax', async (t) => {
  const result = parseMarkdown('![alt](unknown-hash)', {})
  t.ok(result.includes('![alt](unknown-hash)'))
})