Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion lib/features/connections/connection_creation_flow.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<bool> 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);
Expand Down
7 changes: 3 additions & 4 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -707,10 +707,9 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
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<void> _editConnection(ConnectionRow conn) async {
Expand Down
17 changes: 9 additions & 8 deletions lib/features/main_screen/main_screen_dialogs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ class MainScreenDialogs {
workspace.value = ws.toggleReadOnly();
}

Future<void> _saveNewConnection(ConnectionRow row) async {
if (!await saveNewConnection(_context(), row)) return;
await connectionsPanelKey.currentState?.reloadConnectionsFromDb();
}

Future<void> 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<void> onNewDatabaseConnectionFromMenu() async {
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -120,8 +122,7 @@ class MainScreenDialogs {
Future<void> 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() {
Expand Down
115 changes: 115 additions & 0 deletions test/features/connections/save_new_connection_test.dart
Original file line number Diff line number Diff line change
@@ -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<String?> getApplicationSupportPath() async => _root;

@override
Future<String?> getTemporaryPath() async => _root;

@override
Future<String?> 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<material.BuildContext> 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<List<ConnectionRow>> 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();
});
}
Loading