TF-3976 Support DNS SRV resolvers without Cloudflare/Google dependency
This commit is contained in:
@@ -1,23 +1,23 @@
|
||||
import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_url.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart';
|
||||
import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart';
|
||||
|
||||
class LoginDataSourceImpl implements LoginDataSource {
|
||||
|
||||
final DNSService _dnsService;
|
||||
final DnsLookupManager _dnsLookupManager;
|
||||
final ExceptionThrower _exceptionThrower;
|
||||
|
||||
LoginDataSourceImpl(
|
||||
this._dnsService,
|
||||
this._dnsLookupManager,
|
||||
this._exceptionThrower
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> dnsLookupToGetJmapUrl(String emailAddress) {
|
||||
return Future.sync(() async {
|
||||
return await _dnsService.getJmapUrl(emailAddress);
|
||||
return await _dnsLookupManager.lookupJmapUrl(emailAddress);
|
||||
}).catchError(_exceptionThrower.throwException);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:core/utils/build_utils.dart';
|
||||
import 'package:super_dns_client/super_dns_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
|
||||
|
||||
/// Handles DNS SRV lookups for JMAP service discovery.
|
||||
///
|
||||
/// The manager attempts lookups in order of priority:
|
||||
/// **System → Public UDP → Public DoH → Cloud (Google/Cloudflare)**.
|
||||
class DnsLookupManager {
|
||||
static const String _jmapServicePrefix = '_jmap._tcp';
|
||||
static const Duration _defaultTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Builds the JMAP SRV hostname from [emailAddress].
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// input : user@example.com
|
||||
/// output: _jmap._tcp.example.com
|
||||
/// ```
|
||||
String buildJmapHostName(String emailAddress) {
|
||||
final parts = emailAddress.split('@');
|
||||
if (parts.length != 2 || parts[1].isEmpty) {
|
||||
throw ArgumentError('Invalid email address: $emailAddress');
|
||||
}
|
||||
return '$_jmapServicePrefix.${parts[1]}';
|
||||
}
|
||||
|
||||
/// Creates the appropriate [DnsClient] for the given [priority].
|
||||
DnsClient createClient(DnsLookupPriority priority) {
|
||||
const debug = BuildUtils.isDebugMode;
|
||||
switch (priority) {
|
||||
case DnsLookupPriority.system:
|
||||
return SystemUdpSrvClient(debugMode: debug);
|
||||
case DnsLookupPriority.publicUdp:
|
||||
return PublicUdpSrvClient(debugMode: debug);
|
||||
case DnsLookupPriority.publicDoh:
|
||||
return DnsOverHttpsBinaryClient(debugMode: debug);
|
||||
case DnsLookupPriority.cloud:
|
||||
return DnsOverHttps.google(debugMode: debug);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts SRV resolution for the JMAP hostname derived from [emailAddress].
|
||||
///
|
||||
/// Each lookup attempt will timeout after [_defaultTimeout] seconds.
|
||||
/// Returns the first successfully resolved hostname, or an empty string if all fail.
|
||||
Future<String> lookupJmapUrl(String emailAddress) async {
|
||||
final jmapHostName = buildJmapHostName(emailAddress);
|
||||
log('$runtimeType::lookupJmapUrl → Resolving SRV for: $jmapHostName');
|
||||
|
||||
final priorities = List.of(DnsLookupPriority.values)
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
|
||||
for (final priority in priorities) {
|
||||
final client = createClient(priority);
|
||||
log('$runtimeType::lookupJmapUrl → 🔍 Trying ${priority.label} (timeout: ${_defaultTimeout.inSeconds}s)...');
|
||||
|
||||
try {
|
||||
final records = client is DnsOverHttps
|
||||
? await client.lookupSrvParallel(jmapHostName).timeout(
|
||||
_defaultTimeout,
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||
},
|
||||
)
|
||||
: await client.lookupSrv(jmapHostName).timeout(
|
||||
_defaultTimeout,
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||
},
|
||||
);
|
||||
|
||||
if (records.isNotEmpty) {
|
||||
final target = records.first.target;
|
||||
log('$runtimeType::lookupJmapUrl → ✅ Success via ${priority.label}: $target');
|
||||
return target;
|
||||
}
|
||||
|
||||
log('$runtimeType::lookupJmapUrl → ⚠️ No records via ${priority.label}, continuing...');
|
||||
} on TimeoutException catch (_) {
|
||||
logError(
|
||||
'$runtimeType::lookupJmapUrl → ⏱️ ${priority.label} lookup timed out');
|
||||
} catch (error, stack) {
|
||||
logError(
|
||||
'$runtimeType::lookupJmapUrl → ❌ ${priority.label} lookup failed: $error, $stack');
|
||||
}
|
||||
}
|
||||
|
||||
log('$runtimeType::lookupJmapUrl → 🚨 All DNS lookups failed for $jmapHostName');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Represents the priority order and description of different DNS lookup modes.
|
||||
///
|
||||
/// Priority level increases with fallback order:
|
||||
/// 1 → System default
|
||||
/// 2 → Public resolvers (UDP/TCP or DOH)
|
||||
/// 3 → Cloud resolvers (Google/Cloudflare)
|
||||
enum DnsLookupPriority {
|
||||
/// Uses the device's system-configured DNS (e.g., from ISP or OS settings).
|
||||
system(1, 'System Default'),
|
||||
|
||||
/// Uses open DNS resolvers accessible via UDP/TCP (e.g., Quad9, OpenDNS).
|
||||
publicUdp(2, 'Public DNS (UDP/TCP)'),
|
||||
|
||||
/// Uses DNS-over-HTTPS (DoH) resolvers for secure name resolution.
|
||||
publicDoh(2, 'Public DNS (DoH)'),
|
||||
|
||||
/// Uses Google or Cloudflare DNS resolvers.
|
||||
cloud(3, 'Cloud DNS (Google/Cloudflare)');
|
||||
|
||||
/// The lookup priority (lower means higher priority).
|
||||
final int priority;
|
||||
|
||||
/// A human-readable description for UI or logging.
|
||||
final String label;
|
||||
|
||||
const DnsLookupPriority(this.priority, this.label);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:dns_client/dns_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
|
||||
|
||||
class DNSService {
|
||||
static const String _jmapServiceName = '_jmap._tcp';
|
||||
|
||||
Future<String> _dnsLookupToUrlFromSRVType({required String hostName}) async {
|
||||
try {
|
||||
final url = await _dnsLookupToUrlByGoogle(hostName: hostName);
|
||||
if (url.isEmpty) {
|
||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _dnsLookupToUrlByGoogle({required String hostName}) async {
|
||||
final dns = DnsOverHttps.google();
|
||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
||||
if (listData.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
||||
}
|
||||
|
||||
Future<String> _dnsLookupToUrlByCloudflare({required String hostName}) async {
|
||||
final dns = DnsOverHttps.cloudflare();
|
||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
||||
if (listData.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
||||
}
|
||||
|
||||
String _parsingUrlFromDataResourceRecord(String data) {
|
||||
if (data.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
final listFieldData = data.split(' ');
|
||||
if (listFieldData.isEmpty) {
|
||||
throw NotFoundUrlException();
|
||||
}
|
||||
final url = _removeDotAtEndOfString(listFieldData.last);
|
||||
log('DNSService::_parsingUrlFromDataResourceRecord:url: $url');
|
||||
if (url.trim().isEmpty) {
|
||||
throw NotFoundUrlException();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<String> getJmapUrl(String emailAddress) async {
|
||||
final domainName = emailAddress.split('@')[1];
|
||||
final jmapHostName = '$_jmapServiceName.$domainName';
|
||||
log('DNSHandler::getJmapUrl:jmapHostName: $jmapHostName');
|
||||
return await _dnsLookupToUrlFromSRVType(hostName: jmapHostName);
|
||||
}
|
||||
|
||||
String _removeDotAtEndOfString(String value) {
|
||||
if (value.lastIndexOf('.') == value.length - 1) {
|
||||
return value.substring(0, value.length - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.da
|
||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
||||
@@ -74,7 +74,7 @@ class LoginBindings extends BaseBindings {
|
||||
Get.find<CacheExceptionThrower>()
|
||||
));
|
||||
Get.lazyPut(() => LoginDataSourceImpl(
|
||||
Get.find<DNSService>(),
|
||||
Get.find<DnsLookupManager>(),
|
||||
Get.find<RemoteExceptionThrower>(),
|
||||
));
|
||||
Get.lazyPut(() => SaasAuthenticationDataSourceImpl(
|
||||
|
||||
@@ -18,7 +18,7 @@ import 'package:tmail_ui_user/features/login/data/local/authentication_info_cach
|
||||
import 'package:tmail_ui_user/features/login/data/local/oidc_configuration_cache_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/utils/library_platform/app_auth_plugin/app_auth_plugin.dart';
|
||||
@@ -144,6 +144,6 @@ class NetworkBindings extends Bindings {
|
||||
}
|
||||
|
||||
void _bindingServices() {
|
||||
Get.put(DNSService());
|
||||
Get.put(DnsLookupManager());
|
||||
}
|
||||
}
|
||||
+36
-13
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -463,15 +463,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
dns_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: twake-supported
|
||||
resolved-ref: "0966c504c1a813ff40e22ea8896537c32d97b82d"
|
||||
url: "https://github.com/linagora/dns_client.git"
|
||||
source: git
|
||||
version: "0.2.1"
|
||||
dotted_border:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1176,10 +1167,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.4"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2166,6 +2157,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
super_dns:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_dns
|
||||
sha256: "213d06aafacdcb342ba491faceb50cfca19e90df9cbd2216b39949b5080b283d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
super_dns_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: super_dns_client
|
||||
sha256: "78c2b9c38d96d95f85d495902fc3bc49354cd63db7c4ebd15faa55bff2e4d13f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
super_ip:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_ip
|
||||
sha256: "450f781cddd61b34793063e6f0508c190ca0664bb80a7df12a12b9ec4cd90d0e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
super_raw:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_raw
|
||||
sha256: "7f33a7b6d11b0a829c9a8eda27767ea63f8ef61e1bcc179055861a84f1ad5744"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
super_tag_editor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-7
@@ -90,13 +90,6 @@ dependencies:
|
||||
url: https://github.com/linagora/flutter-date-range-picker.git
|
||||
ref: main
|
||||
|
||||
# TODO: We will change it when the PR in upstream repository will be merged
|
||||
# https://github.com/dietfriends/dns_client/pull/9
|
||||
dns_client:
|
||||
git:
|
||||
url: https://github.com/linagora/dns_client.git
|
||||
ref: twake-supported
|
||||
|
||||
linagora_design_flutter:
|
||||
git:
|
||||
url: https://github.com/linagora/linagora-design-flutter.git
|
||||
@@ -112,6 +105,8 @@ dependencies:
|
||||
### Dependencies from pub.dev ###
|
||||
super_tag_editor: 1.1.0
|
||||
|
||||
super_dns_client: 0.3.1
|
||||
|
||||
external_app_launcher: 4.0.3
|
||||
|
||||
cupertino_icons: 1.0.6
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:super_dns_client/super_dns_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
|
||||
|
||||
// Generate mock class for DnsClient
|
||||
@GenerateMocks([DnsClient])
|
||||
import 'dns_lookup_manager_test.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late MockDnsClient mockSystemClient;
|
||||
late MockDnsClient mockPublicClient;
|
||||
late MockDnsClient mockDohClient;
|
||||
late MockDnsClient mockCloudClient;
|
||||
|
||||
setUp(() {
|
||||
mockSystemClient = MockDnsClient();
|
||||
mockPublicClient = MockDnsClient();
|
||||
mockDohClient = MockDnsClient();
|
||||
mockCloudClient = MockDnsClient();
|
||||
});
|
||||
|
||||
group('DnsLookupManager.lookupJmapUrl', () {
|
||||
test('✅ should return target when system resolver succeeds', () async {
|
||||
// Arrange
|
||||
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||
const SrvRecord(
|
||||
name: '_jmap._tcp.example.com',
|
||||
port: 443,
|
||||
priority: 10,
|
||||
weight: 5,
|
||||
ttl: 3600,
|
||||
target: 'mail.example.com',
|
||||
)
|
||||
]);
|
||||
|
||||
final manager = _TestableDnsLookupManager({
|
||||
DnsLookupPriority.system: mockSystemClient,
|
||||
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||
DnsLookupPriority.publicDoh: mockDohClient,
|
||||
DnsLookupPriority.cloud: mockCloudClient,
|
||||
});
|
||||
|
||||
// Act
|
||||
final result = await manager.lookupJmapUrl('user@example.com');
|
||||
|
||||
// Assert
|
||||
expect(result, equals('mail.example.com'));
|
||||
verify(mockSystemClient.lookupSrv('_jmap._tcp.example.com')).called(1);
|
||||
verifyNever(mockPublicClient.lookupSrv(any));
|
||||
});
|
||||
|
||||
test('✅ should fall back when previous resolver fails', () async {
|
||||
// Arrange
|
||||
when(mockSystemClient.lookupSrv(any))
|
||||
.thenThrow(Exception('System failed'));
|
||||
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||
const SrvRecord(
|
||||
name: '_jmap._tcp.example.com',
|
||||
port: 443,
|
||||
priority: 20,
|
||||
weight: 5,
|
||||
ttl: 3600,
|
||||
target: 'mail-backup.example.com',
|
||||
)
|
||||
]);
|
||||
|
||||
final manager = _TestableDnsLookupManager({
|
||||
DnsLookupPriority.system: mockSystemClient,
|
||||
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||
DnsLookupPriority.publicDoh: mockDohClient,
|
||||
DnsLookupPriority.cloud: mockCloudClient,
|
||||
});
|
||||
|
||||
// Act
|
||||
final result = await manager.lookupJmapUrl('user@example.com');
|
||||
|
||||
// Assert
|
||||
expect(result, equals('mail-backup.example.com'));
|
||||
verifyInOrder([
|
||||
mockSystemClient.lookupSrv(any),
|
||||
mockPublicClient.lookupSrv(any),
|
||||
]);
|
||||
});
|
||||
|
||||
test('✅ should skip to next resolver when previous returns empty list',
|
||||
() async {
|
||||
// Arrange
|
||||
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => []);
|
||||
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||
const SrvRecord(
|
||||
name: '_jmap._tcp.example.com',
|
||||
port: 443,
|
||||
priority: 15,
|
||||
weight: 5,
|
||||
ttl: 3600,
|
||||
target: 'mail-fallback.example.com',
|
||||
)
|
||||
]);
|
||||
|
||||
final manager = _TestableDnsLookupManager({
|
||||
DnsLookupPriority.system: mockSystemClient,
|
||||
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||
DnsLookupPriority.publicDoh: mockDohClient,
|
||||
DnsLookupPriority.cloud: mockCloudClient,
|
||||
});
|
||||
|
||||
// Act
|
||||
final result = await manager.lookupJmapUrl('user@example.com');
|
||||
|
||||
// Assert
|
||||
expect(result, equals('mail-fallback.example.com'));
|
||||
verifyInOrder([
|
||||
mockSystemClient.lookupSrv(any),
|
||||
mockPublicClient.lookupSrv(any),
|
||||
]);
|
||||
});
|
||||
|
||||
test('⚠️ should continue when some resolvers throw exceptions', () async {
|
||||
// Arrange
|
||||
when(mockSystemClient.lookupSrv(any))
|
||||
.thenThrow(Exception('System crashed'));
|
||||
when(mockPublicClient.lookupSrv(any))
|
||||
.thenThrow(Exception('Network down'));
|
||||
when(mockDohClient.lookupSrv(any)).thenAnswer((_) async => [
|
||||
const SrvRecord(
|
||||
name: '_jmap._tcp.example.com',
|
||||
port: 443,
|
||||
priority: 5,
|
||||
weight: 5,
|
||||
ttl: 3600,
|
||||
target: 'mail-doh.example.com',
|
||||
)
|
||||
]);
|
||||
|
||||
final manager = _TestableDnsLookupManager({
|
||||
DnsLookupPriority.system: mockSystemClient,
|
||||
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||
DnsLookupPriority.publicDoh: mockDohClient,
|
||||
DnsLookupPriority.cloud: mockCloudClient,
|
||||
});
|
||||
|
||||
// Act
|
||||
final result = await manager.lookupJmapUrl('user@example.com');
|
||||
|
||||
// Assert
|
||||
expect(result, equals('mail-doh.example.com'));
|
||||
verify(mockDohClient.lookupSrv(any)).called(1);
|
||||
});
|
||||
|
||||
test('⏱️ should return empty string when all resolvers timeout', () async {
|
||||
// Arrange: simulate all resolvers hanging
|
||||
when(mockSystemClient.lookupSrv(any)).thenAnswer(
|
||||
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||
when(mockPublicClient.lookupSrv(any)).thenAnswer(
|
||||
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||
when(mockDohClient.lookupSrv(any)).thenAnswer(
|
||||
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||
when(mockCloudClient.lookupSrv(any)).thenAnswer(
|
||||
(_) => Future.delayed(const Duration(seconds: 10), () => []));
|
||||
|
||||
final manager = _TestableDnsLookupManager({
|
||||
DnsLookupPriority.system: mockSystemClient,
|
||||
DnsLookupPriority.publicUdp: mockPublicClient,
|
||||
DnsLookupPriority.publicDoh: mockDohClient,
|
||||
DnsLookupPriority.cloud: mockCloudClient,
|
||||
});
|
||||
|
||||
// Act
|
||||
final result = await manager.lookupJmapUrl('user@example.com');
|
||||
|
||||
// Assert
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('❌ should throw ArgumentError when email is invalid', () async {
|
||||
final manager = _TestableDnsLookupManager({});
|
||||
expect(
|
||||
() => manager.lookupJmapUrl('invalid-email'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('DnsLookupManager._buildJmapHostName', () {
|
||||
test('✅ should build correct host name from email', () {
|
||||
final manager = DnsLookupManager();
|
||||
final result = manager.buildJmapHostNameForTest('user@domain.com');
|
||||
expect(result, equals('_jmap._tcp.domain.com'));
|
||||
});
|
||||
|
||||
test('❌ should throw ArgumentError for malformed email', () {
|
||||
final manager = DnsLookupManager();
|
||||
expect(
|
||||
() => manager.buildJmapHostNameForTest('invalid'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Test helper subclass that injects mock DNS clients.
|
||||
class _TestableDnsLookupManager extends DnsLookupManager {
|
||||
final Map<DnsLookupPriority, DnsClient> clients;
|
||||
|
||||
_TestableDnsLookupManager(this.clients);
|
||||
|
||||
@override
|
||||
DnsClient createClient(DnsLookupPriority priority) {
|
||||
final client = clients[priority];
|
||||
if (client == null) {
|
||||
throw StateError('No mock client provided for $priority');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extends real DnsLookupManager only for testing private helpers.
|
||||
extension DnsLookupManagerTestable on DnsLookupManager {
|
||||
String buildJmapHostNameForTest(String email) => buildJmapHostName(email);
|
||||
}
|
||||
Reference in New Issue
Block a user