23f7624596
ADR-166 MCP Bridge Security Lock / Static-source security lock (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / Compose default binds loopback + Mongo has auth (push) Failing after 2s
CodeQL Advanced / Analyze (rust) (push) Failing after 0s
ADR-166 MCP Bridge Security Lock / plugin-agent-federation bindHost default (push) Failing after 1s
ADR-166 MCP Bridge Security Lock / Runtime behavior — 401 + terminal gate + fail-closed (push) Failing after 4s
business-pods-smoke / smoke (push) Failing after 1s
all-plugins-smoke / smoke-all (push) Failing after 2s
CI/CD Pipeline / Security & Code Quality (push) Failing after 1s
CI/CD Pipeline / Test Suite (ubuntu-latest) (push) Failing after 1s
CI/CD Pipeline / Build & Package (macos-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (ubuntu-latest) (push) Has been skipped
CI/CD Pipeline / Build & Package (windows-latest) (push) Has been skipped
CI/CD Pipeline / Documentation & Examples (push) Failing after 1s
Clone Tracker (14-day rolling) / Snapshot clones for ruflo ecosystem (push) Failing after 1s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
federation-peer-rust / stable-noop (push) Failing after 1s
metaharness-ci / score (push) Failing after 1s
metaharness-ci / router-compat (push) Failing after 0s
metaharness-ci / similarity-tests (push) Failing after 0s
no-agentbbs-smoke / smoke-without-agentbbs (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (windows-latest) (push) Has been skipped
codex-integration-audit / Codex integration audit (push) Failing after 1s
helpers-manifest-guard / guard (push) Failing after 1s
🔗 Cross-Agent Integration Tests / 🤝 Agent Coordination Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🧠 Memory Sharing Integration (push) Has been skipped
🔗 Cross-Agent Integration Tests / 🛡️ Fault Tolerance Tests (push) Has been skipped
🔗 Cross-Agent Integration Tests / ⚡ Performance Integration Tests (push) Has been skipped
metaharness-ci / mcp-scan (push) Failing after 1s
metaharness-ci / eject-dryrun (push) Failing after 1s
metaharness-ci / metaharness-real-data (push) Failing after 0s
no-cli-optdep-bloat-2561 / guard (push) Failing after 1s
no-metaharness-smoke / smoke-without-metaharness (push) Failing after 1s
no-phantom-agentic-flow-subpath / guard (push) Failing after 1s
🔄 Automated Rollback Manager / 🚨 Failure Detection (push) Failing after 1s
V3 CI/CD Pipeline / Plugin hooks smoke / ubuntu-latest / Node 22 (push) Failing after 1s
V3 CI/CD Pipeline / ruflo-graph-intelligence build + test smoke (#2044, ADR-123) (push) Failing after 1s
CVE Audit Gate / Audit root (critical-blocking) (push) Failing after 2s
cost-tracker-smoke / smoke (push) Failing after 3s
oia-audit-weekly / audit (push) Failing after 2s
ruflo-agent-smoke / ruflo-agent structural smoke (push) Failing after 1s
📊 Status Badges Update / 📊 Update Status Badges (push) Failing after 1s
V3 CI/CD Pipeline / Static regression guards (#2267 YAML + (push) Failing after 1s
V3 CI/CD Pipeline / Test V3 Packages (push) Failing after 0s
V3 CI/CD Pipeline / agent_execute provider routing smoke (#2042) (push) Failing after 0s
CVE Audit Gate / Audit v3 (critical-blocking) (push) Failing after 1s
federation-peer-rust / stable-native (push) Failing after 2s
🔗 Cross-Agent Integration Tests / 🚀 Integration Test Setup (push) Failing after 2s
neural-trader-smoke / runtime-smoke (push) Failing after 1s
V3 CI/CD Pipeline / Build V3 (macos-latest) (push) Has been skipped
V3 CI/CD Pipeline / Build V3 (ubuntu-latest) (push) Has been skipped
V3 CI/CD Pipeline / Type Check V3 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 24 (push) Failing after 1s
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / ubuntu-latest / Node 22 (push) Failing after 2s
V3 CI/CD Pipeline / browser rvf create flag smoke (#2015) (push) Failing after 0s
V3 CI/CD Pipeline / Dependency review (#2046) (push) Has been skipped
V3 CI/CD Pipeline / Supply-chain audit (#2046) (push) Failing after 0s
V3 CI/CD Pipeline / witness marker drift smoke (#2021) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader portfolio CG smoke (#2068, ADR-126 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / neural-trader backtest signing smoke (#2068, ADR-126 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / kg-extract type-import classification smoke (#2049) (push) Failing after 0s
V3 CI/CD Pipeline / witness verify precondition smoke (#1880) (push) Failing after 2s
V3 CI/CD Pipeline / neural-trader pipeline risk-gate smoke (#2068, ADR-126 Phase 5) (push) Failing after 0s
V3 CI/CD Pipeline / neural-trader feature attribution smoke (#2068, ADR-126 Phase 6) (push) Failing after 0s
V3 CI/CD Pipeline / plugin-registry signature verification smoke (#1922, CWE-347) (push) Failing after 4s
V3 CI/CD Pipeline / memory stats legacy-DB smoke (#2120) (push) Failing after 4s
V3 CI/CD Pipeline / github deprecated actions smoke (#2089, ADR-127 Phase 3) (push) Failing after 1s
V3 CI/CD Pipeline / graph query + pathfinder smoke (ADR-130 P2+P5) (push) Has been skipped
V3 CI/CD Pipeline / graph trajectory hooks smoke (ADR-130 P3) (push) Has been skipped
V3 CI/CD Pipeline / graph plugin adapter smoke (ADR-130 P4) (push) Has been skipped
V3 CI/CD Pipeline / graph benchmark (ADR-130 P6) (push) Has been skipped
V3 CI/CD Pipeline / statusline generator delegation smoke (#2195) (push) Failing after 1s
V3 CI/CD Pipeline / wizard init regression guard (#2206 (push) Failing after 1s
V3 CI/CD Pipeline / memory no-stray-db smoke (ADR-125 P7) (push) Failing after 1s
V3 CI/CD Pipeline / github-safe injection smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github actions pin smoke (#2089, ADR-127 Phase 1) (push) Failing after 1s
V3 CI/CD Pipeline / github attribution opt-in smoke (#2089, ADR-127 Phase 4) (push) Failing after 1s
V3 CI/CD Pipeline / pre-bash hook safety smoke (#2017) (push) Failing after 1s
V3 CI/CD Pipeline / Memory import smoke / ubuntu-latest (push) Failing after 0s
V3 CI/CD Pipeline / MCP protocol smoke / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / ruvllm WASM auto-init smoke (#2086) (push) Failing after 4s
V3 CI/CD Pipeline / MCP paired-tool round-trip smoke (#1889) (push) Failing after 1s
V3 CI/CD Pipeline / Plugin package install-safety (#1902/#1903/#1904) (push) Failing after 1s
V3 CI/CD Pipeline / Tool description discoverability (ADR-112) (push) Failing after 3s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (22) (push) Failing after 1s
V3 CI/CD Pipeline / CLI npx-install smoke (#1147 / (24) (push) Failing after 1s
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / ubuntu-latest (push) Failing after 2s
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / ubuntu-latest (push) Failing after 1s
V3 CI/CD Pipeline / Vector-index dimension audit (#1947) (push) Failing after 0s
V3 CI/CD Pipeline / Hook-command install safety (#1921) (push) Failing after 1s
V3 CI/CD Pipeline / ToolOutputGuardrail smoke (ADR-131, (push) Failing after 1s
V3 CI/CD Pipeline / init-bundle invariants smoke (#2095, ADR-128 Phase 5) (push) Failing after 1s
V3 CI/CD Pipeline / wasm provider bridge smoke (ADR-129 P1) (push) Failing after 2s
V3 CI/CD Pipeline / wasm gallery CRUD smoke (ADR-129 P3) (push) Failing after 1s
V3 CI/CD Pipeline / wasm plugin bridge smoke (ADR-129 P4) (push) Failing after 0s
V3 CI/CD Pipeline / wasm compose smoke (ADR-129 P2) (push) Failing after 4s
V3 CI/CD Pipeline / graph schema smoke (ADR-130 P1) (push) Failing after 0s
Validate Marketplace / validate (push) Failing after 1s
🔍 Verification Pipeline / 🚀 Setup Verification (push) Failing after 1s
🔍 Verification Pipeline / 🛡️ Security Verification (push) Has been skipped
🔍 Verification Pipeline / 📝 Code Quality (push) Has been skipped
🔍 Verification Pipeline / 🧪 Test Verification (${{ matrix.os }}, Node ${{ matrix.node }}) (push) Has been skipped
🔍 Verification Pipeline / 🏗️ Build Verification (push) Has been skipped
🔍 Verification Pipeline / 📚 Documentation Verification (push) Has been skipped
CVE Audit Gate / High-severity report (warn only) (push) Has been cancelled
🔄 Automated Rollback Manager / 🔄 Execute Rollback (push) Has been cancelled
🔄 Automated Rollback Manager / ✅ Post-Rollback Verification (push) Has been cancelled
🔄 Automated Rollback Manager / 📊 Rollback Monitoring (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook execution smoke (#2132) / windows-latest (push) Has been cancelled
🔄 Automated Rollback Manager / ⏳ Manual Rollback Approval (push) Has been cancelled
V3 CI/CD Pipeline / MCP protocol smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Memory import smoke / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows hook shim smoke (#2132) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Windows init hooks smoke (#2132) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / macos-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / ubuntu-latest (push) Has been cancelled
V3 CI/CD Pipeline / Witness verify (signed manifest) / windows-latest (push) Has been cancelled
V3 CI/CD Pipeline / Publish to npm (alpha) (push) Has been cancelled
V3 CI/CD Pipeline / Smoke (no better-sqlite3) / macos-latest / Node 22 (push) Has been cancelled
V3 CI/CD Pipeline / Plugin hooks smoke / macos-latest / Node 22 (push) Has been cancelled
CI/CD Pipeline / Deploy & Release (push) Has been cancelled
CI/CD Pipeline / CI Status (push) Has been cancelled
🔗 Cross-Agent Integration Tests / 📊 Integration Test Report (push) Has been cancelled
🔄 Automated Rollback Manager / 🔍 Pre-Rollback Validation (push) Has been cancelled
🔍 Verification Pipeline / ⚡ Performance Verification (push) Has been cancelled
🔍 Verification Pipeline / 📊 Verification Report (push) Has been cancelled
1285 lines
37 KiB
TypeScript
1285 lines
37 KiB
TypeScript
/**
|
|
* V3 Claude-Flow Claims Events Unit Tests
|
|
*
|
|
* London School TDD - Behavior Verification
|
|
* Tests event emission, event data correctness, and event ordering
|
|
*
|
|
* @module v3/claims/tests/events
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { createMock, type MockedInterface, InteractionRecorder } from '../../testing/src/helpers/create-mock.js';
|
|
|
|
// =============================================================================
|
|
// Event Types
|
|
// =============================================================================
|
|
|
|
type ClaimEventType =
|
|
| 'IssueClaimed'
|
|
| 'IssueReleased'
|
|
| 'HandoffRequested'
|
|
| 'HandoffAccepted'
|
|
| 'HandoffRejected'
|
|
| 'ClaimStatusUpdated'
|
|
| 'ClaimExpired'
|
|
| 'WorkMarkedStealable'
|
|
| 'WorkStolen'
|
|
| 'StealContested'
|
|
| 'StealContestResolved'
|
|
| 'StaleWorkDetected'
|
|
| 'LoadRebalanced'
|
|
| 'ImbalanceDetected';
|
|
|
|
interface ClaimEvent<T = unknown> {
|
|
id: string;
|
|
type: ClaimEventType;
|
|
timestamp: Date;
|
|
source: string;
|
|
correlationId?: string;
|
|
causationId?: string;
|
|
payload: T;
|
|
metadata: EventMetadata;
|
|
}
|
|
|
|
interface EventMetadata {
|
|
version: number;
|
|
schemaVersion: string;
|
|
environment: string;
|
|
aggregateId: string;
|
|
aggregateType: string;
|
|
sequenceNumber: number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Event Payloads
|
|
// =============================================================================
|
|
|
|
interface IssueClaimedPayload {
|
|
issueId: string;
|
|
claimant: Claimant;
|
|
claimedAt: Date;
|
|
expiresAt: Date;
|
|
}
|
|
|
|
interface IssueReleasedPayload {
|
|
issueId: string;
|
|
releasedBy: Claimant;
|
|
releasedAt: Date;
|
|
reason?: string;
|
|
}
|
|
|
|
interface HandoffRequestedPayload {
|
|
issueId: string;
|
|
from: Claimant;
|
|
to: Claimant;
|
|
reason: string;
|
|
requestedAt: Date;
|
|
}
|
|
|
|
interface HandoffAcceptedPayload {
|
|
issueId: string;
|
|
from: Claimant;
|
|
to: Claimant;
|
|
acceptedAt: Date;
|
|
}
|
|
|
|
interface HandoffRejectedPayload {
|
|
issueId: string;
|
|
rejectedBy: Claimant;
|
|
reason: string;
|
|
rejectedAt: Date;
|
|
}
|
|
|
|
interface ClaimStatusUpdatedPayload {
|
|
issueId: string;
|
|
previousStatus: string;
|
|
newStatus: string;
|
|
updatedAt: Date;
|
|
updatedBy: Claimant;
|
|
}
|
|
|
|
interface ClaimExpiredPayload {
|
|
issueId: string;
|
|
expiredAt: Date;
|
|
originalClaimant: Claimant;
|
|
}
|
|
|
|
interface WorkStolenPayload {
|
|
issueId: string;
|
|
from: Claimant;
|
|
to: Claimant;
|
|
stolenAt: Date;
|
|
}
|
|
|
|
interface LoadRebalancedPayload {
|
|
actions: RebalanceAction[];
|
|
movedClaims: number;
|
|
overloadedCount: number;
|
|
underloadedCount: number;
|
|
rebalancedAt: Date;
|
|
}
|
|
|
|
interface ImbalanceDetectedPayload {
|
|
severity: string;
|
|
imbalanceScore: number;
|
|
overloadedAgents: string[];
|
|
underloadedAgents: string[];
|
|
detectedAt: Date;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Domain Types (reused from other tests)
|
|
// =============================================================================
|
|
|
|
interface Claimant {
|
|
type: 'agent' | 'human';
|
|
id: string;
|
|
agentType?: string;
|
|
humanId?: string;
|
|
}
|
|
|
|
interface Claim {
|
|
issueId: string;
|
|
claimant: Claimant;
|
|
status: string;
|
|
claimedAt: Date;
|
|
expiresAt: Date;
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
interface RebalanceAction {
|
|
type: 'move' | 'reassign' | 'defer';
|
|
claim: { issueId: string };
|
|
fromAgent: string;
|
|
toAgent: string;
|
|
reason: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Interfaces (Collaborators)
|
|
// =============================================================================
|
|
|
|
interface IEventStore {
|
|
append(event: ClaimEvent): Promise<void>;
|
|
appendBatch(events: ClaimEvent[]): Promise<void>;
|
|
getEvents(aggregateId: string, options?: EventQueryOptions): Promise<ClaimEvent[]>;
|
|
getEventsByType(type: ClaimEventType, options?: EventQueryOptions): Promise<ClaimEvent[]>;
|
|
getLatestEvent(aggregateId: string): Promise<ClaimEvent | null>;
|
|
}
|
|
|
|
interface EventQueryOptions {
|
|
fromSequence?: number;
|
|
toSequence?: number;
|
|
limit?: number;
|
|
fromDate?: Date;
|
|
toDate?: Date;
|
|
}
|
|
|
|
interface IEventPublisher {
|
|
publish(event: ClaimEvent): Promise<void>;
|
|
publishBatch(events: ClaimEvent[]): Promise<void>;
|
|
subscribe(type: ClaimEventType, handler: EventHandler): void;
|
|
unsubscribe(type: ClaimEventType, handler: EventHandler): void;
|
|
}
|
|
|
|
type EventHandler = (event: ClaimEvent) => void | Promise<void>;
|
|
|
|
interface IEventIdGenerator {
|
|
generate(): string;
|
|
}
|
|
|
|
interface IClock {
|
|
now(): Date;
|
|
}
|
|
|
|
interface ISequenceGenerator {
|
|
next(aggregateId: string): number;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Event Emitter Service
|
|
// =============================================================================
|
|
|
|
interface EventEmitterConfig {
|
|
environment: string;
|
|
schemaVersion: string;
|
|
enableBatching: boolean;
|
|
batchSize: number;
|
|
batchTimeoutMs: number;
|
|
}
|
|
|
|
const DEFAULT_EVENT_CONFIG: EventEmitterConfig = {
|
|
environment: 'test',
|
|
schemaVersion: '1.0.0',
|
|
enableBatching: false,
|
|
batchSize: 10,
|
|
batchTimeoutMs: 100,
|
|
};
|
|
|
|
class ClaimEventEmitter {
|
|
private pendingEvents: ClaimEvent[] = [];
|
|
private batchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
constructor(
|
|
private readonly eventStore: IEventStore,
|
|
private readonly eventPublisher: IEventPublisher,
|
|
private readonly idGenerator: IEventIdGenerator,
|
|
private readonly clock: IClock,
|
|
private readonly sequenceGenerator: ISequenceGenerator,
|
|
private readonly config: EventEmitterConfig = DEFAULT_EVENT_CONFIG
|
|
) {}
|
|
|
|
async emitIssueClaimed(
|
|
issueId: string,
|
|
claimant: Claimant,
|
|
expiresAt: Date,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<IssueClaimedPayload>> {
|
|
const event = this.createEvent<IssueClaimedPayload>(
|
|
'IssueClaimed',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
claimant,
|
|
claimedAt: this.clock.now(),
|
|
expiresAt,
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitIssueReleased(
|
|
issueId: string,
|
|
releasedBy: Claimant,
|
|
reason?: string,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<IssueReleasedPayload>> {
|
|
const event = this.createEvent<IssueReleasedPayload>(
|
|
'IssueReleased',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
releasedBy,
|
|
releasedAt: this.clock.now(),
|
|
reason,
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitHandoffRequested(
|
|
issueId: string,
|
|
from: Claimant,
|
|
to: Claimant,
|
|
reason: string,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<HandoffRequestedPayload>> {
|
|
const event = this.createEvent<HandoffRequestedPayload>(
|
|
'HandoffRequested',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
from,
|
|
to,
|
|
reason,
|
|
requestedAt: this.clock.now(),
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitHandoffAccepted(
|
|
issueId: string,
|
|
from: Claimant,
|
|
to: Claimant,
|
|
causationId?: string,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<HandoffAcceptedPayload>> {
|
|
const event = this.createEvent<HandoffAcceptedPayload>(
|
|
'HandoffAccepted',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
from,
|
|
to,
|
|
acceptedAt: this.clock.now(),
|
|
},
|
|
correlationId,
|
|
causationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitHandoffRejected(
|
|
issueId: string,
|
|
rejectedBy: Claimant,
|
|
reason: string,
|
|
causationId?: string,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<HandoffRejectedPayload>> {
|
|
const event = this.createEvent<HandoffRejectedPayload>(
|
|
'HandoffRejected',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
rejectedBy,
|
|
reason,
|
|
rejectedAt: this.clock.now(),
|
|
},
|
|
correlationId,
|
|
causationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitClaimStatusUpdated(
|
|
issueId: string,
|
|
previousStatus: string,
|
|
newStatus: string,
|
|
updatedBy: Claimant,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<ClaimStatusUpdatedPayload>> {
|
|
const event = this.createEvent<ClaimStatusUpdatedPayload>(
|
|
'ClaimStatusUpdated',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
previousStatus,
|
|
newStatus,
|
|
updatedAt: this.clock.now(),
|
|
updatedBy,
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitClaimExpired(
|
|
issueId: string,
|
|
originalClaimant: Claimant,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<ClaimExpiredPayload>> {
|
|
const event = this.createEvent<ClaimExpiredPayload>(
|
|
'ClaimExpired',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
expiredAt: this.clock.now(),
|
|
originalClaimant,
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitWorkStolen(
|
|
issueId: string,
|
|
from: Claimant,
|
|
to: Claimant,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<WorkStolenPayload>> {
|
|
const event = this.createEvent<WorkStolenPayload>(
|
|
'WorkStolen',
|
|
issueId,
|
|
'Claim',
|
|
{
|
|
issueId,
|
|
from,
|
|
to,
|
|
stolenAt: this.clock.now(),
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitLoadRebalanced(
|
|
actions: RebalanceAction[],
|
|
overloadedCount: number,
|
|
underloadedCount: number,
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<LoadRebalancedPayload>> {
|
|
const event = this.createEvent<LoadRebalancedPayload>(
|
|
'LoadRebalanced',
|
|
'load-balancer',
|
|
'LoadBalancer',
|
|
{
|
|
actions,
|
|
movedClaims: actions.filter((a) => a.type === 'move' || a.type === 'reassign').length,
|
|
overloadedCount,
|
|
underloadedCount,
|
|
rebalancedAt: this.clock.now(),
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
async emitImbalanceDetected(
|
|
severity: string,
|
|
imbalanceScore: number,
|
|
overloadedAgents: string[],
|
|
underloadedAgents: string[],
|
|
correlationId?: string
|
|
): Promise<ClaimEvent<ImbalanceDetectedPayload>> {
|
|
const event = this.createEvent<ImbalanceDetectedPayload>(
|
|
'ImbalanceDetected',
|
|
'load-balancer',
|
|
'LoadBalancer',
|
|
{
|
|
severity,
|
|
imbalanceScore,
|
|
overloadedAgents,
|
|
underloadedAgents,
|
|
detectedAt: this.clock.now(),
|
|
},
|
|
correlationId
|
|
);
|
|
|
|
await this.persistAndPublish(event);
|
|
return event;
|
|
}
|
|
|
|
// Batch emission for multiple events
|
|
async emitBatch(events: ClaimEvent[]): Promise<void> {
|
|
await this.eventStore.appendBatch(events);
|
|
await this.eventPublisher.publishBatch(events);
|
|
}
|
|
|
|
private createEvent<T>(
|
|
type: ClaimEventType,
|
|
aggregateId: string,
|
|
aggregateType: string,
|
|
payload: T,
|
|
correlationId?: string,
|
|
causationId?: string
|
|
): ClaimEvent<T> {
|
|
return {
|
|
id: this.idGenerator.generate(),
|
|
type,
|
|
timestamp: this.clock.now(),
|
|
source: 'claims-service',
|
|
correlationId,
|
|
causationId,
|
|
payload,
|
|
metadata: {
|
|
version: 1,
|
|
schemaVersion: this.config.schemaVersion,
|
|
environment: this.config.environment,
|
|
aggregateId,
|
|
aggregateType,
|
|
sequenceNumber: this.sequenceGenerator.next(aggregateId),
|
|
},
|
|
};
|
|
}
|
|
|
|
private async persistAndPublish(event: ClaimEvent): Promise<void> {
|
|
if (this.config.enableBatching) {
|
|
this.pendingEvents.push(event);
|
|
if (this.pendingEvents.length >= this.config.batchSize) {
|
|
await this.flushBatch();
|
|
} else if (!this.batchTimeout) {
|
|
this.batchTimeout = setTimeout(
|
|
() => this.flushBatch(),
|
|
this.config.batchTimeoutMs
|
|
);
|
|
}
|
|
} else {
|
|
await this.eventStore.append(event);
|
|
await this.eventPublisher.publish(event);
|
|
}
|
|
}
|
|
|
|
async flushBatch(): Promise<void> {
|
|
if (this.batchTimeout) {
|
|
clearTimeout(this.batchTimeout);
|
|
this.batchTimeout = null;
|
|
}
|
|
if (this.pendingEvents.length > 0) {
|
|
const events = [...this.pendingEvents];
|
|
this.pendingEvents = [];
|
|
await this.emitBatch(events);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test Suite
|
|
// =============================================================================
|
|
|
|
describe('ClaimEventEmitter', () => {
|
|
let emitter: ClaimEventEmitter;
|
|
let mockEventStore: MockedInterface<IEventStore>;
|
|
let mockEventPublisher: MockedInterface<IEventPublisher>;
|
|
let mockIdGenerator: MockedInterface<IEventIdGenerator>;
|
|
let mockClock: MockedInterface<IClock>;
|
|
let mockSequenceGenerator: MockedInterface<ISequenceGenerator>;
|
|
|
|
const baseDate = new Date('2024-01-15T10:00:00Z');
|
|
|
|
const agentClaimant: Claimant = {
|
|
type: 'agent',
|
|
id: 'coder-1',
|
|
agentType: 'coder',
|
|
};
|
|
|
|
const humanClaimant: Claimant = {
|
|
type: 'human',
|
|
id: 'user-123',
|
|
humanId: 'john.doe',
|
|
};
|
|
|
|
const reviewerClaimant: Claimant = {
|
|
type: 'agent',
|
|
id: 'reviewer-1',
|
|
agentType: 'reviewer',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockEventStore = createMock<IEventStore>();
|
|
mockEventPublisher = createMock<IEventPublisher>();
|
|
mockIdGenerator = createMock<IEventIdGenerator>();
|
|
mockClock = createMock<IClock>();
|
|
mockSequenceGenerator = createMock<ISequenceGenerator>();
|
|
|
|
// Default mock behaviors
|
|
mockClock.now.mockReturnValue(baseDate);
|
|
mockIdGenerator.generate.mockReturnValue('event-123');
|
|
mockSequenceGenerator.next.mockReturnValue(1);
|
|
mockEventStore.append.mockResolvedValue(undefined);
|
|
mockEventStore.appendBatch.mockResolvedValue(undefined);
|
|
mockEventPublisher.publish.mockResolvedValue(undefined);
|
|
mockEventPublisher.publishBatch.mockResolvedValue(undefined);
|
|
|
|
emitter = new ClaimEventEmitter(
|
|
mockEventStore,
|
|
mockEventPublisher,
|
|
mockIdGenerator,
|
|
mockClock,
|
|
mockSequenceGenerator
|
|
);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// IssueClaimed event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitIssueClaimed', () => {
|
|
it('should emit event with correct data', async () => {
|
|
// Given
|
|
const expiresAt = new Date('2024-01-16T10:00:00Z');
|
|
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, expiresAt);
|
|
|
|
// Then
|
|
expect(event.type).toBe('IssueClaimed');
|
|
expect(event.payload.issueId).toBe('issue-1');
|
|
expect(event.payload.claimant).toEqual(agentClaimant);
|
|
expect(event.payload.claimedAt).toEqual(baseDate);
|
|
expect(event.payload.expiresAt).toEqual(expiresAt);
|
|
});
|
|
|
|
it('should persist event to store', async () => {
|
|
// Given
|
|
const expiresAt = new Date('2024-01-16T10:00:00Z');
|
|
|
|
// When
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, expiresAt);
|
|
|
|
// Then
|
|
expect(mockEventStore.append).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'IssueClaimed',
|
|
payload: expect.objectContaining({ issueId: 'issue-1' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should publish event to subscribers', async () => {
|
|
// Given
|
|
const expiresAt = new Date('2024-01-16T10:00:00Z');
|
|
|
|
// When
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, expiresAt);
|
|
|
|
// Then
|
|
expect(mockEventPublisher.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'IssueClaimed',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should include correlation ID when provided', async () => {
|
|
// Given
|
|
const expiresAt = new Date('2024-01-16T10:00:00Z');
|
|
const correlationId = 'corr-123';
|
|
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, expiresAt, correlationId);
|
|
|
|
// Then
|
|
expect(event.correlationId).toBe(correlationId);
|
|
});
|
|
|
|
it('should include correct metadata', async () => {
|
|
// Given
|
|
const expiresAt = new Date('2024-01-16T10:00:00Z');
|
|
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, expiresAt);
|
|
|
|
// Then
|
|
expect(event.metadata.aggregateId).toBe('issue-1');
|
|
expect(event.metadata.aggregateType).toBe('Claim');
|
|
expect(event.metadata.schemaVersion).toBe('1.0.0');
|
|
expect(event.metadata.sequenceNumber).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// IssueReleased event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitIssueReleased', () => {
|
|
it('should emit event with correct data', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueReleased('issue-1', agentClaimant, 'Work completed');
|
|
|
|
// Then
|
|
expect(event.type).toBe('IssueReleased');
|
|
expect(event.payload.issueId).toBe('issue-1');
|
|
expect(event.payload.releasedBy).toEqual(agentClaimant);
|
|
expect(event.payload.reason).toBe('Work completed');
|
|
expect(event.payload.releasedAt).toEqual(baseDate);
|
|
});
|
|
|
|
it('should handle optional reason', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueReleased('issue-1', agentClaimant);
|
|
|
|
// Then
|
|
expect(event.payload.reason).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// HandoffRequested event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitHandoffRequested', () => {
|
|
it('should emit event with correct handoff details', async () => {
|
|
// When
|
|
const event = await emitter.emitHandoffRequested(
|
|
'issue-1',
|
|
agentClaimant,
|
|
humanClaimant,
|
|
'Need human review'
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('HandoffRequested');
|
|
expect(event.payload.from).toEqual(agentClaimant);
|
|
expect(event.payload.to).toEqual(humanClaimant);
|
|
expect(event.payload.reason).toBe('Need human review');
|
|
expect(event.payload.requestedAt).toEqual(baseDate);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// HandoffAccepted event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitHandoffAccepted', () => {
|
|
it('should emit event with causation ID linking to request', async () => {
|
|
// Given
|
|
const causationId = 'handoff-request-event-id';
|
|
const correlationId = 'corr-123';
|
|
|
|
// When
|
|
const event = await emitter.emitHandoffAccepted(
|
|
'issue-1',
|
|
agentClaimant,
|
|
humanClaimant,
|
|
causationId,
|
|
correlationId
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('HandoffAccepted');
|
|
expect(event.causationId).toBe(causationId);
|
|
expect(event.correlationId).toBe(correlationId);
|
|
expect(event.payload.from).toEqual(agentClaimant);
|
|
expect(event.payload.to).toEqual(humanClaimant);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// HandoffRejected event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitHandoffRejected', () => {
|
|
it('should emit event with rejection reason', async () => {
|
|
// When
|
|
const event = await emitter.emitHandoffRejected(
|
|
'issue-1',
|
|
humanClaimant,
|
|
'Too busy to take over'
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('HandoffRejected');
|
|
expect(event.payload.rejectedBy).toEqual(humanClaimant);
|
|
expect(event.payload.reason).toBe('Too busy to take over');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ClaimStatusUpdated event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitClaimStatusUpdated', () => {
|
|
it('should emit event with status transition details', async () => {
|
|
// When
|
|
const event = await emitter.emitClaimStatusUpdated(
|
|
'issue-1',
|
|
'active',
|
|
'pending_handoff',
|
|
agentClaimant
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('ClaimStatusUpdated');
|
|
expect(event.payload.previousStatus).toBe('active');
|
|
expect(event.payload.newStatus).toBe('pending_handoff');
|
|
expect(event.payload.updatedBy).toEqual(agentClaimant);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ClaimExpired event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitClaimExpired', () => {
|
|
it('should emit event with expiration details', async () => {
|
|
// When
|
|
const event = await emitter.emitClaimExpired('issue-1', agentClaimant);
|
|
|
|
// Then
|
|
expect(event.type).toBe('ClaimExpired');
|
|
expect(event.payload.issueId).toBe('issue-1');
|
|
expect(event.payload.originalClaimant).toEqual(agentClaimant);
|
|
expect(event.payload.expiredAt).toEqual(baseDate);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// WorkStolen event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitWorkStolen', () => {
|
|
it('should emit event with theft details', async () => {
|
|
// When
|
|
const event = await emitter.emitWorkStolen(
|
|
'issue-1',
|
|
agentClaimant,
|
|
reviewerClaimant
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('WorkStolen');
|
|
expect(event.payload.from).toEqual(agentClaimant);
|
|
expect(event.payload.to).toEqual(reviewerClaimant);
|
|
expect(event.payload.stolenAt).toEqual(baseDate);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// LoadRebalanced event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitLoadRebalanced', () => {
|
|
it('should emit event with rebalance summary', async () => {
|
|
// Given
|
|
const actions: RebalanceAction[] = [
|
|
{ type: 'move', claim: { issueId: 'issue-1' }, fromAgent: 'coder-1', toAgent: 'coder-2', reason: 'Load' },
|
|
{ type: 'reassign', claim: { issueId: 'issue-2' }, fromAgent: 'coder-1', toAgent: 'coder-3', reason: 'Load' },
|
|
];
|
|
|
|
// When
|
|
const event = await emitter.emitLoadRebalanced(actions, 2, 3);
|
|
|
|
// Then
|
|
expect(event.type).toBe('LoadRebalanced');
|
|
expect(event.payload.actions).toEqual(actions);
|
|
expect(event.payload.movedClaims).toBe(2);
|
|
expect(event.payload.overloadedCount).toBe(2);
|
|
expect(event.payload.underloadedCount).toBe(3);
|
|
});
|
|
|
|
it('should correctly count moved claims excluding deferred', async () => {
|
|
// Given
|
|
const actions: RebalanceAction[] = [
|
|
{ type: 'move', claim: { issueId: 'issue-1' }, fromAgent: 'coder-1', toAgent: 'coder-2', reason: 'Load' },
|
|
{ type: 'defer', claim: { issueId: 'issue-2' }, fromAgent: 'coder-1', toAgent: 'coder-3', reason: 'Wait' },
|
|
{ type: 'reassign', claim: { issueId: 'issue-3' }, fromAgent: 'coder-1', toAgent: 'coder-4', reason: 'Load' },
|
|
];
|
|
|
|
// When
|
|
const event = await emitter.emitLoadRebalanced(actions, 1, 3);
|
|
|
|
// Then
|
|
expect(event.payload.movedClaims).toBe(2); // move + reassign, not defer
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// ImbalanceDetected event tests
|
|
// ===========================================================================
|
|
|
|
describe('emitImbalanceDetected', () => {
|
|
it('should emit event with imbalance details', async () => {
|
|
// When
|
|
const event = await emitter.emitImbalanceDetected(
|
|
'severe',
|
|
75.5,
|
|
['coder-1', 'coder-2'],
|
|
['reviewer-1', 'tester-1']
|
|
);
|
|
|
|
// Then
|
|
expect(event.type).toBe('ImbalanceDetected');
|
|
expect(event.payload.severity).toBe('severe');
|
|
expect(event.payload.imbalanceScore).toBe(75.5);
|
|
expect(event.payload.overloadedAgents).toEqual(['coder-1', 'coder-2']);
|
|
expect(event.payload.underloadedAgents).toEqual(['reviewer-1', 'tester-1']);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Event ordering tests
|
|
// ===========================================================================
|
|
|
|
describe('event ordering', () => {
|
|
it('should persist before publishing', async () => {
|
|
// Given
|
|
const callOrder: string[] = [];
|
|
mockEventStore.append.mockImplementation(async () => {
|
|
callOrder.push('persist');
|
|
});
|
|
mockEventPublisher.publish.mockImplementation(async () => {
|
|
callOrder.push('publish');
|
|
});
|
|
|
|
// When
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
|
|
// Then
|
|
expect(callOrder).toEqual(['persist', 'publish']);
|
|
});
|
|
|
|
it('should generate unique IDs for each event', async () => {
|
|
// Given
|
|
let idCounter = 0;
|
|
mockIdGenerator.generate.mockImplementation(() => `event-${++idCounter}`);
|
|
|
|
// When
|
|
const event1 = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
const event2 = await emitter.emitIssueReleased('issue-1', agentClaimant);
|
|
|
|
// Then
|
|
expect(event1.id).toBe('event-1');
|
|
expect(event2.id).toBe('event-2');
|
|
expect(event1.id).not.toBe(event2.id);
|
|
});
|
|
|
|
it('should increment sequence numbers per aggregate', async () => {
|
|
// Given
|
|
let seq = 0;
|
|
mockSequenceGenerator.next.mockImplementation(() => ++seq);
|
|
|
|
// When
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
await emitter.emitClaimStatusUpdated('issue-1', 'active', 'pending_handoff', agentClaimant);
|
|
await emitter.emitIssueReleased('issue-1', agentClaimant);
|
|
|
|
// Then
|
|
expect(mockSequenceGenerator.next).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should maintain causal chain via causation and correlation IDs', async () => {
|
|
// Given
|
|
let idCounter = 0;
|
|
mockIdGenerator.generate.mockImplementation(() => `event-${++idCounter}`);
|
|
const correlationId = 'workflow-123';
|
|
|
|
// When - Simulate handoff workflow
|
|
const requestEvent = await emitter.emitHandoffRequested(
|
|
'issue-1',
|
|
agentClaimant,
|
|
humanClaimant,
|
|
'Need review',
|
|
correlationId
|
|
);
|
|
|
|
const acceptEvent = await emitter.emitHandoffAccepted(
|
|
'issue-1',
|
|
agentClaimant,
|
|
humanClaimant,
|
|
requestEvent.id, // causationId
|
|
correlationId
|
|
);
|
|
|
|
// Then
|
|
expect(requestEvent.correlationId).toBe(correlationId);
|
|
expect(acceptEvent.correlationId).toBe(correlationId);
|
|
expect(acceptEvent.causationId).toBe(requestEvent.id);
|
|
});
|
|
|
|
it('should use consistent timestamps within a workflow', async () => {
|
|
// Given
|
|
let timeOffset = 0;
|
|
mockClock.now.mockImplementation(() => new Date(baseDate.getTime() + timeOffset));
|
|
|
|
// When
|
|
const event1 = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
timeOffset += 1000;
|
|
const event2 = await emitter.emitIssueReleased('issue-1', agentClaimant);
|
|
|
|
// Then
|
|
expect(event1.timestamp.getTime()).toBeLessThan(event2.timestamp.getTime());
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Batch emission tests
|
|
// ===========================================================================
|
|
|
|
describe('batch emission', () => {
|
|
it('should batch events when batching enabled', async () => {
|
|
// Given
|
|
const batchConfig: EventEmitterConfig = {
|
|
...DEFAULT_EVENT_CONFIG,
|
|
enableBatching: true,
|
|
batchSize: 3,
|
|
batchTimeoutMs: 1000,
|
|
};
|
|
emitter = new ClaimEventEmitter(
|
|
mockEventStore,
|
|
mockEventPublisher,
|
|
mockIdGenerator,
|
|
mockClock,
|
|
mockSequenceGenerator,
|
|
batchConfig
|
|
);
|
|
|
|
// When - Emit 3 events (batch size)
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
await emitter.emitIssueClaimed('issue-2', agentClaimant, new Date());
|
|
await emitter.emitIssueClaimed('issue-3', agentClaimant, new Date());
|
|
|
|
// Then - Should trigger batch
|
|
expect(mockEventStore.appendBatch).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: 'IssueClaimed' }),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should emit batch via emitBatch directly', async () => {
|
|
// Given
|
|
const events: ClaimEvent[] = [
|
|
{
|
|
id: 'event-1',
|
|
type: 'IssueClaimed',
|
|
timestamp: baseDate,
|
|
source: 'test',
|
|
payload: {},
|
|
metadata: {
|
|
version: 1,
|
|
schemaVersion: '1.0.0',
|
|
environment: 'test',
|
|
aggregateId: 'issue-1',
|
|
aggregateType: 'Claim',
|
|
sequenceNumber: 1,
|
|
},
|
|
},
|
|
{
|
|
id: 'event-2',
|
|
type: 'IssueReleased',
|
|
timestamp: baseDate,
|
|
source: 'test',
|
|
payload: {},
|
|
metadata: {
|
|
version: 1,
|
|
schemaVersion: '1.0.0',
|
|
environment: 'test',
|
|
aggregateId: 'issue-1',
|
|
aggregateType: 'Claim',
|
|
sequenceNumber: 2,
|
|
},
|
|
},
|
|
];
|
|
|
|
// When
|
|
await emitter.emitBatch(events);
|
|
|
|
// Then
|
|
expect(mockEventStore.appendBatch).toHaveBeenCalledWith(events);
|
|
expect(mockEventPublisher.publishBatch).toHaveBeenCalledWith(events);
|
|
});
|
|
|
|
it('should flush pending batch on explicit flush', async () => {
|
|
// Given
|
|
const batchConfig: EventEmitterConfig = {
|
|
...DEFAULT_EVENT_CONFIG,
|
|
enableBatching: true,
|
|
batchSize: 10, // Large batch size
|
|
batchTimeoutMs: 10000, // Long timeout
|
|
};
|
|
emitter = new ClaimEventEmitter(
|
|
mockEventStore,
|
|
mockEventPublisher,
|
|
mockIdGenerator,
|
|
mockClock,
|
|
mockSequenceGenerator,
|
|
batchConfig
|
|
);
|
|
|
|
// When - Emit fewer than batch size
|
|
await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
await emitter.emitIssueClaimed('issue-2', agentClaimant, new Date());
|
|
|
|
// Events not yet persisted
|
|
expect(mockEventStore.appendBatch).not.toHaveBeenCalled();
|
|
|
|
// Explicit flush
|
|
await emitter.flushBatch();
|
|
|
|
// Then
|
|
expect(mockEventStore.appendBatch).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: 'IssueClaimed' }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Event data correctness tests
|
|
// ===========================================================================
|
|
|
|
describe('event data correctness', () => {
|
|
it('should include all required fields in every event', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
|
|
// Then
|
|
expect(event).toHaveProperty('id');
|
|
expect(event).toHaveProperty('type');
|
|
expect(event).toHaveProperty('timestamp');
|
|
expect(event).toHaveProperty('source');
|
|
expect(event).toHaveProperty('payload');
|
|
expect(event).toHaveProperty('metadata');
|
|
expect(event.metadata).toHaveProperty('version');
|
|
expect(event.metadata).toHaveProperty('schemaVersion');
|
|
expect(event.metadata).toHaveProperty('environment');
|
|
expect(event.metadata).toHaveProperty('aggregateId');
|
|
expect(event.metadata).toHaveProperty('aggregateType');
|
|
expect(event.metadata).toHaveProperty('sequenceNumber');
|
|
});
|
|
|
|
it('should set correct source for all events', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
|
|
// Then
|
|
expect(event.source).toBe('claims-service');
|
|
});
|
|
|
|
it('should preserve claimant structure in payload', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date());
|
|
|
|
// Then
|
|
expect(event.payload.claimant).toEqual(agentClaimant);
|
|
expect(event.payload.claimant.type).toBe('agent');
|
|
expect(event.payload.claimant.agentType).toBe('coder');
|
|
});
|
|
|
|
it('should handle human claimant correctly', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', humanClaimant, new Date());
|
|
|
|
// Then
|
|
expect(event.payload.claimant).toEqual(humanClaimant);
|
|
expect(event.payload.claimant.type).toBe('human');
|
|
expect(event.payload.claimant.humanId).toBe('john.doe');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Edge cases
|
|
// ===========================================================================
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty correlation ID', async () => {
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, new Date(), undefined);
|
|
|
|
// Then
|
|
expect(event.correlationId).toBeUndefined();
|
|
});
|
|
|
|
it('should handle special characters in reason strings', async () => {
|
|
// Given
|
|
const reason = 'Need review: "complex" logic & edge-cases <script>alert(1)</script>';
|
|
|
|
// When
|
|
const event = await emitter.emitHandoffRequested(
|
|
'issue-1',
|
|
agentClaimant,
|
|
humanClaimant,
|
|
reason
|
|
);
|
|
|
|
// Then
|
|
expect(event.payload.reason).toBe(reason);
|
|
});
|
|
|
|
it('should handle very long issue IDs', async () => {
|
|
// Given
|
|
const longIssueId = 'issue-' + 'x'.repeat(500);
|
|
|
|
// When
|
|
const event = await emitter.emitIssueClaimed(longIssueId, agentClaimant, new Date());
|
|
|
|
// Then
|
|
expect(event.payload.issueId).toBe(longIssueId);
|
|
expect(event.metadata.aggregateId).toBe(longIssueId);
|
|
});
|
|
|
|
it('should handle dates in different timezones', async () => {
|
|
// Given
|
|
const utcDate = new Date('2024-01-15T10:00:00.000Z');
|
|
mockClock.now.mockReturnValue(utcDate);
|
|
|
|
// When
|
|
const event = await emitter.emitIssueClaimed('issue-1', agentClaimant, utcDate);
|
|
|
|
// Then
|
|
expect(event.timestamp.toISOString()).toBe('2024-01-15T10:00:00.000Z');
|
|
expect(event.payload.claimedAt.toISOString()).toBe('2024-01-15T10:00:00.000Z');
|
|
});
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Integration tests for event subscribers
|
|
// =============================================================================
|
|
|
|
describe('Event Subscription', () => {
|
|
let mockEventPublisher: MockedInterface<IEventPublisher>;
|
|
let subscribedHandlers: Map<ClaimEventType, EventHandler[]>;
|
|
|
|
beforeEach(() => {
|
|
subscribedHandlers = new Map();
|
|
mockEventPublisher = createMock<IEventPublisher>();
|
|
|
|
mockEventPublisher.subscribe.mockImplementation((type, handler) => {
|
|
const handlers = subscribedHandlers.get(type) || [];
|
|
handlers.push(handler);
|
|
subscribedHandlers.set(type, handlers);
|
|
});
|
|
|
|
mockEventPublisher.unsubscribe.mockImplementation((type, handler) => {
|
|
const handlers = subscribedHandlers.get(type) || [];
|
|
const index = handlers.indexOf(handler);
|
|
if (index > -1) {
|
|
handlers.splice(index, 1);
|
|
}
|
|
subscribedHandlers.set(type, handlers);
|
|
});
|
|
});
|
|
|
|
it('should allow subscribing to specific event types', () => {
|
|
// Given
|
|
const handler: EventHandler = vi.fn();
|
|
|
|
// When
|
|
mockEventPublisher.subscribe('IssueClaimed', handler);
|
|
|
|
// Then
|
|
expect(subscribedHandlers.get('IssueClaimed')).toContain(handler);
|
|
});
|
|
|
|
it('should allow unsubscribing from events', () => {
|
|
// Given
|
|
const handler: EventHandler = vi.fn();
|
|
mockEventPublisher.subscribe('IssueClaimed', handler);
|
|
|
|
// When
|
|
mockEventPublisher.unsubscribe('IssueClaimed', handler);
|
|
|
|
// Then
|
|
expect(subscribedHandlers.get('IssueClaimed')).not.toContain(handler);
|
|
});
|
|
|
|
it('should support multiple handlers for same event type', () => {
|
|
// Given
|
|
const handler1: EventHandler = vi.fn();
|
|
const handler2: EventHandler = vi.fn();
|
|
|
|
// When
|
|
mockEventPublisher.subscribe('IssueClaimed', handler1);
|
|
mockEventPublisher.subscribe('IssueClaimed', handler2);
|
|
|
|
// Then
|
|
const handlers = subscribedHandlers.get('IssueClaimed');
|
|
expect(handlers).toContain(handler1);
|
|
expect(handlers).toContain(handler2);
|
|
expect(handlers?.length).toBe(2);
|
|
});
|
|
|
|
it('should allow same handler for different event types', () => {
|
|
// Given
|
|
const sharedHandler: EventHandler = vi.fn();
|
|
|
|
// When
|
|
mockEventPublisher.subscribe('IssueClaimed', sharedHandler);
|
|
mockEventPublisher.subscribe('IssueReleased', sharedHandler);
|
|
|
|
// Then
|
|
expect(subscribedHandlers.get('IssueClaimed')).toContain(sharedHandler);
|
|
expect(subscribedHandlers.get('IssueReleased')).toContain(sharedHandler);
|
|
});
|
|
});
|