diff --git a/docs/design-principles/0150-all-use-cases.md b/docs/design-principles/0150-all-use-cases.md
index 55dbca3a..22738eb5 100644
--- a/docs/design-principles/0150-all-use-cases.md
+++ b/docs/design-principles/0150-all-use-cases.md
@@ -61,7 +61,11 @@ These are the main use cases of this product that are exposed via "public" APIs
### Users (End User)
-1. Assign [platform] roles to the current user
+1. Assign [platform] roles to an existing user
+1. Unassign [platform] roles to an existing user (except `Standard` role)
+1. Invite a guest to register on the platform (a referral)
+1. Resend an invitation to a guest
+1. Guest verifies an invitation is still valid
### Identities
@@ -78,15 +82,15 @@ These are the main use cases of this product that are exposed via "public" APIs
1. Register a new machine
-#### Passwords
+#### Password Credentials
1. Authenticate the current user (with a password)
-2. Register a new person (with a password)
+2. Register a new person (with a password, and optional invitation)
3. Confirm registration of a person (from email)
#### Single-Sign On
-1. Authenticate and (auto-register) a person from another OAuth2 provider
+1. Authenticate and (auto-register) a person from another OAuth2 provider (with an optional invitation)
### Images
@@ -96,7 +100,6 @@ TBD
1. Create a new organization for the current user
2. Inspect a specific organization
-3.
### Subscriptions
@@ -104,9 +107,10 @@ TBD
### User Profiles
-TBD
+1. Change the names, phone, time zone of the profile,
+2. Change the address of the profile
-## BEFFE
+## Backend for Frontend
These are the main use cases of this product that are exposed via "public" APIs in the Frontend, e.g., `WebsiteHost`.
diff --git a/docs/design-principles/0160-user-lifecycle.md b/docs/design-principles/0160-user-lifecycle.md
new file mode 100644
index 00000000..d0d5e9ff
--- /dev/null
+++ b/docs/design-principles/0160-user-lifecycle.md
@@ -0,0 +1,86 @@
+# User Lifecycle
+
+## Design Principles
+
+* We want to use the email address as a unique identifier for each person's identity (in the universe), and we only want one representation of that person's identity across all organizations (tenants). However, a person will likely have several identities (i.e., a specific company identity versus a personal identity).
+* We want any registered person in the system to invite another person (via an email address) to register themselves to the system. We want to capture their email address early for referral purposes.
+* Only the invited person can self-register after they were "warm" invited, or they approached the platform "cold".
+* When a person self-registers, regardless of whether they were invited (via an email address or not) they can choose the email address they wish to register with.
+* A person can be invited to the platform, or to a specific organization. In the later case, they join that organization by default.
+
+## Implementation
+
+### End Users
+
+Users of the product are managed in the `EndUser` subdomain.
+
+* Essentially an `EndUser` is a representation of an "identity".
+
+* Users cannot be deleted. Once registered, they can be "disabled" (`Access=Suspended`) and cannot authenticate to use the product any further.
+
+* Once registered, the email address they registered with becomes their username. Even though they may change that email address (in the future), no two users on the platform can use the same email address.
+
+Users self-register in the `Identity` subdomain via either `PasswordCredentials`, `SSOUsers`, or other methods, each responsible for its own onboarding flow (e.g., passwords require email confirmations and a managed registration flow, whereas SSO can just automate it).
+
+* New users can be invited by another party and can respond to that invitation (e.g., a "warm" guest invitation) or register without an invite (e.g., a "cold" direct registration).
+
+![User Lifecycle](../images/EndUser-Lifecycle.png)
+
+### Organizations
+
+An `Organization` has a type of either `Personal` or `Shared`.
+
+* `Shared` organizations are intended for use by companies/workgroups/organizations/teams/etc and for persons and machines.
+* `Personal` organizations are for each `EndUser` (person or machine) to use on the platform and cannot be shared with others.
+* A person/machine will have a membership to one `Personal` organization at all times, and can have a membership to one or more `Shared` organizations.
+
+#### Roles and Responsibilities
+
+* Any person can have the `Member` and/or `Owner` and/or `BillingAdmin` roles in an organization.
+* Only `Owner` roles can assign/unassign roles to other members.
+* Any person can have the `BillingAdmin` role of an organization, but they must also have the `Owner` role.
+* Every organization (`Shared` or `Personal`) will always have a person who is the "billing subscriber". This person has a fiscal responsibility to pay for billing charges for that organization (tenant). The "billing subscriber" must always have the `Owner` and `BillingAdmin` roles at all times.
+* When a person creates a new organization, they automatically become an `Owner` and `BillingAdmin` of it, as well as the "billing subscriber" for it.
+* From that point, they can assign the `Owner` and `BillingAdmin` roles to one or more other members of the organization.
+* The "billing subscriber" responsibility must be transferred via a (voluntary) payment method submission from another person with the `BillingAdmin` (role).
+
+#### Personal Organizations
+
+* Every `EndUser` (person or machine) has one `Personal` organization.
+
+* It is automatically created for them when they register on the platform. It is named after that person/machine.
+* That person/machine is the only member of that organization.
+
+* They have the roles of `Owner` and `BillingAdmin`, and they are also the "billing subscriber" for it.
+
+* This organization cannot be deleted, and that person cannot be removed from it, nor can their roles be changed.
+
+> This organization is very important so that the product can be accessed at all times by them, regardless of whether the owner is a member of any `Shared` organizations or not.
+
+#### Shared Organizations
+
+* `Shared` organizations can be created at any time by any person on the platform (not machines).
+* Any other person/machine can be invited into them. When they are, they are created a `Membership` to that `Organization`, and each `Membership` maintains its own roles.
+* A person (or machine) can be a `Member` (role) of any number of `Shared` organizations into which they can be invited, removed, or they can leave themselves.
+* A person (not a machine) can be assigned/unassigned any number of roles in those other organizations.
+* A `Shared` organization must have at least one `Owner` (role) and one `BillingAdmin` (role) at all times, and they can be the same person or different persons. Like a `Personal` organization, a `Shared` will have one and only one "billing subscriber", who is ultimately responsible for any charges for the organization.
+* A `Shared` organization can be deleted. However, they have to be deleted by the designated "billing subscriber" and only once all members are removed from it.
+
+### Guest Invitations
+
+Guest invitations are the mechanism to introduce and refer new users to the product.
+
+#### To The Platform
+
+* Any authenticated user can invite a guest to the platform (i.e., without any affiliation to any organization)
+* A "guest invitation" requires only an email address and has an expiry (7 days, by default)
+* The person is contacted at that email address and given a link to register with the platform (in the web app). The link contains an `InvitationToken`.
+* In the web app, the `InvitationToken` is first verified to check if it is valid (and not expired), and if so, the guest is presented with a registration form, which accepts an email address and password, which is pre-populated with the email address and a "guessed" name (derived from their email address). Or they can sign up with an SSO provider.
+* In either case, when signing up with password credentials or signing in with SSO, the registration includes the referral `InvitationToken`.
+* In the server, the `InvitationToken` is used to "accept" the "guest invitation" before registering the user.
+* The user is registered with their own `Personal` organization and has no other memberships with any other organizations.
+
+#### To An Organization
+
+TBD
+
diff --git a/docs/design-principles/README.md b/docs/design-principles/README.md
index 4045b2c3..dab6bccf 100644
--- a/docs/design-principles/README.md
+++ b/docs/design-principles/README.md
@@ -1,18 +1,20 @@
# Design Principles
+[All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so that you do not have to implement them yourselves)
+
* [REST API Design Guidelines](0010-rest-api.md) how REST API's should be designed
* [REST API Framework](0020-api-framework.md) how REST API's are implemented
-* [Modularity](0025-modularity.md) how we build modules that can be scaled-out later as the product grows
+* [Modularity](0025-modularity.md) is how we build modules that can be scaled-out later as the product grows
* [Recording/Logging/etc](0030-recording.md) how we do crash reporting, logging, auditing, and capture usage metrics
* [Configuration Management](0040-configuration.md) how we manage configuration in the source code at design-time and runtime
* [Domain Driven Design](0050-domain-driven-design.md) how to design your aggregates, and domains
* [Dependency Injection](0060-dependency-injection.md) how you implement DI
* [Persistence](0070-persistence.md) how you design your repository layer, and promote domain events
-* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arms length, and testable, and how we integrate with any 3rd party system
+* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arm's length, and testable, and how we integrate with any 3rd party system
* [Authentication and Authorization](0090-authentication-authorization.md) how we authenticate and authorize users
* [Email Delivery](0100-email-delivery.md) how we send emails and deliver them asynchronously and reliably
* [Backend for Frontend](0110-back-end-for-front-end.md) the BEFFE web server that is tailored for a web UI, and brokers secure access to the backend
* [Feature Flagging](0120-feature-flagging.md) how we enable and disable features at runtime
-* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logically and physical infrastructure)
+* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logical and physical infrastructure)
* [Developer Tooling](0140-developer-tooling.md) all the tooling that is included in this codebase to help developers use this codebase effectively, and consistently
-* [All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so you dont have to)
\ No newline at end of file
+* [User Lifecycle](0160-user-lifecycle.md) how are users managed on the platform, and the relationship to their organizations
\ No newline at end of file
diff --git a/docs/images/EndUser-Lifecycle.png b/docs/images/EndUser-Lifecycle.png
index 9440ed0a..bcda6642 100644
Binary files a/docs/images/EndUser-Lifecycle.png and b/docs/images/EndUser-Lifecycle.png differ
diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx
index 771dc818..531fe4df 100644
Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ
diff --git a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs
index af3cc886..abaa3682 100644
--- a/src/AncillaryInfrastructure/Persistence/AuditRepository.cs
+++ b/src/AncillaryInfrastructure/Persistence/AuditRepository.cs
@@ -5,12 +5,12 @@
using Application.Persistence.Common.Extensions;
using Application.Persistence.Interfaces;
using Common;
-using Common.Extensions;
using Domain.Common.ValueObjects;
using Domain.Interfaces;
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
+using Tasks = Common.Extensions.Tasks;
namespace AncillaryInfrastructure.Persistence;
diff --git a/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs b/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs
index e3efff89..7865a180 100644
--- a/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs
+++ b/src/AncillaryInfrastructure/Persistence/EmailDeliveryRepository.cs
@@ -11,6 +11,7 @@
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
+using Tasks = Common.Extensions.Tasks;
namespace AncillaryInfrastructure.Persistence;
diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs
index dbfd696d..feb009c3 100644
--- a/src/Application.Interfaces/Audits.Designer.cs
+++ b/src/Application.Interfaces/Audits.Designer.cs
@@ -68,6 +68,15 @@ public static string EndUserApplication_PlatformRolesAssigned {
}
}
+ ///
+ /// Looks up a localized string similar to EndUser.PlatformRolesUnassigned.
+ ///
+ public static string EndUserApplication_PlatformRolesUnassigned {
+ get {
+ return ResourceManager.GetString("EndUserApplication_PlatformRolesUnassigned", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to EndUser.TenantRolesAssigned.
///
diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx
index e7167c26..7dcfe3bc 100644
--- a/src/Application.Interfaces/Audits.resx
+++ b/src/Application.Interfaces/Audits.resx
@@ -48,6 +48,9 @@
EndUser.PlatformRolesAssigned
+
+ EndUser.PlatformRolesUnassigned
+
SingleSignOn.AutoRegistered
diff --git a/src/Application.Interfaces/UsageConstants.cs b/src/Application.Interfaces/UsageConstants.cs
index 50b79947..fe0cfaeb 100644
--- a/src/Application.Interfaces/UsageConstants.cs
+++ b/src/Application.Interfaces/UsageConstants.cs
@@ -43,6 +43,7 @@ public static class UsageScenarios
public const string Audit = "Audited";
public const string BookingCancelled = "Booking Cancelled";
public const string BookingCreated = "Booking Created";
+ public const string GuestInvited = "User Guest Invited";
public const string MachineRegistered = "Machine Registered";
public const string Measurement = "Measured";
public const string PersonRegistrationConfirmed = "User Registered";
diff --git a/src/Application.Persistence.Common/Extensions/Tasks.cs b/src/Application.Persistence.Common/Extensions/Tasks.cs
new file mode 100644
index 00000000..9a5eeeda
--- /dev/null
+++ b/src/Application.Persistence.Common/Extensions/Tasks.cs
@@ -0,0 +1,22 @@
+using Common;
+
+namespace Application.Persistence.Common.Extensions;
+
+public static class Tasks
+{
+ ///
+ /// Runs all the specified and returns the first if any
+ ///
+ public static async Task> WhenAllAsync(params Task>[] tasks)
+ {
+ var results = await Task.WhenAll(tasks);
+
+ var hasError = results.Any(result => !result.IsSuccessful);
+ if (hasError)
+ {
+ return results.First(result => !result.IsSuccessful).Error;
+ }
+
+ return results.All(result => result.Value);
+ }
+}
\ No newline at end of file
diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs
index 2ad4e770..82b43fee 100644
--- a/src/Application.Resources.Shared/EndUser.cs
+++ b/src/Application.Resources.Shared/EndUser.cs
@@ -56,4 +56,13 @@ public class Membership : IIdentifiableResource
public List Roles { get; set; } = new();
public required string Id { get; set; }
+}
+
+public class Invitation
+{
+ public required string EmailAddress { get; set; }
+
+ public required string FirstName { get; set; }
+
+ public string? LastName { get; set; }
}
\ No newline at end of file
diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs
index 74a43324..13dd8560 100644
--- a/src/Application.Services.Shared/IEndUsersService.cs
+++ b/src/Application.Services.Shared/IEndUsersService.cs
@@ -19,7 +19,8 @@ Task> RegisterMachinePrivateAsync(ICallerContex
string? timezone,
string? countryCode, CancellationToken cancellationToken);
- Task> RegisterPersonPrivateAsync(ICallerContext caller, string emailAddress,
+ Task> RegisterPersonPrivateAsync(ICallerContext caller, string? invitationToken,
+ string emailAddress,
string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted,
CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/src/Application.Services.Shared/INotificationsService.cs b/src/Application.Services.Shared/INotificationsService.cs
index a57fba2f..684acd85 100644
--- a/src/Application.Services.Shared/INotificationsService.cs
+++ b/src/Application.Services.Shared/INotificationsService.cs
@@ -8,6 +8,12 @@ namespace Application.Services.Shared;
///
public interface INotificationsService
{
+ ///
+ /// Notifies a user, via email, that they have been invited to register with the platform
+ ///
+ Task> NotifyGuestInvitationToPlatformAsync(ICallerContext caller, string token,
+ string inviteeEmailAddress, string inviteeName, string inviterName, CancellationToken cancellationToken);
+
///
/// Notifies a user, via email, to confirm their account registration
///
diff --git a/src/Application.Services.Shared/IUserProfilesService.cs b/src/Application.Services.Shared/IUserProfilesService.cs
index 6f6009d6..1c05bd1a 100644
--- a/src/Application.Services.Shared/IUserProfilesService.cs
+++ b/src/Application.Services.Shared/IUserProfilesService.cs
@@ -16,4 +16,7 @@ Task> CreatePersonProfilePrivateAsync(ICallerContext
Task, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller,
string emailAddress, CancellationToken cancellationToken);
+
+ Task> GetProfilePrivateAsync(ICallerContext caller, string userId,
+ CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/src/Application.Services.Shared/IWebsiteUiService.cs b/src/Application.Services.Shared/IWebsiteUiService.cs
index 0d76a3ba..d3dd5cb9 100644
--- a/src/Application.Services.Shared/IWebsiteUiService.cs
+++ b/src/Application.Services.Shared/IWebsiteUiService.cs
@@ -6,4 +6,6 @@ namespace Application.Services.Shared;
public interface IWebsiteUiService
{
string ConstructPasswordRegistrationConfirmationPageUrl(string token);
+
+ string CreateRegistrationPageUrl(string token);
}
\ No newline at end of file
diff --git a/src/CarsInfrastructure/Persistence/CarRepository.cs b/src/CarsInfrastructure/Persistence/CarRepository.cs
index f06b5431..d3a9afd3 100644
--- a/src/CarsInfrastructure/Persistence/CarRepository.cs
+++ b/src/CarsInfrastructure/Persistence/CarRepository.cs
@@ -5,13 +5,13 @@
using CarsApplication.Persistence.ReadModels;
using CarsDomain;
using Common;
-using Common.Extensions;
using Domain.Common.ValueObjects;
using Domain.Interfaces;
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
using Unavailability = CarsApplication.Persistence.ReadModels.Unavailability;
+using Tasks = Common.Extensions.Tasks;
namespace CarsInfrastructure.Persistence;
diff --git a/src/Domain.Services.Shared/DomainServices/ITokensService.cs b/src/Domain.Services.Shared/DomainServices/ITokensService.cs
index 49e020f0..e25fb310 100644
--- a/src/Domain.Services.Shared/DomainServices/ITokensService.cs
+++ b/src/Domain.Services.Shared/DomainServices/ITokensService.cs
@@ -7,6 +7,8 @@ public interface ITokensService
{
APIKeyToken CreateAPIKey();
+ string CreateGuestInvitationToken();
+
string CreateJWTRefreshToken();
string CreatePasswordResetToken();
diff --git a/src/Domain.Shared.UnitTests/EmailAddressSpec.cs b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs
index 6d444e7d..4c45a701 100644
--- a/src/Domain.Shared.UnitTests/EmailAddressSpec.cs
+++ b/src/Domain.Shared.UnitTests/EmailAddressSpec.cs
@@ -27,7 +27,7 @@ public void WhenConstructedAndInvalidEmail_ThenReturnsError()
[Fact]
public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName()
{
- var name = EmailAddress.Create("auser@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("auser@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Auser");
name.LastName.Should().BeNone();
@@ -36,7 +36,7 @@ public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsName()
{
- var name = EmailAddress.Create("afirstname.amiddlename.alastname@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("afirstname.amiddlename.alastname@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Value.Text.Should().Be("Alastname");
@@ -45,7 +45,7 @@ public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsNam
[Fact]
public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName()
{
- var name = EmailAddress.Create("afirstname.alastname@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("afirstname.alastname@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Value.Text.Should().Be("Alastname");
@@ -54,7 +54,7 @@ public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName()
{
- var name = EmailAddress.Create("afirstname+anothername@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("afirstname+anothername@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
@@ -63,7 +63,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturnsName()
{
- var name = EmailAddress.Create("afirstname+9@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("afirstname+9@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
@@ -72,7 +72,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturns
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsNameWithFallbackFirstName()
{
- var name = EmailAddress.Create("-@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("-@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName);
name.LastName.Should().BeNone();
@@ -81,7 +81,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsN
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNameWithNoLastName()
{
- var name = EmailAddress.Create("afirstname.b@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("afirstname.b@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
@@ -90,7 +90,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNa
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedFirstAndLastNameNotValid_ThenReturnsNameWithNoLastName()
{
- var name = EmailAddress.Create("1.2@company.com").Value.GuessPersonName();
+ var name = EmailAddress.Create("1.2@company.com").Value.GuessPersonFullName();
name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName);
name.LastName.Should().BeNone();
diff --git a/src/Domain.Shared/EmailAddress.cs b/src/Domain.Shared/EmailAddress.cs
index 13969ca1..918e26dc 100644
--- a/src/Domain.Shared/EmailAddress.cs
+++ b/src/Domain.Shared/EmailAddress.cs
@@ -38,7 +38,7 @@ public static ValueObjectFactory Rehydrate()
}
[SkipImmutabilityCheck]
- public PersonName GuessPersonName()
+ public PersonName GuessPersonFullName()
{
var name = GuessPersonNameFromEmailAddress(Value);
diff --git a/src/Domain.Shared/Roles.cs b/src/Domain.Shared/Roles.cs
index 9b8975e5..770db80f 100644
--- a/src/Domain.Shared/Roles.cs
+++ b/src/Domain.Shared/Roles.cs
@@ -50,6 +50,23 @@ public static Result Create(params string[] roles)
return new Roles(list);
}
+ public static Result Create(params RoleLevel[] roles)
+ {
+ var list = new List();
+ foreach (var role in roles)
+ {
+ var rol = Role.Create(role);
+ if (!rol.IsSuccessful)
+ {
+ return rol.Error;
+ }
+
+ list.Add(rol.Value);
+ }
+
+ return new Roles(list);
+ }
+
private Roles() : base(new List())
{
}
diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs
index d2daa5bc..2cb74229 100644
--- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs
+++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs
@@ -5,8 +5,10 @@
using Common.Configuration;
using Domain.Common.Identity;
using Domain.Common.ValueObjects;
+using Domain.Interfaces;
using Domain.Interfaces.Authorization;
using Domain.Interfaces.Entities;
+using Domain.Services.Shared.DomainServices;
using Domain.Shared;
using EndUsersApplication.Persistence;
using EndUsersDomain;
@@ -24,11 +26,12 @@ public class EndUsersApplicationSpec
{
private readonly EndUsersApplication _application;
private readonly Mock _caller;
+ private readonly Mock _endUserRepository;
private readonly Mock _idFactory;
+ private readonly Mock _invitationRepository;
private readonly Mock _notificationsService;
private readonly Mock _organizationsService;
private readonly Mock _recorder;
- private readonly Mock _repository;
private readonly Mock _userProfilesService;
public EndUsersApplicationSpec()
@@ -51,9 +54,10 @@ public EndUsersApplicationSpec()
settings.Setup(
s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny()))
.Returns("");
- _repository = new Mock();
- _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository = new Mock();
+ _endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny()))
.Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root));
+ _invitationRepository = new Mock();
_organizationsService = new Mock();
_organizationsService.Setup(os => os.CreateOrganizationPrivateAsync(It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(),
@@ -68,16 +72,16 @@ public EndUsersApplicationSpec()
_notificationsService = new Mock();
_application =
- new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, _notificationsService.Object,
- _organizationsService.Object,
- _userProfilesService.Object, _repository.Object);
+ new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object,
+ _notificationsService.Object, _organizationsService.Object, _userProfilesService.Object,
+ _invitationRepository.Object, _endUserRepository.Object);
}
[Fact]
public async Task WhenGetPersonAndUnregistered_ThenReturnsUser()
{
var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(user);
var result = await _application.GetPersonAsync(_caller.Object, "anid", CancellationToken.None);
@@ -92,24 +96,27 @@ public async Task WhenGetPersonAndUnregistered_ThenReturnsUser()
}
[Fact]
- public async Task WhenRegisterPersonAndNotAcceptedTerms_ThenReturnsError()
+ public async Task WhenRegisterPersonAsyncAndNotAcceptedTerms_ThenReturnsError()
{
- var result = await _application.RegisterPersonAsync(_caller.Object, "anemailaddress", "afirstname", "alastname",
+ var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com",
+ "afirstname",
+ "alastname",
"atimezone", "acountrycode", false, CancellationToken.None);
result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUsersApplication_NotAcceptedTerms);
}
[Fact]
- public async Task WhenRegisterPersonAndNotExist_ThenRegisters()
+ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegistration()
{
+ var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
_userProfilesService.Setup(ups =>
ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
It.IsAny()))
.ReturnsAsync(Optional.None);
- _repository.Setup(rep =>
+ _invitationRepository.Setup(rep =>
rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(Optional.None);
+ .ReturnsAsync(invitee.ToOptional());
_userProfilesService.Setup(ups =>
ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
@@ -133,9 +140,9 @@ public async Task WhenRegisterPersonAndNotExist_ThenRegisters()
}
});
- var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname",
- "alastname",
- Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None);
+ var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com",
+ "afirstname",
+ "alastname", null, null, true, CancellationToken.None);
result.Should().BeSuccess();
result.Value.Id.Should().Be("anid");
@@ -152,24 +159,40 @@ public async Task WhenRegisterPersonAndNotExist_ThenRegisters()
result.Value.Profile.DisplayName.Should().Be("afirstname");
result.Value.Profile.EmailAddress.Should().Be("auser@company.com");
result.Value.Profile.Timezone.Should().Be("atimezone");
+ _invitationRepository.Verify(rep =>
+ rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never);
_organizationsService.Verify(os =>
os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname",
OrganizationOwnership.Personal,
It.IsAny()));
_userProfilesService.Verify(ups =>
ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname",
- Timezones.Default.ToString(), CountryCodes.Default.ToString(), It.IsAny()));
+ null, null, It.IsAny()));
+ _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
}
[Fact]
- public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration()
+ public async Task WhenRegisterPersonAsyncAndAcceptingGuestInvitation_ThenCompletesRegistration()
{
+ _caller.Setup(cc => cc.CallerId)
+ .Returns(CallerConstants.AnonymousUserId);
+ var tokensService = new Mock();
+ tokensService.Setup(ts => ts.CreateGuestInvitationToken())
+ .Returns("aninvitationtoken");
var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
+ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(),
+ EmailAddress.Create("auser@company.com").Value, (_, _) => Task.FromResult(Result.Ok));
+ _invitationRepository.Setup(rep =>
+ rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(invitee.ToOptional());
_userProfilesService.Setup(ups =>
ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
It.IsAny()))
.ReturnsAsync(Optional.None);
- _repository.Setup(rep =>
+ _invitationRepository.Setup(rep =>
rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(invitee.ToOptional());
_userProfilesService.Setup(ups =>
@@ -195,8 +218,9 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration(
}
});
- var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname",
- "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None);
+ var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "auser@company.com",
+ "afirstname",
+ "alastname", null, null, true, CancellationToken.None);
result.Should().BeSuccess();
result.Value.Id.Should().Be("anid");
@@ -213,13 +237,15 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration(
result.Value.Profile.DisplayName.Should().Be("afirstname");
result.Value.Profile.EmailAddress.Should().Be("auser@company.com");
result.Value.Profile.Timezone.Should().Be("atimezone");
+ _invitationRepository.Verify(rep =>
+ rep.FindInvitedGuestByTokenAsync("aninvitationtoken", It.IsAny()));
_organizationsService.Verify(os =>
os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname",
OrganizationOwnership.Personal,
It.IsAny()));
_userProfilesService.Verify(ups =>
ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname",
- Timezones.Default.ToString(), CountryCodes.Default.ToString(), It.IsAny()));
+ null, null, It.IsAny()));
_notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(),
It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
@@ -227,7 +253,79 @@ public async Task WhenRegisterPersonAndInvitedAsGuest_ThenCompletesRegistration(
}
[Fact]
- public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail()
+ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenRegisters()
+ {
+ _invitationRepository.Setup(rep =>
+ rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ var invitee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
+ _userProfilesService.Setup(ups =>
+ ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _invitationRepository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(invitee.ToOptional());
+ _userProfilesService.Setup(ups =>
+ ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new UserProfile
+ {
+ Id = "aprofileid",
+ Type = UserProfileType.Person,
+ UserId = "apersonid",
+ DisplayName = "afirstname",
+ Name = new PersonName
+ {
+ FirstName = "afirstname",
+ LastName = "alastname"
+ },
+ EmailAddress = "auser@company.com",
+ Timezone = "atimezone",
+ Address =
+ {
+ CountryCode = "acountrycode"
+ }
+ });
+
+ var result = await _application.RegisterPersonAsync(_caller.Object, "anunknowninvitationtoken",
+ "auser@company.com",
+ "afirstname",
+ "alastname", null, null, true, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Id.Should().Be("anid");
+ result.Value.Access.Should().Be(EndUserAccess.Enabled);
+ result.Value.Status.Should().Be(EndUserStatus.Registered);
+ result.Value.Classification.Should().Be(EndUserClassification.Person);
+ result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name);
+ result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name);
+ result.Value.Profile!.Id.Should().Be("aprofileid");
+ result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid");
+ result.Value.Profile.Address.CountryCode.Should().Be("acountrycode");
+ result.Value.Profile.Name.FirstName.Should().Be("afirstname");
+ result.Value.Profile.Name.LastName.Should().Be("alastname");
+ result.Value.Profile.DisplayName.Should().Be("afirstname");
+ result.Value.Profile.EmailAddress.Should().Be("auser@company.com");
+ result.Value.Profile.Timezone.Should().Be("atimezone");
+ _invitationRepository.Verify(rep =>
+ rep.FindInvitedGuestByTokenAsync("anunknowninvitationtoken", It.IsAny()));
+ _organizationsService.Verify(os =>
+ os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname",
+ OrganizationOwnership.Personal,
+ It.IsAny()));
+ _userProfilesService.Verify(ups =>
+ ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname",
+ null, null, It.IsAny()));
+ _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyEmail()
{
var endUser = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
endUser.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value);
@@ -253,7 +351,7 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail(
Timezone = "atimezone"
}.ToOptional());
endUser.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty);
- _repository.Setup(rep =>
+ _endUserRepository.Setup(rep =>
rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(endUser);
_notificationsService.Setup(ns =>
@@ -262,8 +360,9 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail(
It.IsAny()))
.ReturnsAsync(Result.Ok);
- var result = await _application.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname",
- "alastname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, CancellationToken.None);
+ var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com",
+ "afirstname",
+ "alastname", null, null, true, CancellationToken.None);
result.Should().BeSuccess();
result.Value.Id.Should().Be("anid");
@@ -280,6 +379,8 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail(
result.Value.Profile.DisplayName.Should().Be("afirstname");
result.Value.Profile.EmailAddress.Should().Be("anotheruser@company.com");
result.Value.Profile.Timezone.Should().Be("atimezone");
+ _invitationRepository.Verify(rep =>
+ rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never);
_organizationsService.Verify(os =>
os.CreateOrganizationPrivateAsync(_caller.Object, It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny()), Times.Never);
@@ -292,7 +393,70 @@ public async Task WhenRegisterPersonAndAlreadyRegistered_ThenSendsCourtesyEmail(
}
[Fact]
- public async Task WhenRegisterMachineByAnonymousUser_ThenRegistersWithNoFeatures()
+ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_ThenRegisters()
+ {
+ _userProfilesService.Setup(ups =>
+ ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _invitationRepository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _userProfilesService.Setup(ups =>
+ ups.CreatePersonProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new UserProfile
+ {
+ Id = "aprofileid",
+ Type = UserProfileType.Person,
+ UserId = "apersonid",
+ DisplayName = "afirstname",
+ Name = new PersonName
+ {
+ FirstName = "afirstname",
+ LastName = "alastname"
+ },
+ EmailAddress = "auser@company.com",
+ Timezone = "atimezone",
+ Address =
+ {
+ CountryCode = "acountrycode"
+ }
+ });
+
+ var result = await _application.RegisterPersonAsync(_caller.Object, null, "auser@company.com",
+ "afirstname",
+ "alastname", null, null, true, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Id.Should().Be("anid");
+ result.Value.Access.Should().Be(EndUserAccess.Enabled);
+ result.Value.Status.Should().Be(EndUserStatus.Registered);
+ result.Value.Classification.Should().Be(EndUserClassification.Person);
+ result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name);
+ result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name);
+ result.Value.Profile!.Id.Should().Be("aprofileid");
+ result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid");
+ result.Value.Profile.Address.CountryCode.Should().Be("acountrycode");
+ result.Value.Profile.Name.FirstName.Should().Be("afirstname");
+ result.Value.Profile.Name.LastName.Should().Be("alastname");
+ result.Value.Profile.DisplayName.Should().Be("afirstname");
+ result.Value.Profile.EmailAddress.Should().Be("auser@company.com");
+ result.Value.Profile.Timezone.Should().Be("atimezone");
+ _invitationRepository.Verify(rep =>
+ rep.FindInvitedGuestByTokenAsync(It.IsAny(), It.IsAny()), Times.Never);
+ _organizationsService.Verify(os =>
+ os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname",
+ OrganizationOwnership.Personal,
+ It.IsAny()));
+ _userProfilesService.Verify(ups =>
+ ups.CreatePersonProfilePrivateAsync(_caller.Object, "anid", "auser@company.com", "afirstname", "alastname",
+ null, null, It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFeatures()
{
_userProfilesService.Setup(ups =>
ups.CreateMachineProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(),
@@ -344,12 +508,12 @@ public async Task WhenRegisterMachineByAnonymousUser_ThenRegistersWithNoFeatures
}
[Fact]
- public async Task WhenRegisterMachineByAuthenticatedUser_ThenRegistersWithBasicFeatures()
+ public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithBasicFeatures()
{
_caller.Setup(cc => cc.IsAuthenticated)
.Returns(true);
var adder = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(adder);
adder.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value);
adder.AddMembership("anotherorganizationid".ToId(), Roles.Empty, Features.Empty);
@@ -409,11 +573,11 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns()
var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value,
Optional.None);
- _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny()))
.ReturnsAsync(assignee);
var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None);
- _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny()))
.ReturnsAsync(assigner);
var result = await _application.AssignPlatformRolesAsync(_caller.Object, "anassigneeid",
@@ -425,6 +589,32 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns()
}
#endif
+#if TESTINGONLY
+ [Fact]
+ public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("anassignerid");
+ var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
+ assignee.Register(Roles.Create(PlatformRoles.Standard, PlatformRoles.TestingOnly).Value,
+ Features.Create(PlatformFeatures.Basic).Value,
+ Optional.None);
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny()))
+ .ReturnsAsync(assignee);
+ var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
+ assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None);
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny()))
+ .ReturnsAsync(assigner);
+
+ var result = await _application.UnassignPlatformRolesAsync(_caller.Object, "anassigneeid",
+ [PlatformRoles.TestingOnly.Name],
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Roles.Should().ContainSingle(PlatformRoles.Standard.Name);
+ }
+#endif
+
#if TESTINGONLY
[Fact]
public async Task WhenAssignTenantRolesAsync_ThenAssigns()
@@ -436,13 +626,13 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns()
Optional.None);
assignee.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value,
Features.Create(TenantFeatures.Basic).Value);
- _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny()))
.ReturnsAsync(assignee);
var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None);
assigner.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Owner).Value,
Features.Create(TenantFeatures.Basic).Value);
- _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny()))
.ReturnsAsync(assigner);
var result = await _application.AssignTenantRolesAsync(_caller.Object, "anorganizationid", "anassigneeid",
@@ -463,7 +653,7 @@ public async Task WhenFindPersonByEmailAsyncAndNotExists_ThenReturnsNone()
ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
It.IsAny()))
.ReturnsAsync(Optional.None);
- _repository.Setup(rep =>
+ _invitationRepository.Setup(rep =>
rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(Optional.None());
@@ -483,7 +673,7 @@ public async Task WhenFindPersonByEmailAsyncAndExists_ThenReturns()
ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
It.IsAny()))
.ReturnsAsync(Optional.None);
- _repository.Setup(rep =>
+ _invitationRepository.Setup(rep =>
rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(endUser.ToOptional());
@@ -499,7 +689,7 @@ await _application.FindPersonByEmailAddressAsync(_caller.Object, "auser@company.
public async Task WhenGetMembershipsAndNotRegisteredOrMemberAsync_ThenReturnsUser()
{
var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(user);
var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None);
@@ -522,7 +712,7 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser()
EmailAddress.Create("auser@company.com").Value);
user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value,
Features.Create(TenantFeatures.PaidTrial).Value);
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(user);
var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None);
@@ -544,7 +734,7 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser()
[Fact]
public async Task WhenCreateMembershipForCallerAsyncAndUserNoExist_ThenReturnsError()
{
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(Error.EntityNotFound());
var result =
@@ -560,7 +750,7 @@ public async Task WhenCreateMembershipForCallerAsync_ThenAddsMembership()
var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value;
user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value,
EmailAddress.Create("auser@company.com").Value);
- _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
+ _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(user);
var result =
diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs
new file mode 100644
index 00000000..9096fef5
--- /dev/null
+++ b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs
@@ -0,0 +1,328 @@
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using Common;
+using Common.Configuration;
+using Domain.Common.Identity;
+using Domain.Common.ValueObjects;
+using Domain.Interfaces.Entities;
+using Domain.Services.Shared.DomainServices;
+using Domain.Shared;
+using EndUsersApplication.Persistence;
+using EndUsersDomain;
+using FluentAssertions;
+using Moq;
+using UnitTesting.Common;
+using Xunit;
+using PersonName = Application.Resources.Shared.PersonName;
+
+namespace EndUsersApplication.UnitTests;
+
+[Trait("Category", "Unit")]
+public class InvitationsApplicationSpec
+{
+ private readonly InvitationsApplication _application;
+ private readonly Mock _caller;
+ private readonly Mock _repository;
+ private readonly Mock _notificationsService;
+ private readonly Mock _recorder;
+ private readonly Mock _tokensService;
+ private readonly Mock _userProfilesService;
+
+ public InvitationsApplicationSpec()
+ {
+ _recorder = new Mock();
+ _caller = new Mock();
+ var idFactory = new Mock();
+ idFactory.Setup(idf => idf.Create(It.IsAny()))
+ .Returns("anid".ToId());
+ var settings = new Mock();
+ settings.Setup(
+ s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny()))
+ .Returns("");
+ _repository = new Mock();
+ _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny()))
+ .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root));
+ var endUserRepository = new Mock();
+ endUserRepository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny()))
+ .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root));
+ _userProfilesService = new Mock();
+ _notificationsService = new Mock();
+ _tokensService = new Mock();
+ _tokensService.Setup(ts => ts.CreateGuestInvitationToken())
+ .Returns("aninvitationtoken");
+
+ _application =
+ new InvitationsApplication(_recorder.Object, idFactory.Object, _tokensService.Object,
+ _notificationsService.Object, _userProfilesService.Object, _repository.Object);
+ }
+
+ [Fact]
+ public async Task WhenInviteGuestAsyncAndInviteeAlreadyRegistered_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("aninviterid");
+ var inviter = EndUserRoot
+ .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value;
+ _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()))
+ .ReturnsAsync(inviter);
+ var invitee = EndUserRoot
+ .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value;
+ invitee.Register(Roles.Empty, Features.Empty, EmailAddress.Create("aninvitee@company.com").Value);
+ _repository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(invitee.ToOptional());
+ _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny()))
+ .ReturnsAsync(invitee);
+ _userProfilesService.Setup(ups =>
+ ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny()))
+ .ReturnsAsync(new UserProfile
+ {
+ DisplayName = "aninviterdisplayname",
+ Name = new PersonName
+ {
+ FirstName = "aninviterfirstname"
+ },
+ UserId = "aninviterid",
+ Id = "aprofileid"
+ });
+
+ var result =
+ await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.EntityExists, Resources.EndUsersApplication_GuestAlreadyRegistered);
+ _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(),
+ It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenInviteGuestAsyncAndEmailOwnerAlreadyRegistered_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("aninviterid");
+ var inviter = EndUserRoot
+ .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value;
+ _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()))
+ .ReturnsAsync(inviter);
+ _repository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+ _userProfilesService.Setup(ups =>
+ ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new UserProfile
+ {
+ DisplayName = "adisplayname",
+ Name = new PersonName
+ {
+ FirstName = "afirstname"
+ },
+ UserId = "anotheruserid",
+ Id = "aprofileid"
+ }.ToOptional());
+
+ var result =
+ await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.EntityExists, Resources.EndUsersApplication_GuestAlreadyRegistered);
+ _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ _userProfilesService.Verify(ups =>
+ ups.FindPersonByEmailAddressPrivateAsync(_caller.Object, "aninvitee@company.com",
+ It.IsAny()));
+ _repository.Verify(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenInviteGuestAsyncAndAlreadyInvited_ThenReInvitesGuest()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("aninviterid");
+ var inviter = EndUserRoot
+ .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value;
+ _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()))
+ .ReturnsAsync(inviter);
+ var invitee = EndUserRoot
+ .Create(_recorder.Object, "aninviteeid".ToIdentifierFactory(), UserClassification.Person).Value;
+ await invitee.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(),
+ EmailAddress.Create("aninvitee@company.com").Value, (_, _) => Task.FromResult(Result.Ok));
+ _repository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(invitee.ToOptional());
+ _repository.Setup(rep => rep.LoadAsync("aninviteeid".ToId(), It.IsAny()))
+ .ReturnsAsync(invitee);
+ _userProfilesService.Setup(ups =>
+ ups.GetProfilePrivateAsync(It.IsAny(), "aninviterid", It.IsAny()))
+ .ReturnsAsync(new UserProfile
+ {
+ DisplayName = "aninviterdisplayname",
+ Name = new PersonName
+ {
+ FirstName = "aninviterfirstname"
+ },
+ UserId = "aninviterid",
+ Id = "aprofileid"
+ });
+
+ var result =
+ await _application.InviteGuestAsync(_caller.Object, "aninvitee@company.com", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.EmailAddress.Should().Be("aninvitee@company.com");
+ result.Value.FirstName.Should().Be("Aninvitee");
+ result.Value.LastName.Should().BeNull();
+ _notificationsService.Verify(ns => ns.NotifyGuestInvitationToPlatformAsync(_caller.Object, "aninvitationtoken",
+ "aninvitee@company.com", "Aninvitee", "aninviterdisplayname", It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenInviteGuestAsync_ThenInvitesGuest()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("aninviterid");
+ var inviter = EndUserRoot
+ .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value;
+ _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny()))
+ .ReturnsAsync(inviter);
+ _repository.Setup(rep =>
+ rep.FindInvitedGuestByEmailAddressAsync(It.IsAny