getMessage())->toContain('localhost') ->and($exception->getMessage())->toContain('testdb') ->and($exception->getSqlState())->toBe($sqlState) ->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED) ->and($exception->getContext()->data['host'])->toBe('localhost') ->and($exception->getContext()->data['database'])->toBe('testdb'); }); it('creates exception for server rejection', function () { $sqlState = new SqlState('08004'); $exception = ConnectionFailedException::serverRejectedConnection( 'db.example.com', 'dbuser', $sqlState, 'Invalid credentials' ); expect($exception->getMessage())->toContain('db.example.com') ->and($exception->getMessage())->toContain('dbuser') ->and($exception->getSqlState()->code)->toBe('08004') ->and($exception->getContext()->data['host'])->toBe('db.example.com') ->and($exception->getContext()->data['username'])->toBe('dbuser'); }); it('creates exception for connection timeout', function () { $sqlState = new SqlState('HY000'); $exception = ConnectionFailedException::connectionTimeout( 'remote-db.com', 30, $sqlState ); expect($exception->getMessage())->toContain('30 seconds') ->and($exception->getContext()->data['timeout_seconds'])->toBe(30) ->and($exception->getContext()->data['host'])->toBe('remote-db.com'); }); it('creates exception for too many connections', function () { $sqlState = new SqlState('HY000'); $exception = ConnectionFailedException::tooManyConnections( 'localhost', 100, $sqlState ); expect($exception->getMessage())->toContain('100') ->and($exception->getContext()->data['max_connections'])->toBe(100) ->and($exception->getContext()->data['host'])->toBe('localhost'); }); }); describe('ConstraintViolationException', function () { it('creates exception for unique violation', function () { $sqlState = new SqlState('23505'); $exception = ConstraintViolationException::uniqueViolation( 'users', 'email', 'test@example.com', $sqlState ); expect($exception->getMessage())->toContain('email') ->and($exception->getMessage())->toContain('users') ->and($exception->getSqlState())->toBe($sqlState) ->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION) ->and($exception->getContext()->data['table'])->toBe('users') ->and($exception->getContext()->data['column'])->toBe('email'); }); it('creates exception for foreign key violation', function () { $sqlState = new SqlState('23503'); $exception = ConstraintViolationException::foreignKeyViolation( 'orders', 'user_id', 'users', 999, $sqlState ); expect($exception->getMessage())->toContain('orders') ->and($exception->getMessage())->toContain('users') ->and($exception->getContext()->data['table'])->toBe('orders') ->and($exception->getContext()->data['foreign_key'])->toBe('user_id') ->and($exception->getContext()->data['referenced_table'])->toBe('users'); }); it('creates exception for not null violation', function () { $sqlState = new SqlState('23502'); $exception = ConstraintViolationException::notNullViolation( 'products', 'price', $sqlState ); expect($exception->getMessage())->toContain('price') ->and($exception->getMessage())->toContain('products') ->and($exception->getContext()->data['table'])->toBe('products') ->and($exception->getContext()->data['column'])->toBe('price'); }); it('creates exception for composite unique violation', function () { $sqlState = new SqlState('23505'); $exception = ConstraintViolationException::compositeUniqueViolation( 'user_permissions', ['user_id', 'permission_id'], [123, 456], $sqlState ); expect($exception->getMessage())->toContain('user_id') ->and($exception->getMessage())->toContain('permission_id') ->and($exception->getContext()->data['columns'])->toBe(['user_id', 'permission_id']) ->and($exception->getContext()->debug['duplicate_values'])->toBe(['123', '456']); }); }); describe('DeadlockException', function () { it('creates exception for basic deadlock detection', function () { $sqlState = new SqlState('40001'); $exception = DeadlockException::detected($sqlState); expect($exception->getMessage())->toContain('Deadlock detected') ->and($exception->getSqlState())->toBe($sqlState) ->and($exception->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED) ->and($exception->getContext()->data['recoverable'])->toBeTrue() ->and($exception->getContext()->data['retry_recommended'])->toBeTrue(); }); it('creates exception with transaction details', function () { $sqlState = new SqlState('40001'); $exception = DeadlockException::inTransaction( 'tx_12345', $sqlState, attemptNumber: 2 ); expect($exception->getMessage())->toContain('tx_12345') ->and($exception->getMessage())->toContain('attempt 2') ->and($exception->getContext()->data['transaction_id'])->toBe('tx_12345') ->and($exception->getContext()->data['attempt_number'])->toBe(2); }); it('creates exception for max retries exceeded', function () { $sqlState = new SqlState('40001'); $exception = DeadlockException::maxRetriesExceeded( 3, $sqlState, 'tx_99999' ); expect($exception->getMessage())->toContain('3 retry attempts') ->and($exception->getContext()->data['recoverable'])->toBeFalse() ->and($exception->getContext()->data['retry_recommended'])->toBeFalse() ->and($exception->getContext()->data['max_attempts'])->toBe(3); }); it('calculates retry delay with exponential backoff', function () { $delay1 = DeadlockException::getRetryDelay(1); $delay2 = DeadlockException::getRetryDelay(2); $delay3 = DeadlockException::getRetryDelay(3); // Test basic properties of exponential backoff (jitter makes exact comparisons unreliable) expect($delay1)->toBeGreaterThan(0) ->and($delay2)->toBeGreaterThan(0) ->and($delay3)->toBeGreaterThan(0) ->and($delay3)->toBeLessThanOrEqual(5000); // Max 5 seconds }); it('determines retry eligibility', function () { expect(DeadlockException::shouldRetry(1, 3))->toBeTrue() ->and(DeadlockException::shouldRetry(2, 3))->toBeTrue() ->and(DeadlockException::shouldRetry(3, 3))->toBeFalse() ->and(DeadlockException::shouldRetry(4, 3))->toBeFalse(); }); }); describe('QuerySyntaxException', function () { it('creates exception for basic syntax error', function () { $sqlState = new SqlState('42601'); $exception = QuerySyntaxException::forQuery( 'SELCT * FROM users', $sqlState, 'Syntax error near "SELCT"' ); expect($exception->getMessage())->toContain('Syntax error') ->and($exception->getSqlState())->toBe($sqlState) ->and($exception->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR); }); it('creates exception for table not found', function () { $sqlState = new SqlState('42P01'); $exception = QuerySyntaxException::tableNotFound( 'nonexistent_table', $sqlState ); expect($exception->getMessage())->toContain('nonexistent_table') ->and($exception->getContext()->data['table_name'])->toBe('nonexistent_table'); }); it('creates exception for column not found', function () { $sqlState = new SqlState('42703'); $exception = QuerySyntaxException::columnNotFound( 'invalid_column', 'users', $sqlState ); expect($exception->getMessage())->toContain('users.invalid_column') ->and($exception->getContext()->data['column_name'])->toBe('invalid_column') ->and($exception->getContext()->data['table_name'])->toBe('users'); }); it('creates exception for insufficient privileges', function () { $sqlState = new SqlState('42501'); $exception = QuerySyntaxException::insufficientPrivileges( 'DELETE', 'sensitive_data', $sqlState ); expect($exception->getMessage())->toContain('DELETE') ->and($exception->getMessage())->toContain('sensitive_data') ->and($exception->getContext()->data['operation'])->toBe('DELETE') ->and($exception->getContext()->data['object_name'])->toBe('sensitive_data'); }); it('truncates long queries for safety', function () { $longQuery = str_repeat('SELECT * FROM users WHERE id = 1 AND ', 20) . 'name = "test"'; $sqlState = new SqlState('42601'); $exception = QuerySyntaxException::forQuery($longQuery, $sqlState); expect(strlen($exception->getContext()->data['query']))->toBeLessThanOrEqual(220); // 200 + " (truncated)" }); }); describe('QueryExecutionException', function () { it('creates exception for data type mismatch', function () { $sqlState = new SqlState('22P02'); $exception = QueryExecutionException::forDataTypeMismatch( 'age', 'integer', 'not_a_number', $sqlState ); expect($exception->getMessage())->toContain('age') ->and($exception->getMessage())->toContain('integer') ->and($exception->getContext()->data['column'])->toBe('age') ->and($exception->getContext()->data['expected_type'])->toBe('integer'); }); it('creates exception for data truncation', function () { $sqlState = new SqlState('22001'); $exception = QueryExecutionException::forDataTruncation( 'description', 255, 500, $sqlState ); expect($exception->getMessage())->toContain('description') ->and($exception->getMessage())->toContain('255') ->and($exception->getContext()->data['max_length'])->toBe(255) ->and($exception->getContext()->data['actual_length'])->toBe(500); }); it('creates exception for division by zero', function () { $sqlState = new SqlState('22012'); $exception = QueryExecutionException::forDivisionByZero( 'total / count', $sqlState ); expect($exception->getMessage())->toContain('Division by zero') ->and($exception->getContext()->data['expression'])->toBe('total / count'); }); it('sanitizes sensitive values in context', function () { $sqlState = new SqlState('22P02'); $exception = QueryExecutionException::forDataTypeMismatch( 'password', 'string', 'my-secret-password-123', $sqlState ); // Value is stored in debug (not data) for security // Note: sanitizeValue only truncates strings > 100 chars expect($exception->getContext()->debug)->toHaveKey('actual_value'); expect($exception->getContext()->debug['actual_value'])->toBeString(); }); }); describe('PoolExhaustedException', function () { it('creates exception when no connections available', function () { $exception = PoolExhaustedException::noConnectionsAvailable( currentConnections: 10, maxConnections: 10, connectionsInUse: 10 ); expect($exception->getMessage())->toContain('10/10') ->and($exception->getErrorCode())->toBe(DatabaseErrorCode::POOL_EXHAUSTED) ->and($exception->getContext()->data['current_connections'])->toBe(10) ->and($exception->getContext()->data['max_connections'])->toBe(10) ->and($exception->getContext()->data['connections_in_use'])->toBe(10) ->and($exception->getContext()->data['free_connections'])->toBe(0) ->and($exception->getContext()->data['utilization_percent'])->toBe(100.0); }); it('creates exception when all connections unhealthy', function () { $exception = PoolExhaustedException::allConnectionsUnhealthy( maxConnections: 5, unhealthyConnections: 5 ); expect($exception->getMessage())->toContain('unhealthy') ->and($exception->getContext()->data['health_status'])->toBe('critical'); }); it('provides retry recommendations', function () { $exception = PoolExhaustedException::withRetryRecommendation( currentConnections: 8, maxConnections: 10, recommendedRetryDelayMs: 500 ); expect($exception->getMessage())->toContain('retry after 500ms') ->and($exception->getRetryAfter())->toBe(1) // Rounded up to 1 second ->and($exception->getContext()->data['recommended_retry_delay_ms'])->toBe(500); }); it('provides context-aware recommended actions', function () { $highUtilization = PoolExhaustedException::noConnectionsAvailable( currentConnections: 100, maxConnections: 100, connectionsInUse: 100 ); $unhealthyConnections = PoolExhaustedException::allConnectionsUnhealthy( maxConnections: 10, unhealthyConnections: 10 ); expect($highUtilization->getRecommendedAction())->toContain('Increase max_connections'); expect($unhealthyConnections->getRecommendedAction())->toContain('Check database server health'); }); }); describe('Exception Integration', function () { it('maintains SQLSTATE through exception hierarchy', function () { $sqlState = new SqlState('23505'); $exception = ConstraintViolationException::uniqueViolation( 'users', 'email', 'test@example.com', $sqlState ); expect($exception->getSqlState())->toBe($sqlState) ->and($exception->getSqlState()->code)->toBe('23505') ->and($exception->getSqlState()->isUniqueViolation())->toBeTrue() ->and($exception->getSqlState()->isConstraintViolation())->toBeTrue(); }); it('includes proper error categorization', function () { $connectionError = ConnectionFailedException::cannotConnect( 'localhost', 'testdb', new SqlState('08001') ); $syntaxError = QuerySyntaxException::tableNotFound( 'missing_table', new SqlState('42P01') ); $deadlock = DeadlockException::detected(new SqlState('40001')); expect($connectionError->isCategory('DB'))->toBeTrue(); expect($syntaxError->isCategory('DB'))->toBeTrue(); expect($deadlock->isCategory('DB'))->toBeTrue(); expect($connectionError->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED); expect($syntaxError->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR); expect($deadlock->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED); }); it('preserves previous exception chain', function () { $pdoException = new \PDOException('Original PDO error'); $sqlState = new SqlState('08001'); $exception = ConnectionFailedException::cannotConnect( 'localhost', 'testdb', $sqlState, 'Connection failed', $pdoException ); expect($exception->getPrevious())->toBe($pdoException); }); }); });