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
35 changes: 28 additions & 7 deletions lib/core/team/team_profile.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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`).
///
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<int> 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<TeamProfileImportResult> importInto(
LocalDb db,
List<ConnectionRow> rows,
) async {
Expand All @@ -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);
}
}
22 changes: 17 additions & 5 deletions lib/features/connections/team_profile_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> exportTeamProfile(BuildContext context) async {
Expand Down Expand Up @@ -39,23 +40,34 @@ Future<void> 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);
}
}
121 changes: 121 additions & 0 deletions test/core/team/team_profile_import_test.dart
Original file line number Diff line number Diff line change
@@ -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<String?> getApplicationSupportPath() async => _root;

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

@override
Future<String?> 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<List<String>> 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);
});
}
Loading