Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
931 changes: 931 additions & 0 deletions src/__tests__/edge-cases.test.ts

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions src/__tests__/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, expect, mock, test } from 'bun:test'
import { ErrorHandler } from '../core/request/error-handler.js'
import { AccountManager } from '../plugin/accounts.js'
import type { ManagedAccount } from '../plugin/types.js'

mock.module('../plugin/storage/sqlite.js', () => ({
kiroDb: {
getAccounts: () => [],
upsertAccount: () => Promise.resolve(),
deleteAccount: () => Promise.resolve(),
batchUpsertAccounts: () => Promise.resolve()
}
}))
mock.module('../plugin/sync/kiro-cli.js', () => ({
syncFromKiroCli: () => Promise.resolve(),
writeToKiroCli: () => Promise.resolve()
}))
mock.module('../kiro/auth.js', () => ({
decodeRefreshToken: (t: string) => ({ refreshToken: t }),
encodeRefreshToken: (p: any) => p.refreshToken,
accessTokenExpired: () => false
}))

const defaultConfig = { rate_limit_max_retries: 3, rate_limit_retry_delay_ms: 100 }
const noToast = () => {}

function makeAccount(overrides: Partial<ManagedAccount> = {}): ManagedAccount {
return {
id: 'acc-1',
email: 'test@example.com',
authMethod: 'idc',
region: 'eu-central-1',
refreshToken: 'r',
accessToken: 'a',
expiresAt: Date.now() + 3600000,
rateLimitResetTime: 0,
isHealthy: true,
failCount: 0,
lastUsed: 0,
usedCount: 0,
limitCount: 0,
...overrides
}
}

function makeRepo(accounts: ManagedAccount[]) {
return {
findAll: async () => accounts,
batchSave: async () => {},
save: async () => {},
invalidateCache: () => {}
} as any
}

function makeResponse(status: number, body: any, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers }
})
}

// ── 400 ───────────────────────────────────────────────────────────────────────

describe('ErrorHandler: 400', () => {
test('returns shouldRetry=false', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(400, { message: 'Bad Request' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(false)
})
})

// ── 401 ───────────────────────────────────────────────────────────────────────

describe('ErrorHandler: 401', () => {
test('retries when under max retries', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(401, { message: 'Unauthorized' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(result.newContext?.retry).toBe(1)
})

test('stops retrying at max retries', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(401, { message: 'Unauthorized' })
const result = await handler.handle(null, res, acc, { retry: 3 }, noToast)
expect(result.shouldRetry).toBe(false)
})
})

// ── 403 single account ────────────────────────────────────────────────────────

describe('ErrorHandler: 403 single account', () => {
test('bearer token invalid 403 forces token refresh (sets expiresAt=0) and retries', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(403, {
message: 'The bearer token included in the request is invalid'
})
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
// Should retry so the token refresher can get a fresh token
expect(result.shouldRetry).toBe(true)
// Account stays healthy — refresh will handle it
expect(acc.isHealthy).toBe(true)
// expiresAt zeroed so refreshIfNeeded triggers on next iteration
expect(acc.expiresAt).toBe(0)
})

test('TEMPORARILY_SUSPENDED marks account unhealthy', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(403, { reason: 'TEMPORARILY_SUSPENDED', message: 'Suspended' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(false)
expect(acc.isHealthy).toBe(false)
})

test('non-permanent 403 retries with backoff', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(403, { message: 'Forbidden' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(result.newContext?.retry).toBe(1)
})

test('INVALID_MODEL_ID throws immediately', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(403, { reason: 'INVALID_MODEL_ID', message: 'bad model' })
await expect(handler.handle(null, res, acc, { retry: 0 }, noToast)).rejects.toThrow(
'Invalid model: bad model'
)
})
})

// ── 403 multi account ─────────────────────────────────────────────────────────

describe('ErrorHandler: 403 multi account', () => {
test('switches account on any 403, increments failCount', async () => {
const a = makeAccount({ id: 'a' })
const b = makeAccount({ id: 'b', email: 'b@example.com' })
const mgr = new AccountManager([a, b])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([a, b]))
const res = makeResponse(403, { message: 'Forbidden' })
const result = await handler.handle(null, res, a, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(result.switchAccount).toBe(true)
// non-permanent 403: failCount incremented but account still available
expect(a.failCount).toBe(1)
expect(a.isHealthy).toBe(true)
})
})

// ── 429 ───────────────────────────────────────────────────────────────────────

describe('ErrorHandler: 429', () => {
test('marks account rate-limited', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = new Response('', {
status: 429,
headers: { 'retry-after': '1' } // 1s not 30s
})
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(acc.rateLimitResetTime).toBeGreaterThan(Date.now() - 100)
})

test('with single account, sleep is excluded from request timeout budget', async () => {
const acc = makeAccount()
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = new Response('', {
status: 429,
headers: { 'retry-after': '1' } // 1s sleep
})
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(result.newContext?.excludedMs).toBeGreaterThanOrEqual(1000)
})

test('with multiple accounts, switches without sleeping', async () => {
const acc1 = makeAccount({ id: 'a' })
const acc2 = makeAccount({ id: 'b' })
const mgr = new AccountManager([acc1, acc2])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc1, acc2]))
const res = new Response('', { status: 429, headers: { 'retry-after': '60' } })
const start = Date.now()
const result = await handler.handle(null, res, acc1, { retry: 0 }, noToast)
const elapsed = Date.now() - start
expect(result.switchAccount).toBe(true)
expect(elapsed).toBeLessThan(500) // didn't sleep
})
})

