/** * test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be). * * Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage. * Security: SEC-M7 — unknown + narrowing, string length limits. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { parseStatusLine } from '../../src/http/statusline.js' import type { StatusTelemetry } from '../../src/types.js' // Representative full Claude Code statusLine JSON body. // Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct, // cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed, // model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. const FULL_BODY = { context_window: { used_percentage: 42.5 }, cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 }, model: { display_name: 'Claude 3.5 Sonnet' }, effort: { level: 'normal' }, pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }, rate_limits: { fiveHourPct: 10, sevenDayPct: 20 }, } const FIXED_NOW = 1_700_000_000_000 describe('parseStatusLine — null for non-objects', () => { it('returns null for null', () => { expect(parseStatusLine(null)).toBeNull() }) it('returns null for undefined', () => { expect(parseStatusLine(undefined)).toBeNull() }) it('returns null for a string', () => { expect(parseStatusLine('hello')).toBeNull() }) it('returns null for a number', () => { expect(parseStatusLine(42)).toBeNull() }) it('returns null for a boolean', () => { expect(parseStatusLine(true)).toBeNull() }) it('returns null for an array (typeof is "object" but is not a plain object)', () => { expect(parseStatusLine([])).toBeNull() expect(parseStatusLine([1, 2, 3])).toBeNull() }) }) describe('parseStatusLine — full valid body', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW) }) afterEach(() => { vi.restoreAllMocks() }) it('maps all fields correctly from a fully-populated body', () => { const result = parseStatusLine(FULL_BODY) expect(result).not.toBeNull() const t = result as StatusTelemetry expect(t.contextUsedPct).toBe(42.5) expect(t.costUsd).toBe(0.05) expect(t.linesAdded).toBe(100) expect(t.linesRemoved).toBe(50) expect(t.model).toBe('Claude 3.5 Sonnet') expect(t.effort).toBe('normal') expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }) expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 }) expect(t.at).toBe(FIXED_NOW) }) }) describe('parseStatusLine — empty/minimal body', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW) }) afterEach(() => { vi.restoreAllMocks() }) it('returns a StatusTelemetry with only at for an empty object', () => { const result = parseStatusLine({}) expect(result).not.toBeNull() expect(result?.at).toBe(FIXED_NOW) expect(result?.contextUsedPct).toBeUndefined() expect(result?.costUsd).toBeUndefined() expect(result?.linesAdded).toBeUndefined() expect(result?.linesRemoved).toBeUndefined() expect(result?.model).toBeUndefined() expect(result?.effort).toBeUndefined() expect(result?.pr).toBeUndefined() expect(result?.rate).toBeUndefined() }) }) describe('parseStatusLine — at timestamp', () => { afterEach(() => { vi.restoreAllMocks() }) it('sets at to Date.now() on each call', () => { const t1 = 1000 const t2 = 2000 vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2) expect(parseStatusLine({})?.at).toBe(t1) expect(parseStatusLine({})?.at).toBe(t2) }) }) describe('parseStatusLine — dirty number fields', () => { it('omits contextUsedPct when used_percentage is NaN', () => { expect( parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct, ).toBeUndefined() }) it('omits contextUsedPct when used_percentage is Infinity', () => { expect( parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct, ).toBeUndefined() }) it('omits contextUsedPct when used_percentage is a string', () => { expect( parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct, ).toBeUndefined() }) it('omits costUsd when total_cost_usd is Infinity', () => { expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined() }) it('omits costUsd when total_cost_usd is NaN', () => { expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined() }) it('omits linesAdded when total_lines_added is a string', () => { expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined() }) it('omits linesRemoved when total_lines_removed is null', () => { expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined() }) it('parses partial cost fields independently', () => { const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } }) expect(result?.costUsd).toBe(1.5) expect(result?.linesAdded).toBeUndefined() expect(result?.linesRemoved).toBeUndefined() }) }) describe('parseStatusLine — context_window not an object', () => { it('omits contextUsedPct when context_window is a string', () => { expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined() }) it('omits contextUsedPct when context_window is null', () => { expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined() }) it('omits contextUsedPct when context_window is a number', () => { expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined() }) }) describe('parseStatusLine — cost not an object', () => { it('omits all cost fields when cost is a string', () => { const result = parseStatusLine({ cost: 'bad' }) expect(result?.costUsd).toBeUndefined() expect(result?.linesAdded).toBeUndefined() expect(result?.linesRemoved).toBeUndefined() }) it('omits all cost fields when cost is null', () => { const result = parseStatusLine({ cost: null }) expect(result?.costUsd).toBeUndefined() expect(result?.linesAdded).toBeUndefined() expect(result?.linesRemoved).toBeUndefined() }) }) describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => { it('omits model when display_name exceeds 200 characters', () => { expect( parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model, ).toBeUndefined() }) it('accepts model at exactly 200 characters', () => { const model200 = 'a'.repeat(200) expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200) }) it('omits model when model field is not an object', () => { expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined() expect(parseStatusLine({ model: null })?.model).toBeUndefined() expect(parseStatusLine({ model: 42 })?.model).toBeUndefined() }) it('omits effort when level is a number', () => { expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined() }) it('omits effort when level exceeds 100 characters', () => { expect( parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort, ).toBeUndefined() }) it('accepts effort at exactly 100 characters', () => { const effort100 = 'e'.repeat(100) expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100) }) it('omits effort when effort field is not an object', () => { expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined() expect(parseStatusLine({ effort: null })?.effort).toBeUndefined() expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined() }) }) describe('parseStatusLine — pr parsing', () => { it('parses pr with all fields including optional reviewState', () => { const result = parseStatusLine({ pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }, }) expect(result?.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending', }) }) it('parses pr without optional reviewState', () => { const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } }) expect(result?.pr).toBeDefined() expect(result?.pr?.number).toBe(5) expect(result?.pr?.url).toBe('https://github.com/pr/5') expect(result?.pr?.reviewState).toBeUndefined() }) it('omits pr when pr.number is missing', () => { expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined() }) it('omits pr when pr.url is missing', () => { expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined() }) it('omits pr when both required fields are missing', () => { expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined() }) it('omits pr when pr is not an object', () => { expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined() expect(parseStatusLine({ pr: null })?.pr).toBeUndefined() expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined() }) it('omits pr when pr.url exceeds 2000 characters', () => { const longUrl = 'https://example.com/' + 'x'.repeat(1990) expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined() }) it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => { const result = parseStatusLine({ pr: { number: 1, url: 'https://github.com/pr/1', reviewState: 'x'.repeat(101), }, }) expect(result?.pr).toBeDefined() expect(result?.pr?.number).toBe(1) expect(result?.pr?.url).toBe('https://github.com/pr/1') expect(result?.pr?.reviewState).toBeUndefined() }) it('omits pr when pr.number is NaN', () => { expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined() }) }) describe('parseStatusLine — rate_limits parsing', () => { it('parses rate_limits with both fields', () => { const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } }) expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 }) }) it('parses rate_limits with only fiveHourPct', () => { const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } }) expect(result?.rate?.fiveHourPct).toBe(25) expect(result?.rate?.sevenDayPct).toBeUndefined() }) it('parses rate_limits with only sevenDayPct', () => { const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } }) expect(result?.rate?.fiveHourPct).toBeUndefined() expect(result?.rate?.sevenDayPct).toBe(75) }) it('omits rate when rate_limits is not an object', () => { expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined() expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined() expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined() }) it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => { const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } }) // rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid expect(result?.rate).toBeDefined() expect(result?.rate?.fiveHourPct).toBeUndefined() expect(result?.rate?.sevenDayPct).toBeUndefined() }) it('omits invalid sub-fields and keeps valid ones', () => { const result = parseStatusLine({ rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 }, }) expect(result?.rate?.fiveHourPct).toBeUndefined() expect(result?.rate?.sevenDayPct).toBe(80) }) }) describe('parseStatusLine — never throw (SEC-M7)', () => { it('never throws for wildly malformed bodies', () => { const malformed: unknown[] = [ null, undefined, 42, 'string', true, [], { context_window: 'not-an-object' }, { context_window: { used_percentage: {} } }, { cost: null }, { model: 42 }, { effort: [] }, { pr: 'not-an-object' }, { pr: { number: 'bad', url: {} } }, { rate_limits: 'bad' }, { rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } }, { pr: { number: NaN, url: '' } }, { context_window: { used_percentage: null } }, { cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } }, ] for (const body of malformed) { expect(() => parseStatusLine(body)).not.toThrow() } }) })