diff --git a/pkgs/sdk/client/src/Hooks/Hook.cs b/pkgs/sdk/client/src/Hooks/Hook.cs index c0d9de03..344703c8 100644 --- a/pkgs/sdk/client/src/Hooks/Hook.cs +++ b/pkgs/sdk/client/src/Hooks/Hook.cs @@ -74,6 +74,35 @@ public virtual SeriesData BeforeEvaluation(EvaluationSeriesContext context, Seri public virtual SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, EvaluationDetail detail) => data; + + /// + /// BeforeIdentify is executed by the SDK before an identify operation. + /// + /// The modified data is not shared with any other hook. It will be passed to subsequent stages in the identify + /// series, including . + /// + /// + /// parameters associated with this identify operation + /// user-configurable data, currently empty + /// user-configurable data, which will be forwarded to + public virtual SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) => + data; + + + /// + /// AfterIdentify is executed by the SDK after an identify operation. + /// + /// The function should return the given unmodified, for forward compatibility with subsequent + /// stages that may be added. + /// + /// + /// parameters associated with this identify operation + /// user-configurable data from the stage + /// the result of the identify operation + /// user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK + public virtual SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) => data; + /// /// Constructs a new Hook with the given name. The name may be used in log messages. /// diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs new file mode 100644 index 00000000..5614117c --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs @@ -0,0 +1,33 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// IdentifySeriesContext represents parameters associated with an identify operation. It is + /// made available in stage callbacks. + /// + public sealed class IdentifySeriesContext + { + /// + /// The Context being identified. + /// + public Context Context { get; } + + /// + /// The timeout for the identify operation. A value of indicates + /// that no timeout was specified. + /// + public TimeSpan Timeout { get; } + + /// + /// Constructs a new IdentifySeriesContext. + /// + /// the context being identified + /// the timeout for the identify operation + public IdentifySeriesContext(Context context, TimeSpan timeout) + { + Context = context; + Timeout = timeout; + } + } +} diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs new file mode 100644 index 00000000..b803e51f --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs @@ -0,0 +1,38 @@ +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// IdentifySeriesResult contains the outcome of an identify operation. + /// + public sealed class IdentifySeriesResult + { + /// + /// Represents the possible statuses of an identify operation. + /// + public enum IdentifySeriesStatus + { + /// + /// The identify operation completed successfully. + /// + Completed, + + /// + /// The identify operation encountered an error. + /// + Error + } + + /// + /// The status of the identify operation. + /// + public IdentifySeriesStatus Status { get; } + + /// + /// Constructs a new IdentifySeriesResult. + /// + /// the status of the identify operation + public IdentifySeriesResult(IdentifySeriesStatus status) + { + Status = status; + } + } +} diff --git a/pkgs/sdk/client/src/ILdClientExtensions.cs b/pkgs/sdk/client/src/ILdClientExtensions.cs index 549adaf7..de08c847 100644 --- a/pkgs/sdk/client/src/ILdClientExtensions.cs +++ b/pkgs/sdk/client/src/ILdClientExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Interfaces; diff --git a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs index 722e971c..d4e4c3ca 100644 --- a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs +++ b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace LaunchDarkly.Sdk.Client.Interfaces { diff --git a/pkgs/sdk/client/src/Interfaces/ILdClient.cs b/pkgs/sdk/client/src/Interfaces/ILdClient.cs index 5629eb8e..bf1d3f8b 100644 --- a/pkgs/sdk/client/src/Interfaces/ILdClient.cs +++ b/pkgs/sdk/client/src/Interfaces/ILdClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Integrations; diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs index 890e19c5..b05a6139 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; @@ -16,12 +17,17 @@ internal sealed class Executor : IHookExecutor private readonly IStageExecutor _beforeEvaluation; private readonly IStageExecutor> _afterEvaluation; + private readonly IStageExecutor _beforeIdentify; + private readonly IStageExecutor _afterIdentify; + public Executor(Logger logger, IEnumerable hooks) { _logger = logger; _hooks = hooks.ToList(); _beforeEvaluation = new BeforeEvaluation(logger, _hooks, EvaluationStage.Order.Forward); _afterEvaluation = new AfterEvaluation(logger, _hooks, EvaluationStage.Order.Reverse); + _beforeIdentify = new BeforeIdentify(logger, _hooks, EvaluationStage.Order.Forward); + _afterIdentify = new AfterIdentify(logger, _hooks, EvaluationStage.Order.Reverse); } public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, @@ -38,6 +44,31 @@ public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, return detail; } + public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) + { + var identifyContext = new IdentifySeriesContext(context, maxWaitTime); + var seriesData = _beforeIdentify.Execute(identifyContext, default); + + try + { + var result = await identify(); + + _afterIdentify.Execute(identifyContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Completed), + seriesData); + + return result; + } + catch (Exception) + { + _afterIdentify.Execute(identifyContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Error), + seriesData); + + return false; + } + } + public void Dispose() { foreach (var hook in _hooks) diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs index cdc4a39a..9fa95fb2 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; @@ -13,6 +14,8 @@ internal sealed class NoopExecutor : IHookExecutor public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate) => evaluate(); + public Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) => identify(); + public void Dispose() { } diff --git a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs index 6899bac1..61486abc 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Hooks; namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces @@ -21,5 +22,14 @@ internal interface IHookExecutor : IDisposable /// the EvaluationDetail returned from the evaluator EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate); + + /// + /// IdentifySeries should run the identify series for each configured hook. + /// + /// the evaluation context being identified + /// the timeout for the identify operation + /// async function that performs the identify operation + /// the result of the identify operation + Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify); } } diff --git a/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs b/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs new file mode 100644 index 00000000..a8e2d2db --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Series +{ + using SeriesData = ImmutableDictionary; + + internal class IdentifyStage + { + protected enum Stage + { + BeforeIdentify, + AfterIdentify + } + + protected readonly EvaluationStage.Order _order; + private readonly Logger _logger; + + protected IdentifyStage(Logger logger, EvaluationStage.Order order) + { + _logger = logger; + _order = order; + } + + protected void LogFailure(IdentifySeriesContext context, Hook h, Stage stage, Exception e) + { + _logger.Error("During identify of context \"{0}\", stage \"{1}\" of hook \"{2}\" reported error: {3}", + context.Context.Key, stage.ToString(), h.Metadata.Name, e.Message); + } + } + + internal sealed class BeforeIdentify : IdentifyStage, IStageExecutor + { + private readonly IEnumerable _hooks; + + public BeforeIdentify(Logger logger, IEnumerable hooks, EvaluationStage.Order order) : base(logger, order) + { + _hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(IdentifySeriesContext context, IEnumerable _) + { + return _hooks.Select(hook => + { + try + { + return hook.BeforeIdentify(context, SeriesData.Empty); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.BeforeIdentify, e); + return SeriesData.Empty; + } + }).ToList(); + } + } + + internal sealed class AfterIdentify : IdentifyStage, IStageExecutor + { + private readonly IEnumerable _hooks; + + public AfterIdentify(Logger logger, IEnumerable hooks, EvaluationStage.Order order) : base(logger, order) + { + _hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(IdentifySeriesContext context, IdentifySeriesResult result, + IEnumerable seriesData) + { + return _hooks.Zip((_order == EvaluationStage.Order.Reverse ? seriesData.Reverse() : seriesData), (hook, data) => + { + try + { + return hook.AfterIdentify(context, data, result); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.AfterIdentify, e); + return SeriesData.Empty; + } + }).ToList(); + } + } +} diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 3e8f7321..55f1f168 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -222,17 +222,7 @@ public sealed class LdClient : ILdClient _ = _connectionManager.SetNetworkEnabled(networkAvailable); // do not await the result }; - // Send an initial identify event, but only if we weren't explicitly set to be offline - - if (!_config.Offline) - { - _eventProcessor.RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent - { - Timestamp = UnixMillisecondTime.Now, - Context = _context - }); - } - + // Build the plugin config and environment metadata var pluginConfig = (_config.Plugins ?? Components.Plugins()).Build(); var environmentMetadata = pluginConfig.Plugins.Any() ? CreateEnvironmentMetadata() : null; var hooks = pluginConfig.Plugins.Any() @@ -245,8 +235,10 @@ public sealed class LdClient : ILdClient this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + // Start the background mode manager _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; + } void Start(TimeSpan maxWaitTime) @@ -261,6 +253,7 @@ void Start(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } + _ = IdentifyWithHook(_context, maxWaitTime); } /// @@ -283,6 +276,7 @@ async Task StartAsync(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } + await IdentifyWithHook(_context, maxWaitTime); } /// @@ -291,6 +285,7 @@ async Task StartAsync(TimeSpan maxWaitTime) async Task StartAsync() { await _connectionManager.Start(); + await IdentifyWithHook(_context, TimeSpan.Zero); } /// @@ -902,11 +897,13 @@ public Task FlushAndWaitAsync(TimeSpan timeout) => /// public bool Identify(Context context, TimeSpan maxWaitTime) { - return AsyncUtils.WaitSafely(() => IdentifyAsync(context), maxWaitTime); + return AsyncUtils.WaitSafely(() => InternalIdentifyAsync(context, maxWaitTime), maxWaitTime); } /// - public async Task IdentifyAsync(Context context) + public Task IdentifyAsync(Context context) => InternalIdentifyAsync(context, TimeSpan.Zero); + + private async Task InternalIdentifyAsync(Context context, TimeSpan maxWaitTime) { Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); if (_config.AutoEnvAttributes) @@ -943,13 +940,22 @@ public async Task IdentifyAsync(Context context) false // false means "don't rewrite the flags to persistent storage" ); - EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + await IdentifyWithHook(newContext, maxWaitTime); + return await _connectionManager.SetContext(newContext); + } + + private async Task IdentifyWithHook(Context newContext, TimeSpan maxWaitTime) + { + await _hookExecutor.IdentifySeries(newContext, maxWaitTime, () => { - Timestamp = UnixMillisecondTime.Now, - Context = newContext - }); + EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = newContext + }); - return await _connectionManager.SetContext(newContext); + return Task.FromResult(true); + }); } private EnvironmentMetadata CreateEnvironmentMetadata() @@ -970,7 +976,7 @@ private EnvironmentMetadata CreateEnvironmentMetadata() return new EnvironmentMetadata(sdkMetadata, _config.MobileKey, CredentialType.MobileKey, applicationMetadata); } - + /// /// Permanently shuts down the SDK client. /// diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs new file mode 100644 index 00000000..9b8c4ace --- /dev/null +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Series; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Executor; +using LaunchDarkly.Sdk.Client.Plugins; +using LaunchDarkly.Sdk.Integrations.Plugins; +using Xunit; +using LogLevel = LaunchDarkly.Logging.LogLevel; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + using SeriesData = ImmutableDictionary; + + public class IdentifySeriesTest : BaseTest + { + private class SpyHook : Hook + { + private readonly List _recorder; + + public SpyHook(string name, List recorder) : base(name) + { + _recorder = recorder; + } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + _recorder.Add(Metadata.Name + "_before"); + return data; + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + _recorder.Add(Metadata.Name + "_after"); + return data; + } + } + + private class ThrowingHook : SpyHook + { + private readonly string _beforeError; + private readonly string _afterError; + + public ThrowingHook(string name, List recorder, string beforeError, string afterError) + : base(name, recorder) + { + _beforeError = beforeError; + _afterError = afterError; + } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + if (_beforeError != null) + { + throw new Exception(_beforeError); + } + return base.BeforeIdentify(context, data); + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + if (_afterError != null) + { + throw new Exception(_afterError); + } + return base.AfterIdentify(context, data, result); + } + } + + private class DataCapturingHook : Hook + { + public IdentifySeriesResult CapturedResult { get; private set; } + public SeriesData CapturedAfterData { get; private set; } + + public DataCapturingHook(string name) : base(name) { } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + var builder = data.ToBuilder(); + builder["before"] = "was called"; + return builder.ToImmutable(); + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + CapturedResult = result; + CapturedAfterData = data; + return data; + } + } + + private static Context MakeContext(string key = "test") => Context.New(key); + + [Theory] + [InlineData(new string[] { }, new string[] { })] + [InlineData(new[] { "a" }, new[] { "a_before", "a_after" })] + [InlineData(new[] { "a", "b", "c" }, + new[] { "a_before", "b_before", "c_before", "c_after", "b_after", "a_after" })] + public async Task HooksAreExecutedInLifoOrder(string[] hookNames, string[] executions) + { + var got = new List(); + + var context = MakeContext(); + + var executor = new Executor(testLogger, hookNames.Select(name => new SpyHook(name, got))); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.Equal(executions, got); + } + + [Fact] + public async Task MultipleExceptionsThrownFromDifferentStagesShouldNotPreventOtherStagesFromRunning() + { + var got = new List(); + + var seriesContext = new IdentifySeriesContext(MakeContext(), TimeSpan.Zero); + + var hooks = new List + { + new ThrowingHook("a", got, "error in before!", "error in after!"), + new SpyHook("b", got), + new ThrowingHook("c", got, null, "error in after!"), + new SpyHook("d", got), + new ThrowingHook("e", got, "error in before!", null), + new SpyHook("f", got) + }; + + var before = new BeforeIdentify(testLogger, hooks, EvaluationStage.Order.Forward); + var after = new AfterIdentify(testLogger, hooks, EvaluationStage.Order.Reverse); + + var beforeData = before.Execute(seriesContext, null).ToList(); + + Assert.True(beforeData.Count == hooks.Count); + Assert.True(beforeData.All(d => d.Equals(SeriesData.Empty))); + + var afterData = after.Execute(seriesContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Completed), beforeData).ToList(); + + Assert.True(afterData.Count == hooks.Count); + Assert.True(afterData.All(d => d.Equals(SeriesData.Empty))); + + var expected = new List + { + "b_before", "c_before", "d_before", "f_before", + "f_after", "e_after", "d_after", "b_after" + }; + Assert.Equal(expected, got); + } + + [Theory] + [InlineData("test-context-1", "LaunchDarkly Test hook", "before failed", "after failed")] + [InlineData("test-context-2", "test-hook", "before exception!", "after exception!")] + public async Task StageFailureLogsExpectedMessages(string contextKey, string hookName, string beforeError, + string afterError) + { + var hooks = new List + { + new ThrowingHook(hookName, new List(), beforeError, afterError) + }; + + var context = MakeContext(contextKey); + + var executor = new Executor(testLogger, hooks); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.True(logCapture.GetMessages().Count == 2); + + logCapture.HasMessageWithText(LogLevel.Error, + $"During identify of context \"{contextKey}\", stage \"BeforeIdentify\" of hook \"{hookName}\" reported error: {beforeError}"); + logCapture.HasMessageWithText(LogLevel.Error, + $"During identify of context \"{contextKey}\", stage \"AfterIdentify\" of hook \"{hookName}\" reported error: {afterError}"); + } + + [Fact] + public async Task IdentifyResultIsCapturedAsCompleted() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.NotNull(hook.CapturedResult); + Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Completed, hook.CapturedResult.Status); + } + + [Fact] + public async Task IdentifyResultIsCapturedAsErrorOnException() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + var result = await executor.IdentifySeries(context, TimeSpan.Zero, + () => Task.FromException(new InvalidOperationException("identify failed"))); + + Assert.False(result); + Assert.NotNull(hook.CapturedResult); + Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Error, hook.CapturedResult.Status); + } + + [Fact] + public async Task BeforeHookPassesDataToAfterHook() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.NotNull(hook.CapturedAfterData); + Assert.Equal("was called", hook.CapturedAfterData["before"]); + } + + [Fact] + public async Task IdentifyOperationResultIsReturned() + { + var got = new List(); + var hooks = new List { new SpyHook("a", got) }; + + var context = MakeContext(); + var executor = new Executor(testLogger, hooks); + + var result = await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.True(result); + } + + [Fact] + public async Task IdentifyHookIsActivatedAfterInitAsync() + { + var hook = new IdentifyTrackingHook("init-hook"); + var plugin = new HookProvidingPlugin("test-plugin", hook); + var config = Configuration.Builder("mobile-key", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .BackgroundModeManager(new MockBackgroundModeManager()) + .ConnectivityStateManager(new MockConnectivityStateManager(true)) + .DataSource(new MockDataSource().AsSingletonFactory()) + .Events(Components.NoEvents) + .Logging(testLogging) + .Persistence( + Components.Persistence().Storage( + new MockPersistentDataStore().AsSingletonFactory())) + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = await TestUtil.CreateClientAsync(config, MakeContext(), TimeSpan.FromSeconds(5))) + { + Assert.True(hook.BeforeIdentifyCalled, "BeforeIdentify should have been called after InitAsync"); + Assert.True(hook.AfterIdentifyCalled, "AfterIdentify should have been called after InitAsync"); + } + } + + private class IdentifyTrackingHook : Hook + { + public bool BeforeIdentifyCalled { get; private set; } + public bool AfterIdentifyCalled { get; private set; } + + public IdentifyTrackingHook(string name) : base(name) { } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + BeforeIdentifyCalled = true; + return data; + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + AfterIdentifyCalled = true; + return data; + } + } + + private class HookProvidingPlugin : Plugin + { + private readonly Hook _hook; + + public HookProvidingPlugin(string name, Hook hook) : base(name) + { + _hook = hook; + } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) { } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + return new List { _hook }; + } + } + } +}