From bfb207d66a1f4fd55455580cd1c397b8820587e5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 18:28:36 +0300 Subject: [PATCH] refactor(core): introduce QueryaDatabaseException hierarchy and error mapper (#1037) Add a sealed QueryaDatabaseException family (auth, host unreachable, timeout, database/table not found, syntax, lock timeout) with remediation hints, a mapper for PostgreSQL, MySQL, SQLite, MongoDB and Redis errors, and use it for connect failures so users see plain messages with hints. --- lib/core/database/database_error_mapper.dart | 209 ++++++++++++++ lib/core/database/mongodb_connection.dart | 5 +- lib/core/database/mysql_connection.dart | 5 +- lib/core/database/mysql_connection_pool.dart | 4 +- lib/core/database/postgres_connection.dart | 4 +- .../database/postgres_connection_pool.dart | 4 +- .../database/querya_database_exception.dart | 131 +++++++++ lib/core/database/redis_connection.dart | 11 + lib/core/database/sqlite_connection_pool.dart | 4 +- .../database/database_error_mapper_test.dart | 254 ++++++++++++++++++ 10 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 lib/core/database/database_error_mapper.dart create mode 100644 lib/core/database/querya_database_exception.dart create mode 100644 test/core/database/database_error_mapper_test.dart diff --git a/lib/core/database/database_error_mapper.dart b/lib/core/database/database_error_mapper.dart new file mode 100644 index 00000000..a05f6570 --- /dev/null +++ b/lib/core/database/database_error_mapper.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_connection.dart'; + +/// Which engine produced an error; only used to tailor the wording. +enum DatabaseDriver { postgres, mysql, sqlite, mongodb, redis, extension } + +/// Maps a driver / OS error to a [QueryaDatabaseException]. +/// +/// Already-mapped exceptions are returned as is. Unrecognized errors become an +/// [UnknownDatabaseException] that keeps the original text. +QueryaDatabaseException mapDatabaseError( + Object error, { + StackTrace? stackTrace, + DatabaseDriver? driver, +}) { + if (error is QueryaDatabaseException) return error; + + final raw = _fullText(error); + final lower = raw.toLowerCase(); + final engine = _engineName(driver); + + QueryaDatabaseException? mapped; + + if (_hasAny(lower, const [ + 'lock wait timeout', + 'lock timeout', + 'database is locked', + 'sqlite_busy', + 'sqlite is busy', + 'deadlock detected', + 'deadlock found', + ])) { + mapped = LockTimeoutException( + 'The database is locked by another operation', + remediationHint: + 'Commit or roll back any open transaction and try again in a moment', + originalError: error, + stackTrace: stackTrace, + ); + } else if (_hasAny(lower, const [ + 'password authentication failed', + 'access denied for user', + 'authentication failed', + 'auth failed', + 'wrongpass', + 'noauth', + 'invalid password', + 'invalid username-password', + 'sqlstate 28p01', + 'sqlstate 28000', + ])) { + mapped = AuthFailedException( + 'Authentication failed', + detailedExplanation: '$engine rejected the supplied credentials.', + remediationHint: driver == DatabaseDriver.redis + ? 'Check the username and password (the server may require ' + '"requirepass" or an ACL user)' + : 'Check the username and password in the connection settings', + originalError: error, + stackTrace: stackTrace, + ); + } else if (_firstMatch(raw, [ + RegExp(r'database "([^"]+)" does not exist', caseSensitive: false), + RegExp(r"unknown database '([^']+)'", caseSensitive: false), + ]) + case final db?) { + mapped = DatabaseNotFoundException( + 'Database "$db" does not exist', + databaseName: db, + remediationHint: + 'Check the database name in the connection settings or create it first', + originalError: error, + stackTrace: stackTrace, + ); + } else if (_firstMatch(raw, [ + RegExp(r'relation "([^"]+)" does not exist', caseSensitive: false), + RegExp(r"table '([^']+)' doesn't exist", caseSensitive: false), + RegExp(r'no such table: ([\w."]+)', caseSensitive: false), + ]) + case final table?) { + mapped = TableNotFoundException( + 'Table "$table" does not exist', + tableName: table, + remediationHint: + 'Check the table name and schema, or refresh the object tree', + originalError: error, + stackTrace: stackTrace, + ); + } else if (_hasAny(lower, const [ + 'syntax error', + 'error in your sql syntax', + 'err syntax', + ])) { + mapped = QuerySyntaxException( + 'Syntax error in the query', + detailedExplanation: _innermost(raw), + remediationHint: 'Fix the statement near the position reported by the ' + 'server and run it again', + originalError: error, + stackTrace: stackTrace, + ); + } else if (error is TimeoutException || + _hasAny(lower, const ['timed out', 'timeout expired', 'etimedout'])) { + mapped = ConnectionTimeoutException( + 'The operation timed out', + remediationHint: 'Check the network and firewall, or that the server ' + 'is not overloaded, then try again', + originalError: error, + stackTrace: stackTrace, + ); + } else if (error is SocketException || + _hasAny(lower, const [ + 'connection refused', + 'failed host lookup', + 'no route to host', + 'network is unreachable', + 'name or service not known', + 'econnrefused', + 'socketexception', + ])) { + mapped = HostUnreachableException( + 'Cannot reach the database server', + detailedExplanation: '$engine did not accept the connection.', + remediationHint: 'Check the host and port, that the server is running, ' + 'and any firewall or SSH tunnel settings', + originalError: error, + stackTrace: stackTrace, + ); + } + + return mapped ?? + UnknownDatabaseException( + _innermost(raw), + originalError: error, + stackTrace: stackTrace, + ); +} + +/// One-line, user-facing text for [error] (message plus remediation hint). +/// +/// Unrecognized errors keep their original text, so callers can use this in +/// place of `error.toString()` without losing information. +String describeDatabaseError(Object error, {DatabaseDriver? driver}) { + final mapped = mapDatabaseError(error, driver: driver); + if (mapped is UnknownDatabaseException) return error.toString(); + return mapped.displayText; +} + +String _engineName(DatabaseDriver? driver) => switch (driver) { + DatabaseDriver.postgres => 'PostgreSQL', + DatabaseDriver.mysql => 'MySQL', + DatabaseDriver.sqlite => 'SQLite', + DatabaseDriver.mongodb => 'MongoDB', + DatabaseDriver.redis => 'Redis', + DatabaseDriver.extension => 'The extension driver', + null => 'The server', + }; + +/// Error text including the wrapped `cause` of the connection exceptions. +String _fullText(Object error) { + final parts = [error.toString()]; + final cause = switch (error) { + PostgresConnectionException(:final cause) => cause, + MysqlConnectionException(:final cause) => cause, + SqliteConnectionException(:final cause) => cause, + _ => null, + }; + if (cause != null) parts.add(cause.toString()); + if (error is RedisConnectionException) parts.add(error.message); + return parts.join('\n'); +} + +String _innermost(String raw) { + final line = raw.split('\n').first.trim(); + final i = line.lastIndexOf(': '); + final tail = i >= 0 ? line.substring(i + 2).trim() : line; + return tail.isEmpty ? line : tail; +} + +bool _hasAny(String lower, List needles) => + needles.any(lower.contains); + +String? _firstMatch(String text, List patterns) { + for (final p in patterns) { + final m = p.firstMatch(text); + if (m != null) return m.group(1); + } + return null; +} + +/// Rethrows [error] as a recognized [QueryaDatabaseException], or unchanged +/// when it is not recognized (so existing typed errors keep their type). +Never rethrowMappedDatabaseError( + Object error, + StackTrace stackTrace, { + DatabaseDriver? driver, +}) { + final mapped = mapDatabaseError(error, stackTrace: stackTrace, driver: driver); + Error.throwWithStackTrace( + mapped is UnknownDatabaseException ? error : mapped, + stackTrace, + ); +} diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 5f99de02..5f0bd20a 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; @@ -200,11 +201,11 @@ class MongoConnection { _openedDbs[defaultName] = _db!; } scrubCredentials(); - } catch (e) { + } catch (e, st) { _isConnected = false; _db = null; await _cleanupTempTlsKey(); - rethrow; + rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mongodb); } } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 16c905e4..2341f305 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; @@ -218,10 +219,10 @@ class MysqlConnection { } _isConnected = true; scrubCredentials(); - } catch (e) { + } catch (e, st) { _isConnected = false; _conn = null; - rethrow; + rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mysql); } } diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 74903ff1..c4670dbc 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -98,7 +99,8 @@ class MysqlConnectionPool { } catch (e, st) { Error.throwWithStackTrace( MysqlConnectionException( - 'Failed to acquire MySQL connection for database "$database": $e', + 'Failed to acquire MySQL connection for database "$database": ' + '${describeDatabaseError(e, driver: DatabaseDriver.mysql)}', cause: e, stackTrace: st, ), diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index e008370a..5296cc79 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -3,6 +3,7 @@ import 'dart:io' show SecurityContext; import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -247,7 +248,8 @@ class PostgresConnection { _conn = null; Error.throwWithStackTrace( PostgresConnectionException( - 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e', + 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: ' + '${describeDatabaseError(e, driver: DatabaseDriver.postgres)}', cause: e, stackTrace: st, ), diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 575fbf53..cb60357e 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -110,7 +111,8 @@ class PostgresConnectionPool { } catch (e, st) { Error.throwWithStackTrace( PostgresConnectionException( - 'Failed to acquire PostgreSQL connection for database "$database": $e', + 'Failed to acquire PostgreSQL connection for database "$database": ' + '${describeDatabaseError(e, driver: DatabaseDriver.postgres)}', cause: e, stackTrace: st, ), diff --git a/lib/core/database/querya_database_exception.dart b/lib/core/database/querya_database_exception.dart new file mode 100644 index 00000000..b5a678e8 --- /dev/null +++ b/lib/core/database/querya_database_exception.dart @@ -0,0 +1,131 @@ +/// Driver-independent database failure with a user-facing explanation. +/// +/// Drivers throw their own low-level errors (`PgException`, `MySQLException`, +/// `MongoDartError`, `SocketException`, `StateError`, ...). Those are mapped +/// into this hierarchy by `mapDatabaseError` so the UI can show a plain +/// message plus a [remediationHint] instead of a raw driver string. +sealed class QueryaDatabaseException implements Exception { + const QueryaDatabaseException( + this.message, { + this.detailedExplanation, + this.remediationHint, + this.originalError, + this.stackTrace, + }); + + /// Short, plain-language summary (one line). + final String message; + + /// Longer explanation of what went wrong, when it adds information. + final String? detailedExplanation; + + /// What the user can do to fix the problem. + final String? remediationHint; + + /// The driver error this was mapped from. + final Object? originalError; + final StackTrace? stackTrace; + + /// [message] followed by the remediation hint, for single-string surfaces + /// such as tree errors, the status bar and toasts. + String get displayText => remediationHint == null || remediationHint!.isEmpty + ? message + : '$message. $remediationHint'; + + @override + String toString() => displayText; +} + +/// Wrong or missing credentials. +final class AuthFailedException extends QueryaDatabaseException { + const AuthFailedException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// The server could not be reached (refused, DNS failure, no route). +final class HostUnreachableException extends QueryaDatabaseException { + const HostUnreachableException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// Connecting or running a command took longer than the allowed time. +final class ConnectionTimeoutException extends QueryaDatabaseException { + const ConnectionTimeoutException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// The requested database / schema does not exist on the server. +final class DatabaseNotFoundException extends QueryaDatabaseException { + const DatabaseNotFoundException( + super.message, { + this.databaseName, + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); + + final String? databaseName; +} + +/// The statement or command could not be parsed. +final class QuerySyntaxException extends QueryaDatabaseException { + const QuerySyntaxException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// A table / relation referenced by a statement does not exist. +final class TableNotFoundException extends QueryaDatabaseException { + const TableNotFoundException( + super.message, { + this.tableName, + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); + + final String? tableName; +} + +/// A lock could not be acquired in time (busy / locked / deadlock). +final class LockTimeoutException extends QueryaDatabaseException { + const LockTimeoutException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// Anything the mapper does not recognize; keeps the original text. +final class UnknownDatabaseException extends QueryaDatabaseException { + const UnknownDatabaseException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index b28ad7bd..9f8c67e2 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; @@ -98,6 +99,16 @@ class RedisConnection { } Future connect() async { + try { + await _openConnection(); + } on RedisConnectionException { + rethrow; + } catch (e, st) { + rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.redis); + } + } + + Future _openConnection() async { if (_isConnected && _command != null) return; var effectivePassword = _password; diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index 6ed042e4..52543087 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -106,7 +107,8 @@ class SqliteConnectionPool { } catch (e, st) { Error.throwWithStackTrace( SqliteConnectionException( - 'Failed to acquire SQLite connection: $e', + 'Failed to acquire SQLite connection: ' + '${describeDatabaseError(e, driver: DatabaseDriver.sqlite)}', cause: e, stackTrace: st, ), diff --git a/test/core/database/database_error_mapper_test.dart b/test/core/database/database_error_mapper_test.dart new file mode 100644 index 00000000..1c510558 --- /dev/null +++ b/test/core/database/database_error_mapper_test.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_connection.dart'; + +void main() { + group('mapDatabaseError PostgreSQL', () { + test('wrong password is AuthFailedException', () { + final e = mapDatabaseError( + PostgresConnectionException( + 'Failed to connect', + cause: Exception('password authentication failed for user "bob"'), + ), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + expect(e.remediationHint, isNotEmpty); + }); + + test('missing database is DatabaseNotFoundException with the name', () { + final e = mapDatabaseError( + Exception('database "shop" does not exist'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + expect((e as DatabaseNotFoundException).databaseName, 'shop'); + }); + + test('missing relation is TableNotFoundException keeping the name', () { + final e = mapDatabaseError( + Exception('relation "public.Users" does not exist'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + expect((e as TableNotFoundException).tableName, 'public.Users'); + }); + + test('syntax error', () { + final e = mapDatabaseError( + Exception('syntax error at or near "SELEC"'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + }); + + test('lock timeout', () { + final e = mapDatabaseError( + Exception('canceling statement due to lock timeout'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + }); + }); + + group('mapDatabaseError MySQL', () { + test('access denied', () { + final e = mapDatabaseError( + MysqlConnectionException( + 'Failed to connect', + cause: Exception("Access denied for user 'root'@'localhost'"), + ), + driver: DatabaseDriver.mysql, + ); + expect(e, isA()); + }); + + test('unknown database', () { + final e = mapDatabaseError( + Exception("Unknown database 'nope'"), + driver: DatabaseDriver.mysql, + ); + expect(e, isA()); + }); + + test("table doesn't exist", () { + final e = mapDatabaseError( + Exception("Table 'shop.orders' doesn't exist"), + driver: DatabaseDriver.mysql, + ); + expect(e, isA()); + expect((e as TableNotFoundException).tableName, 'shop.orders'); + }); + + test('syntax error', () { + final e = mapDatabaseError( + Exception('You have an error in your SQL syntax; check the manual'), + driver: DatabaseDriver.mysql, + ); + expect(e, isA()); + }); + + test('lock wait timeout', () { + final e = mapDatabaseError( + Exception('Lock wait timeout exceeded; try restarting transaction'), + driver: DatabaseDriver.mysql, + ); + expect(e, isA()); + }); + }); + + group('mapDatabaseError SQLite', () { + test('no such table', () { + final e = mapDatabaseError( + SqliteConnectionException( + 'Query failed', + cause: Exception('no such table: notes'), + ), + driver: DatabaseDriver.sqlite, + ); + expect(e, isA()); + expect((e as TableNotFoundException).tableName, 'notes'); + }); + + test('database is locked', () { + final e = mapDatabaseError( + Exception('database is locked'), + driver: DatabaseDriver.sqlite, + ); + expect(e, isA()); + }); + + test('syntax error near token', () { + final e = mapDatabaseError( + Exception('near "FORM": syntax error'), + driver: DatabaseDriver.sqlite, + ); + expect(e, isA()); + }); + }); + + group('mapDatabaseError MongoDB', () { + test('authentication failed', () { + final e = mapDatabaseError( + StateError('Authentication failed.'), + driver: DatabaseDriver.mongodb, + ); + expect(e, isA()); + }); + + test('socket exception is HostUnreachableException', () { + final e = mapDatabaseError( + const SocketException('Connection refused'), + driver: DatabaseDriver.mongodb, + ); + expect(e, isA()); + }); + }); + + group('mapDatabaseError Redis', () { + test('WRONGPASS', () { + final e = mapDatabaseError( + RedisConnectionException( + 'WRONGPASS invalid username-password pair or user is disabled.', + ), + driver: DatabaseDriver.redis, + ); + expect(e, isA()); + expect(e.remediationHint, contains('requirepass')); + }); + + test('NOAUTH', () { + final e = mapDatabaseError( + Exception('NOAUTH Authentication required.'), + driver: DatabaseDriver.redis, + ); + expect(e, isA()); + }); + + test('ERR syntax error', () { + final e = mapDatabaseError( + RedisConnectionException('ERR syntax error'), + driver: DatabaseDriver.redis, + ); + expect(e, isA()); + }); + }); + + group('mapDatabaseError generic', () { + test('TimeoutException is ConnectionTimeoutException', () { + final e = mapDatabaseError(TimeoutException('connect')); + expect(e, isA()); + }); + + test('failed host lookup is HostUnreachableException', () { + final e = mapDatabaseError( + const SocketException('Failed host lookup: "db.invalid"'), + ); + expect(e, isA()); + }); + + test('already mapped exceptions are returned unchanged', () { + const original = AuthFailedException('nope'); + expect(identical(mapDatabaseError(original), original), isTrue); + }); + + test('unrecognized errors become UnknownDatabaseException', () { + final e = mapDatabaseError(const FormatException('bad connection')); + expect(e, isA()); + expect(e.originalError, isA()); + }); + + test('displayText appends the remediation hint', () { + const e = AuthFailedException('Authentication failed', + remediationHint: 'Check the password'); + expect(e.displayText, 'Authentication failed. Check the password'); + expect(e.toString(), e.displayText); + }); + }); + + group('describeDatabaseError', () { + test('uses message and hint for recognized errors', () { + final text = describeDatabaseError( + Exception('password authentication failed for user "x"'), + driver: DatabaseDriver.postgres, + ); + expect(text, startsWith('Authentication failed')); + expect(text, contains('username and password')); + }); + + test('keeps the original text for unrecognized errors', () { + const err = FormatException('bad connection string'); + expect(describeDatabaseError(err), err.toString()); + }); + }); + + group('rethrowMappedDatabaseError', () { + test('throws the mapped exception for recognized errors', () { + expect( + () => rethrowMappedDatabaseError( + const SocketException('Connection refused'), + StackTrace.current, + driver: DatabaseDriver.redis, + ), + throwsA(isA()), + ); + }); + + test('rethrows unrecognized errors unchanged', () { + expect( + () => rethrowMappedDatabaseError( + const FormatException('x'), + StackTrace.current, + ), + throwsA(isA()), + ); + }); + }); +}