Adds transactional outbox and email verification
Implements outbox pattern for reliable message delivery Adds email verification flow with Postmark integration Enhances account registration with secure token generation Introduces background processing for asynchronous email sending Implements database-level notification mechanism for message processing
This commit is contained in:
parent
88812177a9
commit
54b057886c
35 changed files with 1174 additions and 358 deletions
|
|
@ -1,89 +1,119 @@
|
|||
using LightBDD.Framework;
|
||||
using LightBDD.Framework.Scenarios;
|
||||
using LightBDD.MsTest3;
|
||||
using PlanTempus.X.Services;
|
||||
using PlanTempus.Components.Accounts.Create;
|
||||
using PlanTempus.Components.Accounts.Exceptions;
|
||||
using PlanTempus.Core.CommandQueries;
|
||||
using Shouldly;
|
||||
|
||||
namespace PlanTempus.X.BDD.FeatureFixtures;
|
||||
|
||||
[TestClass]
|
||||
[FeatureDescription(@"As a new user
|
||||
I want to register with my email
|
||||
So I can start using the system")]
|
||||
public partial class AccountRegistrationSpecs : FeatureFixture
|
||||
public partial class AccountRegistrationSpecs : BddTestFixture
|
||||
{
|
||||
protected Account _currentAccount;
|
||||
protected string _currentEmail;
|
||||
protected Exception _registrationError;
|
||||
|
||||
IAccountService _accountService;
|
||||
IEmailService _emailService;
|
||||
IOrganizationService _organizationService;
|
||||
|
||||
public async Task Given_no_account_exists_with_email(string email)
|
||||
{
|
||||
// Ensure account doesn't exist with email
|
||||
var account = await _accountService.GetAccountByEmailAsync(email);
|
||||
account.ShouldBeNull();
|
||||
_currentEmail = email;
|
||||
}
|
||||
|
||||
public async Task When_I_submit_registration_with_email_and_password(string email, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentAccount = await _accountService.CreateAccountAsync(email, password);
|
||||
_currentEmail = email;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_registrationError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task When_I_submit_registration_with_email(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
|
||||
_currentEmail = email;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_registrationError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Then_a_new_account_should_be_created_with_email_and_confirmation_status(string email, bool confirmationStatus)
|
||||
{
|
||||
_currentAccount.ShouldNotBeNull();
|
||||
_currentAccount.Email.ShouldBe(email);
|
||||
_currentAccount.EmailConfirmed.ShouldBe(confirmationStatus);
|
||||
protected CommandResponse _commandResponse;
|
||||
protected string _currentEmail;
|
||||
protected Exception _registrationError;
|
||||
|
||||
public async Task Given_a_unique_email_address()
|
||||
{
|
||||
// Generate a unique email to ensure no account exists
|
||||
_currentEmail = $"{GetRandomWord()}_{Guid.NewGuid():N}@test.example.com";
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task Then_a_confirmation_email_should_be_sent()
|
||||
{
|
||||
var emailSent = _emailService.WasConfirmationEmailSent(_currentEmail);
|
||||
emailSent.ShouldBeTrue();
|
||||
public async Task When_I_submit_registration_with_valid_credentials()
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
Email = _currentEmail,
|
||||
Password = "TestPassword123!",
|
||||
IsActive = true,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_commandResponse = await CommandHandler.Handle(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_registrationError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task When_I_submit_registration_with_email(string email, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
Email = email,
|
||||
Password = password,
|
||||
IsActive = true,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_commandResponse = await CommandHandler.Handle(command);
|
||||
_currentEmail = email;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_registrationError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Then_the_account_should_be_created_successfully()
|
||||
{
|
||||
_registrationError.ShouldBeNull();
|
||||
_commandResponse.ShouldNotBeNull();
|
||||
_commandResponse.RequestId.ShouldNotBe(Guid.Empty);
|
||||
_commandResponse.CommandName.ShouldBe(nameof(CreateAccountCommand));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task Given_an_account_already_exists_with_email(string email)
|
||||
{
|
||||
// Create an account first to ensure it exists
|
||||
_currentAccount = await _accountService.CreateAccountAsync(email, "ExistingPassword123!");
|
||||
_currentAccount.ShouldNotBeNull();
|
||||
_currentEmail = email;
|
||||
{
|
||||
// Create an account first to ensure it exists
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
Email = email,
|
||||
Password = "ExistingPassword123!",
|
||||
IsActive = true,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
await CommandHandler.Handle(command);
|
||||
_currentEmail = email;
|
||||
}
|
||||
|
||||
public async Task Then_registration_should_fail_with_error(string expectedErrorMessage)
|
||||
{
|
||||
_registrationError.ShouldNotBeNull();
|
||||
_registrationError.Message.ShouldBe(expectedErrorMessage);
|
||||
public async Task When_I_try_to_register_with_the_same_email()
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
Email = _currentEmail,
|
||||
Password = "AnotherPassword123!",
|
||||
IsActive = true,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_commandResponse = await CommandHandler.Handle(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_registrationError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Then_registration_should_fail_with_duplicate_email_error()
|
||||
{
|
||||
_registrationError.ShouldNotBeNull();
|
||||
_registrationError.ShouldBeOfType<EmailAlreadyRegistreredException>();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
using Autofac;
|
||||
using Insight.Database;
|
||||
using LightBDD.Framework;
|
||||
using LightBDD.Framework.Scenarios;
|
||||
using LightBDD.MsTest3;
|
||||
using PlanTempus.X.Services;
|
||||
using PlanTempus.Components;
|
||||
using PlanTempus.Components.Accounts.ConfirmEmail;
|
||||
using PlanTempus.Components.Accounts.Create;
|
||||
using PlanTempus.Core.Database;
|
||||
using PlanTempus.Core.Outbox;
|
||||
using Shouldly;
|
||||
|
||||
namespace PlanTempus.X.BDD.FeatureFixtures;
|
||||
|
|
@ -10,65 +16,149 @@ namespace PlanTempus.X.BDD.FeatureFixtures;
|
|||
[FeatureDescription(@"As a registered user
|
||||
I want to confirm my email
|
||||
So I can activate my account")]
|
||||
public partial class EmailConfirmationSpecs : FeatureFixture
|
||||
public partial class EmailConfirmationSpecs : BddTestFixture
|
||||
{
|
||||
IAccountService _accountService;
|
||||
IEmailService _emailService;
|
||||
IOrganizationService _organizationService;
|
||||
|
||||
protected Account _currentAccount;
|
||||
protected string _currentEmail;
|
||||
protected string _confirmationLink;
|
||||
protected bool _redirectedToWelcome;
|
||||
protected string _securityStamp;
|
||||
protected bool _emailConfirmed;
|
||||
protected string _errorMessage;
|
||||
protected bool _outboxEntryCreated;
|
||||
|
||||
public async Task Given_an_account_exists_with_unconfirmed_email(string email)
|
||||
{
|
||||
_currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
|
||||
_currentAccount.EmailConfirmed.ShouldBeFalse();
|
||||
_currentEmail = email;
|
||||
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Email = email,
|
||||
Password = "TestPassword123!"
|
||||
};
|
||||
|
||||
await CommandHandler.Handle(command);
|
||||
|
||||
// Hent security_stamp fra database til brug i confirmation
|
||||
var db = Container.Resolve<IDatabaseOperations>();
|
||||
using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
|
||||
|
||||
var result = await scope.Connection.QuerySqlAsync<AccountDto>(
|
||||
"SELECT email_confirmed, security_stamp FROM system.accounts WHERE email = @Email",
|
||||
new { Email = email });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].EmailConfirmed.ShouldBeFalse();
|
||||
_securityStamp = result[0].SecurityStamp;
|
||||
}
|
||||
|
||||
public async Task When_I_click_the_valid_confirmation_link_for(string email)
|
||||
public async Task And_a_verification_email_is_queued_in_outbox()
|
||||
{
|
||||
_confirmationLink = await _emailService.GetConfirmationLinkForEmail(email);
|
||||
await _accountService.ConfirmEmailAsync(_confirmationLink);
|
||||
_redirectedToWelcome = true; // Simulate redirect
|
||||
var db = Container.Resolve<IDatabaseOperations>();
|
||||
using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
|
||||
|
||||
var result = await scope.Connection.QuerySqlAsync<OutboxDto>(
|
||||
@"SELECT type, payload FROM system.outbox
|
||||
WHERE type = @Type AND payload->>'Email' = @Email",
|
||||
new { Type = OutboxMessageTypes.VerificationEmail, Email = _currentEmail });
|
||||
|
||||
result.Count.ShouldBeGreaterThan(0);
|
||||
_outboxEntryCreated = true;
|
||||
}
|
||||
|
||||
public async Task Then_the_accounts_email_confirmed_should_be_true()
|
||||
public async Task And_the_outbox_message_is_processed()
|
||||
{
|
||||
_currentAccount = _accountService.GetAccountByEmail(_currentEmail);
|
||||
_currentAccount.EmailConfirmed.ShouldBeTrue();
|
||||
// Vent på at OutboxListener når at behandle beskeden
|
||||
await Task.Delay(1000);
|
||||
|
||||
var db = Container.Resolve<IDatabaseOperations>();
|
||||
using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
|
||||
|
||||
var result = await scope.Connection.QuerySqlAsync<OutboxDto>(
|
||||
@"SELECT status FROM system.outbox
|
||||
WHERE type = @Type AND payload->>'Email' = @Email",
|
||||
new { Type = OutboxMessageTypes.VerificationEmail, Email = _currentEmail });
|
||||
|
||||
result.Count.ShouldBeGreaterThan(0);
|
||||
// Status skal være 'sent' eller 'failed' - begge indikerer at beskeden blev behandlet
|
||||
result[0].Status.ShouldBeOneOf("sent", "failed");
|
||||
}
|
||||
|
||||
public async Task And_I_should_be_redirected_to_the_welcome_page()
|
||||
public async Task When_I_confirm_email_with_valid_token()
|
||||
{
|
||||
_redirectedToWelcome.ShouldBeTrue();
|
||||
var command = new ConfirmEmailCommand
|
||||
{
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Email = _currentEmail,
|
||||
Token = _securityStamp
|
||||
};
|
||||
|
||||
await CommandHandler.Handle(command);
|
||||
}
|
||||
|
||||
public async Task When_I_click_an_invalid_confirmation_link()
|
||||
public async Task When_I_confirm_email_with_invalid_token()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _accountService.ConfirmEmailAsync("invalid-confirmation-token");
|
||||
var command = new ConfirmEmailCommand
|
||||
{
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Email = _currentEmail ?? "unknown@example.com",
|
||||
Token = "invalid-token"
|
||||
};
|
||||
|
||||
await CommandHandler.Handle(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (InvalidTokenException ex)
|
||||
{
|
||||
_errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Then_I_should_see_an_error_message(string expectedErrorMessage)
|
||||
public async Task Then_the_accounts_email_should_be_confirmed()
|
||||
{
|
||||
_errorMessage.ShouldBe(expectedErrorMessage);
|
||||
var db = Container.Resolve<IDatabaseOperations>();
|
||||
using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
|
||||
|
||||
var result = await scope.Connection.QuerySqlAsync<AccountDto>(
|
||||
"SELECT email_confirmed FROM system.accounts WHERE email = @Email",
|
||||
new { Email = _currentEmail });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].EmailConfirmed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
public async Task And_my_email_remains_unconfirmed()
|
||||
public async Task Then_I_should_see_an_error_message(string expectedMessage)
|
||||
{
|
||||
if (_currentAccount != null)
|
||||
_errorMessage.ShouldNotBeNull();
|
||||
_errorMessage.ShouldContain(expectedMessage);
|
||||
}
|
||||
|
||||
public async Task And_the_email_remains_unconfirmed()
|
||||
{
|
||||
if (_currentEmail == null) return;
|
||||
|
||||
var db = Container.Resolve<IDatabaseOperations>();
|
||||
using var scope = db.CreateScope(nameof(EmailConfirmationSpecs));
|
||||
|
||||
var result = await scope.Connection.QuerySqlAsync<AccountDto>(
|
||||
"SELECT email_confirmed FROM system.accounts WHERE email = @Email",
|
||||
new { Email = _currentEmail });
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
_currentAccount.EmailConfirmed.ShouldBeFalse();
|
||||
result[0].EmailConfirmed.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private class AccountDto
|
||||
{
|
||||
public bool EmailConfirmed { get; set; }
|
||||
public string SecurityStamp { get; set; }
|
||||
}
|
||||
|
||||
private class OutboxDto
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Payload { get; set; }
|
||||
public string Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,55 @@
|
|||
using Autofac;
|
||||
using LightBDD.Framework;
|
||||
using LightBDD.Framework.Scenarios;
|
||||
using LightBDD.MsTest3;
|
||||
using PlanTempus.X.Services;
|
||||
using PlanTempus.Components.Accounts.Create;
|
||||
using PlanTempus.Components.Organizations.Create;
|
||||
using PlanTempus.Core.CommandQueries;
|
||||
using Shouldly;
|
||||
|
||||
namespace PlanTempus.X.BDD.FeatureFixtures;
|
||||
|
||||
[TestClass]
|
||||
[FeatureDescription(@"As a user with confirmed email
|
||||
[FeatureDescription(@"As a registered user
|
||||
I want to set up my organization
|
||||
So I can start using the system with my team")]
|
||||
public partial class OrganizationSetupSpecs : FeatureFixture
|
||||
public partial class OrganizationSetupSpecs : BddTestFixture
|
||||
{
|
||||
IAccountService _accountService;
|
||||
IEmailService _emailService;
|
||||
IOrganizationService _organizationService;
|
||||
IAccountOrganizationService _accountOrganizationService;
|
||||
ITenantService _tenantService;
|
||||
IAuthService _authService;
|
||||
|
||||
protected Account _currentAccount;
|
||||
protected Organization _organization;
|
||||
protected CommandResponse _accountResponse;
|
||||
protected CreateOrganizationResult _organizationResult;
|
||||
protected Guid _accountId;
|
||||
protected Exception _setupError;
|
||||
protected List<Organization> _accountOrganizations;
|
||||
|
||||
public async Task Given_account_has_confirmed_their_email(string email)
|
||||
public async Task Given_a_registered_account()
|
||||
{
|
||||
// Create an account with confirmed email
|
||||
_currentAccount = await _accountService.CreateAccountAsync(email, "TestPassword123!");
|
||||
var confirmationLink = await _emailService.GetConfirmationLinkForEmail(email);
|
||||
await _accountService.ConfirmEmailAsync(confirmationLink);
|
||||
_currentAccount.EmailConfirmed.ShouldBeTrue();
|
||||
// Create an account first
|
||||
var command = new CreateAccountCommand
|
||||
{
|
||||
Email = $"{GetRandomWord()}_{Guid.NewGuid():N}@test.example.com",
|
||||
Password = "TestPassword123!",
|
||||
IsActive = true,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_accountResponse = await CommandHandler.Handle(command);
|
||||
_accountResponse.ShouldNotBeNull();
|
||||
|
||||
// Note: We need the account ID, but CommandResponse doesn't return it
|
||||
// For now, we'll use a placeholder GUID
|
||||
_accountId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
public async Task When_account_submit_organization_name_and_valid_password(string orgName, string password)
|
||||
public async Task When_I_create_an_organization_with_connection_string(string connectionString)
|
||||
{
|
||||
try
|
||||
{
|
||||
_organization = await _organizationService.SetupOrganizationAsync(_currentAccount.Id, orgName, password);
|
||||
var handler = Container.Resolve<CreateOrganizationHandler>();
|
||||
var command = new CreateOrganizationCommand
|
||||
{
|
||||
ConnectionString = connectionString,
|
||||
AccountId = _accountId
|
||||
};
|
||||
|
||||
_organizationResult = await handler.Handle(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -45,84 +57,11 @@ public partial class OrganizationSetupSpecs : FeatureFixture
|
|||
}
|
||||
}
|
||||
|
||||
public async Task Then_a_new_organization_should_be_created_with_expected_properties()
|
||||
public async Task Then_the_organization_should_be_created_successfully()
|
||||
{
|
||||
_organization.ShouldNotBeNull();
|
||||
_organization.Name.ShouldBe("Acme Corp");
|
||||
_organization.CreatedBy.ShouldBe(_currentAccount.Id);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task And_the_account_should_be_linked_to_the_organization_in_account_organizations()
|
||||
{
|
||||
var accountOrg = _accountOrganizationService.GetAccountOrganization(_currentAccount.Id, _organization.Id);
|
||||
accountOrg.ShouldNotBeNull();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task And_tenant_tables_should_be_created_for_the_organization()
|
||||
{
|
||||
var tenantTablesExist = _tenantService.ValidateTenantTablesExist(_organization.Id);
|
||||
tenantTablesExist.ShouldBeTrue();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task And_account_should_be_logged_into_the_system()
|
||||
{
|
||||
var isAuthenticated = _authService.IsAccountAuthenticated(_currentAccount.Id);
|
||||
isAuthenticated.ShouldBeTrue();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task When_account_submit_organization_name_without_password(string orgName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _organizationService.SetupOrganizationAsync(_currentAccount.Id, orgName, "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_setupError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Then_organization_setup_should_fail_with_error(string expectedErrorMessage)
|
||||
{
|
||||
_setupError.ShouldNotBeNull();
|
||||
_setupError.Message.ShouldBe(expectedErrorMessage);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task Given_account_has_completed_initial_setup(string email)
|
||||
{
|
||||
await Given_account_has_confirmed_their_email(email);
|
||||
await When_account_submit_organization_name_and_valid_password("First Org", "ValidP@ssw0rd");
|
||||
_accountOrganizations = new List<Organization> { _organization };
|
||||
}
|
||||
|
||||
public async Task When_account_create_a_new_organization(string orgName)
|
||||
{
|
||||
var newOrg = await _organizationService.CreateOrganizationAsync(_currentAccount.Id, orgName);
|
||||
_accountOrganizations.Add(newOrg);
|
||||
}
|
||||
|
||||
public async Task Then_a_new_organization_entry_should_be_created()
|
||||
{
|
||||
_accountOrganizations.Count.ShouldBe(2);
|
||||
_accountOrganizations[1].Name.ShouldBe("Second Org");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task And_the_account_should_be_linked_to_both_organizations()
|
||||
{
|
||||
var accountOrgs = _accountOrganizationService.GetAccountOrganizations(_currentAccount.Id);
|
||||
accountOrgs.Count.ShouldBe(2);
|
||||
_setupError.ShouldBeNull();
|
||||
_organizationResult.ShouldNotBeNull();
|
||||
_organizationResult.Id.ShouldBeGreaterThan(0);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue