Cover the avatar read path in the public profile specs

This commit is contained in:
Aaron Po
2026-10-11 22:20:31 -04:00
parent fb0b1748aa
commit 43eb3a1ddb
3 changed files with 294 additions and 3 deletions

View File

@@ -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

View File

@@ -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<string>(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);
}
/// <summary>
/// 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;
}
}
/// <summary>
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
/// </summary>
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;
}
}
}

View File

@@ -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");
}
/// <summary>
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
/// 1 KiB minimum.
/// </summary>
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" },
};
}
/// <summary>
/// Uploads an avatar and records the object key the storage provider actually received, rather
/// than re-deriving the key template the handler uses.
/// </summary>
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
Scenario[PreviousAvatarKeyKey] = existing;
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
}
private MockFileStorageProvider GetStorageProvider()
{
return (MockFileStorageProvider)
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
}
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"
);
}
/// <summary>
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
/// storage key would resolve against the page's own origin, so the shape is the contract here,
/// not just the value.
/// </summary>
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<string>(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();
}
/// <summary>
/// Writes the biography straight through the repository: registration creates the profile row
/// with an empty biography, and no endpoint edits it yet.
/// </summary>
[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<IUserProfileRepository>();
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);
}
/// <summary>Finds the registered account in a listing response.</summary>
private JsonElement GetMyListedAccount()
{
Scenario.TryGetValue<string>(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")]