Skip to content

Commit b28c9e8

Browse files
authored
Add backend refinements, new API tests, and fix flaky tests (#795)
### Summary & Motivation Add SharedKernel improvements, new API tests, and fix flaky tests caused by non-deterministic test data. - Add `StronglyTypedString<T>` base class for creating string-based strongly typed IDs with optional prefix validation (Stripe-style IDs like `cus_abc123`). - Add `IBlobStorageClient` interface to enable mocking and improve testability. - Add `Result.Redirect()` and corresponding `ApiResult` support for HTTP redirects. - Add bulk repository interfaces `IBulkAddRepository<T>` and `IBulkUpdateRepository<T>`, and rename `BulkRemove` to `RemoveRange` for consistency with EF Core naming. - Add `AddRangeAsync`, `UpdateRange`, and `RemoveRange` methods to `RepositoryBase`. - Add `MapStronglyTypedString` and `OwnedNavigationBuilder` overloads to `ModelBuilderExtensions` for EF Core mapping of owned entities. - Add `CreateContainerIfNotExistsAsync`, `DeleteIfExistsAsync`, and User Delegation Key SAS support to `BlobStorageClient`. - Fix `UseStringForEnums` to handle nullable enum properties. - Simplify email validation by using `MailAddress.TryCreate` instead of custom regex rules. - Move `AddCrossServiceDataProtection` from `ApiDependencyConfiguration` to `SharedDependencyConfiguration`. - Exclude owned entities from global tenant query filters to fix query issues. - Fix `NotSupportedException` when copying avatar during tenant switch by buffering Azure's non-seekable stream. - Add API tests for `Logout`, `ChangeLocale`, and `ChangeUserRole` commands. - Fix flaky tests by adding `Faker.Internet.UniqueEmail()` to prevent email collisions when tests run in parallel. - Fix flaky test using `Faker.Random.String2()` instead of `String()` to avoid whitespace trimming issues. ### Downstream projects 1. Update any `BlobStorageClient` usage to use the `IBlobStorageClient` interface: ```diff - BlobStorageClient blobStorageClient + IBlobStorageClient blobStorageClient ``` ```diff - services.AddKeyedSingleton("your-self-contained-system-storage", + services.AddKeyedSingleton<IBlobStorageClient>("your-self-contained-system-storage", ``` 2. Rename `BulkRemove` to `RemoveRange` in repositories: ```diff - repository.BulkRemove(entities); + repository.RemoveRange(entities); ``` ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents c094a88 + 46a2c86 commit b28c9e8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1032
-94
lines changed

application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
namespace PlatformPlatform.AppGateway.Transformations;
55

6-
public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] BlobStorageClient accountManagementBlobStorageClient)
6+
public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] IBlobStorageClient accountManagementBlobStorageClient)
77
: RequestTransform
88
{
99
public override ValueTask ApplyAsync(RequestTransformContext context)

application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public sealed class SwitchTenantHandler(
2020
AuthenticationTokenService authenticationTokenService,
2121
AvatarUpdater avatarUpdater,
2222
[FromKeyedServices("account-management-storage")]
23-
BlobStorageClient blobStorageClient,
23+
IBlobStorageClient blobStorageClient,
2424
IExecutionContext executionContext,
2525
ITelemetryEventsCollector events,
2626
ILogger<SwitchTenantHandler> logger
@@ -73,8 +73,14 @@ private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationT
7373
var avatarData = await blobStorageClient.DownloadAsync("avatars", sourceBlobPath, cancellationToken);
7474
if (avatarData is not null)
7575
{
76+
// Copy to MemoryStream since Azure's RetriableStream doesn't support seeking (Position reset)
77+
await using var avatarStream = avatarData.Value.Stream;
78+
using var memoryStream = new MemoryStream();
79+
await avatarStream.CopyToAsync(memoryStream, cancellationToken);
80+
memoryStream.Position = 0;
81+
7682
// Upload the avatar to the target tenant's storage location
77-
await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, avatarData.Value.Stream, cancellationToken);
83+
await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, memoryStream, cancellationToken);
7884
}
7985
}
8086

