Files
aitbc/apps/blockchain-node/tests/test_consensus.py
aitbc e60cc3226c
Some checks failed
API Endpoint Tests / test-api-endpoints (push) Successful in 9s
Blockchain Synchronization Verification / sync-verification (push) Failing after 1s
CLI Tests / test-cli (push) Failing after 3s
Documentation Validation / validate-docs (push) Successful in 6s
Documentation Validation / validate-policies-strict (push) Successful in 2s
Integration Tests / test-service-integration (push) Successful in 40s
Multi-Node Blockchain Health Monitoring / health-check (push) Successful in 1s
P2P Network Verification / p2p-verification (push) Successful in 2s
Production Tests / Production Integration Tests (push) Successful in 21s
Python Tests / test-python (push) Successful in 13s
Security Scanning / security-scan (push) Failing after 46s
Smart Contract Tests / test-solidity (map[name:aitbc-token path:packages/solidity/aitbc-token]) (push) Successful in 17s
Smart Contract Tests / lint-solidity (push) Successful in 10s
Add sys import to test files and remove obsolete integration tests
- Add sys import to 29 test files across agent-coordinator, blockchain-event-bridge, blockchain-node, and coordinator-api
- Remove apps/blockchain-event-bridge/tests/test_integration.py (obsolete bridge integration tests)
- Remove apps/coordinator-api/tests/test_integration.py (obsolete API integration tests)
- Implement GPU registration in marketplace_gpu.py with GPURegistry model persistence
2026-04-23 16:43:17 +02:00

354 lines
13 KiB
Python

