From b02cff5728822b4a8a63b5efecf6728c55bbc39f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:18 +0300 Subject: [PATCH 1/4] fix: use Uri.parse() for MongoDB database replacement Replace regex-based URI manipulation with Uri.parse() and uri.replace() to correctly handle database path replacement. This fixes the bug where \ was interpreted as a literal string instead of a regex capture group, causing errors like 'Could not connect to admin\'. - mongodb_connection.dart: Use Uri.parse() in listDatabases() and listCollections() - mongodb_service.dart: Use Uri.parse() in executeCommand(), find(), and aggregate() - Preserves query parameters (authSource, replicaSet, ssl) when replacing database --- lib/core/database/mongodb_connection.dart | 19 +++++++++++------ lib/core/database/mongodb_service.dart | 25 ++++++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 254076aa..5cf067c5 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -98,10 +98,13 @@ class MongoConnection { /// Disconnects from MongoDB server. Future disconnect() async { - if (_db != null && _isConnected) { - await _db!.close(); - _db = null; - _isConnected = false; + _isConnected = false; + final db = _db; + _db = null; + try { + await db?.close(); + } catch (_) { + // Connection may already be closed — ignore. } } @@ -119,7 +122,9 @@ class MongoConnection { try { // Switch to admin database to list all databases - final adminUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/admin\$1'); + final baseUri = buildConnectionUri(); + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); final adminDb = await Db.create(adminUri); await adminDb.open(); try { @@ -147,7 +152,9 @@ class MongoConnection { try { // Create a new Db connection to the specified database - final dbUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$databaseName\$1'); + final baseUri = buildConnectionUri(); + final uri = Uri.parse(baseUri); + final dbUri = uri.replace(path: '/$databaseName').toString(); final db = await Db.create(dbUri); await db.open(); try { diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 4d627017..35e6c409 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -10,14 +10,23 @@ class MongoService { final Map _connections = {}; - /// Creates a MongoDB connection from ConnectionRow. + /// Creates (or replaces) a [MongoConnection] for the given [ConnectionRow]. + /// If a connection with the same ID already exists it is disconnected first. MongoConnection createConnection(ConnectionRow row) { if (row.type != 'mongodb') { throw ArgumentError('Connection type must be mongodb'); } + final id = row.id ?? 0; + + // Disconnect previous connection for this ID, if any. + final existing = _connections[id]; + if (existing != null) { + existing.disconnect(); // fire-and-forget; disconnect is safe + } + final connection = MongoConnection( - id: row.id ?? 0, + id: id, name: row.name, host: row.host ?? 'localhost', port: row.port ?? 27017, @@ -67,7 +76,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + final baseUri = connection.buildConnectionUri(); + final uri = Uri.parse(baseUri); + final dbUri = uri.replace(path: '/$database').toString(); final db = await Db.create(dbUri); await db.open(); try { @@ -94,7 +105,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + final baseUri = connection.buildConnectionUri(); + final uri = Uri.parse(baseUri); + final dbUri = uri.replace(path: '/$database').toString(); final db = await Db.create(dbUri); await db.open(); try { @@ -133,7 +146,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + final baseUri = connection.buildConnectionUri(); + final uri = Uri.parse(baseUri); + final dbUri = uri.replace(path: '/$database').toString(); final db = await Db.create(dbUri); await db.open(); try { From 6800c7a674ed0e998657f7605a212e38b7dbbe54 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:21 +0300 Subject: [PATCH 2/4] feat: add MongoDB stats view similar to Redis stats Create MongoStatsView widget that displays MongoDB server statistics in a card-based UI similar to RedisView. Features: - Auto-refresh every 3 seconds - Summary chips (Version, Uptime, Connections, Queries) - Memory, Operations, Connections, Network cards - Server, Storage, Replication, WiredTiger sections - Safe connection management with proper cleanup on dispose --- lib/features/mongodb/mongo_stats_view.dart | 534 +++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 lib/features/mongodb/mongo_stats_view.dart diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart new file mode 100644 index 00000000..308489ae --- /dev/null +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -0,0 +1,534 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 3); +const _summaryChipHeight = 72.0; +const _gridCardHeight = 220.0; + +class MongoStatsView extends material.StatefulWidget { + const MongoStatsView({super.key, required this.connectionRow}); + final ConnectionRow connectionRow; + + @override + material.State createState() => _MongoStatsViewState(); +} + +class _MongoStatsViewState extends material.State { + MongoConnection? _connection; + Map? _serverStatus; + bool _loading = true; + String? _error; + Timer? _timer; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant MongoStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); + _disconnectCurrent(); + _load(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _disconnectCurrent(); + super.dispose(); + } + + /// Safely disconnects and clears the current MongoDB connection. + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + if (conn != null) { + conn.disconnect(); // fire-and-forget; disconnect handles errors + } + } + + Future _load() async { + _timer?.cancel(); + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _serverStatus = null; + }); + try { + final conn = MongoService.instance.createConnection(widget.connectionRow); + await conn.connect(); + if (!mounted) { + // Widget was disposed while connecting — clean up immediately. + conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + Future _fetch() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + setState(() { + _serverStatus = status; + _loading = false; + }); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + setState(() => _serverStatus = status); + } catch (_) {} + }); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + final err = _error; + if (err != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(err, style: material.TextStyle(color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final status = _serverStatus; + if (status == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, status), + const Gap(24), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: _memoryCard(context, status)), + const Gap(16), + material.Expanded(child: _operationsCard(context, status)), + ], + ), + const Gap(16), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: _connectionsCard(context, status)), + const Gap(16), + material.Expanded(child: _networkCard(context, status)), + ], + ), + const Gap(24), + _sectionCard(context, 'Server', _extractServerInfo(status)), + const Gap(12), + _sectionCard(context, 'Storage', _extractStorageInfo(status)), + const Gap(12), + _sectionCard(context, 'Replication', _extractReplicationInfo(status)), + const Gap(12), + _sectionCard(context, 'WiredTiger', _extractWiredTigerInfo(status)), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') + .muted().small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _summaryChips(material.BuildContext context, Map status) { + final cs = shadcn.Theme.of(context).colorScheme; + final version = _getString(status, 'version') ?? '—'; + final uptime = _getInt(status, 'uptime') ?? 0; + final uptimeDays = (uptime / 86400).toStringAsFixed(1); + final connections = _getNestedInt(status, 'connections', 'current') ?? 0; + final maxConnections = _getNestedInt(status, 'connections', 'available') ?? 0; + final ops = _getNestedInt(status, 'opcounters', 'query') ?? 0; + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.SizedBox( + height: _summaryChipHeight, + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), + ), + child: material.Row( + children: [ + material.Icon(icon, size: 20, color: cs.primary), + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + Text(value).semiBold().small(), + ], + ), + ), + ], + ), + ), + ), + ); + } + return material.Row( + children: [ + chip('Version', version, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', '$uptimeDays days', material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$connections / $maxConnections', material.Icons.people_outline_rounded), + const Gap(12), + chip('Queries', '$ops', material.Icons.speed_rounded), + ], + ); + } + + material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + height: height, + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold(), + const Gap(12), + body, + ], + ), + ); + } + + material.Widget _memoryCard(material.BuildContext context, Map status) { + final mem = status['mem'] as Map?; + final resident = _getInt(mem, 'resident') ?? 0; + final virtual = _getInt(mem, 'virtual') ?? 0; + final mapped = _getInt(mem, 'mapped') ?? 0; + final mappedWithJournal = _getInt(mem, 'mappedWithJournal') ?? 0; + return _card( + context, + 'Memory', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Resident', _formatBytes(resident)), + _row(context, 'Virtual', _formatBytes(virtual)), + _row(context, 'Mapped', _formatBytes(mapped)), + _row(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _operationsCard(material.BuildContext context, Map status) { + final opcounters = status['opcounters'] as Map?; + final inserts = _getInt(opcounters, 'insert') ?? 0; + final queries = _getInt(opcounters, 'query') ?? 0; + final updates = _getInt(opcounters, 'update') ?? 0; + final deletes = _getInt(opcounters, 'delete') ?? 0; + return _card( + context, + 'Operations', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Inserts', '$inserts'), + _row(context, 'Queries', '$queries'), + _row(context, 'Updates', '$updates'), + _row(context, 'Deletes', '$deletes'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _connectionsCard(material.BuildContext context, Map status) { + final connections = status['connections'] as Map?; + final current = _getInt(connections, 'current') ?? 0; + final available = _getInt(connections, 'available') ?? 0; + final active = _getNestedInt(status, 'globalLock', 'activeClients', 'total') ?? 0; + return _card( + context, + 'Connections', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Current', '$current'), + _row(context, 'Available', '$available'), + _row(context, 'Active clients', '$active'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _networkCard(material.BuildContext context, Map status) { + final network = status['network'] as Map?; + final bytesIn = _getInt(network, 'bytesIn') ?? 0; + final bytesOut = _getInt(network, 'bytesOut') ?? 0; + final numRequests = _getInt(network, 'numRequests') ?? 0; + return _card( + context, + 'Network', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Bytes in', _formatBytes(bytesIn)), + _row(context, 'Bytes out', _formatBytes(bytesOut)), + _row(context, 'Requests', '$numRequests'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _sectionCard(material.BuildContext context, String title, Map? data) { + if (data == null || data.isEmpty) return const material.SizedBox.shrink(); + return _card( + context, + title, + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in data.entries) _row(context, e.key, e.value)], + ), + ); + } + + material.Widget _row(material.BuildContext context, String key, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox(width: 160, child: Text(key).muted().small()), + material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + ], + ), + ); + } + + // Helper methods to extract data from serverStatus + String? _getString(Map? map, String key) { + final v = map?[key]; + return v?.toString(); + } + + int? _getInt(Map? map, String key) { + final v = map?[key]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + + int? _getNestedInt(Map? map, String key1, String key2, [String? key3]) { + final m1 = map?[key1] as Map?; + if (m1 == null) return null; + if (key3 != null) { + final m2 = m1[key2] as Map?; + if (m2 == null) return null; + final v = m2[key3]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + final v = m1[key2]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + + Map _extractServerInfo(Map status) { + final result = {}; + if (status['host'] != null) result['Host'] = status['host'].toString(); + if (status['version'] != null) result['Version'] = status['version'].toString(); + if (status['process'] != null) result['Process'] = status['process'].toString(); + final uptime = _getInt(status, 'uptime'); + if (uptime != null) { + final days = (uptime / 86400).toStringAsFixed(1); + result['Uptime'] = '$days days ($uptime seconds)'; + } + return result; + } + + Map _extractStorageInfo(Map status) { + final result = {}; + final dur = status['dur'] as Map?; + if (dur != null) { + if (dur['commitsInWriteLock'] != null) { + result['Commits in write lock'] = dur['commitsInWriteLock'].toString(); + } + } + return result; + } + + Map _extractReplicationInfo(Map status) { + final result = {}; + final repl = status['repl'] as Map?; + if (repl != null) { + if (repl['setName'] != null) result['Replica set'] = repl['setName'].toString(); + if (repl['ismaster'] != null) result['Is master'] = repl['ismaster'].toString(); + if (repl['secondary'] != null) result['Secondary'] = repl['secondary'].toString(); + } + return result; + } + + Map _extractWiredTigerInfo(Map status) { + final result = {}; + final wiredTiger = status['wiredTiger'] as Map?; + if (wiredTiger != null) { + final cache = wiredTiger['cache'] as Map?; + if (cache != null) { + final maxSize = _getInt(cache, 'maximum bytes configured'); + if (maxSize != null) result['Max cache size'] = _formatBytes(maxSize); + final usedSize = _getInt(cache, 'bytes currently in the cache'); + if (usedSize != null) result['Cache used'] = _formatBytes(usedSize); + } + } + return result; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} From 1619648f46be9127413569691aac5308237faa95 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:24 +0300 Subject: [PATCH 3/4] feat: show MongoStatsView for MongoDB connections Update workspace_panel to display MongoStatsView instead of MongoDatabasesView when a MongoDB connection is selected, providing server statistics similar to Redis connections. --- lib/features/main_screen/workspace_panel.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f2345118..fc96d0f1 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' as material show Container, EdgeInsets, B import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:querya_desktop/features/mongodb/mongo_databases_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; @@ -30,12 +30,17 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - // If a MongoDB connection is selected, show the databases view + // If a MongoDB connection is selected, show the stats view if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { - return MongoDatabasesView( - key: ValueKey(widget.activeConnection!.id), - connectionRow: widget.activeConnection!, + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: MongoStatsView( + key: ValueKey(widget.activeConnection!.id), + connectionRow: widget.activeConnection!, + ), + ), ); } From 26b0a0237c32ee37d5ac8e29394d98b08f1ae458 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:26 +0300 Subject: [PATCH 4/4] test: add comprehensive tests for MongoDB URI replacement Add unit tests to verify correct database path replacement in MongoDB URIs: - Replaces database in URI without existing database - Replaces database in URI with existing database - Preserves query parameters (authSource, replicaSet, ssl) - Handles various URI formats (with/without auth, custom ports, connection strings) - Ensures no literal \ appears in final URI (the bug we fixed) --- .../mongodb_uri_replacement_test.dart | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 test/core/database/mongodb_uri_replacement_test.dart diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart new file mode 100644 index 00000000..bcb2e060 --- /dev/null +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; + +void main() { + group('MongoDB URI database replacement', () { + test('replaces database in URI without existing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://root:root@127.0.0.1'); + + // Simulate the replacement logic used in listDatabases + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, 'mongodb://root:root@127.0.0.1/admin'); + }); + + test('replaces database in URI with existing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://root:root@127.0.0.1/mydb'); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, 'mongodb://root:root@127.0.0.1/admin'); + }); + + test('preserves query parameters when replacing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + authSource: 'admin', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, contains('?authSource=admin')); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, contains('/admin')); + expect(adminUri, contains('?authSource=admin')); + expect(adminUri, 'mongodb://root:root@127.0.0.1/admin?authSource=admin'); + }); + + test('preserves multiple query parameters when replacing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + authSource: 'admin', + replicaSet: 'rs0', + useSSL: true, + ); + final baseUri = conn.buildConnectionUri(); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, contains('/admin')); + expect(adminUri, contains('authSource=admin')); + expect(adminUri, contains('replicaSet=rs0')); + expect(adminUri, contains('ssl=true')); + }); + + test('replaces database with custom database name', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + database: 'olddb', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://localhost/olddb'); + + // Simulate the replacement logic used in listCollections + final uri = Uri.parse(baseUri); + final newDbUri = uri.replace(path: '/newdb').toString(); + expect(newDbUri, 'mongodb://localhost/newdb'); + }); + + test('handles URI without authentication', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://localhost'); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, 'mongodb://localhost/admin'); + }); + + test('handles URI with custom port', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + port: 27018, + username: 'root', + password: 'root', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://root:root@127.0.0.1:27018'); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, 'mongodb://root:root@127.0.0.1:27018/admin'); + }); + + test('handles connectionString URI replacement', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'mongodb://user:pass@host:27017/mydb?authSource=admin', + ); + final baseUri = conn.buildConnectionUri(); + expect(baseUri, 'mongodb://user:pass@host:27017/mydb?authSource=admin'); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + expect(adminUri, 'mongodb://user:pass@host:27017/admin?authSource=admin'); + }); + + test('ensures no literal dollar sign appears in final URI', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + ); + final baseUri = conn.buildConnectionUri(); + + // Simulate the replacement logic + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); + + // Critical: ensure no literal $1 appears (the bug we fixed) + expect(adminUri, isNot(contains(r'$1'))); + expect(adminUri, isNot(contains('admin\$1'))); + expect(adminUri, isNot(contains(r'admin$1'))); + expect(adminUri, contains('/admin')); + }); + }); +}