using System.Text.Json; using API.Specs.Mocks; using Features.ImageUploads.Services; using Features.Users.Commands.Profile.UploadAvatar; using Features.Users.Repository; using FluentAssertions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Reqnroll; namespace API.Specs.Steps; [Binding] public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) { private const string AccessTokenKey = "accessToken"; private const string RegisteredUserIdKey = "registeredUserId"; private const string RegisteredUsernameKey = "registeredUsername"; private const string CurrentAvatarKeyKey = "currentAvatarKey"; private const string PreviousAvatarKeyKey = "previousAvatarKey"; private const string BiographyKey = "biography"; private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; private async Task SendAsync(HttpRequestMessage requestMessage) { HttpClient client = GetClient(); HttpResponseMessage response = await client.SendAsync(requestMessage); string responseBody = await response.Content.ReadAsStringAsync(); Scenario[ResponseKey] = response; Scenario[ResponseBodyKey] = responseBody; } private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url) { HttpRequestMessage requestMessage = new(method, url); if (Scenario.TryGetValue(AccessTokenKey, out string? accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); return requestMessage; } private Guid GetRegisteredUserId() { return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException("registered user ID not found in scenario"); } /// /// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the /// 1 KiB minimum. /// private static IFormFile CreateAvatarFile() { byte[] content = new byte[2048]; PngSignature.CopyTo(content, 0); MemoryStream stream = new(content); return new FormFile(stream, 0, content.Length, "file", "avatar.png") { Headers = new HeaderDictionary { ["Content-Type"] = "image/png" }, }; } /// /// Uploads an avatar and records the object key the storage provider actually received, rather /// than re-deriving the key template the handler uses. /// private async Task UploadAvatarAsync() { Guid userId = GetRegisteredUserId(); using IServiceScope scope = GetFactory().Services.CreateScope(); IMediator mediator = scope.ServiceProvider.GetRequiredService(); await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile())); if (Scenario.TryGetValue(CurrentAvatarKeyKey, out string? existing)) Scenario[PreviousAvatarKeyKey] = existing; Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey; } private MockFileStorageProvider GetStorageProvider() { return (MockFileStorageProvider) GetFactory().Services.GetRequiredService(); } private string GetCurrentAvatarKey() { return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null ? key : throw new InvalidOperationException("no avatar has been uploaded in this scenario"); } private string GetPreviousAvatarKey() { return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null ? key : throw new InvalidOperationException( "only one avatar has been uploaded in this scenario" ); } /// /// Asserts that is something a browser can fetch -- an absolute /// HTTP(S) URL -- pointing at the object stored under . A bare /// storage key would resolve against the page's own origin, so the shape is the contract here, /// not just the value. /// private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey) { avatarUrl.Should().NotBeNullOrWhiteSpace(); Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri) .Should() .BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl); uri!.Scheme.Should().BeOneOf("http", "https"); uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object"); return uri; } private string GetAvatarUrlFromResponse() { Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); using JsonDocument doc = JsonDocument.Parse(responseBody!); return doc.RootElement.GetProperty("avatarUrl").GetString()!; } [Given("I have uploaded an avatar")] public async Task GivenIHaveUploadedAnAvatar() { await UploadAvatarAsync(); } [Given("I have uploaded a new avatar, replacing the previous one")] public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne() { await UploadAvatarAsync(); } /// /// Writes the biography straight through the repository: registration creates the profile row /// with an empty biography, and no endpoint edits it yet. /// [Given("I have set my biography to {string}")] public async Task GivenIHaveSetMyBiographyTo(string biography) { using IServiceScope scope = GetFactory().Services.CreateScope(); IUserProfileRepository profiles = scope.ServiceProvider.GetRequiredService(); await profiles.UpdateBiographyAsync( GetRegisteredUserId(), biography, CancellationToken.None ); Scenario[BiographyKey] = biography; } [Then("the public profile response should carry my current avatar")] public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar() { AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey()); } [Then("the public profile response should not carry my previous avatar")] public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar() { Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey()); uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey()); } [Then("my listed account should carry my current avatar")] public void ThenMyListedAccountShouldCarryMyCurrentAvatar() { AssertFetchableUrl( GetMyListedAccount().GetProperty("avatarUrl").GetString(), GetCurrentAvatarKey() ); } [Then("my listed account should carry my biography")] public void ThenMyListedAccountShouldCarryMyBiography() { string expected = Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null ? biography : throw new InvalidOperationException("no biography was set in this scenario"); GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected); } /// Finds the registered account in a listing response. private JsonElement GetMyListedAccount() { Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); using JsonDocument doc = JsonDocument.Parse(responseBody!); doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); return doc .RootElement.EnumerateArray() .Should() .ContainSingle(item => item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId() ) .Subject.Clone(); } [When("I retrieve the public profile by ID")] public async Task WhenIRetrieveThePublicProfileById() { await SendAsync( NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile") ); } [When("I retrieve the public profile by ID without authentication")] public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication() { await SendAsync( new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile") ); } [When("I retrieve the public profile by a non-existent ID")] public async Task WhenIRetrieveThePublicProfileByANonExistentId() { await SendAsync( NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile") ); } [When("I retrieve my own account without authentication")] public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication() { await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated")); } [When("I list user accounts without authentication")] public async Task WhenIListUserAccountsWithoutAuthentication() { await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user")); } [When("I list user accounts")] public async Task WhenIListUserAccounts() { await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user")); } [When("I retrieve my own account")] public async Task WhenIRetrieveMyOwnAccount() { await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated")); } [Then("the public profile response should match the registered account")] public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount() { Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? u) ? u : throw new InvalidOperationException("registered username not found in scenario"); using JsonDocument doc = JsonDocument.Parse(responseBody!); JsonElement root = doc.RootElement; root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId()); root.GetProperty("username").GetString().Should().Be(username); } [Then("the account response should be for a different account than the registered one")] public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne() { Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); using JsonDocument doc = JsonDocument.Parse(responseBody!); doc.RootElement.GetProperty("userAccountId") .GetGuid() .Should() .NotBe(GetRegisteredUserId()); } }