"""Test suite for Proof-of-Authority consensus mechanism."""
from __future__ import annotations
import sys
import asyncio
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, Mock, patch
from typing import Generator
from sqlmodel import Session, create_engine, select
from sqlmodel.pool import StaticPool
from aitbc_chain.consensus.poa import CircuitBreaker, PoAProposer
from aitbc_chain.config import ProposerConfig
from aitbc_chain.models import Block, Account
from aitbc_chain.mempool import InMemoryMempool
@pytest.fixture
def test_db() -> Generator[Session, None, None]:
"""Create a test database session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Block.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture
def proposer_config() -> ProposerConfig:
"""Create a test proposer configuration."""
return ProposerConfig(
chain_id="test-chain",
proposer_id="test-proposer",
interval_seconds=1.0,
max_txs_per_block=10,
max_block_size_bytes=1_000_000,
)
@pytest.fixture
def mock_session_factory(test_db: Session) -> Generator[callable, None, None]:
"""Create a mock session factory."""
def factory():
return test_db
yield factory
@pytest.fixture
def mock_mempool() -> Mock:
"""Create a mock mempool."""
mempool = Mock(spec=InMemoryMempool)
mempool.drain.return_value = []
return mempool
class TestCircuitBreaker:
"""Test circuit breaker functionality."""
def test_initial_state(self) -> None:
"""Test circuit breaker starts in closed state."""
breaker = CircuitBreaker(threshold=5, timeout=60)
assert breaker.state == "closed"
assert breaker.allow_request() is True
def test_failure_threshold_opens_circuit(self) -> None:
"""Test that exceeding failure threshold opens circuit."""
breaker = CircuitBreaker(threshold=3, timeout=60)
# Record failures up to threshold
for _ in range(3):
breaker.record_failure()
assert breaker.state == "open"
assert breaker.allow_request() is False
def test_timeout_transitions_to_half_open(self) -> None:
"""Test that timeout transitions circuit to half-open."""
breaker = CircuitBreaker(threshold=1, timeout=0.1)
# Trigger open state
breaker.record_failure()
assert breaker.state == "open"
# Wait for timeout
import time
time.sleep(0.2)
assert breaker.state == "half-open"
assert breaker.allow_request() is True
def test_success_resets_circuit(self) -> None:
"""Test that success resets circuit to closed."""
breaker = CircuitBreaker(threshold=2, timeout=60)
# Trigger open state
breaker.record_failure()
breaker.record_failure()
assert breaker.state == "open"
# Record success
breaker.record_success()
assert breaker.state == "closed"
assert breaker.allow_request() is True
def test_half_open_allows_request(self) -> None:
"""Test that half-open state allows requests."""
breaker = CircuitBreaker(threshold=1, timeout=0.1)
# Trigger open then wait for timeout
breaker.record_failure()
import time
time.sleep(0.2)
assert breaker.state == "half-open"
assert breaker.allow_request() is True
class TestPoAProposer:
"""Test Proof-of-Authority proposer functionality."""
@pytest.fixture
def proposer(self, proposer_config: ProposerConfig, mock_session_factory: callable) -> PoAProposer:
"""Create a PoA proposer instance."""
return PoAProposer(config=proposer_config, session_factory=mock_session_factory)
def test_proposer_initialization(self, proposer: PoAProposer, proposer_config: ProposerConfig) -> None:
"""Test proposer initialization."""
assert proposer._config == proposer_config
assert proposer._task is None
assert not proposer._stop_event.is_set()
@pytest.mark.asyncio
async def test_start_stop_proposer(self, proposer: PoAProposer) -> None:
"""Test starting and stopping the proposer."""
# Start proposer
await proposer.start()
assert proposer._task is not None
assert not proposer._stop_event.is_set()
# Stop proposer
await proposer.stop()
assert proposer._task is None
assert proposer._stop_event.is_set()
@pytest.mark.asyncio
async def test_start_already_running(self, proposer: PoAProposer) -> None:
"""Test that starting an already running proposer doesn't create duplicate tasks."""
await proposer.start()
original_task = proposer._task
# Try to start again
await proposer.start()
assert proposer._task is original_task
await proposer.stop()
@pytest.mark.asyncio
async def test_stop_not_running(self, proposer: PoAProposer) -> None:
"""Test stopping a proposer that isn't running."""
# Should not raise an exception
await proposer.stop()
assert proposer._task is None
@pytest.mark.asyncio
async def test_propose_block_genesis(self, proposer: PoAProposer, test_db: Session, mock_mempool: Mock) -> None:
"""Test proposing a genesis block."""
with patch('aitbc_chain.mempool.get_mempool', return_value=mock_mempool):
with patch('aitbc_chain.consensus.poa.gossip_broker', new=AsyncMock()):
await proposer._propose_block()
# Verify genesis block was created
block = test_db.exec(select(Block).where(Block.chain_id == proposer._config.chain_id)).first()
assert block is not None
assert block.height == 0
assert block.parent_hash == "0x00"
assert block.proposer == proposer._config.proposer_id
assert block.tx_count == 0
@pytest.mark.asyncio
async def test_propose_block_with_parent(self, proposer: PoAProposer, test_db: Session, mock_mempool: Mock) -> None:
"""Test proposing a block with a parent block."""
# Create parent block
parent = Block(
chain_id=proposer._config.chain_id,
height=0,
hash="0xparent",
parent_hash="0x00",
proposer="previous-proposer",
timestamp=datetime.utcnow(),
tx_count=0,
)
test_db.add(parent)
test_db.commit()
# Mock mempool with transactions
mock_tx = Mock()
mock_tx.tx_hash = "0xtx"
mock_tx.content = {"sender": "alice", "recipient": "bob", "amount": 100}
mock_mempool.drain.return_value = [mock_tx]
with patch('aitbc_chain.mempool.get_mempool', return_value=mock_mempool):
with patch('aitbc_chain.consensus.poa.gossip_broker', new=AsyncMock()):
await proposer._propose_block()
# Verify new block was created
block = test_db.exec(select(Block).where(Block.chain_id == proposer._config.chain_id).order_by(Block.height.desc())).first()
assert block is not None
assert block.height == 1
assert block.parent_hash == "0xparent"
assert block.proposer == proposer._config.proposer_id
@pytest.mark.asyncio
async def test_wait_until_next_slot_no_head(self, proposer: PoAProposer) -> None:
"""Test waiting for next slot when no head exists."""
# Should return immediately when no head
await proposer._wait_until_next_slot()
@pytest.mark.asyncio
async def test_wait_until_next_slot_with_head(self, proposer: PoAProposer, test_db: Session) -> None:
"""Test waiting for next slot with existing head."""
# Create recent head block
head = Block(
chain_id=proposer._config.chain_id,
height=0,
hash="0xhead",
parent_hash="0x00",
proposer="test-proposer",
timestamp=datetime.utcnow(),
tx_count=0,
)
test_db.add(head)
test_db.commit()
# Should wait for the configured interval
start_time = datetime.utcnow()
await proposer._wait_until_next_slot()
elapsed = (datetime.utcnow() - start_time).total_seconds()
# Should wait at least some time (but less than full interval since block is recent)
assert elapsed >= 0.1
@pytest.mark.asyncio
async def test_wait_until_next_slot_stop_event(self, proposer: PoAProposer, test_db: Session) -> None:
"""Test that stop event interrupts slot waiting."""
# Create old head block to ensure waiting
head = Block(
chain_id=proposer._config.chain_id,
height=0,
hash="0xhead",
parent_hash="0x00",
proposer="test-proposer",
timestamp=datetime.utcnow() - timedelta(seconds=10),
tx_count=0,
)
test_db.add(head)
test_db.commit()
# Set stop event and wait
proposer._stop_event.set()
start_time = datetime.utcnow()
await proposer._wait_until_next_slot()
elapsed = (datetime.utcnow() - start_time).total_seconds()
# Should return immediately due to stop event
assert elapsed < 0.1
@pytest.mark.asyncio
async def test_run_loop_stops_on_event(self, proposer: PoAProposer, mock_mempool: Mock) -> None:
"""Test that run loop stops when stop event is set."""
with patch('aitbc_chain.mempool.get_mempool', return_value=mock_mempool):
with patch('aitbc_chain.consensus.poa.gossip_broker', new=AsyncMock()):
# Start the loop
proposer._stop_event.clear()
task = asyncio.create_task(proposer._run_loop())
# Let it run briefly then stop
await asyncio.sleep(0.1)
proposer._stop_event.set()
# Wait for task to complete
await task
def test_compute_block_hash(self, proposer: PoAProposer) -> None:
"""Test block hash computation."""
height = 1
parent_hash = "0xparent"
timestamp = datetime.utcnow()
processed_txs = []
block_hash = proposer._compute_block_hash(height, parent_hash, timestamp, processed_txs)
assert isinstance(block_hash, str)
assert block_hash.startswith("0x")
assert len(block_hash) == 66 # 0x + 64 hex chars
def test_compute_block_hash_with_transactions(self, proposer: PoAProposer) -> None:
"""Test block hash computation with transactions."""
height = 1
parent_hash = "0xparent"
timestamp = datetime.utcnow()
mock_tx = Mock()
mock_tx.tx_hash = "0xtx"
processed_txs = [mock_tx]
block_hash = proposer._compute_block_hash(height, parent_hash, timestamp, processed_txs)
assert isinstance(block_hash, str)
assert block_hash.startswith("0x")
assert len(block_hash) == 66
def test_ensure_genesis_block_existing(self, proposer: PoAProposer, test_db: Session) -> None:
"""Test genesis block creation when block already exists."""
# Create existing block
block = Block(
chain_id=proposer._config.chain_id,
height=0,
hash="0xexisting",
parent_hash="0x00",
proposer="test-proposer",
timestamp=datetime.utcnow(),
tx_count=0,
)
test_db.add(block)
test_db.commit()
# Should not create duplicate
proposer._ensure_genesis_block()
blocks = test_db.exec(select(Block).where(Block.chain_id == proposer._config.chain_id)).all()
assert len(blocks) == 1
assert blocks[0].hash == "0xexisting"
def test_sanitize_metric_suffix(self) -> None:
"""Test metric suffix sanitization."""
from aitbc_chain.consensus.poa import _sanitize_metric_suffix
# Test normal string
assert _sanitize_metric_suffix("normal") == "normal"
# Test with special characters
assert _sanitize_metric_suffix("test@#$") == "test"
# Test empty string
assert _sanitize_metric_suffix("") == "unknown"
# Test only special characters
assert _sanitize_metric_suffix("@#$") == "unknown"