From 882720b4d9996958c0f206365822e6119d8a0711 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 12:11:38 +0300 Subject: [PATCH] fix(team-profile): import tries every row and reports how many could not be saved (#1759) A keyring refusal on one row no longer aborts the loop, hides the rows that were saved or loses the count. importInto returns added, failed and the first error; the toast says so. --- lib/core/team/team_profile.dart | 35 ++++- .../connections/team_profile_actions.dart | 22 +++- test/core/team/team_profile_import_test.dart | 121 ++++++++++++++++++ 3 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 test/core/team/team_profile_import_test.dart diff --git a/lib/core/team/team_profile.dart b/lib/core/team/team_profile.dart index f80e08cf..93cb4ba6 100644 --- a/lib/core/team/team_profile.dart +++ b/lib/core/team/team_profile.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:querya_desktop/core/storage/local_db.dart'; /// Thrown when a team profile file cannot be read. @@ -11,6 +12,14 @@ class TeamProfileFormatException implements Exception { String toString() => message; } +/// What [TeamProfileCodec.importInto] did: rows saved, rows that could not be +/// saved, and the error of the first of those. +typedef TeamProfileImportResult = ({ + int added, + int failed, + Object? firstError, +}); + /// Shareable, secret-free snapshot of a set of connections /// (`team-profile.querya`). /// @@ -122,8 +131,7 @@ class TeamProfileCodec { } final v = doc['version']; if (v is! int || v > version) { - throw TeamProfileFormatException( - 'Unsupported team profile version: $v.'); + throw TeamProfileFormatException('Unsupported team profile version: $v.'); } final list = doc['connections']; if (list is! List) { @@ -187,8 +195,12 @@ class TeamProfileCodec { } /// Imports [rows] into [db], skipping ones that already exist - /// (same type, name, host, port and database). Returns the added count. - static Future importInto( + /// (same type, name, host, port and database). + /// + /// Every row is tried: a row that cannot be saved (typically the system + /// keyring refusing a write) is counted in `failed` and does not stop the + /// rest. `firstError` is the error of the first such row. + static Future importInto( LocalDb db, List rows, ) async { @@ -197,11 +209,20 @@ class TeamProfileCodec { '${r.type}|${r.name}|${r.host}|${r.port}|${r.databaseName}'; final known = {for (final e in existing) key(e)}; var added = 0; + var failed = 0; + Object? firstError; for (final r in rows) { if (!known.add(key(r))) continue; - await db.addConnection(r); - added++; + try { + await db.addConnection(r); + added++; + } catch (e, st) { + debugPrint('TeamProfileCodec.importInto: "${r.name}" failed: ' + '${e.runtimeType}: $e\n$st'); + failed++; + firstError ??= e; + } } - return added; + return (added: added, failed: failed, firstError: firstError); } } diff --git a/lib/features/connections/team_profile_actions.dart b/lib/features/connections/team_profile_actions.dart index b6425d40..873a9c81 100644 --- a/lib/features/connections/team_profile_actions.dart +++ b/lib/features/connections/team_profile_actions.dart @@ -8,7 +8,8 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/team/team_profile.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -const _typeGroup = XTypeGroup(label: 'Querya team profile', extensions: ['querya']); +const _typeGroup = + XTypeGroup(label: 'Querya team profile', extensions: ['querya']); /// Saves all connections (without passwords) to a `team-profile.querya` file. Future exportTeamProfile(BuildContext context) async { @@ -39,23 +40,34 @@ Future importTeamProfile(BuildContext context) async { if (file == null || !context.mounted) return; try { final read = TeamProfileCodec.read(utf8.decode(await file.readAsBytes())); - final added = await TeamProfileCodec.importInto(LocalDb.instance, read.rows); + final result = + await TeamProfileCodec.importInto(LocalDb.instance, read.rows); + final added = result.added; if (!context.mounted) return; QueryaCommandHost.maybeOf(context)?.onReloadConnections?.call(); showAppToast( context: context, message: [ added == 0 - ? 'Nothing new to import' + ? (result.failed == 0 + ? 'Nothing new to import' + : 'Nothing imported.') : 'Imported $added connection(s). Enter passwords when connecting.', + if (result.failed > 0) + '${result.failed} could not be saved: ${result.firstError}', if (read.skipped > 0) '${read.skipped} entr${read.skipped == 1 ? 'y was' : 'ies were'} ' 'unreadable and skipped.', ].join(' '), - variant: read.skipped > 0 ? AppToastVariant.info : AppToastVariant.success, + variant: result.failed > 0 + ? AppToastVariant.error + : read.skipped > 0 + ? AppToastVariant.info + : AppToastVariant.success, ); } on TeamProfileFormatException catch (e) { if (!context.mounted) return; - showAppToast(context: context, message: e.message, variant: AppToastVariant.error); + showAppToast( + context: context, message: e.message, variant: AppToastVariant.error); } } diff --git a/test/core/team/team_profile_import_test.dart b/test/core/team/team_profile_import_test.dart new file mode 100644 index 00000000..bda38c29 --- /dev/null +++ b/test/core/team/team_profile_import_test.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +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/core/team/team_profile.dart'; + +import '../../memory_secrets_backend.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; +} + +/// `importInto` tries every row: a row the keyring refuses is counted and +/// reported, the others are saved (#1759). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_team_import_'); + 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!); + } + }); + + ConnectionRow row(String name) => ConnectionRow( + type: 'postgresql', + name: name, + host: 'db.example.com', + port: 5432, + createdAt: '2026-01-01T00:00:00Z', + ); + + Future> savedNames() async => [ + for (final c in await LocalDb.instance.getConnections()) c.name, + ]..sort(); + + test('all rows are saved when the keyring answers', () async { + final result = await TeamProfileCodec.importInto( + LocalDb.instance, + [row('A'), row('B'), row('C')], + ); + + expect(result.added, 3); + expect(result.failed, 0); + expect(result.firstError, isNull); + expect(await savedNames(), ['A', 'B', 'C']); + }); + + test( + 'a refusal on the middle row does not stop the rest, and is counted ' + 'with its error', () async { + // Each row writes two secrets: A is writes 1-2, B starts at write 3. + final error = StateError('keyring refused B'); + testMemorySecrets.failOnCall(SecretsOp.write, 3, error); + + final result = await TeamProfileCodec.importInto( + LocalDb.instance, + [row('A'), row('B'), row('C')], + ); + + expect(result.added, 2); + expect(result.failed, 1); + expect(result.firstError, same(error)); + expect(await savedNames(), ['A', 'C']); + }); + + test('when every row fails, the first error is kept and nothing is saved', + () async { + final first = StateError('first'); + testMemorySecrets.failOnCall(SecretsOp.write, 1, first); + testMemorySecrets.failOnCall(SecretsOp.write, 2, StateError('second')); + + final result = await TeamProfileCodec.importInto( + LocalDb.instance, + [row('A'), row('B')], + ); + + expect(result.added, 0); + expect(result.failed, 2); + expect(result.firstError, same(first)); + expect(await savedNames(), isEmpty); + }); + + test('a row that already exists is skipped, not counted as failed', () async { + await LocalDb.instance.addConnection(row('A')); + + final result = await TeamProfileCodec.importInto( + LocalDb.instance, + [row('A'), row('B')], + ); + + expect(result.added, 1); + expect(result.failed, 0); + }); +}