Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:02:19 +08:00

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);
});
});