From 6c5d34b4176023e29dbf9c12c76ba3d89485de46 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sun, 14 Jul 2024 22:27:43 +1200 Subject: [PATCH] Added UserPilot adapter. #4 --- .../100-build-adapter-third-party.md | 63 +- src/ApiHost1/appsettings.json | 7 +- .../UserPilotHttpServiceClientSpec.cs | 142 ++ .../appsettings.Testing.json | 5 + .../UserPilotHttpServiceClientSpec.cs | 1455 +++++++++++++++++ ...ravatarHttpServiceClient.GravatarClient.cs | 2 +- ...rPilotHttpServiceClient.UserPilotClient.cs | 119 ++ .../External/UserPilotHttpServiceClient.cs | 450 +++++ .../UserPilot/UserPilotIdentifyUserRequest.cs | 17 + .../UserPilot/UserPilotTrackEventRequest.cs | 17 + .../Stubs/StubUsageDeliveryService.cs | 19 + .../WebApiSpec.cs | 2 + .../Api/StubFlagsmithApi.cs | 2 +- .../Api/StubUserPilotApi.cs | 33 + 14 files changed, 2300 insertions(+), 33 deletions(-) create mode 100644 src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs create mode 100644 src/Infrastructure.Shared.UnitTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.UserPilotClient.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotIdentifyUserRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotTrackEventRequest.cs create mode 100644 src/IntegrationTesting.WebApi.Common/Stubs/StubUsageDeliveryService.cs create mode 100644 src/TestingStubApiHost/Api/StubUserPilotApi.cs diff --git a/docs/how-to-guides/100-build-adapter-third-party.md b/docs/how-to-guides/100-build-adapter-third-party.md index 2bcac939..f61fc3f7 100644 --- a/docs/how-to-guides/100-build-adapter-third-party.md +++ b/docs/how-to-guides/100-build-adapter-third-party.md @@ -22,16 +22,17 @@ Most of these adapters will require static configuration settings. These will li ## Before you build the adapter -3rd party adapters are unique integrations, that require extra levels of testing to ensure that they operate as you expect and that they keep operating over time as things change. -Depending on the vendor of the 3rd party technology you are integrating with, you will want to be sure that changes that they make, over time, do not break your integration. +3rd party adapters are unique integrations that require extra levels of testing to ensure that they operate as you expect and that they keep operating over time as things change. + +Depending on the vendor of the 3rd party technology you are integrating with, you will want to be sure that changes that they make over time do not break your integration. When changes happen by the vendor, you will want to verify that things still work the same against a live/sandbox system. -> Ideally, vendors won't make (breaking) changes that break your integrations, but it does happen in reality. There are other ways that happen that are non-breaking, that occur over long periods of time, including: (1) they change the public interface that you were using, and release a newer version. (2) they obsoleted a public interface and replaced it with something else, that at some point they stop supporting, (3) they ship new SDKs and stop supporting older integrations. (4) they fix defects in their SDKs that change the behavior of your integration. +> Ideally, vendors won't make (breaking) changes that break your integrations, but it does happen in reality. There are other ways that happen that are non-breaking that occur over long periods of time, including: (1) they change the public interface that you were using and release a newer version. (2) They obsoleted a public interface and replaced it with something else that, at some point, they stopped supporting; (3) They shipped new SDKs and stopped supporting older integrations. (4) They fix defects in their SDKs that change the behavior of your integration. -When you upgrade your adapter (perhaps, to use a new updated SDK version) you will also want to be confident you have done that properly and have not broken your old integration with this newer version. Again, automated testing against a live/sandbox system will easily verify that. +When you upgrade your adapter (perhaps to use a new, updated SDK version), you will also want to be confident you have done that properly and have not broken your old integration with this newer version. Again, automated testing against a live/sandbox system will easily verify that. -Lastly, if this adapter is used by default in local development, you will not want to be sending remote calls over the internet to a live/sandbox service in the cloud. You will instead want to be using a stub API, so that the integration keeps working in a pre-programmed and limited way. +Lastly, if this adapter is used by default in local development, you will not want to be sending remote calls over the internet to a live/sandbox service in the cloud. You will instead want to use a stub API so that the integration keeps working in a pre-programmed and limited way. These are some of the things that make building an adapter to a 3rd party service unique, and that require extra work. But payoff in the long run. @@ -55,21 +56,22 @@ If you do not wish to use an SDK from a vendor, you can easily build your own HT A note about testing. -Not only will you want to integration test your adapter against the real 3rd party service (or a sandbox version of it) to ensure that it does as you designed it to do (against a real service in the cloud). Ideally, you will also want unit test it as well (particularly, if it more than the most trivial of all adapters) +Not only will you want to integration-test your adapter against the real 3rd party service (or a sandbox version of it) to ensure that it does as you designed it to do (against a real service in the cloud). You will also want unit-test it as well (particularly if it is simpler than the most trivial of all adapters) If you are using a 3rd party SDK library, you will likely have trouble mocking the service client in that library. -If that is hte case, we recommend that you build an abstraction of the HTTP service client of that SDK, which you can mock in unit testing. + +If that is the case, we recommend that you build an abstraction of the HTTP service client of that SDK, which you can mock in unit testing. This means that you need to do some extra work. This will be worth it in the long run for a couple of reasons: -1. You can easily unit test your adapter now, and not worry about HTTP concerns. +1. You can easily unit-test your adapter now, and not worry about HTTP concerns. 2. You can focus on the processing logic and mapping in your adapter (which most adapters will have). 3. Your custom `IServiceClient` implementation can focus on all aspects of logging and error handling when issuing remote HTTP calls, and you can keep this code separate from the logic and mapping code in your adapter. -> You can see several examples of custom service clients being used by other adapters in the `Infrastructure.Shared` project, particularly the `ChargebeeHttpServiceClient`. +> You can see several examples of custom service clients being used by other adapters in the `Infrastructure.Shared` project, particularly the `ChargebeeHttpServiceClient`, or `UserPilotHttpServiceClient`. -> Notice that the `FlagsmithHttpServiceClient` is an example of an adapter that is not using a custom service client abstraction, and you can als see tha there are no unit tests for it either. Arguably it should have some unit tests as well, since it is not trivial code, and if it did, a custom service client would need to be built for it. +> Notice that the `FlagsmithHttpServiceClient` is an example of an adapter that is not using a custom service client abstraction, and you can also see that there are no unit tests for it either. Arguably it should have some unit tests as well, since it is not trivial code, and if it did, a custom service client would need to be built for it to be able to do that. ## How to test the adapter @@ -77,14 +79,14 @@ This will be worth it in the long run for a couple of reasons: Write your unit tests in the `Infrastructure.Shared.UnitTests` project, and verify the behavior of your adapter. -> Unit tests are always marked with the `Category=Unit` trait, and these tests are run very frequently, and every build. +> Unit tests are always marked with the `Category=Unit` trait, and these tests are run very frequently and in every build. ### Integration testing -There are two kind of integration testing that both need to get done for a 3rd party service adapter. +There are two kinds of integration testing that both need to be done for a 3rd party service adapter. 1. External integration tests (used to test the adapter directly) -2. API integration tests (where the adapter might be injected into subdomains being tested) +2. API integration tests (where the adapter might be injected into the subdomains being tested) #### External testing @@ -97,12 +99,12 @@ Please take note. Integration testing 3rd party adapters is different from other Here are some reasons why: 1. These integration tests may (but not always) require infrastructure to be installed on your local machine (i.e. database servers, docker images etc.). If not that, then they will require live/sandboxed systems (in the cloud) to test against, for proper verification. -2. Many of these integrations will require some real configuration settings (i.e. connection strings, and credentials) to give your tests access to real live systems (local or in the cloud). This configuration may be sensitive, but should never compromise your production systems, even if they are exposed in your source code. Needless to say, production configuration secret or otherwise should never be saved in source code - ever! However, if you are using separate credentials and sandboxed environments for testing, then you can save these configuration settings in your source code in `appsettings.json` files. But be aware tha sometimes this is not permitted either. Then you will need to use secret files locally only. -3. These integration tests will be expensive to run (in time), since you will need to be prepopulating your test data (in those 3rd party systems) before each test is run (or once before the test run). You will also need to be cleaning up previous test data (from previous test runs) before your test runs. This will make these tests run far slower than other kinds of tests. -4. When your adapter is functionally complete, and all tests pass, it is unlikely that you your tests will fail in the coming hours and days (as long as nothing changes in your code). We recommend always running these tests whenever changing code in the adapter. However, these tests will need to be run frequently enough that you can detect changes with the 3rd party systems that you are testing against change. We recommend running them on the frequency of once a week, normally (perhaps in a scheduled CI build). +2. Many of these integrations will require some real configuration settings (i.e., connection strings, and credentials) to give your tests access to real live systems (local or in the cloud). This configuration may be sensitive, but should never compromise your production systems, even if they are exposed in your source code. Needless to say, production configuration secrets should never be saved in source code - ever! However, if you are using separate credentials and sandboxed environments for testing, then you can save these configuration settings in your source code in `appsettings.json` files. But be aware that sometimes this is not permitted either. Then you will need to use secret files locally only. +3. These integration tests will be expensive to run (in time) since you will need to be prepopulating your test data (in those 3rd party systems) before each test is run (or once before the test run). You will also need to be cleaning up previous test data (from previous test runs) before your test runs. This will make these tests run far slower than other kinds of tests. +4. When your adapter is functionally complete, and all tests pass, it will be unlikely that your tests will fail in the coming hours and days (as long as nothing changes in your code). We recommend always running these tests whenever changing the code in the adapter. However, these tests will need to be run frequently enough that you can detect changes with the 3rd party systems that you are testing against change. We recommend running them on the frequency of once a week, normally (perhaps in a scheduled CI build). 5. Some of the cloud systems and sandbox environments you will test against, may have usage limits or charge small fees for use. You certainly do not want to be racking up those quotas and possible charges, by running your tests more often than needed. -These are just some of the reasons why these integration tests should be tagged with the `Category=Integration.External` tests, and need to be run infrequently. +These are just some of the reasons why these integration tests should be tagged with the `Category=Integration.External` tests and need to be run infrequently. ##### Write your tests @@ -110,7 +112,7 @@ Create a new test class in the `Infrastructure.Shared.IntegrationTests` project. Mark up your class with the attributes: `[Trait("Category", "Integration.External")]` and `[Collection("External")]`. -> The `[Collection("External")]` attribute is used to ensure that your tests do NOT run in parallel, and share the same common setup and tear down methods. +> The `[Collection("External")]` attribute is used to ensure that your tests do NOT run in parallel and share the same common setup and tear down methods. Inherit from the `ExternalApiSpec` class, which will require you to inject the `ExternalApiSpec` instance into your constructor. This is also where you can get access to other DI dependencies for use in your tests. @@ -164,12 +166,12 @@ public class MyCustomHttpServiceClientSpec : ExternalApiSpec #### API testing -These kinds of integration tests test the API's provided by one of the subdomains of the product. +These kinds of integration tests test the APIs provided by one of the subdomains of the product. Depending on how your adapter is configured in the DI container when these tests run, determines whether your adapter is used at this time. Generally, speaking, your adapter will be injected when the API integration tests are being run, and you will not want to be accessing a live/sandboxed 3rd party environment at this time, since these tests are run on every desktop very frequently. (see the notes above). -In this case, you will want to replace your adapter entirely with a stub adapter, that does the bare minimum to respond to code that is dependent on it. +In this case, you will want to replace your adapter entirely with a stub adapter that does the bare minimum to respond to code that is dependent on it. ##### Build the stub adapter @@ -177,13 +179,17 @@ Create a new test class in the `IntegrationTesting.WebApi.Common` project, in th Derive that class from the interface of the adapter. -Implement the methods of that interface, and in general provide public properties that expose the data that could be fed to this interface, so that the API integration tests can ensure that the adapter ws used in certain scenarios. +> Name your stub class according to the interface, not the technology. + +Implement the methods of that interface. + +> For some adapters, you might want to allow tests to check its usage. If you do, provide some public getter properties that expose the data that could be fed to this interface, so that the API integration tests can ensure that the adapter was used in certain scenarios. ##### Register the stub adapter In the `WebApiSetup` class of the `IntegrationTesting.WebApi.Common` project, inject your stub class in the `ConfigureTestServices()` handler of the `ConfigureWebHost()` method. (along with the others you see there). -> Note: this registration will override any registrations of your real adapter from DI code in the `ApiHost` modules, and thus be used in all API integration testing. +> Note: this registration will override any registrations of your real adapter from DI code in the `ApiHost` modules and thus be used in all API integration testing. ## Stub your adapter in local development @@ -194,9 +200,9 @@ If you register your new adapter via DI, and it is used (by default) in local (F To do this, you will need to build a stub API that your adapter can talk to, that will return pre-programmed responses, and not actually talk to the cloud system. -You will also need to be able to control whether your adapter talks to your stub API, or talks to the real remote API, by changing configuration in `appsettings.json`, or via some other clever tricks. For example using `#if TESTINGONLY` blocks of code. +You will also need to be able to control whether your adapter talks to your stub API, or talks to the real remote API by changing configuration in `appsettings.json`, or via some other clever tricks. For example using `#if TESTINGONLY` blocks of code. -> Note: Unfortunately, some vendor SDK will not let you change the base URL of the service client they use in their SDK. This is unfortunate and short-sighted of them, and a blocker for testing. Which means you cannot easily get your adapter to point to your stub API during local development. Which leaves you with few other choices. +> Note: Unfortunately, some vendor SDK will not let you change the base URL of the service client they use in their SDK. This is unfortunate and short-sighted of them, and a blocker for testing. This means you cannot easily get your adapter to point to your stub API during local development. Which leaves you with few other choices. > One solution to this problem, is to not use the vendors SDK, and roll your own HTTP service client - which is not ideal, but not impossible either. ### Build your stub API @@ -220,11 +226,10 @@ Now, you need to provide APIs for each of the endpoints that your adapter uses o 1. You will define these APIs and their respect request and response types the same way you normally build APIs for your subdomains. However, these request and response types should live in the `Infrastructure.Web.Api.Operations.Shared` project in the `3rdParties/Vendor` folder. 2. You will not need to specify any `[Authorize]` attributes on these request types, since those attributes won't be honoured by the Stub API project. 3. You may need to use JSON attributes like: `[JsonPropertyName("name")]`, on the properties of these request types, if the request require specific names, or casing (i.e. snake_casing). -4. Now implement your API methods. You'll need the exact same shape as those required by the 3rd party API, since that will be what your adapter will be producing (using which ever service client library you are using). Use your vendors docs for more details. -5. Make sure to use the `Recorder.TraceInformation()` method to output a trace that can be seen in the console of the running Stub Service. This is important detail for debugging locally, and seeing what's going on. -6. You will likely need to use in memory cached objects in your stub API, so that you can remember certain things to give meaningful responses. The goal here is to provide some pre-programmed behavior. It is not to try and accurately re-create the behaviour of the real 3rd party service. However, some "memory" can be useful for normal operation. -7. Follow the same patterns as other stub APIs in the same location, consistency is important. - +4. Now implement your API methods. You'll need the exact same shape as those required by the 3rd party API since that will be what your adapter will be producing (using whichever service client library you are using). Use your vendor's docs for more details. +5. Make sure to use the `Recorder.TraceInformation()` method to output a trace that can be seen in the console of the running Stub Service. This is an important detail for debugging locally and seeing what's going on. +6. You will likely need to use in memory cached objects in your stub API, so that you can remember certain things to give meaningful responses. The goal here is to provide some pre-programmed behavior. It is not to try and accurately re-create the behavior of the real 3rd party service. However, some "memory" can be useful for normal operation. +7. Follow the same patterns as other stub APIs in the same location. Consistency is important. diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index bcc72ab2..53e29f00 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -24,6 +24,9 @@ "SenderEmailAddress": "noreply@saastack.com", "SenderDisplayName": "Support" }, + "EventNotifications": { + "SubscriptionName": "ApiHost1" + }, "SSOProvidersService": { "SSOUserTokens": { "AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A==" @@ -36,8 +39,8 @@ "Gravatar": { "BaseUrl": "https://localhost:5656/gravatar/" }, - "EventNotifications": { - "SubscriptionName": "ApiHost1" + "UserPilot": { + "BaseUrl": "https://localhost:5656/userpilot/" } }, "Hosts": { diff --git a/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs new file mode 100644 index 00000000..fd9ed289 --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs @@ -0,0 +1,142 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common.Configuration; +using Common.Recording; +using Domain.Interfaces; +using Infrastructure.Shared.ApplicationServices.External; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.IntegrationTests.ApplicationServices.External; + +[Trait("Category", "Integration.External")] +[Collection("External")] +public class UserPilotHttpServiceClientSpec : ExternalApiSpec +{ + private readonly UserPilotHttpServiceClient _serviceClient; + + public UserPilotHttpServiceClientSpec(ExternalApiSetup setup) : base(setup, OverrideDependencies) + { + var settings = setup.GetRequiredService(); + _serviceClient = new UserPilotHttpServiceClient(NoOpRecorder.Instance, settings, new TestHttpClientFactory()); + } + + [Fact] + public async Task WhenDeliverAsyncWithNonIdentifiableEventByAnonymousUntenanted_ThenTracksEvent() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), CallerConstants.AnonymousUserId, "aneventname", + new Dictionary + { + { "aname", "avalue" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithNonIdentifiableEventByUserUntenanted_ThenTracksEvent() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), "user_1234567890123456789012", "aneventname", + new Dictionary + { + { "aname", "avalue" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithNonIdentifiableEventByAnonymousTenanted_ThenTracksEvent() + { + var result = await _serviceClient.DeliverAsync(new TestCaller("org_1234567890123456789012"), + CallerConstants.AnonymousUserId, "aneventname", + new Dictionary + { + { "aname", "avalue" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithNonIdentifiableEventByUserTenanted_ThenTracksEvent() + { + var result = await _serviceClient.DeliverAsync(new TestCaller("org_1234567890123456789012"), + "user_1234567890123456789012", "aneventname", + new Dictionary + { + { "aname", "avalue" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithUserLoginEvent_ThenIdentifiesAndTracks() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), "auserid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, new Dictionary + { + { UsageConstants.Properties.UserIdOverride, "user_1234567890123456789012" }, + { UsageConstants.Properties.AuthProvider, "credentials" }, + { UsageConstants.Properties.Name, "aperson" }, + { UsageConstants.Properties.EmailAddress, "aperson@company.com" }, + { UsageConstants.Properties.DefaultOrganizationId, "org_1234567890123456789012" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithOrganizationCreatedEvent_ThenIdentifiesAndTracks() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), "auserid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, new Dictionary + { + { UsageConstants.Properties.Id, "org_1234567890123456789012" }, + { UsageConstants.Properties.Name, "anorganization" }, + { UsageConstants.Properties.Ownership, OrganizationOwnership.Shared.ToString() }, + { UsageConstants.Properties.CreatedById, "user_1234567890123456789012" }, + { UsageConstants.Properties.UserIdOverride, "user_1234567890123456789012" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithMembershipAddedEvent_ThenIdentifiesAndTracks() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), "auserid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, new Dictionary + { + { UsageConstants.Properties.Id, "membership_1234567890123456789012" }, + { UsageConstants.Properties.TenantIdOverride, "org_1234567890123456789012" }, + { UsageConstants.Properties.UserIdOverride, "user_1234567890123456789012" } + }); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenDeliverAsyncWithMembershipChangedEvent_ThenIdentifiesAndTracks() + { + var result = await _serviceClient.DeliverAsync(new TestCaller(), "auserid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, new Dictionary + { + { UsageConstants.Properties.Id, "membership_1234567890123456789012" }, + { UsageConstants.Properties.TenantIdOverride, "org_1234567890123456789012" }, + { UsageConstants.Properties.UserIdOverride, "user_1234567890123456789012" }, + { UsageConstants.Properties.Name, "aperson" }, + { UsageConstants.Properties.EmailAddress, "aperson@company.com" } + }); + + result.Should().BeSuccess(); + } + + private static void OverrideDependencies(IServiceCollection services) + { + //Do nothing + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json index 61c521a9..8ceeec10 100644 --- a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json +++ b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json @@ -13,6 +13,7 @@ } }, "Flagsmith": { + "TODO": "You need to provide settings to a real 'testingonly' instance of Flagsmith here, or in appsettings.Testing,local.json", "BaseUrl": "https://edge.api.flagsmith.com/api/v1/", "EnvironmentKey": "", "TestingOnly": { @@ -24,6 +25,10 @@ }, "Gravatar": { "BaseUrl": "https://www.gravatar.com" + }, + "UserPilot": { + "TODO": "You need to provide settings to a real 'testingonly' instance of UserPilot here, or in appsettings.Testing,local.json", + "BaseUrl": "https://analytex-eu.userpilot.io/v1" } } } \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs new file mode 100644 index 00000000..dd44dad4 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/UserPilotHttpServiceClientSpec.cs @@ -0,0 +1,1455 @@ +using Application.Interfaces; +using Common; +using Common.Extensions; +using Domain.Interfaces; +using Infrastructure.Shared.ApplicationServices.External; +using JetBrains.Annotations; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices.External; + +[UsedImplicitly] +public class UserPilotHttpServiceClientSpec +{ + [Trait("Category", "Unit")] + public class GivenANonIdentifiableEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenANonIdentifiableEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNotTenantedByAnonymous_ThenTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, CallerConstants.AnonymousUserId, "aneventname", null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "anonymous@platform", "aneventname", + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndNotTenantedByUser_ThenTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", "aneventname", null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", "aneventname", + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndTenantedByAnonymous_ThenTracks() + { + _caller.Setup(cc => cc.TenantId) + .Returns("atenantid"); + var result = + await _serviceClient.DeliverAsync(_caller.Object, CallerConstants.AnonymousUserId, "aneventname", null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "anonymous@atenantid", "aneventname", + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == "atenantid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndTenantedByUser_ThenTracks() + { + _caller.Setup(cc => cc.TenantId) + .Returns("atenantid"); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", "aneventname", null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@atenantid", "aneventname", + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == "atenantid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndAdditionalPropertiesByAnonymous_ThenTracks() + { + var datum = DateTime.UtcNow; + + var result = + await _serviceClient.DeliverAsync(_caller.Object, CallerConstants.AnonymousUserId, "aneventname", + new Dictionary + { + { "aname1", "avalue1" }, + { "aname2", datum.ToIso8601() }, + { UsageConstants.Properties.UserIdOverride, "anoverriddenuserid" } + }, CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "anoverriddenuserid@platform", + "aneventname", + It.Is>(dic => + dic.Count == 3 + && dic["aname1"] == "avalue1" + && dic["aname2"] == datum.ToUnixSeconds().ToString() + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndAdditionalPropertiesByUser_ThenTracks() + { + var datum = DateTime.UtcNow; + + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", "aneventname", + new Dictionary + { + { "aname1", "avalue1" }, + { "aname2", datum.ToIso8601() }, + { UsageConstants.Properties.UserIdOverride, "anoverriddenuserid" } + }, CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "anoverriddenuserid@platform", + "aneventname", + It.Is>(dic => + dic.Count == 3 + && dic["aname1"] == "avalue1" + && dic["aname2"] == datum.ToUnixSeconds().ToString() + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenAnAuthenticationEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenAnAuthenticationEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoIdOverride_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverrideButNoDefaultOrganizationId_ThenIdentifiesAndTracksPlatformUser() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + new Dictionary + { + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.AuthProvider, "aprovider" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 3 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.AuthProvider] == "aprovider" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverride_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + new Dictionary + { + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.AuthProvider, "aprovider" }, + { UsageConstants.Properties.DefaultOrganizationId, "adefaultorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@adefaultorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 3 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.AuthProvider] == "aprovider" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@adefaultorganizationid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 3 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.AuthProvider] == "aprovider" + && dic[UsageConstants.Properties.TenantId] == "adefaultorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverrideNameAndEmailAddress_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + new Dictionary + { + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.AuthProvider, "aprovider" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.EmailAddress, "anemailaddress" }, + { UsageConstants.Properties.DefaultOrganizationId, "adefaultorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@adefaultorganizationid", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 5 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.AuthProvider] == "aprovider" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + ), It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@adefaultorganizationid", + UsageConstants.Events.UsageScenarios.Generic.UserLogin, + It.Is>(dic => + dic.Count == 5 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.AuthProvider] == "aprovider" + && dic[UsageConstants.Properties.TenantId] == "adefaultorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenThePersonRegistrationCreatedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenThePersonRegistrationCreatedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoIdOverride_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndId_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@platform", + It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now) + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverride_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "auserid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now) + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverrideAndNameAndEmailAddress_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "auserid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.EmailAddress, "anemailaddress" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 3 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now) + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenTheUserProfileChangedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenTheUserProfileChangedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoIdOverride_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverrideButNoNameOrEmailAddress_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "aprofileid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.Classification, "aclassification" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 3 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + && dic[UsageConstants.Properties.Classification] == "aclassification" + ), It.IsAny())); + } + + [Fact] + public async Task + WhenDeliverAsyncAndIdOverrideAndNameAndEmailAddressButNoDefaultOrganizationId_ThenIdentifiesAndTracksPlatformUser() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "aprofileid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.EmailAddress, "anemailaddress" }, + { UsageConstants.Properties.Classification, "aclassification" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 5 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + && dic[UsageConstants.Properties.Classification] == "aclassification" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndIdOverrideAndNameAndEmailAddress_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "aprofileid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.EmailAddress, "anemailaddress" }, + { UsageConstants.Properties.Classification, "aclassification" }, + { UsageConstants.Properties.DefaultOrganizationId, "adefaultorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@platform", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@adefaultorganizationid", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 0 + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@platform", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 5 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + && dic[UsageConstants.Properties.Classification] == "aclassification" + ), It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@adefaultorganizationid", + UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged, + It.Is>(dic => + dic.Count == 5 + && dic[UsageConstants.Properties.Id] == "auserid" + && dic[UsageConstants.Properties.TenantId] == "adefaultorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + && dic[UsageConstants.Properties.Classification] == "aclassification" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenTheOrganizationCreatedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenTheOrganizationCreatedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoOrganizationId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationId_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now, TimeSpan.FromMinutes(1)) + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationIdAndName_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.Ownership, "anownership" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 3 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + && dic[UserPilotHttpServiceClient.CompanyNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now, TimeSpan.FromMinutes(1)) + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.Ownership] == "anownership" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationIdAndNameAndUserIdOverride_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.Ownership, "anownership" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 3 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + && dic[UserPilotHttpServiceClient.CompanyNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now, TimeSpan.FromMinutes(1)) + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.Ownership] == "anownership" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenTheOrganizationChangedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenTheOrganizationChangedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoOrganizationId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationId_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationIdAndName_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.Ownership, "anownership" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + && dic[UserPilotHttpServiceClient.CompanyNamePropertyName] == "aname" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.Ownership] == "anownership" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndOrganizationIdAndNameAndUserIdOverride_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "anorganizationid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.Ownership, "anownership" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + && dic[UserPilotHttpServiceClient.CompanyNamePropertyName] == "aname" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "anorganizationid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.Ownership] == "anownership" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenTheMembershipAddedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenTheMembershipAddedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoMembershipId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdButNoTenantOverrideId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdAndTenantOverrideId_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" }, + { UsageConstants.Properties.TenantIdOverride, "anorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now) + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdAndTenantIdOverrideAndUserIdOverride_ThenIdentifiesAndTracks() + { + var now = DateTime.UtcNow.ToNearestSecond(); + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" }, + { UsageConstants.Properties.TenantIdOverride, "anorganizationid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@anorganizationid", + It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CreatedAtPropertyName].ToLong().FromUnixTimestamp() + .IsNear(now) + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.MembershipAdded, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenTheMembershipChangedEvent + { + private readonly Mock _caller; + private readonly Mock _client; + private readonly UserPilotHttpServiceClient _serviceClient; + + public GivenTheMembershipChangedEvent() + { + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var recorder = new Mock(); + _client = new Mock(); + + _serviceClient = new UserPilotHttpServiceClient(recorder.Object, _client.Object); + } + + [Fact] + public async Task WhenDeliverAsyncAndNoProperties_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, null, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 1 + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncButNoMembershipId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + new Dictionary + { + { "aname", "avalue" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 2 + && dic["aname"] == "avalue" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdButNoTenantOverrideId_ThenJustTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny>(), + It.IsAny()), Times.Never); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@platform", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == UserPilotHttpServiceClient.UnTenantedValue + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdAndTenantOverrideId_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" }, + { UsageConstants.Properties.TenantIdOverride, "anorganizationid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "aforid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "aforid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenDeliverAsyncAndMembershipIdAndTenantIdOverrideAndUserIdOverride_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" }, + { UsageConstants.Properties.TenantIdOverride, "anorganizationid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@anorganizationid", + It.Is>(dic => + dic.Count == 0 + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 2 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + + [Fact] + public async Task + WhenDeliverAsyncAndMembershipIdAndTenantIdOverrideAndUserIdOverrideAndNameAndEmail_ThenIdentifiesAndTracks() + { + var result = + await _serviceClient.DeliverAsync(_caller.Object, "aforid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + new Dictionary + { + { UsageConstants.Properties.Id, "amembershipid" }, + { UsageConstants.Properties.Name, "aname" }, + { UsageConstants.Properties.EmailAddress, "anemailaddress" }, + { UsageConstants.Properties.TenantIdOverride, "anorganizationid" }, + { UsageConstants.Properties.UserIdOverride, "auserid" } + }, + CancellationToken.None); + + result.Should().BeSuccess(); + _client.Verify( + c => c.IdentifyUserAsync(It.IsAny(), "auserid@anorganizationid", + It.Is>(dic => + dic.Count == 2 + && dic[UserPilotHttpServiceClient.UserNamePropertyName] == "aname" + && dic[UserPilotHttpServiceClient.UserEmailAddressPropertyName] == "anemailaddress" + ), It.Is>(dic => + dic.Count == 1 + && dic[UserPilotHttpServiceClient.CompanyIdPropertyName] == "anorganizationid" + ), + It.IsAny())); + _client.Verify(c => c.TrackEventAsync(It.IsAny(), "auserid@anorganizationid", + UsageConstants.Events.UsageScenarios.Generic.MembershipChanged, + It.Is>(dic => + dic.Count == 4 + && dic[UsageConstants.Properties.Id] == "amembershipid" + && dic[UsageConstants.Properties.Name] == "aname" + && dic[UsageConstants.Properties.EmailAddress] == "anemailaddress" + && dic[UsageConstants.Properties.TenantId] == "anorganizationid" + ), It.IsAny())); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/GravatarHttpServiceClient.GravatarClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/GravatarHttpServiceClient.GravatarClient.cs index d78eaf18..fff0fc1b 100644 --- a/src/Infrastructure.Shared/ApplicationServices/External/GravatarHttpServiceClient.GravatarClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/External/GravatarHttpServiceClient.GravatarClient.cs @@ -67,7 +67,7 @@ public async Task, Error>> FindAvatarAsync(ICallerCo } catch (HttpRequestException) { - _recorder.TraceInformation(caller.ToCall(), + _recorder.TraceError(caller.ToCall(), "Error retrieving gravatar for {EmailAddress}", emailAddress); return Optional.None; } diff --git a/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.UserPilotClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.UserPilotClient.cs new file mode 100644 index 00000000..473467dd --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.UserPilotClient.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using Application.Common; +using Common; +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.UserPilot; +using Infrastructure.Web.Common.Clients; +using Infrastructure.Web.Common.Extensions; +using Infrastructure.Web.Interfaces.Clients; +using Polly; + +namespace Infrastructure.Shared.ApplicationServices.External; + +public interface IUserPilotClient +{ + Task> IdentifyUserAsync(ICallContext caller, string userId, Dictionary metadata, + Dictionary company, CancellationToken cancellationToken); + + Task> TrackEventAsync(ICallContext caller, string userId, string eventName, + Dictionary metadata, CancellationToken cancellationToken); +} + +public sealed class UserPilotClient : IUserPilotClient +{ + internal const string APIVersionHeaderName = "X-API-Version"; + private const string APIKeySettingName = "ApplicationServices:UserPilot:ApiKey"; + private const string BaseUrlSettingName = "ApplicationServices:UserPilot:BaseUrl"; + private readonly string _apiKey; + private readonly IRecorder _recorder; + private readonly IAsyncPolicy _retryPolicy; + private readonly IServiceClient _serviceClient; + + public UserPilotClient(IRecorder recorder, IConfigurationSettings settings, IHttpClientFactory httpClientFactory) + : this(recorder, settings.GetString(BaseUrlSettingName), settings.GetString(APIKeySettingName), + ApiClientRetryPolicies.CreateRetryWithExponentialBackoffAndJitter(), httpClientFactory) + { + } + + internal UserPilotClient(IRecorder recorder, IServiceClient serviceClient, IAsyncPolicy retryPolicy, string apiKey) + { + _recorder = recorder; + _serviceClient = serviceClient; + _retryPolicy = retryPolicy; + _apiKey = apiKey; + } + + private UserPilotClient(IRecorder recorder, string baseUrl, string apiKey, IAsyncPolicy retryPolicy, + IHttpClientFactory httpClientFactory) : this(recorder, + new ApiServiceClient(httpClientFactory, JsonSerializerOptions.Default, baseUrl), retryPolicy, apiKey) + { + } + + public async Task> IdentifyUserAsync(ICallContext call, string userId, + Dictionary metadata, Dictionary company, + CancellationToken cancellationToken) + { + var caller = Caller.CreateAsCallerFromCall(call); + try + { + var response = await _retryPolicy.ExecuteAsync(() => _serviceClient.PostAsync(caller, + new UserPilotIdentifyUserRequest + { + UserId = userId, + Metadata = metadata, + Company = company + }, req => req.PrepareRequest(_apiKey), cancellationToken)); + if (response.IsFailure) + { + return response.Error.ToError(); + } + + return Result.Ok; + } + catch (HttpRequestException ex) + { + _recorder.TraceError(call, "Error identifying UserPilot user {User}", userId); + return ex.ToError(ErrorCode.Unexpected); + } + } + + public async Task> TrackEventAsync(ICallContext call, string userId, string eventName, + Dictionary metadata, + CancellationToken cancellationToken) + { + var caller = Caller.CreateAsCallerFromCall(call); + try + { + var response = await _retryPolicy.ExecuteAsync(() => _serviceClient.PostAsync(caller, + new UserPilotTrackEventRequest + { + UserId = userId, + EventName = eventName, + Metadata = metadata + }, req => req.PrepareRequest(_apiKey), cancellationToken)); + if (response.IsFailure) + { + return response.Error.ToError(); + } + + return Result.Ok; + } + catch (HttpRequestException ex) + { + _recorder.TraceError(call, "Error tracking UserPilot event {Event} for user {User}", eventName, + userId); + return ex.ToError(ErrorCode.Unexpected); + } + } +} + +internal static class UserPilotHttpClientExtensions +{ + public static void PrepareRequest(this HttpRequestMessage message, string apiKey) + { + message.Headers.Add(HttpConstants.Headers.Authorization, $"token {apiKey}"); + message.Headers.Add(UserPilotClient.APIVersionHeaderName, "2020-09-22"); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.cs new file mode 100644 index 00000000..9eca4bd9 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/UserPilotHttpServiceClient.cs @@ -0,0 +1,450 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Persistence.Shared; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Interfaces; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides an adapter to the UserPilot.com service +/// +/// In UserPilot, a user is assumed to be unique across all companies, which means that a unique user belongs to a +/// unique company. A unique user cannot belong to two different companies at the same time (also they cannot be +/// removed from a company). +/// Thus, when we identify a user, we need to use their userId@organizationId as their unique identifier. +/// Certain events will "identify" users to UserPilot, and we will these moments to set the user's details, +/// and set their company's details: +/// * UserLogin - identify/create the platform-user and default-tenant-user with their email and name (2x calls) +/// * PersonRegistrationCreated/MachineRegistered - identify/create the platform-user with their email and name +/// * UserProfileChanged - identify/create the platform-user and default-tenant-user and change the email and name of +/// both (2x calls) +/// * OrganizationCreated - identify/create the tenant-user and create the company with its name +/// * OrganizationChanged - identify/create the platform-user and change their company's name +/// * MembershipAdded - identify/create the tenant-user and change their company +/// * MembershipChanged - identify/create the tenanted-user and change their email and name (of their "changed" +/// organization) +/// +public sealed class UserPilotHttpServiceClient : IUsageDeliveryService +{ + internal const string CompanyIdPropertyName = "id"; + internal const string CompanyNamePropertyName = "name"; + internal const string CreatedAtPropertyName = "created_at"; + internal const string UnTenantedValue = "platform"; + internal const string UserEmailAddressPropertyName = "email"; + internal const string UserNamePropertyName = "name"; + private const string AnonymousUserId = "anonymous"; + private const string UserIdDelimiter = "@"; + private static readonly string[] IgnoredCustomEventProperties = + [ + UsageConstants.Properties.UserIdOverride, + UsageConstants.Properties.TenantIdOverride, + UsageConstants.Properties.DefaultOrganizationId + ]; + private readonly IRecorder _recorder; + private readonly IUserPilotClient _serviceClient; + + public UserPilotHttpServiceClient(IRecorder recorder, IConfigurationSettings settings, + IHttpClientFactory httpClientFactory) : this(recorder, + new UserPilotClient(recorder, settings, httpClientFactory)) + { + } + + internal UserPilotHttpServiceClient(IRecorder recorder, IUserPilotClient serviceClient) + { + _recorder = recorder; + _serviceClient = serviceClient; + } + + public async Task> DeliverAsync(ICallerContext caller, string forId, string eventName, + Dictionary? additional = null, CancellationToken cancellationToken = default) + { + var options = DetermineOptions(caller, forId, eventName, additional); + var userId = DetermineUserId(options, additional); + var isIdentifiableEvent = IsReIdentifiableEvent(eventName, additional); + if (isIdentifiableEvent) + { + var identified = await IdentifyUserAsync(caller, userId, eventName, additional, cancellationToken); + if (identified.IsFailure) + { + return identified.Error; + } + } + + var trackedMetadata = CreateTrackedEventProperties(options, eventName, additional); + var tracked = await TrackEventAsync(caller, userId, eventName, trackedMetadata, cancellationToken); + if (tracked.IsFailure) + { + return tracked.Error; + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.UserLogin + or UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged) + { + if (!isIdentifiableEvent) + { + return Result.Ok; + } + + if (!additional.Exists() + || !additional.TryGetValue(UsageConstants.Properties.DefaultOrganizationId, + out var defaultOrganizationId)) + { + return Result.Ok; + } + + options.ResetTenantIdOverride(defaultOrganizationId); + var secondUserId = DetermineUserId(options, additional); + var secondTrackedMetadata = CreateTrackedEventProperties(options, eventName, additional); + + var identifiedTenant = + await IdentifyUserAsync(caller, secondUserId, eventName, additional, cancellationToken); + if (identifiedTenant.IsFailure) + { + return identifiedTenant.Error; + } + + var trackedTenant = + await TrackEventAsync(caller, secondUserId, eventName, secondTrackedMetadata, cancellationToken); + if (trackedTenant.IsFailure) + { + return trackedTenant.Error; + } + } + + return Result.Ok; + } + + private static ContextOptions DetermineOptions(ICallerContext caller, string forId, string eventName, + Dictionary? additional) + { + string? tenantIdOverride = null; + switch (eventName) + { + case UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated: + case UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged: + tenantIdOverride = additional.Exists() + && additional.TryGetValue(UsageConstants.Properties.Id, + out var organizationId) + ? organizationId + : null; + break; + } + + return new ContextOptions( + forId, + caller.TenantId.HasValue() + ? caller.TenantId + : UnTenantedValue, + tenantIdOverride); + } + + private static bool IsReIdentifiableEvent(string eventName, Dictionary? additional) + { + if (additional.NotExists()) + { + return false; + } + + // Updates the user details + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.UserLogin) + { + return additional.TryGetValue(UsageConstants.Properties.UserIdOverride, out _); + } + + // Updates the email or name of a user + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated + or UsageConstants.Events.UsageScenarios.Generic.MachineRegistered) + { + return additional.TryGetValue(UsageConstants.Properties.Id, out _); + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged) + { + return additional.TryGetValue(UsageConstants.Properties.Id, out _) + && (additional.TryGetValue(UsageConstants.Properties.Name, out _) + || additional.TryGetValue(UsageConstants.Properties.EmailAddress, out _)); + } + + // Updates the company details + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated + or UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged) + { + return additional.TryGetValue(UsageConstants.Properties.Id, out _); + } + + // Updates the company details and user details + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.MembershipAdded + or UsageConstants.Events.UsageScenarios.Generic.MembershipChanged) + { + return additional.TryGetValue(UsageConstants.Properties.Id, out _) + && additional.TryGetValue(UsageConstants.Properties.TenantIdOverride, out _); + } + + return false; + } + + private async Task> IdentifyUserAsync(ICallerContext caller, string userId, + string eventName, Dictionary? additional, CancellationToken cancellationToken) + { + _recorder.TraceInformation(caller.ToCall(), "Identifying user in UserPilot for {User}", userId); + + var userMetadata = CreateIdentifiedUserProperties(eventName, additional); + var companyMetadata = CreateIdentifiedCompanyProperties(eventName, additional); + var identified = + await _serviceClient.IdentifyUserAsync(caller.ToCall(), userId, userMetadata, companyMetadata, + cancellationToken); + if (identified.IsFailure) + { + return identified.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Identified user in UserPilot for {User} successfully", + userId); + + return Result.Ok; + } + + private async Task> TrackEventAsync(ICallerContext caller, string userId, + string eventName, + Dictionary metadata, CancellationToken cancellationToken) + { + _recorder.TraceInformation(caller.ToCall(), "Tracking event {Event} in UserPilot for {User}", eventName, + userId); + + var tracked = + await _serviceClient.TrackEventAsync(caller.ToCall(), userId, eventName, metadata, cancellationToken); + if (tracked.IsFailure) + { + return tracked.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Tracked event {Event} in UserPilot for {User} successfully", + eventName, userId); + + return Result.Ok; + } + + private static Dictionary CreateIdentifiedUserProperties(string eventName, + Dictionary? additional) + { + var metadata = new Dictionary(); + if (additional.NotExists()) + { + return metadata; + } + + if (eventName is UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated + or UsageConstants.Events.UsageScenarios.Generic.MachineRegistered + or UsageConstants.Events.UsageScenarios.Generic.MembershipAdded) + { + var now = DateTime.UtcNow.ToNearestSecond().ToIso8601(); + metadata.TryAdd(CreatedAtPropertyName, ConvertToUserPilotDataType(now)); + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.UserLogin + or UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated + or UsageConstants.Events.UsageScenarios.Generic.MachineRegistered + or UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged + or UsageConstants.Events.UsageScenarios.Generic.MembershipChanged + ) + { + if (additional.TryGetValue(UsageConstants.Properties.EmailAddress, out var emailAddress)) + { + metadata.TryAdd(UserEmailAddressPropertyName, ConvertToUserPilotDataType(emailAddress)); + } + + if (additional.TryGetValue(UsageConstants.Properties.Name, out var name)) + { + metadata.TryAdd(UserNamePropertyName, ConvertToUserPilotDataType(name)); + } + } + + return metadata; + } + + private static Dictionary CreateIdentifiedCompanyProperties(string eventName, + Dictionary? additional) + { + var metadata = new Dictionary(); + if (additional.NotExists()) + { + return metadata; + } + + if (eventName is UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated) + { + var now = DateTime.UtcNow.ToNearestSecond().ToIso8601(); + metadata.TryAdd(CreatedAtPropertyName, ConvertToUserPilotDataType(now)); + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.OrganizationCreated + or UsageConstants.Events.UsageScenarios.Generic.OrganizationChanged + ) + { + if (additional.TryGetValue(UsageConstants.Properties.Id, out var companyId)) + { + metadata.TryAdd(CompanyIdPropertyName, ConvertToUserPilotDataType(companyId)); + } + + if (additional.TryGetValue(UsageConstants.Properties.Name, out var name)) + { + metadata.TryAdd(CompanyNamePropertyName, ConvertToUserPilotDataType(name)); + } + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.MembershipAdded + ) + { + if (additional.TryGetValue(UsageConstants.Properties.TenantIdOverride, out var companyId)) + { + metadata.TryAdd(CompanyIdPropertyName, ConvertToUserPilotDataType(companyId)); + } + + if (additional.TryGetValue(UsageConstants.Properties.Name, out var name)) + { + metadata.TryAdd(CompanyNamePropertyName, ConvertToUserPilotDataType(name)); + } + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.MembershipChanged + ) + { + if (additional.TryGetValue(UsageConstants.Properties.TenantIdOverride, out var companyId)) + { + metadata.TryAdd(CompanyIdPropertyName, ConvertToUserPilotDataType(companyId)); + } + } + + return metadata; + } + + private static Dictionary CreateTrackedEventProperties(ContextOptions options, string eventName, + Dictionary? additional) + { + var metadata = new Dictionary(); + + var tenantId = DetermineTenantId(options, additional); + metadata.TryAdd(UsageConstants.Properties.TenantId, tenantId); + + if (additional.NotExists()) + { + return metadata; + } + + if (eventName + is UsageConstants.Events.UsageScenarios.Generic.UserLogin + or UsageConstants.Events.UsageScenarios.Generic.PersonRegistrationCreated + or UsageConstants.Events.UsageScenarios.Generic.UserProfileChanged + ) + { + if (additional.TryGetValue(UsageConstants.Properties.UserIdOverride, out var overriddenUserId)) + { + metadata.TryAdd(UsageConstants.Properties.Id, ConvertToUserPilotDataType(overriddenUserId)); + } + } + + foreach (var pair in additional.Where( + pair => IgnoredCustomEventProperties.NotContainsIgnoreCase(pair.Key))) + { + metadata.TryAdd(pair.Key, ConvertToUserPilotDataType(pair.Value)); + } + + return metadata; + } + + /// + /// UserPilot only supports strings, where: datetime is in UnixSeconds + /// + private static string ConvertToUserPilotDataType(string value) + { + if (DateTime.TryParse(value, out var dateTime)) + { + return dateTime.ToUnixSeconds().ToString(); + } + + return value; + } + + private static string DetermineUserId(ContextOptions options, Dictionary? additional) + { + var tenantId = DetermineTenantId(options, additional); + return DetermineUserId(options, additional, tenantId); + } + + private static string DetermineUserId(ContextOptions options, Dictionary? additional, + string tenantId) + { + var userId = options.ForId; + if (additional.Exists()) + { + if (additional.TryGetValue(UsageConstants.Properties.UserIdOverride, out var overriddenUserId)) + { + userId = overriddenUserId; + } + } + + if (userId.EqualsIgnoreCase(CallerConstants.AnonymousUserId)) + { + userId = AnonymousUserId; + } + + return $"{userId}{UserIdDelimiter}{tenantId}"; + } + + private static string DetermineTenantId(ContextOptions options, Dictionary? additional) + { + if (additional.Exists()) + { + if (options.TenantIdOverride.HasValue()) + { + return options.TenantIdOverride; + } + + if (additional.TryGetValue(UsageConstants.Properties.TenantIdOverride, out var overriddenTenantId)) + { + return overriddenTenantId; + } + + if (additional.TryGetValue(UsageConstants.Properties.TenantId, out var specifiedTenantId)) + { + return specifiedTenantId; + } + } + + return options.TenantId; + } + + private sealed class ContextOptions + { + public ContextOptions(string forId, string tenantId, string? tenantIdOverride) + { + ForId = forId; + TenantId = tenantId; + TenantIdOverride = tenantIdOverride; + } + + public string ForId { get; } + + public string TenantId { get; } + + public string? TenantIdOverride { get; set; } + + public void ResetTenantIdOverride(string tenantId) + { + TenantIdOverride = tenantId; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotIdentifyUserRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotIdentifyUserRequest.cs new file mode 100644 index 00000000..c7370617 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotIdentifyUserRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.UserPilot; + +/// +/// Identifies the user +/// +[Route("/identify", OperationMethod.Post)] +public class UserPilotIdentifyUserRequest : IWebRequest +{ + [JsonPropertyName("company")] public Dictionary Company { get; set; } = new(); + + [JsonPropertyName("metadata")] public Dictionary Metadata { get; set; } = new(); + + [JsonPropertyName("user_id")] public string? UserId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotTrackEventRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotTrackEventRequest.cs new file mode 100644 index 00000000..5154536f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/UserPilot/UserPilotTrackEventRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.UserPilot; + +/// +/// Identifies the user +/// +[Route("/track", OperationMethod.Post)] +public class UserPilotTrackEventRequest : IWebRequest +{ + [JsonPropertyName("event_name")] public string? EventName { get; set; } + + [JsonPropertyName("metadata")] public Dictionary Metadata { get; set; } = new(); + + [JsonPropertyName("user_id")] public string? UserId { get; set; } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/Stubs/StubUsageDeliveryService.cs b/src/IntegrationTesting.WebApi.Common/Stubs/StubUsageDeliveryService.cs new file mode 100644 index 00000000..8cf6146c --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/Stubs/StubUsageDeliveryService.cs @@ -0,0 +1,19 @@ +using Application.Interfaces; +using Application.Persistence.Shared; +using Common; + +namespace IntegrationTesting.WebApi.Common.Stubs; + +/// +/// Provides a stub for testing +/// +public sealed class StubUsageDeliveryService : IUsageDeliveryService +{ + public async Task> DeliverAsync(ICallerContext caller, string forId, string eventName, + Dictionary? additional = null, + CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 87a832b0..c3d91552 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text.Json; +using Application.Persistence.Shared; using Application.Resources.Shared; using Application.Services.Shared; using Common; @@ -84,6 +85,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (_overridenTestingDependencies.Exists()) { _overridenTestingDependencies.Invoke(services); diff --git a/src/TestingStubApiHost/Api/StubFlagsmithApi.cs b/src/TestingStubApiHost/Api/StubFlagsmithApi.cs index 7a804434..38da5ae6 100644 --- a/src/TestingStubApiHost/Api/StubFlagsmithApi.cs +++ b/src/TestingStubApiHost/Api/StubFlagsmithApi.cs @@ -20,7 +20,7 @@ public async Task> Create FlagsmithCreateIdentityRequest request, CancellationToken cancellationToken) { await Task.CompletedTask; - Recorder.TraceInformation(null, "StubFlagsmith: CreateIdentity"); + Recorder.TraceInformation(null, "StubFlagsmith: CreateIdentity for {Identifier}", request.Identifier ?? "none"); return () => new PostResult(new FlagsmithCreateIdentityResponse { diff --git a/src/TestingStubApiHost/Api/StubUserPilotApi.cs b/src/TestingStubApiHost/Api/StubUserPilotApi.cs new file mode 100644 index 00000000..29824454 --- /dev/null +++ b/src/TestingStubApiHost/Api/StubUserPilotApi.cs @@ -0,0 +1,33 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.UserPilot; + +namespace TestingStubApiHost.Api; + +[WebService("/userpilot")] +public class StubUserPilotApi : StubApiBase +{ + public StubUserPilotApi(IRecorder recorder, IConfigurationSettings settings) : base(recorder, settings) + { + } + + public async Task IdentifyUser(UserPilotIdentifyUserRequest request, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, "StubUserPilot: IdentifyUser for {User} with {Metadata}, for company {Company}", + request.UserId ?? "none", request.Metadata.ToJson()!, request.Company.ToJson()!); + return () => new EmptyResponse(); + } + + public async Task TrackEvent(UserPilotTrackEventRequest request, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, "StubUserPilot: TrackEvent for {Event} for {User} with {Metadata}", + request.EventName ?? "none", request.UserId ?? "none", request.Metadata.ToJson()!); + return () => new EmptyResponse(); + } +} \ No newline at end of file