Load the current avatar photo alongside user accounts

This commit is contained in:
Aaron Po
2026-09-30 22:16:04 -04:00
parent c5972b9f04
commit bce8fb0472
11 changed files with 390 additions and 29 deletions

View File

@@ -13,9 +13,53 @@ 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: 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 +70,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 +95,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"

View File

@@ -0,0 +1,38 @@
using Features.ImageUploads.Services;
namespace API.Specs.Mocks;
/// <summary>
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
/// exercise photo-backed features without object storage.
/// </summary>
public class MockFileStorageProvider : IFileStorageProvider
{
private readonly Dictionary<string, byte[]> _objects = new();
public IReadOnlyCollection<string> UploadedKeys => _objects.Keys.ToList();
public Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
using MemoryStream buffer = new();
content.CopyTo(buffer);
_objects[key] = buffer.ToArray();
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
_objects.Remove(key);
return Task.CompletedTask;
}
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
return $"https://storage.test/{key}";
}
}

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,9 @@
using System.Text.Json;
using Features.Users.Commands.Profile.UploadAvatar;
using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
@@ -10,6 +14,10 @@ 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 CurrentAvatarUrlKey = "currentAvatarUrl";
private const string PreviousAvatarUrlKey = "previousAvatarUrl";
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage)
{
@@ -36,6 +44,99 @@ 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" },
};
}
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
Guid photoId = await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarUrlKey, out string? existing))
Scenario[PreviousAvatarUrlKey] = existing;
Scenario[CurrentAvatarUrlKey] = $"avatars/{userId}/{photoId}";
}
private string GetCurrentAvatarUrl()
{
return Scenario.TryGetValue(CurrentAvatarUrlKey, out string? url) && url is not null
? url
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
}
private string GetPreviousAvatarUrl()
{
return Scenario.TryGetValue(PreviousAvatarUrlKey, out string? url) && url is not null
? url
: throw new InvalidOperationException("only one avatar has been uploaded in this scenario");
}
[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();
}
[Then("the public profile response should carry my current avatar")]
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("avatarUrl").GetString().Should().Be(GetCurrentAvatarUrl());
}
[Then("the public profile response should not carry my previous avatar")]
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("avatarUrl").GetString().Should().NotBe(GetPreviousAvatarUrl());
}
[Then("my listed account should carry my current avatar")]
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
JsonElement listed = doc
.RootElement.EnumerateArray()
.Should()
.ContainSingle(item =>
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
)
.Subject;
listed.GetProperty("avatarUrl").GetString().Should().Be(GetCurrentAvatarUrl());
}
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{

View File

@@ -1,6 +1,7 @@
using API.Core;
using API.Specs.Mocks;
using Features.Emails.Services;
using Features.ImageUploads.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
@@ -32,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory<Program>
services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IFileStorageProvider)
);
if (fileStorageDescriptor != null)
services.Remove(fileStorageDescriptor);
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
});
}
}

View File

@@ -20,4 +20,5 @@ public sealed class UserAccount
// Navigation properties
public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
public UserAvatar? UserAvatar { get; set; }
}

View File

@@ -47,4 +47,43 @@ public class GetAllUsersHandlerTests
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
}
[Fact]
public async Task Handle_MapsEachUsersOwnAvatarUrl()
{
Mock<IUserListRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object);
UserAccount withAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "has-avatar",
FirstName = "Has",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar
{
Photo = new Photo { Hyperlink = "https://cdn.example.com/avatar.jpg" },
},
};
UserAccount withoutAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "no-avatar",
FirstName = "No",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
};
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result
.Select(dto => dto.AvatarUrl)
.Should()
.Equal("https://cdn.example.com/avatar.jpg", string.Empty);
}
}

View File

@@ -31,6 +31,10 @@ public class GetPublicUserProfileByIdHandlerTests
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar
{
Photo = new Photo { Hyperlink = "https://cdn.example.com/avatar.jpg" },
},
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
@@ -48,13 +52,14 @@ public class GetPublicUserProfileByIdHandlerTests
user.FirstName,
user.LastName,
"Drinks lagers.",
"https://cdn.example.com/avatar.jpg",
user.CreatedAt
)
);
}
[Fact]
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
{
UserAccount user = new()
{
@@ -72,6 +77,53 @@ public class GetPublicUserProfileByIdHandlerTests
);
result.Biography.Should().BeEmpty();
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().Be("Drinks lagers.");
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar(),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.AvatarUrl.Should().BeEmpty();
}
[Fact]

View File

@@ -14,6 +14,7 @@ public static class UserDtoMapper
FirstName : user.FirstName,
LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty,
AvatarUrl : user.UserAvatar?.Photo?.Hyperlink ?? string.Empty,
CreatedAt : user.CreatedAt
);
}

View File

@@ -7,5 +7,6 @@ public record PublicUserProfileDto(
string FirstName,
string LastName,
string Biography,
string AvatarUrl,
DateTime CreatedAt
);

View File

@@ -13,61 +13,87 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IUserListRepository
{
/// <inheritdoc />
/// <remarks>
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
/// <c>null</c> for an account whose profile has yet to be created.
/// </remarks>
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
IEnumerable<UserAccount> results = await connection.QueryAsync<
UserAccount,
UserProfile,
Photo,
UserAvatar,
UserAccount
>(
"""
SELECT
ua.UserAccountID,
ua.Username,
ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
ua.DateOfBirth, ua.RowVersion,
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
ua.RowVersion, up.UserProfileID, up.UserAccountID,
up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserProfile up
ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID
AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
WHERE ua.UserAccountID = @UserAccountId
""",
MapUserRow,
new { UserAccountId = id },
splitOn: "UserProfileID"
splitOn: AvatarSplitOn
);
return results.SingleOrDefault();
}
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
{
user.UserProfile = profile;
return user;
}
/// <inheritdoc />
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
/// profiles, so those fields are left at their default rather than read from the database.
/// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>(
return await connection.QueryAsync<
UserAccount,
UserProfile,
Photo,
UserAvatar,
UserAccount
>(
"""
SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
FROM Auth.UserAccount
ORDER BY CreatedAt DESC
SELECT
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
ORDER BY ua.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
MapUserRow,
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
splitOn: AvatarSplitOn
);
}
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
private static UserAccount MapUserRow(
UserAccount user,
UserProfile? profile,
Photo? photo,
UserAvatar? avatar
)
{
user.UserProfile = profile;
avatar?.Photo = photo;
user.UserAvatar = avatar;
return user;
}
}