From 43eb3a1ddb9867ec949ef49795fb366779de1769 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 11 Oct 2026 22:20:31 -0400 Subject: [PATCH] Cover the avatar read path in the public profile specs --- .../Features/PublicUserProfile.feature | 62 ++++++ .../API/API.Specs/Steps/ApiGeneralSteps.cs | 46 +++++ web/backend/API/API.Specs/Steps/UserSteps.cs | 189 +++++++++++++++++- 3 files changed, 294 insertions(+), 3 deletions(-) diff --git a/web/backend/API/API.Specs/Features/PublicUserProfile.feature b/web/backend/API/API.Specs/Features/PublicUserProfile.feature index 6469dcfc..39f1beb6 100644 --- a/web/backend/API/API.Specs/Features/PublicUserProfile.feature +++ b/web/backend/API/API.Specs/Features/PublicUserProfile.feature @@ -13,9 +13,61 @@ Scenario: Public profile exposes only allowlisted fields Then the response has HTTP status 200 And the public profile response should match the registered account And the response JSON should have "biography" equal "" + And the response JSON should have "avatarUrl" equal "" And the response JSON should not contain "email" And the response JSON should not contain "dateOfBirth" +Scenario: Public profile exposes the uploaded avatar + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + When I retrieve the public profile by ID + Then the response has HTTP status 200 + And the public profile response should carry my current avatar + +Scenario: Public profile exposes the current avatar after it is replaced + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + And I have uploaded a new avatar, replacing the previous one + When I retrieve the public profile by ID + Then the response has HTTP status 200 + And the public profile response should carry my current avatar + And the public profile response should not carry my previous avatar + +Scenario: Fetching the avatar without authentication succeeds + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + When I retrieve the public profile by ID without authentication + Then the response has HTTP status 200 + And the public profile response should carry my current avatar + +Scenario: A different user can read someone else's avatar + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + And I am logged in as a different user + When I retrieve the public profile by ID + Then the response has HTTP status 200 + And the public profile response should carry my current avatar + +Scenario: Listing user accounts exposes each account's avatar + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + When I list user accounts + Then the response has HTTP status 200 + And my listed account should carry my current avatar + +Scenario: Listing user accounts exposes each account's biography + Given I have registered a new account + And I have a valid access token for my account + And I have set my biography to "Drinks lagers." + When I list user accounts + Then the response has HTTP status 200 + And my listed account should carry my biography + Scenario: Public profile for a non-existent account returns not found Given I have registered a new account And I have a valid access token for my account @@ -26,6 +78,7 @@ Scenario: Fetching the public profile without authentication succeeds Given I have registered a new account When I retrieve the public profile by ID without authentication Then the response has HTTP status 200 + And the response JSON should have "avatarUrl" equal "" And the response JSON should not contain "email" And the response JSON should not contain "dateOfBirth" @@ -50,6 +103,7 @@ Scenario: Listing user accounts as an authenticated user excludes private fields And I have a valid access token for my account When I list user accounts Then the response has HTTP status 200 + And the response JSON should contain "avatarUrl" And the response JSON should not contain "email" And the response JSON should not contain "dateOfBirth" @@ -60,6 +114,14 @@ Scenario: The caller can fetch their own account Then the response has HTTP status 200 And the public profile response should match the registered account +Scenario: The caller's own account omits the avatar graph + Given I have registered a new account + And I have a valid access token for my account + And I have uploaded an avatar + When I retrieve my own account + Then the response has HTTP status 200 + And the response JSON should not contain "userAvatar" + Scenario: The account endpoint serves the caller named by the access token Given I have registered a new account And I am logged in as a different user diff --git a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs index 211f9355..ea462f57 100644 --- a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs +++ b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -183,6 +183,25 @@ public class ApiGeneralSteps(ScenarioContext scenario) AssertFieldAbsent(payloadElem, field); } + [Then("the response JSON should contain {string}")] + public void ThenTheResponseJsonShouldContainString(string field) + { + scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Object && + !root.TryGetProperty(field, out _) && + root.TryGetProperty("payload", out JsonElement payloadElem)) + { + AssertFieldPresent(payloadElem, field); + return; + } + + AssertFieldPresent(root, field); + } + /// /// Recurses into arrays so a listing endpoint's response can be asserted the same way as a /// single-resource response. @@ -203,4 +222,31 @@ public class ApiGeneralSteps(ScenarioContext scenario) break; } } + + /// + /// Recurses into arrays so every item of a listing endpoint's response must carry the field. + /// + private static void AssertFieldPresent(JsonElement element, string field) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + element + .TryGetProperty(field, out _) + .Should() + .BeTrue("Expected field '{0}' to be present in the response", field); + break; + case JsonValueKind.Array: + element + .EnumerateArray() + .Should() + .NotBeEmpty( + "Expected at least one item to assert field '{0}' against", + field + ); + foreach (JsonElement item in element.EnumerateArray()) + AssertFieldPresent(item, field); + break; + } + } } diff --git a/web/backend/API/API.Specs/Steps/UserSteps.cs b/web/backend/API/API.Specs/Steps/UserSteps.cs index d1957205..e5d72313 100644 --- a/web/backend/API/API.Specs/Steps/UserSteps.cs +++ b/web/backend/API/API.Specs/Steps/UserSteps.cs @@ -1,5 +1,12 @@ 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; @@ -10,6 +17,11 @@ 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) { @@ -36,22 +48,193 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) : 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")); + 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")); + 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")); + await SendAsync( + NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile") + ); } [When("I retrieve my own account without authentication")]