mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Show a user's avatar on the public profile (#333)
This commit is contained in:
@@ -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
|
||||
|
||||
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
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 List<string> _keys = [];
|
||||
private readonly Dictionary<string, byte[]> _objects = new();
|
||||
|
||||
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
|
||||
|
||||
/// <summary>The key of the most recent upload.</summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
|
||||
public string LastUploadedKey =>
|
||||
_keys.Count > 0
|
||||
? _keys[^1]
|
||||
: throw new InvalidOperationException("no file has been uploaded");
|
||||
|
||||
public Task UploadAsync(
|
||||
string key,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using MemoryStream buffer = new();
|
||||
content.CopyTo(buffer);
|
||||
|
||||
if (!_objects.ContainsKey(key))
|
||||
_keys.Add(key);
|
||||
|
||||
_objects[key] = buffer.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_objects.Remove(key);
|
||||
_keys.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
|
||||
/// carrying the key in its path, so specs can assert the shape callers actually receive.
|
||||
/// </summary>
|
||||
public string GetPresignedUrl(string key, TimeSpan expiresIn)
|
||||
{
|
||||
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
|
||||
public const string SmtpFromName = "SMTP_FROM_NAME";
|
||||
|
||||
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
||||
|
||||
/// <summary>
|
||||
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
|
||||
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
|
||||
/// the container-internal endpoint is only usable by callers that can resolve that host.
|
||||
/// </summary>
|
||||
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
|
||||
|
||||
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
||||
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
||||
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Domain.Entities;
|
||||
|
||||
public sealed class UserAccount
|
||||
@@ -20,4 +22,10 @@ public sealed class UserAccount
|
||||
// Navigation properties
|
||||
public UserCredential? UserCredential { get; set; }
|
||||
public UserProfile? UserProfile { get; set; }
|
||||
|
||||
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
|
||||
// storage key and uploader of the underlying Photo. Clients read the avatar from
|
||||
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
|
||||
[JsonIgnore]
|
||||
public UserAvatar? UserAvatar { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
{
|
||||
private readonly string _bucket;
|
||||
private readonly IAmazonS3 _client;
|
||||
private readonly IAmazonS3 _presignClient;
|
||||
private readonly Protocol _presignProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
|
||||
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional
|
||||
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the required configuration values is not set.
|
||||
@@ -45,6 +48,31 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
|
||||
|
||||
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
|
||||
|
||||
// A presigned URL is signed over its host, so one signed against the container-internal
|
||||
// endpoint is only usable from inside the network. Sign downloads against the public
|
||||
// endpoint instead, so a browser can fetch the object.
|
||||
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
|
||||
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
|
||||
? serviceUrl
|
||||
: configuredPublicUrl;
|
||||
|
||||
_presignClient =
|
||||
publicUrl == serviceUrl
|
||||
? _client
|
||||
: new AmazonS3Client(
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
|
||||
);
|
||||
|
||||
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
|
||||
// take the scheme from the endpoint the URL is signed against.
|
||||
_presignProtocol =
|
||||
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
|
||||
&& publicUri.Scheme == Uri.UriSchemeHttp
|
||||
? Protocol.HTTP
|
||||
: Protocol.HTTPS;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -82,14 +110,18 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
BucketName = _bucket,
|
||||
Key = key,
|
||||
Expires = DateTime.UtcNow.Add(expiresIn),
|
||||
Protocol = _presignProtocol,
|
||||
};
|
||||
|
||||
return _client.GetPreSignedURL(request);
|
||||
return _presignClient.GetPreSignedURL(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!ReferenceEquals(_presignClient, _client))
|
||||
_presignClient.Dispose();
|
||||
|
||||
_client.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Repository;
|
||||
@@ -9,27 +10,36 @@ namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetAllUsersHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
private readonly GetAllUsersHandler _handler;
|
||||
|
||||
public GetAllUsersHandlerTests()
|
||||
{
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
@@ -38,13 +48,60 @@ public class GetAllUsersHandlerTests
|
||||
LastName = "One",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
|
||||
result
|
||||
.Should()
|
||||
.ContainSingle()
|
||||
.Which.Should()
|
||||
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsEachUsersOwnAvatarUrl()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
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 = avatarKey } },
|
||||
};
|
||||
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://storage.test/{avatarKey}?signed", string.Empty);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Repository;
|
||||
@@ -12,15 +13,21 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
{
|
||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
|
||||
public GetPublicUserProfileByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
@@ -31,6 +38,7 @@ 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 = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
@@ -48,13 +56,48 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
"Drinks lagers.",
|
||||
$"https://storage.test/{avatarKey}?signed",
|
||||
user.CreatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
|
||||
/// the stored value: a bare key resolves against the calling page's own origin.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
|
||||
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.AvatarUrl.Should().NotBe(avatarKey);
|
||||
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
|
||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
||||
uri.AbsolutePath.Should().EndWith(avatarKey);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
@@ -72,6 +115,57 @@ 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();
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Converts user domain entities into API response models.</summary>
|
||||
public static class UserDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
|
||||
/// short enough that a leaked URL stops working.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
|
||||
/// <param name="user">The account to convert.</param>
|
||||
/// <param name="fileStorageProvider">
|
||||
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
|
||||
/// rather than a URL, so it has to be signed before a client can fetch it.
|
||||
/// </param>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(
|
||||
this UserAccount user,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
)
|
||||
{
|
||||
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
|
||||
|
||||
return new PublicUserProfileDto(
|
||||
UserAccountId : user.UserAccountId,
|
||||
Username : user.Username,
|
||||
FirstName : user.FirstName,
|
||||
LastName : user.LastName,
|
||||
Biography : user.UserProfile?.Biography ?? string.Empty,
|
||||
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
|
||||
? string.Empty
|
||||
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
|
||||
CreatedAt : user.CreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ public record PublicUserProfileDto(
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Biography,
|
||||
string AvatarUrl,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
public class GetAllUsersHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
{
|
||||
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
@@ -14,6 +17,6 @@ public class GetAllUsersHandler(IUserListRepository repository)
|
||||
)
|
||||
{
|
||||
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
return users.Select(user => user.ToPublicProfileDto());
|
||||
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
public class GetPublicUserProfileByIdHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<PublicUserProfileDto> Handle(
|
||||
@@ -18,6 +21,6 @@ public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user.ToPublicProfileDto();
|
||||
return user.ToPublicProfileDto(fileStorageProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <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.
|
||||
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the
|
||||
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
|
||||
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
|
||||
/// with no avatar.
|
||||
/// </remarks>
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -23,51 +26,89 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
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.
|
||||
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are
|
||||
/// part of a public profile. 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user