// ── 500 ───────────────────────────────────────────────────────────────────────

describe('ErrorHandler: 500', () => {
test('retries with backoff on first failure', async () => {
const acc = makeAccount({ failCount: 0 })
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(500, { message: 'Internal Server Error' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.shouldRetry).toBe(true)
expect(acc.failCount).toBe(1)
})

test('marks unhealthy after 5 failures', async () => {
const acc = makeAccount({ failCount: 4 })
const mgr = new AccountManager([acc])
const handler = new ErrorHandler(defaultConfig, mgr, makeRepo([acc]))
const res = makeResponse(500, { message: 'Internal Server Error' })
const result = await handler.handle(null, res, acc, { retry: 0 }, noToast)
expect(result.switchAccount).toBe(true)
expect(acc.isHealthy).toBe(false)
})
})
89 changes: 89 additions & 0 deletions src/__tests__/event-stream-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test } from 'bun:test'
import {
parseAwsEventStreamBuffer,
parseEventLine
} from '../infrastructure/transformers/event-stream-parser.js'

// ── parseEventLine ────────────────────────────────────────────────────────────

describe('parseEventLine', () => {
test('parses valid JSON', () => {
expect(parseEventLine('{"content":"hello"}')).toEqual({ content: 'hello' })
})

test('returns null on invalid JSON', () => {
expect(parseEventLine('not json')).toBeNull()
expect(parseEventLine('{')).toBeNull()
})
})

// ── parseAwsEventStreamBuffer ─────────────────────────────────────────────────

describe('parseAwsEventStreamBuffer', () => {
test('empty buffer returns empty array', () => {
expect(parseAwsEventStreamBuffer('')).toEqual([])
})

test('parses content event', () => {
const result = parseAwsEventStreamBuffer('{"content":"Hello"}')
expect(result).toHaveLength(1)
expect(result[0]!.type).toBe('content')
expect(result[0]!.data).toBe('Hello')
})

test('skips followupPrompt as not content', () => {
const result = parseAwsEventStreamBuffer('{"content":"x","followupPrompt":"y"}')
expect(result).toHaveLength(0)
})

test('parses toolUse event', () => {
const result = parseAwsEventStreamBuffer(
'{"name":"bash","toolUseId":"t-1","input":"ls","stop":false}'
)
expect(result).toHaveLength(1)
expect(result[0]!.type).toBe('toolUse')
expect(result[0]!.data.name).toBe('bash')
expect(result[0]!.data.toolUseId).toBe('t-1')
})

test('parses toolUseInput event', () => {
const result = parseAwsEventStreamBuffer('{"input":"-la"}')
expect(result).toHaveLength(1)
expect(result[0]!.type).toBe('toolUseInput')
expect(result[0]!.data.input).toBe('-la')
})

test('parses toolUseStop event', () => {
const result = parseAwsEventStreamBuffer('{"stop":true}')
expect(result).toHaveLength(1)
expect(result[0]!.type).toBe('toolUseStop')
expect(result[0]!.data.stop).toBe(true)
})

test('parses contextUsage event', () => {
const result = parseAwsEventStreamBuffer('{"contextUsagePercentage":42}')
expect(result).toHaveLength(1)
expect(result[0]!.type).toBe('contextUsage')
expect(result[0]!.data.contextUsagePercentage).toBe(42)
})

test('parses multiple events from a single buffer', () => {
const buffer = '{"content":"Hello"}\n{"content":" world"}\n{"contextUsagePercentage":25}'
const result = parseAwsEventStreamBuffer(buffer)
expect(result).toHaveLength(3)
expect(result[0]!.data).toBe('Hello')
expect(result[1]!.data).toBe(' world')
expect(result[2]!.type).toBe('contextUsage')
})

test('handles incomplete JSON at end (no jsonEnd)', () => {
const result = parseAwsEventStreamBuffer('{"content":"truncated')
expect(result).toHaveLength(0)
})

test('handles escaped strings inside JSON values', () => {
const result = parseAwsEventStreamBuffer('{"content":"say \\"hello\\""}')
expect(result).toHaveLength(1)
expect(result[0]!.data).toBe('say "hello"')
})
})
Loading