diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index ddc62c1..3bd6cbb 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -49,6 +49,32 @@ export 'src/config/common_platform.dart' show CommonPlatform; export 'src/config/events_config.dart' show EventsConfig; export 'src/config/credential/credential_source.dart' show CredentialSource; export 'src/connection_mode.dart' show ConnectionMode; +export 'src/fdv2_connection_mode.dart' + show + FDv2ConnectionMode, + FDv2Streaming, + FDv2Polling, + FDv2Offline, + FDv2Background; +export 'src/resolved_connection_mode.dart' + show + ResolvedConnectionMode, + ResolvedStreaming, + ResolvedPolling, + ResolvedBackground, + ResolvedOffline; +export 'src/offline_detail.dart' + show + OfflineDetail, + OfflineSetOffline, + OfflineNetworkUnavailable, + OfflineBackgroundDisabled; +export 'src/data_sources/fdv2/mode_resolution.dart' + show + ModeState, + ModeResolutionEntry, + resolveMode, + flutterDefaultResolutionTable; export 'src/data_sources/data_source_status.dart' show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState; diff --git a/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart b/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart new file mode 100644 index 0000000..4125926 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart @@ -0,0 +1,53 @@ +import 'mode_definition.dart'; + +/// Built-in [ModeDefinition] values. +abstract final class BuiltInModes { + BuiltInModes._(); + + /// Default foreground poll interval. + static const Duration _foregroundPollInterval = Duration(seconds: 300); + + /// Default background poll interval. + static const Duration defaultBackgroundPollInterval = Duration(seconds: 3600); + + /// Streaming: combination of cache and polling initializers, and streaming and fallback polling synchronizer. + static const ModeDefinition streaming = ModeDefinition( + initializers: [ + CacheInitializer(), + PollingInitializer(), + ], + synchronizers: [ + StreamingSynchronizer(), + PollingSynchronizer(), + ], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: _foregroundPollInterval, + ), + ); + + /// Polling-only mode. + static const ModeDefinition polling = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [PollingSynchronizer()], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: _foregroundPollInterval, + ), + ); + + /// Offline: cache initializer only; no synchronizers. + static const ModeDefinition offline = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [], + ); + + /// Background: cache initializer, reduced-rate polling synchronizer + static const ModeDefinition background = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [ + PollingSynchronizer(pollInterval: defaultBackgroundPollInterval), + ], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: defaultBackgroundPollInterval, + ), + ); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart new file mode 100644 index 0000000..14ca1ff --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/service_endpoints.dart'; +import 'cache_initializer.dart' as cache_src; +import 'source_factory_context.dart'; +import 'mode_definition.dart' as mode; +import 'polling_base.dart'; +import 'polling_initializer.dart'; +import 'polling_synchronizer.dart'; +import 'requestor.dart'; +import 'selector.dart'; +import 'source.dart'; + +/// Merges per-entry [mode.EndpointConfig] overrides into [base]. +ServiceEndpoints mergeServiceEndpoints( + ServiceEndpoints base, + mode.EndpointConfig? override, +) { + if (override == null) { + return base; + } + if (override.pollingBaseUri == null && override.streamingBaseUri == null) { + return base; + } + return ServiceEndpoints.custom( + polling: override.pollingBaseUri?.toString() ?? base.polling, + streaming: override.streamingBaseUri?.toString() ?? base.streaming, + events: base.events, + ); +} + +/// Builds a fresh [FDv2PollingBase] (and its underlying [FDv2Requestor]) per +/// call. Must be invoked inside the factory's `create` lambda so each +/// produced instance owns its own requestor; the requestor holds mutable +/// per-call state (e.g. ETag) and cannot be safely shared across instances. +FDv2PollingBase _buildPollingBase({ + required mode.EndpointConfig? endpoints, + required bool usePost, + required SourceFactoryContext ctx, +}) { + final endpointsResolved = + mergeServiceEndpoints(ctx.serviceEndpoints, endpoints); + final requestor = FDv2Requestor( + logger: ctx.logger, + endpoints: endpointsResolved, + contextEncoded: base64UrlEncode(utf8.encode(ctx.contextJson)), + contextJson: ctx.contextJson, + usePost: usePost, + withReasons: ctx.withReasons, + httpProperties: ctx.httpProperties, + httpClientFactory: ctx.httpClientFactory ?? _defaultHttpClientFactory, + ); + return FDv2PollingBase( + logger: ctx.logger, + requestor: requestor, + ); +} + +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { + return HttpClient(httpProperties: httpProperties); +} + +/// A factory for creating [Initializer] instances. +final class InitializerFactory { + /// True for cache initializers. + final bool isCache; + + final Initializer Function(SelectorGetter selectorGetter) _create; + + InitializerFactory({ + required Initializer Function(SelectorGetter selectorGetter) create, + this.isCache = false, + }) : _create = create; + + Initializer create(SelectorGetter selectorGetter) => _create(selectorGetter); +} + +/// A factory for creating [Synchronizer] instances. +final class SynchronizerFactory { + final Synchronizer Function(SelectorGetter selectorGetter) _create; + + SynchronizerFactory({ + required Synchronizer Function(SelectorGetter selectorGetter) create, + }) : _create = create; + + Synchronizer create(SelectorGetter selectorGetter) => _create(selectorGetter); +} + +/// Builds an [InitializerFactory] for a single [mode.InitializerEntry]. +/// +/// Throws [UnsupportedError] for unsupported entry types. +InitializerFactory createInitializerFactoryFromEntry( + mode.InitializerEntry entry, + SourceFactoryContext ctx, +) { + switch (entry) { + case mode.CacheInitializer(): + return InitializerFactory( + isCache: true, + create: (_) => cache_src.CacheInitializer( + reader: ctx.cachedFlagsReader, + context: ctx.context, + logger: ctx.logger, + ), + ); + case final mode.PollingInitializer e: + return InitializerFactory( + create: (SelectorGetter selectorGetter) { + final base = _buildPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + return FDv2PollingInitializer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + logger: ctx.logger, + ); + }, + ); + case mode.StreamingInitializer(): + throw UnsupportedError( + 'FDv2 StreamingInitializer factories are not implemented yet', + ); + } +} + +/// Builds a [SynchronizerFactory] for a single [mode.SynchronizerEntry]. +/// +/// Throws [UnsupportedError] for unsupported entry types. +SynchronizerFactory createSynchronizerFactoryFromEntry( + mode.SynchronizerEntry entry, + SourceFactoryContext ctx, +) { + switch (entry) { + case final mode.PollingSynchronizer e: + final interval = e.pollInterval ?? ctx.defaultPollingInterval; + return SynchronizerFactory( + create: (SelectorGetter selectorGetter) { + final base = _buildPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + return FDv2PollingSynchronizer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + interval: interval, + logger: ctx.logger, + ); + }, + ); + case mode.StreamingSynchronizer(): + throw UnsupportedError( + 'FDv2 StreamingSynchronizer factories are not implemented yet', + ); + } +} + +/// One factory per entry, in list order. +List buildInitializerFactories( + List entries, + SourceFactoryContext ctx, +) { + return entries.map((e) => createInitializerFactoryFromEntry(e, ctx)).toList(); +} + +/// One factory per entry, in list order. +List buildSynchronizerFactories( + List entries, + SourceFactoryContext ctx, +) { + return entries + .map((e) => createSynchronizerFactoryFromEntry(e, ctx)) + .toList(); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart b/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart new file mode 100644 index 0000000..2096a93 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart @@ -0,0 +1,114 @@ +/// Per-source endpoint overrides. When fields are null, the client uses +/// the default [ServiceEndpoints] from config. +final class EndpointConfig { + final Uri? pollingBaseUri; + final Uri? streamingBaseUri; + + const EndpointConfig({this.pollingBaseUri, this.streamingBaseUri}); +} + +/// Marker class for separating initializers from other types of source entries. +sealed class InitializerEntry { + const InitializerEntry(); +} + +/// Marker class for separating synchronizers from other types of source entries. +sealed class SynchronizerEntry { + const SynchronizerEntry(); +} + +/// Initializer that will read data from cache. +final class CacheInitializer extends InitializerEntry { + const CacheInitializer(); +} + +/// Initializer that will fetch data from polling endpoints. +final class PollingInitializer extends InitializerEntry { + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const PollingInitializer({ + this.endpoints, + this.usePost = false, + }); +} + +/// Streaming initializer (e.g. first payload from a stream). +final class StreamingInitializer extends InitializerEntry { + /// Initial reconnect delay for the streaming source. + final Duration? initialReconnectDelay; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const StreamingInitializer({ + this.initialReconnectDelay, + this.endpoints, + this.usePost = false, + }); +} + +/// Long-lived polling synchronizer; [pollInterval] overrides client default when set. +final class PollingSynchronizer extends SynchronizerEntry { + /// Minimum polling interval for the synchronizer. + final Duration? pollInterval; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const PollingSynchronizer({ + this.pollInterval, + this.endpoints, + this.usePost = false, + }); +} + +/// Long-lived streaming synchronizer. +final class StreamingSynchronizer extends SynchronizerEntry { + final Duration? initialReconnectDelay; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const StreamingSynchronizer({ + this.initialReconnectDelay, + this.endpoints, + this.usePost = false, + }); +} + +/// Defines the initializers and synchronizers for a FDv2 connection mode. +final class ModeDefinition { + final List initializers; + final List synchronizers; + final Fdv1FallbackConfig? fdv1Fallback; + + const ModeDefinition({ + required this.initializers, + required this.synchronizers, + this.fdv1Fallback, + }); +} + +/// Configuration for the FDv1 fallback tier. +final class Fdv1FallbackConfig { + /// Minimum polling interval for the fallback synchronizer + final Duration? pollInterval; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + const Fdv1FallbackConfig({this.pollInterval, this.endpoints}); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart new file mode 100644 index 0000000..b19e420 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -0,0 +1,108 @@ +import '../../fdv2_connection_mode.dart'; +import '../../offline_detail.dart'; +import '../../resolved_connection_mode.dart'; + +/// Inputs for automatic mode resolution (lifecycle, network, mode slots). +final class ModeState { + /// Whether the network is available. + final bool networkAvailable; + + /// Application lifecycle: true in foreground, false in background. + final bool inForeground; + + /// When false, the app is treated as not allowed to receive updates while + /// backgrounded. + final bool runInBackground; + + /// Configured foreground mode slot. + final FDv2ConnectionMode foregroundConnectionMode; + + /// Configured background mode slot when the table selects the background row. + final FDv2ConnectionMode backgroundConnectionMode; + + const ModeState({ + required this.networkAvailable, + required this.inForeground, + required this.runInBackground, + required this.foregroundConnectionMode, + required this.backgroundConnectionMode, + }); +} + +/// One row in an ordered mode resolution table (first match wins). +final class ModeResolutionEntry { + final bool Function(ModeState state) predicate; + + final ResolvedConnectionMode Function(ModeState state) resolve; + + const ModeResolutionEntry({ + required this.predicate, + required this.resolve, + }); +} + +/// First matching row in [table] wins. If none match, maps +/// [state.foregroundConnectionMode] to a [ResolvedConnectionMode]. +/// +ResolvedConnectionMode resolveMode( + List table, + ModeState state, +) { + for (final entry in table) { + if (entry.predicate(state)) { + return entry.resolve(state); + } + } + return _resolvedFromConnectionMode(state.foregroundConnectionMode); +} + +/// Default ordered table for Flutter. +List flutterDefaultResolutionTable() { + return const [ + ModeResolutionEntry( + predicate: _networkDown, + resolve: _offlineNetworkUnavailable, + ), + ModeResolutionEntry( + predicate: _backgroundWithoutUpdates, + resolve: _offlineBackgroundDisabled, + ), + ModeResolutionEntry( + predicate: _inBackground, + resolve: _backgroundSlotResolved, + ), + ModeResolutionEntry( + predicate: _alwaysTrue, + resolve: _foregroundSlotResolved, + ), + ]; +} + +ResolvedConnectionMode _offlineNetworkUnavailable(ModeState _) => + const ResolvedOffline(OfflineNetworkUnavailable()); + +ResolvedConnectionMode _offlineBackgroundDisabled(ModeState _) => + const ResolvedOffline(OfflineBackgroundDisabled()); + +ResolvedConnectionMode _backgroundSlotResolved(ModeState s) => + _resolvedFromConnectionMode(s.backgroundConnectionMode); + +ResolvedConnectionMode _foregroundSlotResolved(ModeState s) => + _resolvedFromConnectionMode(s.foregroundConnectionMode); + +ResolvedConnectionMode _resolvedFromConnectionMode(FDv2ConnectionMode mode) => + switch (mode) { + FDv2Streaming() => const ResolvedStreaming(), + FDv2Polling() => const ResolvedPolling(), + FDv2Background() => const ResolvedBackground(), + FDv2Offline() => const ResolvedOffline(OfflineSetOffline()), + }; + +bool _networkDown(ModeState s) => !s.networkAvailable; + +bool _backgroundWithoutUpdates(ModeState s) => + !s.inForeground && !s.runInBackground; + +bool _inBackground(ModeState s) => !s.inForeground; + +bool _alwaysTrue(ModeState s) => true; diff --git a/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart b/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart new file mode 100644 index 0000000..566f7e1 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/service_endpoints.dart'; +import 'cache_initializer.dart'; +import 'requestor.dart'; + +/// Shared dependencies for building [InitializerFactory] and [SynchronizerFactory] +/// factories from [ModeDefinition] entries (see [createInitializerFactoryFromEntry], +/// [createSynchronizerFactoryFromEntry]). +final class SourceFactoryContext { + final LDContext context; + + final LDLogger logger; + + final HttpProperties httpProperties; + + final ServiceEndpoints serviceEndpoints; + + final String contextJson; + + final bool withReasons; + + final Duration defaultPollingInterval; + + final CachedFlagsReader cachedFlagsReader; + + final HttpClientFactory? httpClientFactory; + + const SourceFactoryContext({ + required this.context, + required this.logger, + required this.httpProperties, + required this.serviceEndpoints, + required this.contextJson, + required this.withReasons, + required this.defaultPollingInterval, + required this.cachedFlagsReader, + this.httpClientFactory, + }); + + factory SourceFactoryContext.fromClientConfig({ + required LDContext context, + required LDLogger logger, + required HttpProperties httpProperties, + required ServiceEndpoints serviceEndpoints, + required bool withReasons, + required Duration defaultPollingInterval, + required CachedFlagsReader cachedFlagsReader, + HttpClientFactory? httpClientFactory, + }) { + final plainContextString = + jsonEncode(LDContextSerialization.toJson(context, isEvent: false)); + return SourceFactoryContext( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: serviceEndpoints, + contextJson: plainContextString, + withReasons: withReasons, + defaultPollingInterval: defaultPollingInterval, + cachedFlagsReader: cachedFlagsReader, + httpClientFactory: httpClientFactory, + ); + } +} diff --git a/packages/common_client/lib/src/fdv2_connection_mode.dart b/packages/common_client/lib/src/fdv2_connection_mode.dart new file mode 100644 index 0000000..4fde6ec --- /dev/null +++ b/packages/common_client/lib/src/fdv2_connection_mode.dart @@ -0,0 +1,62 @@ +/// Identifies a built-in FDv2 connection mode. Each variant maps to a +/// pipeline of initializers and synchronizers that are active when the SDK +/// is operating in that mode. +/// +/// This type is not stable, and not subject to any backwards compatibility +/// guarantees or semantic versioning. It is in early access. If you want +/// access to this feature please join the EAP. +/// https://launchdarkly.com/docs/sdk/features/data-saving-mode +/// +/// Not to be confused with the FDv1 [ConnectionMode] enum +/// (`connection_mode.dart`), which is the public type used by existing +/// SDK configuration and `setMode` APIs. [FDv2ConnectionMode] is an FDv2 +/// concept describing the desired data-acquisition pipeline; the FDv1 +/// [ConnectionMode] continues to drive existing public APIs. +sealed class FDv2ConnectionMode { + const FDv2ConnectionMode(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is FDv2ConnectionMode && other.runtimeType == runtimeType; + } + + @override + int get hashCode => runtimeType.hashCode; +} + +/// Foreground streaming mode. Cache + polling initializers, then streaming +/// with polling fallback. Suitable for mobile foreground and desktop use. +final class FDv2Streaming extends FDv2ConnectionMode { + const FDv2Streaming(); + + @override + String toString() => 'streaming'; +} + +/// Polling-only mode at the configured polling interval. +final class FDv2Polling extends FDv2ConnectionMode { + const FDv2Polling(); + + @override + String toString() => 'polling'; +} + +/// Offline mode. Cache initializer only; no synchronizers. +final class FDv2Offline extends FDv2ConnectionMode { + const FDv2Offline(); + + @override + String toString() => 'offline'; +} + +/// Mobile background mode. Cache initializer and a reduced-rate polling +/// synchronizer (one hour by default). +final class FDv2Background extends FDv2ConnectionMode { + const FDv2Background(); + + @override + String toString() => 'background'; +} diff --git a/packages/common_client/lib/src/offline_detail.dart b/packages/common_client/lib/src/offline_detail.dart new file mode 100644 index 0000000..17edeb8 --- /dev/null +++ b/packages/common_client/lib/src/offline_detail.dart @@ -0,0 +1,45 @@ +/// Details for [ResolvedOffline] to help consumers make decisions (e.g. status +/// reporting). +sealed class OfflineDetail { + const OfflineDetail(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is OfflineDetail && + switch ((this, other)) { + (OfflineSetOffline(), OfflineSetOffline()) => true, + (OfflineNetworkUnavailable(), OfflineNetworkUnavailable()) => true, + (OfflineBackgroundDisabled(), OfflineBackgroundDisabled()) => true, + _ => false, + }; + } + + @override + int get hashCode => switch (this) { + OfflineSetOffline() => 0, + OfflineNetworkUnavailable() => 1, + OfflineBackgroundDisabled() => 2, + }; +} + +/// Offline because the application or client chose not to connect (including +/// explicit SDK offline and connection mode override to offline). +/// Corresponds to [DataSourceState.setOffline]. +final class OfflineSetOffline extends OfflineDetail { + const OfflineSetOffline(); +} + +/// Offline because automatic resolution detected no usable network. +/// Corresponds to [DataSourceState.networkUnavailable]. +final class OfflineNetworkUnavailable extends OfflineDetail { + const OfflineNetworkUnavailable(); +} + +/// Offline because the app is backgrounded and background updates are +/// disabled. Corresponds to [DataSourceState.backgroundDisabled]. +final class OfflineBackgroundDisabled extends OfflineDetail { + const OfflineBackgroundDisabled(); +} diff --git a/packages/common_client/lib/src/resolved_connection_mode.dart b/packages/common_client/lib/src/resolved_connection_mode.dart new file mode 100644 index 0000000..0c42e9d --- /dev/null +++ b/packages/common_client/lib/src/resolved_connection_mode.dart @@ -0,0 +1,65 @@ +import 'fdv2_connection_mode.dart'; +import 'offline_detail.dart'; + +/// Unlike [FDv2ConnectionMode] alone, [ResolvedOffline] also carries +/// resolution details such as [OfflineDetail]. +sealed class ResolvedConnectionMode { + const ResolvedConnectionMode(); + + /// Underlying mode. + FDv2ConnectionMode get connectionMode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is ResolvedConnectionMode && + switch ((this, other)) { + (ResolvedStreaming(), ResolvedStreaming()) => true, + (ResolvedPolling(), ResolvedPolling()) => true, + (ResolvedBackground(), ResolvedBackground()) => true, + (final ResolvedOffline r1, final ResolvedOffline r2) => + r1.detail == r2.detail, + _ => false, + }; + } + + @override + int get hashCode => switch (this) { + ResolvedStreaming() => 1, + ResolvedPolling() => 2, + ResolvedBackground() => 3, + ResolvedOffline(:final detail) => 4 ^ detail.hashCode, + }; +} + +final class ResolvedStreaming extends ResolvedConnectionMode { + const ResolvedStreaming(); + + @override + FDv2ConnectionMode get connectionMode => const FDv2Streaming(); +} + +final class ResolvedPolling extends ResolvedConnectionMode { + const ResolvedPolling(); + + @override + FDv2ConnectionMode get connectionMode => const FDv2Polling(); +} + +final class ResolvedBackground extends ResolvedConnectionMode { + const ResolvedBackground(); + + @override + FDv2ConnectionMode get connectionMode => const FDv2Background(); +} + +final class ResolvedOffline extends ResolvedConnectionMode { + final OfflineDetail detail; + + const ResolvedOffline(this.detail); + + @override + FDv2ConnectionMode get connectionMode => const FDv2Offline(); +} diff --git a/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart b/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart new file mode 100644 index 0000000..4f86114 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart @@ -0,0 +1,149 @@ +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/built_in_modes.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/cache_initializer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/entry_factories.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_factory_context.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/mode_definition.dart' + hide CacheInitializer; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/polling_synchronizer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/selector.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_result.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +LDContext _context() => LDContextBuilder().kind('user', 'test-key').build(); + +Selector _selectorGetter() => Selector.empty; + +SourceFactoryContext _testContext({ + CachedFlagsReader? reader, + Duration? defaultPollingInterval, +}) { + return SourceFactoryContext.fromClientConfig( + context: _context(), + logger: LDLogger(level: LDLogLevel.error), + httpProperties: HttpProperties(), + serviceEndpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + withReasons: false, + defaultPollingInterval: + defaultPollingInterval ?? const Duration(seconds: 300), + cachedFlagsReader: reader ?? ((_) async => null), + ); +} + +void main() { + group('mergeServiceEndpoints', () { + test('returns base when override is null', () { + final base = ServiceEndpoints.custom( + polling: 'https://poll.example', + streaming: 'https://stream.example', + ); + expect(mergeServiceEndpoints(base, null), same(base)); + }); + + test('overrides polling when entry provides pollingBaseUri', () { + final base = ServiceEndpoints.custom( + polling: 'https://poll.example', + streaming: 'https://stream.example', + ); + final merged = mergeServiceEndpoints( + base, + EndpointConfig(pollingBaseUri: Uri.parse('https://custom.poll/')), + ); + expect(merged.polling, 'https://custom.poll/'); + expect(merged.streaming, base.streaming); + }); + }); + + group('buildInitializerFactories', () { + test('offline mode is cache only', () { + final ctx = _testContext(); + final list = + buildInitializerFactories(BuiltInModes.offline.initializers, ctx); + expect(list, hasLength(1)); + expect(list.single.isCache, isTrue); + final init = list.single.create(_selectorGetter); + expect(init, isA()); + }); + + test('polling mode initializer factories are cache only', () { + final ctx = _testContext(); + final list = + buildInitializerFactories(BuiltInModes.polling.initializers, ctx); + expect(list, hasLength(1)); + expect(list.single.isCache, isTrue); + expect(list.single.create(_selectorGetter), isA()); + }); + + test('polling mode synchronizer factories are polling', () { + final ctx = + _testContext(defaultPollingInterval: const Duration(seconds: 1)); + final list = + buildSynchronizerFactories(BuiltInModes.polling.synchronizers, ctx); + expect(list, hasLength(1)); + final sync = list.single.create(_selectorGetter); + expect(sync, isA()); + sync.close(); + }); + + test('each create() returns a new initializer instance', () { + final ctx = _testContext(); + final factory = buildInitializerFactories( + BuiltInModes.offline.initializers, + ctx, + ).single; + final a = factory.create(_selectorGetter); + final b = factory.create(_selectorGetter); + expect(identical(a, b), isFalse); + }); + }); + + group('createSynchronizerFactoryFromEntry', () { + test('builds factory whose create returns FDv2PollingSynchronizer', () { + final ctx = + _testContext(defaultPollingInterval: const Duration(seconds: 1)); + final factory = createSynchronizerFactoryFromEntry( + PollingSynchronizer(pollInterval: const Duration(seconds: 42)), + ctx, + ); + final sync = factory.create(_selectorGetter); + expect(sync, isA()); + sync.close(); + }); + + test('streaming synchronizer is unsupported', () { + final ctx = _testContext(); + expect( + () => createSynchronizerFactoryFromEntry(StreamingSynchronizer(), ctx), + throwsA(isA()), + ); + }); + }); + + group('createInitializerFactoryFromEntry', () { + test('streaming initializer is unsupported', () { + final ctx = _testContext(); + expect( + () => createInitializerFactoryFromEntry(StreamingInitializer(), ctx), + throwsA(isA()), + ); + }); + }); + + test('cache initializer from factory.create runs with reader', () async { + final ctx = _testContext( + reader: (_) async => null, + ); + final factory = buildInitializerFactories( + BuiltInModes.offline.initializers, + ctx, + ).single; + final init = factory.create(_selectorGetter) as CacheInitializer; + final result = await init.run(); + expect(result, isA()); + final cs = result as ChangeSetResult; + expect(cs.payload.type, PayloadType.none); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart new file mode 100644 index 0000000..1e429ea --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart @@ -0,0 +1,76 @@ +import 'package:launchdarkly_common_client/src/data_sources/fdv2/mode_resolution.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:launchdarkly_common_client/src/offline_detail.dart'; +import 'package:launchdarkly_common_client/src/resolved_connection_mode.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'flutter default table: network down yields ResolvedOffline(OfflineNetworkUnavailable)', + () { + const state = ModeState( + networkAvailable: false, + inForeground: true, + runInBackground: true, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + expect(r.connectionMode, const FDv2Offline()); + }); + + test( + 'flutter default table: background without updates yields ' + 'ResolvedOffline(OfflineBackgroundDisabled)', () { + const state = ModeState( + networkAvailable: true, + inForeground: false, + runInBackground: false, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + }); + + test( + 'flutter default table: background slot configured offline yields ' + 'ResolvedOffline(OfflineSetOffline)', () { + const state = ModeState( + networkAvailable: true, + inForeground: false, + runInBackground: true, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + }); + + test('resolveMode foreground slot exposes connectionMode', () { + const state = ModeState( + networkAvailable: true, + inForeground: true, + runInBackground: true, + foregroundConnectionMode: FDv2Polling(), + backgroundConnectionMode: FDv2Offline(), + ); + final table = flutterDefaultResolutionTable(); + final resolved = resolveMode(table, state); + expect(resolved, isA()); + expect(resolved.connectionMode, const FDv2Polling()); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart b/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart new file mode 100644 index 0000000..9f7d726 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/cache_initializer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_factory_context.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +void main() { + group('SourceFactoryContext.fromClientConfig', () { + test( + 'contextJson matches jsonEncode(LDContextSerialization.toJson ' + '(context, isEvent: false))', () { + final context = LDContextBuilder() + .kind('user', 'alice') + .name('Alice') + .setString('segment', 'beta') + .build(); + final logger = LDLogger(level: LDLogLevel.error); + final httpProperties = HttpProperties(); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: endpoints, + withReasons: true, + defaultPollingInterval: const Duration(seconds: 42), + cachedFlagsReader: reader, + ); + + final expected = jsonEncode( + LDContextSerialization.toJson(context, isEvent: false), + ); + expect(ctx.contextJson, expected); + }); + + test( + 'contextJson differs from isEvent: true serialization for anonymous ' + 'context when redaction would apply', () { + final context = LDContextBuilder() + .kind('user', 'key1') + .anonymous(true) + .setString('email', 'a@b.c') + .build(); + final logger = LDLogger(level: LDLogLevel.error); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: HttpProperties(), + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + cachedFlagsReader: reader, + ); + + final nonEvent = jsonEncode( + LDContextSerialization.toJson(context, isEvent: false), + ); + final asEvent = jsonEncode( + LDContextSerialization.toJson( + context, + isEvent: true, + redactAnonymous: true, + ), + ); + + expect(ctx.contextJson, nonEvent); + expect(ctx.contextJson, isNot(asEvent)); + }); + + test( + 'decoded contextJson is a multi-kind payload when context has ' + 'multiple kinds', () { + final context = + LDContextBuilder().kind('user', 'u1').kind('org', 'o1').build(); + final logger = LDLogger(level: LDLogLevel.error); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: HttpProperties(), + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + cachedFlagsReader: reader, + ); + + final decoded = jsonDecode(ctx.contextJson) as Map; + expect(decoded['kind'], 'multi'); + expect(decoded['user'], isA>()); + expect(decoded['org'], isA>()); + }); + + test( + 'passes through context, logger, endpoints, flags, and optional ' + 'httpClientFactory', () { + final context = LDContextBuilder().kind('user', 'k').build(); + final logger = LDLogger(level: LDLogLevel.warn); + final httpProperties = HttpProperties(); + final endpoints = ServiceEndpoints.custom( + polling: 'https://p.example', + streaming: 'https://s.example', + ); + Future reader(LDContext _) async => null; + + HttpClient httpClientFactory(HttpProperties p) => + HttpClient(httpProperties: p); + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(minutes: 5), + cachedFlagsReader: reader, + httpClientFactory: httpClientFactory, + ); + + expect(ctx.context, same(context)); + expect(ctx.logger, same(logger)); + expect(ctx.httpProperties, same(httpProperties)); + expect(ctx.serviceEndpoints, same(endpoints)); + expect(ctx.withReasons, isFalse); + expect(ctx.defaultPollingInterval, const Duration(minutes: 5)); + expect(ctx.cachedFlagsReader, same(reader)); + expect(ctx.httpClientFactory, same(httpClientFactory)); + }); + }); +}