From 7002942c6223791d6ade6d53b2c12c6463b466cc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 11:07:07 +0300 Subject: [PATCH 1/2] fix(connections): a new connection that fails to save is reported and logged instead of vanishing --- .../connections/connection_creation_flow.dart | 27 ++++++++++++++++++- .../connections/connections_panel.dart | 7 +++-- .../main_screen/main_screen_dialogs.dart | 17 ++++++------ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 6f4d61e8..8cce9588 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; @@ -14,6 +14,31 @@ import 'package:querya_desktop/shared/widgets/app_toast.dart'; export 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; +/// Stores a connection a creation flow returned. A failed save (typically the +/// system keyring refusing the password) rolls the row back; it is reported +/// and logged instead of leaving the user with a closed dialog and no +/// connection. Returns whether the connection was stored. +Future saveNewConnection( + material.BuildContext context, + ConnectionRow row, +) async { + try { + await LocalDb.instance.addConnection(row); + return true; + } catch (e, st) { + debugPrint('saveNewConnection: "${row.name}" failed: ' + '${e.runtimeType}: $e\n$st'); + if (context.mounted) { + showAppToast( + context: context, + message: 'The connection was not saved. $e', + variant: AppToastVariant.error, + ); + } + return false; + } +} + /// Context that stays mounted after menu overlays close (multi-step dialog flow). material.BuildContext _dialogAnchorContext(material.BuildContext context) { final navigator = material.Navigator.maybeOf(context, rootNavigator: true); diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 594c7d38..c8524505 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -707,10 +707,9 @@ class ConnectionsPanelState extends State { context, folderId: folderId, ); - if (row != null && mounted) { - await LocalDb.instance.addConnection(row); - await _loadData(); - } + if (row == null || !mounted) return; + if (!await saveNewConnection(context, row)) return; + if (mounted) await _loadData(); } Future _editConnection(ConnectionRow conn) async { diff --git a/lib/features/main_screen/main_screen_dialogs.dart b/lib/features/main_screen/main_screen_dialogs.dart index 6c453659..a64208dc 100644 --- a/lib/features/main_screen/main_screen_dialogs.dart +++ b/lib/features/main_screen/main_screen_dialogs.dart @@ -79,11 +79,15 @@ class MainScreenDialogs { workspace.value = ws.toggleReadOnly(); } + Future _saveNewConnection(ConnectionRow row) async { + if (!await saveNewConnection(_context(), row)) return; + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + Future openNewConnectionFromHero() async { final row = await promptCreateConnection(_context()); if (!_mounted() || row == null) return; - await LocalDb.instance.addConnection(row); - await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + await _saveNewConnection(row); } Future onNewDatabaseConnectionFromMenu() async { @@ -95,8 +99,7 @@ class MainScreenDialogs { if (!_mounted()) return; final row = await promptCreateConnection(_context(), folderId: null); if (!_mounted() || row == null) return; - await LocalDb.instance.addConnection(row); - await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + await _saveNewConnection(row); } finally { _openingConnectionDialog = false; } @@ -110,8 +113,7 @@ class MainScreenDialogs { if (!_mounted()) return; final row = await showNewConnectionUrlDialog(_context()); if (!_mounted() || row == null) return; - await LocalDb.instance.addConnection(row); - await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + await _saveNewConnection(row); } finally { _openingConnectionDialog = false; } @@ -120,8 +122,7 @@ class MainScreenDialogs { Future openSqliteFromHero() async { final row = await showSqliteConnectionForm(_context()); if (!_mounted() || row == null) return; - await LocalDb.instance.addConnection(row); - await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + await _saveNewConnection(row); } void onOpenWelcomeTour() { From f45c8de72321dfec5a5e756ce25b888fe346aadd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 11:13:01 +0300 Subject: [PATCH 2/2] test(connections): saveNewConnection returns false, rolls back and reports a failed save --- .../connections/save_new_connection_test.dart | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 test/features/connections/save_new_connection_test.dart diff --git a/test/features/connections/save_new_connection_test.dart b/test/features/connections/save_new_connection_test.dart new file mode 100644 index 00000000..2e3a4bcb --- /dev/null +++ b/test/features/connections/save_new_connection_test.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; + +import '../../memory_secrets_backend.dart'; +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +const _row = ConnectionRow( + type: 'postgres', + name: 'Save flow', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'secret', + createdAt: '2026-01-01T00:00:00Z', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_save_new_conn_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + testMemorySecrets.clear(); + for (final c in await LocalDb.instance.getConnections()) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + }); + + Future pumpHost(WidgetTester tester) async { + late material.BuildContext host; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) { + host = context; + return const material.SizedBox(); + }, + ), + ), + ); + return host; + } + + Future> savedRows(WidgetTester tester) async => + (await tester.runAsync(() => LocalDb.instance.getConnections()))! + .where((c) => c.name == _row.name) + .toList(); + + testWidgets('a stored connection returns true and shows no toast', + (tester) async { + final host = await pumpHost(tester); + + final saved = await tester.runAsync(() => saveNewConnection(host, _row)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(saved, isTrue); + expect(await savedRows(tester), hasLength(1)); + expect(find.textContaining('was not saved'), findsNothing); + }); + + testWidgets( + 'a failed secret write returns false, rolls the row back and says so', + (tester) async { + final host = await pumpHost(tester); + testMemorySecrets.failNextWrite = StateError('keychain write failed'); + + final saved = await tester.runAsync(() => saveNewConnection(host, _row)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(saved, isFalse); + expect(await savedRows(tester), isEmpty); + expect( + find.textContaining( + 'The connection was not saved. Bad state: keychain write failed', + ), + findsOneWidget, + ); + + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + }); +}