application/account-management/Core/Features/Tenants/Commands/DeleteTenant.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public async Task<Result> Handle(DeleteTenantCommand command, CancellationToken
3232
if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found.");
3333

3434
var tenantUsers = await userRepository.GetTenantUsers(cancellationToken);
35-
userRepository.BulkRemove(tenantUsers);
35+
userRepository.RemoveRange(tenantUsers);
3636

3737
tenantRepository.Remove(tenant);
3838

application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public sealed class UpdateTenantLogoHandler(
3232
ITenantRepository tenantRepository,
3333
IExecutionContext executionContext,
3434
[FromKeyedServices("account-management-storage")]
35-
BlobStorageClient blobStorageClient,
35+
IBlobStorageClient blobStorageClient,
3636
ITelemetryEventsCollector events
3737
)
3838
: IRequestHandler<UpdateTenantLogoCommand, Result>

application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public async Task<Result> Handle(BulkDeleteUsersCommand command, CancellationTok
4949
return Result.NotFound($"Users with ids '{string.Join(", ", missingUserIds.Select(id => id.ToString()))}' not found.");
5050
}
5151

52-
userRepository.BulkRemove(usersToDelete);
52+
userRepository.RemoveRange(usersToDelete);
5353

5454
foreach (var userId in command.UserIds)
5555
{

application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace PlatformPlatform.AccountManagement.Features.Users.Shared;
77

8-
public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] BlobStorageClient blobStorageClient)
8+
public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] IBlobStorageClient blobStorageClient)
99
{
1010
private const string ContainerName = "avatars";
1111

application/account-management/Tests/Authentication/CompleteLoginTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc
201201
[("Name", "Test Company")]
202202
);
203203

204-
var email = Faker.Internet.Email();
204+
var email = Faker.Internet.UniqueEmail();
205205
var inviteUserCommand = new InviteUserCommand(email);
206206
await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", inviteUserCommand);
207207
TelemetryEventsCollectorSpy.Reset();
@@ -210,9 +210,11 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc
210210
var command = new CompleteLoginCommand(CorrectOneTimePassword);
211211

212212
// Act
213-
await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command);
213+
var response = await AnonymousHttpClient
214+
.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command);
214215

215216
// Assert
217+
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);
216218
Connection.ExecuteScalar<long>(
217219
"SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 1",
218220
[new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() }]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using FluentAssertions;
4+
using PlatformPlatform.AccountManagement.Database;
5+
using PlatformPlatform.AccountManagement.Features.Authentication.Commands;
6+
using PlatformPlatform.SharedKernel.Tests;
7+
using Xunit;
8+
9+
namespace PlatformPlatform.AccountManagement.Tests.Authentication;
10+
11+
public sealed class LogoutTests : EndpointBaseTest<AccountManagementDbContext>
12+
{
13+
[Fact]
14+
public async Task Logout_WhenAuthenticatedAsOwner_ShouldSucceedAndCollectLogoutEvent()
15+
{
16+
// Arrange
17+
var command = new LogoutCommand();
18+
19+
// Act
20+
var response = await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);
21+
22+
// Assert
23+
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);
24+
25+
TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
26+
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout");
27+
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
28+
}
29+
30+
[Fact]
31+
public async Task Logout_WhenAuthenticatedAsMember_ShouldSucceedAndCollectLogoutEvent()
32+
{
33+
// Arrange
34+
var command = new LogoutCommand();
35+
36+
// Act
37+
var response = await AuthenticatedMemberHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);
38+
39+
// Assert
40+
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);
41+
42+
TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
43+
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout");
44+
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
45+
}
46+
47+
[Fact]
48+
public async Task Logout_WhenNotAuthenticated_ShouldReturnUnauthorized()
49+
{
50+
// Arrange
51+
var command = new LogoutCommand();
52+
53+
// Act
54+
var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);
55+
56+
// Assert
57+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
58+
59+
TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
60+
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
61+
}
62+
}

application/account-management/Tests/Authentication/StartLoginTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail()
6868
[Theory]
6969
[InlineData("Invalid Email Format", "invalid-email")]
7070
[InlineData("Email Too Long", "abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789@example.com")]
71+
[InlineData("Double Dots In Domain", "neo@gmail..com")]
72+
[InlineData("Comma Instead Of Dot", "q@q,com")]
73+
[InlineData("Space In Domain", "tje@mentum .dk")]
7174
public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, string invalidEmail)
7275
{
7376
// Arrange
@@ -91,7 +94,7 @@ public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario,
9194
public async Task StartLoginCommand_WhenUserDoesNotExist_ShouldReturnFakeLoginId()
9295
{
9396
// Arrange
94-
var email = Faker.Internet.Email();
97+
var email = Faker.Internet.UniqueEmail();
9598
var command = new StartLoginCommand(email);
9699

97100
// Act

application/account-management/Tests/Authentication/SwitchTenantTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo
106106
("Id", UserId.NewId().ToString()),
107107
("CreatedAt", TimeProvider.System.GetUtcNow()),
108108
("ModifiedAt", null),
109-
("Email", Faker.Internet.Email()),
109+
("Email", Faker.Internet.UniqueEmail()),
110110
("EmailConfirmed", true),
111111
("FirstName", Faker.Name.FirstName()),
112112
("LastName", Faker.Name.LastName()),

0 commit comments

Comments
 (0)