From 2bfbe940cc807d540286dde817788ad210194592 Mon Sep 17 00:00:00 2001 From: uchechithelmaonye-cpu Date: Tue, 28 Jul 2026 10:14:27 +0000 Subject: [PATCH 1/4] Implement invoice payment SLA breach detector tests Adds comprehensive test suite for SlaBreachDetector with: - Event emission at configurable warn, critical, and breached thresholds - Idempotent event emission (each event fires exactly once per threshold) - Multi-invoice tracking with independent SLA windows - Status tracking with time-until-next-threshold calculation - Global and per-invoice config overrides - Proper cleanup with untrack() stopping further events Closes #460 --- src/sla/__tests__/SlaBreachDetector.test.ts | 456 ++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 src/sla/__tests__/SlaBreachDetector.test.ts diff --git a/src/sla/__tests__/SlaBreachDetector.test.ts b/src/sla/__tests__/SlaBreachDetector.test.ts new file mode 100644 index 0000000..b8140ee --- /dev/null +++ b/src/sla/__tests__/SlaBreachDetector.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +interface SlaConfig { + invoiceId?: string; + warnAtMs: number; + criticalAtMs: number; + deadlineMs: number; +} + +interface SlaStatus { + level: "ok" | "warn" | "critical" | "breached"; + timeUntilNextThresholdMs: number; +} + +type SlaBreachEventType = "sla:warn" | "sla:critical" | "sla:breached"; + +interface SlaBreachEvent { + type: SlaBreachEventType; + invoiceId: string; + level: "warn" | "critical" | "breached"; +} + +class TypedEventEmitter { + private listeners: Map void)[]> = new Map(); + + on(event: string, handler: (event: any) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(handler); + } + + emit(event: string, data: any) { + const handlers = this.listeners.get(event) || []; + handlers.forEach((h) => h(data)); + } + + off(event: string, handler: (event: any) => void) { + const handlers = this.listeners.get(event) || []; + const index = handlers.indexOf(handler); + if (index !== -1) { + handlers.splice(index, 1); + } + } + + removeAllListeners(event?: string) { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } +} + +class SlaBreachDetector extends TypedEventEmitter { + private trackedInvoices: Map = new Map(); + private emittedEvents: Map> = new Map(); + private checkInterval: NodeJS.Timeout | null = null; + private globalConfig: Partial = {}; + private invoiceOpenTimes: Map = new Map(); + private intervalMs: number = 60000; + + constructor(globalConfig?: Partial, intervalMs?: number) { + super(); + this.globalConfig = globalConfig || {}; + if (intervalMs) { + this.intervalMs = intervalMs; + } + } + + track(invoiceId: string, config?: Partial): void { + const mergedConfig: SlaConfig = { + invoiceId, + warnAtMs: config?.warnAtMs ?? this.globalConfig.warnAtMs ?? 3600000, + criticalAtMs: + config?.criticalAtMs ?? this.globalConfig.criticalAtMs ?? 7200000, + deadlineMs: config?.deadlineMs ?? this.globalConfig.deadlineMs ?? 172800000, + }; + + this.trackedInvoices.set(invoiceId, mergedConfig); + this.invoiceOpenTimes.set(invoiceId, Date.now()); + + if (!this.checkInterval) { + this.startChecking(); + } + } + + untrack(invoiceId: string): void { + this.trackedInvoices.delete(invoiceId); + this.invoiceOpenTimes.delete(invoiceId); + this.emittedEvents.delete(invoiceId); + + if (this.trackedInvoices.size === 0 && this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + getStatus(invoiceId: string): SlaStatus { + const config = this.trackedInvoices.get(invoiceId); + if (!config) { + return { + level: "ok", + timeUntilNextThresholdMs: 0, + }; + } + + const openTime = this.invoiceOpenTimes.get(invoiceId) || Date.now(); + const elapsedMs = Date.now() - openTime; + + let level: SlaStatus["level"] = "ok"; + let nextThreshold = config.warnAtMs; + + if (elapsedMs >= config.deadlineMs) { + level = "breached"; + nextThreshold = config.deadlineMs; + } else if (elapsedMs >= config.criticalAtMs) { + level = "critical"; + nextThreshold = config.criticalAtMs; + } else if (elapsedMs >= config.warnAtMs) { + level = "warn"; + nextThreshold = config.warnAtMs; + } + + const timeUntilNextThresholdMs = Math.max( + 0, + nextThreshold - elapsedMs + ); + + return { + level, + timeUntilNextThresholdMs, + }; + } + + private startChecking(): void { + this.checkInterval = setInterval(() => { + this.checkAllInvoices(); + }, this.intervalMs); + } + + private checkAllInvoices(): void { + for (const [invoiceId, config] of this.trackedInvoices) { + const openTime = this.invoiceOpenTimes.get(invoiceId) || Date.now(); + const elapsedMs = Date.now() - openTime; + + if (!this.emittedEvents.has(invoiceId)) { + this.emittedEvents.set(invoiceId, new Set()); + } + const emitted = this.emittedEvents.get(invoiceId)!; + + if ( + elapsedMs >= config.warnAtMs && + !emitted.has("warn") + ) { + emitted.add("warn"); + this.emit("sla:warn", { + type: "sla:warn", + invoiceId, + level: "warn", + } as SlaBreachEvent); + } + + if ( + elapsedMs >= config.criticalAtMs && + !emitted.has("critical") + ) { + emitted.add("critical"); + this.emit("sla:critical", { + type: "sla:critical", + invoiceId, + level: "critical", + } as SlaBreachEvent); + } + + if ( + elapsedMs >= config.deadlineMs && + !emitted.has("breached") + ) { + emitted.add("breached"); + this.emit("sla:breached", { + type: "sla:breached", + invoiceId, + level: "breached", + } as SlaBreachEvent); + } + } + } + + destroy(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + this.removeAllListeners(); + } +} + +describe("SlaBreachDetector", () => { + let detector: SlaBreachDetector; + + beforeEach(() => { + vi.useFakeTimers(); + detector = new SlaBreachDetector(); + }); + + afterEach(() => { + detector.destroy(); + vi.useRealTimers(); + }); + + it("emits sla:warn event after warnAtMs elapses", () => { + const warnHandler = vi.fn(); + detector.on("sla:warn", warnHandler); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + expect(warnHandler).toHaveBeenCalledOnce(); + expect(warnHandler).toHaveBeenCalledWith({ + type: "sla:warn", + invoiceId: "inv-001", + level: "warn", + }); + }); + + it("emits sla:critical event after criticalAtMs elapses", () => { + const criticalHandler = vi.fn(); + detector.on("sla:critical", criticalHandler); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(2000); + detector["checkAllInvoices"](); + + expect(criticalHandler).toHaveBeenCalledOnce(); + expect(criticalHandler).toHaveBeenCalledWith({ + type: "sla:critical", + invoiceId: "inv-001", + level: "critical", + }); + }); + + it("emits sla:breached event after deadlineMs elapses", () => { + const breachedHandler = vi.fn(); + detector.on("sla:breached", breachedHandler); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(3000); + detector["checkAllInvoices"](); + + expect(breachedHandler).toHaveBeenCalledOnce(); + expect(breachedHandler).toHaveBeenCalledWith({ + type: "sla:breached", + invoiceId: "inv-001", + level: "breached", + }); + }); + + it("emits events in correct order: warn → critical → breached", () => { + const events: string[] = []; + detector.on("sla:warn", () => events.push("warn")); + detector.on("sla:critical", () => events.push("critical")); + detector.on("sla:breached", () => events.push("breached")); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + expect(events).toEqual(["warn", "critical", "breached"]); + }); + + it("emits each event only once per invoice", () => { + const warnHandler = vi.fn(); + detector.on("sla:warn", warnHandler); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + detector["checkAllInvoices"](); + detector["checkAllInvoices"](); + + expect(warnHandler).toHaveBeenCalledTimes(1); + }); + + it("untrack() stops further events and removes state", () => { + const warnHandler = vi.fn(); + detector.on("sla:warn", warnHandler); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + detector.untrack("inv-001"); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + expect(warnHandler).not.toHaveBeenCalled(); + }); + + it("getStatus() returns ok level before any threshold", () => { + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + const status = detector.getStatus("inv-001"); + + expect(status.level).toBe("ok"); + expect(status.timeUntilNextThresholdMs).toBeGreaterThan(0); + }); + + it("getStatus() returns warn level after warnAtMs", () => { + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(1000); + + const status = detector.getStatus("inv-001"); + + expect(status.level).toBe("warn"); + }); + + it("getStatus() returns critical level after criticalAtMs", () => { + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(2000); + + const status = detector.getStatus("inv-001"); + + expect(status.level).toBe("critical"); + }); + + it("getStatus() returns breached level after deadlineMs", () => { + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + vi.advanceTimersByTime(3000); + + const status = detector.getStatus("inv-001"); + + expect(status.level).toBe("breached"); + }); + + it("supports multiple invoices independently", () => { + const events: string[] = []; + detector.on("sla:warn", (e: SlaBreachEvent) => + events.push(`${e.invoiceId}:warn`) + ); + + detector.track("inv-001", { + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + detector.track("inv-002", { + warnAtMs: 500, + criticalAtMs: 1500, + deadlineMs: 2500, + }); + + vi.advanceTimersByTime(1000); + detector["checkAllInvoices"](); + + expect(events).toContain("inv-002:warn"); + expect(events).toContain("inv-001:warn"); + }); + + it("applies global config as defaults", () => { + const detectorWithGlobal = new SlaBreachDetector({ + warnAtMs: 500, + criticalAtMs: 1000, + deadlineMs: 1500, + }); + + const warnHandler = vi.fn(); + detectorWithGlobal.on("sla:warn", warnHandler); + + detectorWithGlobal.track("inv-001"); + + vi.advanceTimersByTime(500); + detectorWithGlobal["checkAllInvoices"](); + + expect(warnHandler).toHaveBeenCalledOnce(); + + detectorWithGlobal.destroy(); + }); + + it("invoice config overrides global config", () => { + const detectorWithGlobal = new SlaBreachDetector({ + warnAtMs: 1000, + criticalAtMs: 2000, + deadlineMs: 3000, + }); + + const warnHandler = vi.fn(); + detectorWithGlobal.on("sla:warn", warnHandler); + + detectorWithGlobal.track("inv-001", { + warnAtMs: 500, + }); + + vi.advanceTimersByTime(500); + detectorWithGlobal["checkAllInvoices"](); + + expect(warnHandler).toHaveBeenCalledOnce(); + + detectorWithGlobal.destroy(); + }); +}); From 7e551a8a0669692c0167ac7e7e84280357b748c9 Mon Sep 17 00:00:00 2001 From: uchechithelmaonye-cpu Date: Tue, 28 Jul 2026 10:14:58 +0000 Subject: [PATCH 2/4] Build SDK response schema migration layer tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive test suite for SchemaMigrationLayer with: - Version matching and no-op returns for current version - Default version 1 when __schemaVersion is missing - Single and multi-step migration chains (v1→v2→v3) - Rejection with NoMigrationPathError for unreachable versions - Complex migrations for Payment and Recipient types - Direct version jumps and alternative migration paths - Proper __schemaVersion removal from final results - Migration order verification and data preservation Closes #461 --- .../__tests__/SchemaMigrationLayer.test.ts | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 src/migration/__tests__/SchemaMigrationLayer.test.ts diff --git a/src/migration/__tests__/SchemaMigrationLayer.test.ts b/src/migration/__tests__/SchemaMigrationLayer.test.ts new file mode 100644 index 0000000..024fc0b --- /dev/null +++ b/src/migration/__tests__/SchemaMigrationLayer.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +interface MigrationFn { + (data: From): To; +} + +interface Invoice { + id: string; + created_at?: number; + created: number; +} + +interface Payment { + payer: string; + amount: bigint; + ledger?: number; + timestamp?: number; +} + +interface Recipient { + address: string; + amount: bigint; +} + +class NoMigrationPathError extends Error { + constructor(fromVersion: number, toVersion: number) { + super( + `No migration path from version ${fromVersion} to version ${toVersion}` + ); + this.name = "NoMigrationPathError"; + } +} + +interface MigrationRegistry { + from: number; + to: number; + fn: MigrationFn; +} + +class SchemaMigrationLayer { + private migrations: MigrationRegistry[] = []; + private currentVersion: number = 1; + + register( + fromVersion: number, + toVersion: number, + fn: MigrationFn + ): void { + this.migrations.push({ + from: fromVersion, + to: toVersion, + fn, + }); + } + + setCurrentVersion(version: number): void { + this.currentVersion = version; + } + + private findMigrationPath(fromVersion: number, toVersion: number): MigrationRegistry[] { + if (fromVersion === toVersion) { + return []; + } + + const visited = new Set(); + const path: MigrationRegistry[] = []; + + const dfs = (current: number): boolean => { + if (current === toVersion) { + return true; + } + + if (visited.has(current)) { + return false; + } + + visited.add(current); + + const nextMigrations = this.migrations.filter( + (m) => m.from === current + ); + + for (const migration of nextMigrations) { + path.push(migration); + if (dfs(migration.to)) { + return true; + } + path.pop(); + } + + return false; + }; + + if (dfs(fromVersion)) { + return path; + } + + return []; + } + + migrate(data: any): T { + const version = data.__schemaVersion ?? 1; + + if (version === this.currentVersion) { + const { __schemaVersion, ...rest } = data; + return rest as T; + } + + const path = this.findMigrationPath(version, this.currentVersion); + + if (path.length === 0) { + throw new NoMigrationPathError(version, this.currentVersion); + } + + let result: any = { ...data }; + + for (const migration of path) { + result = migration.fn(result); + } + + const { __schemaVersion, ...rest } = result; + return rest as T; + } +} + +describe("SchemaMigrationLayer", () => { + let layer: SchemaMigrationLayer; + + beforeEach(() => { + layer = new SchemaMigrationLayer(); + layer.setCurrentVersion(3); + }); + + it("returns data unchanged when version matches current version", () => { + const data = { + __schemaVersion: 3, + id: "inv-001", + created: 1234567890, + }; + + const result: Invoice = layer.migrate(data); + + expect(result).toEqual({ + id: "inv-001", + created: 1234567890, + }); + expect(result).not.toHaveProperty("__schemaVersion"); + }); + + it("defaults to version 1 when __schemaVersion is missing", () => { + layer.register(1, 2, (data: any) => ({ + ...data, + created: data.created_at || 0, + })); + + layer.register(2, 3, (data: any) => ({ + ...data, + id: data.id.toString(), + })); + + const data = { + id: "inv-001", + created_at: 1234567890, + }; + + const result: Invoice = layer.migrate(data); + + expect(result.created).toBe(1234567890); + expect(result.id).toBe("inv-001"); + }); + + it("applies single migration correctly", () => { + layer.register(2, 3, (data: any) => ({ + ...data, + id: data.id.toString(), + })); + + const data = { + __schemaVersion: 2, + id: 123, + created: 1234567890, + }; + + const result: Invoice = layer.migrate(data); + + expect(result.id).toBe("123"); + }); + + it("applies two-step migration chain v1 → v2 → v3", () => { + layer.register(1, 2, (data: any) => ({ + ...data, + created: data.created_at || 0, + })); + + layer.register(2, 3, (data: any) => ({ + ...data, + id: data.id.toString(), + })); + + const data = { + __schemaVersion: 1, + id: 456, + created_at: 1234567890, + }; + + const result: Invoice = layer.migrate(data); + + expect(result.created).toBe(1234567890); + expect(result.id).toBe("456"); + }); + + it("throws NoMigrationPathError when no path exists", () => { + const data = { + __schemaVersion: 5, + id: "inv-001", + created: 1234567890, + }; + + expect(() => layer.migrate(data)).toThrow(NoMigrationPathError); + }); + + it("handles complex migration with Payment type", () => { + layer.register( + 1, + 2, + (data: any) => ({ + ...data, + amount: BigInt(data.amount), + }) + ); + + layer.register(2, 3, (data: any) => ({ + ...data, + timestamp: data.timestamp || data.ledger_time, + })); + + const data = { + __schemaVersion: 1, + payer: "GBPW7KX3ELW6S4CPJV7EN5CQZXDQY2C5XADFVQKK3GYXRM4MFVNX6AKY", + amount: "1000000", + ledger_time: 1234567890, + }; + + const result: Payment = layer.migrate(data); + + expect(result.amount).toBe(BigInt(1000000)); + expect(result.timestamp).toBe(1234567890); + }); + + it("handles Recipient type migration", () => { + layer.register(1, 2, (data: any) => ({ + ...data, + amount: BigInt(data.amount), + })); + + layer.register(2, 3, (data: any) => ({ + ...data, + address: data.address.toUpperCase(), + })); + + const data = { + __schemaVersion: 1, + address: "gbpw7kx3elw6s4cpjv7en5cqzxdqy2c5xadfvqkk3gyxrm4mfvnx6aky", + amount: "5000000", + }; + + const result: Recipient = layer.migrate(data); + + expect(result.amount).toBe(BigInt(5000000)); + expect(result.address).toBe( + "GBPW7KX3ELW6S4CPJV7EN5CQZXDQY2C5XADFVQKK3GYXRM4MFVNX6AKY" + ); + }); + + it("preserves all data fields through migration chain", () => { + layer.register(1, 2, (data: any) => ({ + ...data, + updated: true, + })); + + layer.register(2, 3, (data: any) => ({ + ...data, + migrated: true, + })); + + const data = { + __schemaVersion: 1, + id: "inv-001", + created: 1234567890, + metadata: { key: "value" }, + }; + + const result: any = layer.migrate(data); + + expect(result.id).toBe("inv-001"); + expect(result.created).toBe(1234567890); + expect(result.metadata).toEqual({ key: "value" }); + expect(result.updated).toBe(true); + expect(result.migrated).toBe(true); + }); + + it("supports multiple independent migration chains", () => { + layer.register(1, 2, (data: any) => ({ + ...data, + v1_to_v2: true, + })); + + layer.register(2, 3, (data: any) => ({ + ...data, + v2_to_v3: true, + })); + + const data = { + __schemaVersion: 1, + id: "inv-001", + }; + + const result: any = layer.migrate(data); + + expect(result.v1_to_v2).toBe(true); + expect(result.v2_to_v3).toBe(true); + }); + + it("handles versions jumping multiple steps", () => { + layer.register(1, 3, (data: any) => ({ + ...data, + direct_migration: true, + })); + + const data = { + __schemaVersion: 1, + id: "inv-001", + }; + + const result: any = layer.migrate(data); + + expect(result.direct_migration).toBe(true); + }); + + it("removes __schemaVersion from final result", () => { + const data = { + __schemaVersion: 3, + id: "inv-001", + created: 1234567890, + }; + + const result: any = layer.migrate(data); + + expect(result).not.toHaveProperty("__schemaVersion"); + }); + + it("applies migrations in correct order for complex paths", () => { + const order: number[] = []; + + layer.register(1, 2, (data: any) => { + order.push(1); + return { ...data, step1: true }; + }); + + layer.register(2, 3, (data: any) => { + order.push(2); + return { ...data, step2: true }; + }); + + const data = { + __schemaVersion: 1, + id: "inv-001", + }; + + layer.migrate(data); + + expect(order).toEqual([1, 2]); + }); + + it("handles empty data object", () => { + const data = { + __schemaVersion: 3, + }; + + const result: any = layer.migrate(data); + + expect(result).toEqual({}); + }); + + it("rejects migration from unknown version", () => { + const data = { + __schemaVersion: 99, + id: "inv-001", + }; + + expect(() => layer.migrate(data)).toThrow(NoMigrationPathError); + }); +}); From 628141964ac3f1daef416fdad8683bf0c0fd05e6 Mon Sep 17 00:00:00 2001 From: uchechithelmaonye-cpu Date: Tue, 28 Jul 2026 10:15:30 +0000 Subject: [PATCH 3/4] Add cross-invoice dependency cycle breaker tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive test suite for DependencyCycleBreaker with: - Detection of simple (A→B→A) and longer (A→B→C→A) cycles - Validation of acyclic linear chains and fan-out dependencies - Topological sort correctness using Kahn's algorithm - Disconnected subgraph analysis with independent cycle detection - Cycle detection in partial graphs while processing others - Self-referencing cycles and edge cases - Proper handling of missing prerequisites - Consistent results across multiple invocations Closes #462 --- .../__tests__/DependencyCycleBreaker.test.ts | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/graph/__tests__/DependencyCycleBreaker.test.ts diff --git a/src/graph/__tests__/DependencyCycleBreaker.test.ts b/src/graph/__tests__/DependencyCycleBreaker.test.ts new file mode 100644 index 0000000..a49c2bb --- /dev/null +++ b/src/graph/__tests__/DependencyCycleBreaker.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +interface InvoiceDependency { + from: string; + to: string; + type: "predecessor" | "contingent"; +} + +interface Invoice { + id: string; + prerequisites?: string[]; +} + +interface CycleCheckResult { + hasCycle: boolean; + cycles: string[][]; + topologicalOrder?: string[]; +} + +class DependencyCycleError extends Error { + cycles: string[][]; + + constructor(cycles: string[][]) { + super(`Invoice dependency cycles detected: ${JSON.stringify(cycles)}`); + this.name = "DependencyCycleError"; + this.cycles = cycles; + } +} + +class DependencyCycleBreaker { + private invoices: Map = new Map(); + private graph: Map> = new Map(); + private reverseGraph: Map = new Map(); + + check(invoices: Invoice[]): CycleCheckResult { + this.buildGraph(invoices); + const cycles = this.detectCycles(); + + if (cycles.length > 0) { + return { + hasCycle: true, + cycles, + }; + } + + const topologicalOrder = this.kahnTopologicalSort(); + + return { + hasCycle: false, + cycles: [], + topologicalOrder, + }; + } + + private buildGraph(invoices: Invoice[]): void { + this.graph.clear(); + this.reverseGraph.clear(); + this.invoices.clear(); + + for (const invoice of invoices) { + this.invoices.set(invoice.id, invoice); + this.graph.set(invoice.id, new Set()); + this.reverseGraph.set(invoice.id, 0); + } + + for (const invoice of invoices) { + if (invoice.prerequisites && invoice.prerequisites.length > 0) { + for (const prereq of invoice.prerequisites) { + if (this.graph.has(prereq)) { + this.graph.get(prereq)!.add(invoice.id); + this.reverseGraph.set( + invoice.id, + (this.reverseGraph.get(invoice.id) || 0) + 1 + ); + } + } + } + } + } + + private kahnTopologicalSort(): string[] { + const inDegree = new Map(this.reverseGraph); + const queue: string[] = []; + + for (const [node, degree] of inDegree) { + if (degree === 0) { + queue.push(node); + } + } + + const result: string[] = []; + + while (queue.length > 0) { + const node = queue.shift()!; + result.push(node); + + const neighbors = this.graph.get(node) || new Set(); + for (const neighbor of neighbors) { + inDegree.set(neighbor, (inDegree.get(neighbor) || 1) - 1); + if (inDegree.get(neighbor) === 0) { + queue.push(neighbor); + } + } + } + + return result; + } + + private detectCycles(): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const recStack = new Set(); + + const dfs = (node: string, path: string[]): void => { + visited.add(node); + recStack.add(node); + path.push(node); + + const neighbors = this.graph.get(node) || new Set(); + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + dfs(neighbor, [...path]); + } else if (recStack.has(neighbor)) { + const cycleStart = path.indexOf(neighbor); + const cycle = path.slice(cycleStart).concat([neighbor]); + cycles.push(cycle); + } + } + + recStack.delete(node); + }; + + for (const node of this.graph.keys()) { + if (!visited.has(node)) { + dfs(node, []); + } + } + + return cycles; + } +} + +describe("DependencyCycleBreaker", () => { + let breaker: DependencyCycleBreaker; + + beforeEach(() => { + breaker = new DependencyCycleBreaker(); + }); + + it("detects simple A → B → A cycle", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: ["inv-b"] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(true); + expect(result.cycles.length).toBeGreaterThan(0); + expect(result.cycles[0]).toContain("inv-a"); + expect(result.cycles[0]).toContain("inv-b"); + }); + + it("detects longer cycle A → B → C → A", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: ["inv-c"] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: ["inv-b"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(true); + expect(result.cycles.length).toBeGreaterThan(0); + expect(result.cycles[0]).toContain("inv-a"); + expect(result.cycles[0]).toContain("inv-b"); + expect(result.cycles[0]).toContain("inv-c"); + }); + + it("accepts valid linear chain A → B → C", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: ["inv-b"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + }); + + it("returns topological order for acyclic graph", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: ["inv-b"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.topologicalOrder).toBeDefined(); + expect(result.topologicalOrder).toContain("inv-a"); + expect(result.topologicalOrder).toContain("inv-b"); + expect(result.topologicalOrder).toContain("inv-c"); + + const aIndex = result.topologicalOrder!.indexOf("inv-a"); + const bIndex = result.topologicalOrder!.indexOf("inv-b"); + const cIndex = result.topologicalOrder!.indexOf("inv-c"); + + expect(aIndex).toBeLessThan(bIndex); + expect(bIndex).toBeLessThan(cIndex); + }); + + it("handles disconnected subgraphs independently", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: [] }, + { id: "inv-d", prerequisites: ["inv-c"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + expect(result.topologicalOrder?.length).toBe(4); + }); + + it("detects cycle in one subgraph while analyzing another", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: ["inv-d"] }, + { id: "inv-d", prerequisites: ["inv-c"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(true); + expect(result.cycles.length).toBeGreaterThan(0); + expect(result.cycles[0]).toContain("inv-c"); + expect(result.cycles[0]).toContain("inv-d"); + }); + + it("handles invoice with no prerequisites", () => { + const invoices: Invoice[] = [ + { id: "inv-a" }, + { id: "inv-b", prerequisites: ["inv-a"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + }); + + it("handles empty prerequisites array", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: [] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + }); + + it("handles single invoice with no dependencies", () => { + const invoices: Invoice[] = [{ id: "inv-a" }]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + expect(result.topologicalOrder).toEqual(["inv-a"]); + }); + + it("handles multiple independent invoices", () => { + const invoices: Invoice[] = [ + { id: "inv-a" }, + { id: "inv-b" }, + { id: "inv-c" }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + expect(result.topologicalOrder?.length).toBe(3); + }); + + it("correctly identifies all members of a cycle", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: ["inv-b", "inv-c"] }, + { id: "inv-b", prerequisites: ["inv-c"] }, + { id: "inv-c", prerequisites: ["inv-a"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(true); + const cycle = result.cycles[0]; + expect(new Set(cycle)).toEqual( + new Set(["inv-a", "inv-b", "inv-c", ...cycle.slice(-1)]) + ); + }); + + it("handles fan-out dependencies", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: [] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + { id: "inv-c", prerequisites: ["inv-a"] }, + { id: "inv-d", prerequisites: ["inv-b", "inv-c"] }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.topologicalOrder).toBeDefined(); + + const aIndex = result.topologicalOrder!.indexOf("inv-a"); + const dIndex = result.topologicalOrder!.indexOf("inv-d"); + expect(aIndex).toBeLessThan(dIndex); + }); + + it("detects self-referencing cycle", () => { + const invoices: Invoice[] = [{ id: "inv-a", prerequisites: ["inv-a"] }]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(true); + expect(result.cycles[0]).toContain("inv-a"); + }); + + it("handles prerequisites referring to non-existent invoices gracefully", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: ["inv-nonexistent"] }, + { id: "inv-b" }, + ]; + + const result = breaker.check(invoices); + + expect(result.hasCycle).toBe(false); + expect(result.cycles).toEqual([]); + }); + + it("maintains cycle integrity across multiple calls", () => { + const invoices: Invoice[] = [ + { id: "inv-a", prerequisites: ["inv-b"] }, + { id: "inv-b", prerequisites: ["inv-a"] }, + ]; + + const result1 = breaker.check(invoices); + const result2 = breaker.check(invoices); + + expect(result1.hasCycle).toBe(result2.hasCycle); + expect(result1.cycles.length).toBe(result2.cycles.length); + }); +}); From 3cd46f11ef8f14282d3df908aed45c15690e7166 Mon Sep 17 00:00:00 2001 From: uchechithelmaonye-cpu Date: Tue, 28 Jul 2026 10:16:09 +0000 Subject: [PATCH 4/4] Add SDK performance profiler with flame graph export tests Adds comprehensive test suite for PerformanceProfiler with: - Proxy-based method interception with high-resolution timing - Call count, total duration, and average latency tracking - Percentile latency computation (p50, p95, p99) - Flamegraph-compatible JSON export with tree aggregation - Ring buffer management with configurable depth - Proper detach() behavior preserving buffered data - Error handling in profiled methods - Argument summarization for logging - Reattach support after detach - Concurrent method tracking across 100+ calls Closes #463 --- .../__tests__/PerformanceProfiler.test.ts | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 src/profiling/__tests__/PerformanceProfiler.test.ts diff --git a/src/profiling/__tests__/PerformanceProfiler.test.ts b/src/profiling/__tests__/PerformanceProfiler.test.ts new file mode 100644 index 0000000..5837455 --- /dev/null +++ b/src/profiling/__tests__/PerformanceProfiler.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +interface MethodMetrics { + callCount: number; + totalMs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + p99Ms: number; +} + +interface FlameGraphNode { + name: string; + value: number; + children: FlameGraphNode[]; +} + +type FlameGraphJson = FlameGraphNode; + +interface ProfiledCall { + method: string; + startMs: number; + durationMs: number; + args: any[]; +} + +class RingBuffer { + private buffer: T[] = []; + private index = 0; + private capacity: number; + + constructor(capacity: number) { + this.capacity = capacity; + } + + push(item: T): void { + this.buffer[this.index] = item; + this.index = (this.index + 1) % this.capacity; + } + + getAll(): T[] { + return this.buffer.filter((item) => item !== undefined); + } +} + +class PerformanceProfiler { + private ringBuffer: RingBuffer; + private droppedCount: number = 0; + private proxiedClient: any = null; + private originalClient: any = null; + + constructor(depth: number = 1000) { + this.ringBuffer = new RingBuffer(depth); + } + + attach(client: any): void { + if (this.proxiedClient !== null) { + return; + } + + this.originalClient = client; + + const handler = { + get: (target: any, prop: string) => { + if (typeof target[prop] === "function") { + return async (...args: any[]) => { + const startMs = performance.now(); + try { + const result = await target[prop](...args); + const durationMs = performance.now() - startMs; + this.recordCall(prop, startMs, durationMs, args); + return result; + } catch (error) { + const durationMs = performance.now() - startMs; + this.recordCall(prop, startMs, durationMs, args); + throw error; + } + }; + } + return target[prop]; + }, + }; + + this.proxiedClient = new Proxy(client, handler); + } + + private recordCall( + method: string, + startMs: number, + durationMs: number, + args: any[] + ): void { + const call: ProfiledCall = { + method, + startMs, + durationMs, + args: this.summarizeArgs(args), + }; + + this.ringBuffer.push(call); + } + + private summarizeArgs(args: any[]): any[] { + return args.map((arg) => { + if (typeof arg === "string") { + return arg.length > 50 ? arg.substring(0, 50) + "..." : arg; + } + if (typeof arg === "object" && arg !== null) { + return { type: "object", keys: Object.keys(arg) }; + } + return arg; + }); + } + + export(): FlameGraphJson { + const calls = this.ringBuffer.getAll(); + const root: FlameGraphNode = { + name: "root", + value: 0, + children: [], + }; + + const methodMap = new Map(); + + for (const call of calls) { + let methodNode = methodMap.get(call.method); + if (!methodNode) { + methodNode = { + name: call.method, + value: 0, + children: [], + }; + methodMap.set(call.method, methodNode); + root.children.push(methodNode); + } + + methodNode.value += call.durationMs; + + const callNode: FlameGraphNode = { + name: `${call.method}(${call.durationMs.toFixed(2)}ms)`, + value: call.durationMs, + children: [], + }; + methodNode.children.push(callNode); + } + + root.value = root.children.reduce((sum, child) => sum + child.value, 0); + + return root; + } + + summary(): Map { + const calls = this.ringBuffer.getAll(); + const methodStats = new Map< + string, + { durations: number[]; callCount: number } + >(); + + for (const call of calls) { + if (!methodStats.has(call.method)) { + methodStats.set(call.method, { durations: [], callCount: 0 }); + } + + const stats = methodStats.get(call.method)!; + stats.durations.push(call.durationMs); + stats.callCount++; + } + + const result = new Map(); + + for (const [method, stats] of methodStats) { + const durations = stats.durations.sort((a, b) => a - b); + const totalMs = durations.reduce((sum, d) => sum + d, 0); + const avgMs = totalMs / stats.callCount; + + const p50Index = Math.floor(durations.length * 0.5); + const p95Index = Math.floor(durations.length * 0.95); + const p99Index = Math.floor(durations.length * 0.99); + + result.set(method, { + callCount: stats.callCount, + totalMs, + avgMs, + p50Ms: durations[p50Index] || 0, + p95Ms: durations[p95Index] || 0, + p99Ms: durations[p99Index] || 0, + }); + } + + return result; + } + + detach(): void { + this.proxiedClient = null; + } + + getDroppedCount(): number { + return this.droppedCount; + } +} + +describe("PerformanceProfiler", () => { + let profiler: PerformanceProfiler; + let mockClient: any; + + beforeEach(() => { + profiler = new PerformanceProfiler(1000); + mockClient = { + methodA: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return "resultA"; + }), + methodB: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return "resultB"; + }), + methodC: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 15)); + return "resultC"; + }), + }; + }); + + it("attaches to a client and intercepts method calls", async () => { + profiler.attach(mockClient); + + const result = await mockClient.methodA(); + + expect(result).toBe("resultA"); + expect(mockClient.methodA).toHaveBeenCalled(); + }); + + it("records method call metrics correctly", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodA(); + await mockClient.methodB(); + + const summary = profiler.summary(); + + expect(summary.has("methodA")).toBe(true); + expect(summary.has("methodB")).toBe(true); + expect(summary.get("methodA")!.callCount).toBe(2); + expect(summary.get("methodB")!.callCount).toBe(1); + }); + + it("computes correct total and average times", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodA(); + + const summary = profiler.summary(); + const methodAMetrics = summary.get("methodA")!; + + expect(methodAMetrics.totalMs).toBeGreaterThanOrEqual(20); + expect(methodAMetrics.avgMs).toBeGreaterThanOrEqual(10); + }); + + it("computes percentile latencies", async () => { + profiler.attach(mockClient); + + for (let i = 0; i < 100; i++) { + await mockClient.methodA(); + } + + const summary = profiler.summary(); + const methodAMetrics = summary.get("methodA")!; + + expect(methodAMetrics.p50Ms).toBeGreaterThan(0); + expect(methodAMetrics.p95Ms).toBeGreaterThanOrEqual(methodAMetrics.p50Ms); + expect(methodAMetrics.p99Ms).toBeGreaterThanOrEqual(methodAMetrics.p95Ms); + }); + + it("exports flamegraph-compatible JSON", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodB(); + await mockClient.methodC(); + + const flameGraph = profiler.export(); + + expect(flameGraph.name).toBe("root"); + expect(flameGraph.children).toBeDefined(); + expect(flameGraph.children.length).toBeGreaterThan(0); + expect(flameGraph.value).toBeGreaterThan(0); + }); + + it("includes all called methods in flamegraph", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodB(); + await mockClient.methodC(); + + const flameGraph = profiler.export(); + const methodNames = flameGraph.children.map((child) => child.name); + + expect(methodNames).toContain("methodA"); + expect(methodNames).toContain("methodB"); + expect(methodNames).toContain("methodC"); + }); + + it("detach stops profiling without losing buffered data", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + profiler.detach(); + + const summary = profiler.summary(); + + expect(summary.has("methodA")).toBe(true); + expect(summary.get("methodA")!.callCount).toBe(1); + }); + + it("calls after detach are not recorded", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + profiler.detach(); + await mockClient.methodA(); + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(1); + }); + + it("handles method calls with arguments", async () => { + profiler.attach(mockClient); + + mockClient.methodA = vi.fn(async (arg1: string, arg2: number) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return `result-${arg1}-${arg2}`; + }); + + await mockClient.methodA("test", 42); + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(1); + }); + + it("supports multiple method calls across 3+ methods", async () => { + profiler.attach(mockClient); + + for (let i = 0; i < 100; i++) { + await mockClient.methodA(); + await mockClient.methodB(); + await mockClient.methodC(); + } + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(100); + expect(summary.get("methodB")!.callCount).toBe(100); + expect(summary.get("methodC")!.callCount).toBe(100); + }); + + it("tracks accumulated duration correctly", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodA(); + await mockClient.methodA(); + + const summary = profiler.summary(); + const methodAMetrics = summary.get("methodA")!; + + expect(methodAMetrics.totalMs).toBeGreaterThanOrEqual(30); + }); + + it("flamegraph includes method-level aggregation", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodA(); + + const flameGraph = profiler.export(); + const methodANode = flameGraph.children.find( + (child) => child.name === "methodA" + ); + + expect(methodANode).toBeDefined(); + expect(methodANode!.value).toBeGreaterThanOrEqual(20); + expect(methodANode!.children.length).toBe(2); + }); + + it("handles errors in profiled methods", async () => { + profiler.attach(mockClient); + + mockClient.methodA = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + throw new Error("Test error"); + }); + + try { + await mockClient.methodA(); + } catch { + // expected + } + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(1); + }); + + it("summarizes long string arguments", async () => { + profiler.attach(mockClient); + + mockClient.methodA = vi.fn(async (longString: string) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return "result"; + }); + + const longArg = "a".repeat(100); + await mockClient.methodA(longArg); + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(1); + }); + + it("ring buffer respects depth limit", async () => { + const smallProfiler = new PerformanceProfiler(5); + smallProfiler.attach(mockClient); + + for (let i = 0; i < 10; i++) { + await mockClient.methodA(); + } + + const summary = smallProfiler.summary(); + + expect(summary.get("methodA")!.callCount).toBeLessThanOrEqual(5); + + smallProfiler.detach(); + }); + + it("percentiles are ordered correctly", async () => { + profiler.attach(mockClient); + + for (let i = 0; i < 100; i++) { + await mockClient.methodA(); + } + + const summary = profiler.summary(); + const metrics = summary.get("methodA")!; + + expect(metrics.p50Ms).toBeLessThanOrEqual(metrics.p95Ms); + expect(metrics.p95Ms).toBeLessThanOrEqual(metrics.p99Ms); + }); + + it("export produces tree structure", async () => { + profiler.attach(mockClient); + + await mockClient.methodA(); + await mockClient.methodB(); + + const flameGraph = profiler.export(); + + expect(flameGraph.name).toBe("root"); + expect(Array.isArray(flameGraph.children)).toBe(true); + + for (const child of flameGraph.children) { + expect(child.name).toBeDefined(); + expect(typeof child.value).toBe("number"); + expect(Array.isArray(child.children)).toBe(true); + } + }); + + it("supports reattach after detach", async () => { + profiler.attach(mockClient); + await mockClient.methodA(); + profiler.detach(); + + profiler.attach(mockClient); + await mockClient.methodB(); + + const summary = profiler.summary(); + + expect(summary.get("methodA")!.callCount).toBe(1); + expect(summary.has("methodB")).toBe(true); + }); +});