Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
builder.Services.AddScoped<Orbit.Application.Social.Services.SocialNotificationDispatcher>();
builder.Services.AddScoped<Orbit.Application.Social.Services.IFriendFeedEventEmitter, Orbit.Application.Social.Services.FriendFeedEmitter>();
builder.Services.AddScoped<IFriendFeedReader, FriendFeedReader>();
builder.Services.AddScoped<ISocialGraphReader, SocialGraphReader>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.IChallengeProgressService, Orbit.Application.Challenges.Services.ChallengeProgressService>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.ChallengeProgressRepositories>(sp =>
new Orbit.Application.Challenges.Services.ChallengeProgressRepositories(
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public static class AppConstants
public const int MaxCheerNoteLength = DomainConstants.MaxCheerNoteLength;
public const int MaxReportDetailsLength = DomainConstants.MaxReportDetailsLength;
public const int MaxCheersPerDay = 20;
public const int MaxCheersReturned = 200;
public const int CheersLookbackDays = 90;
public const int MaxFriendRequestsPerDay = 30;
public const int MaxAccountabilityPairs = 20;
public const int MaxAccountabilityNoteLength = DomainConstants.MaxAccountabilityNoteLength;
Expand Down
24 changes: 10 additions & 14 deletions src/Orbit.Application/Social/Queries/GetCheersQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public record GetCheersQuery(Guid UserId, string Direction) : IRequest<Result<Ch

public class GetCheersQueryHandler(
SocialAccessGuard socialAccessGuard,
IGenericRepository<Cheer> cheerRepository,
IGenericRepository<BlockedUser> blockedUserRepository,
IGenericRepository<User> userRepository) : IRequestHandler<GetCheersQuery, Result<CheersPage>>
ISocialGraphReader socialGraphReader,
IGenericRepository<User> userRepository,
TimeProvider timeProvider) : IRequestHandler<GetCheersQuery, Result<CheersPage>>
{
public const string ReceivedDirection = "received";

Expand All @@ -35,25 +35,21 @@ public async Task<Result<CheersPage>> Handle(GetCheersQuery request, Cancellatio
if (access.IsFailure)
return access.PropagateError<CheersPage>();

var blocks = await blockedUserRepository.FindAsync(
b => b.BlockerId == request.UserId || b.BlockedId == request.UserId,
cancellationToken);
var blockedIds = blocks
.Select(b => b.BlockerId == request.UserId ? b.BlockedId : b.BlockerId)
.ToHashSet();

var isReceived = string.Equals(request.Direction, ReceivedDirection, StringComparison.OrdinalIgnoreCase);
var since = timeProvider.GetUtcNow().UtcDateTime.AddDays(-AppConstants.CheersLookbackDays);

var cheers = isReceived
? await cheerRepository.FindAsync(c => c.RecipientId == request.UserId && !blockedIds.Contains(c.SenderId), cancellationToken)
: await cheerRepository.FindAsync(c => c.SenderId == request.UserId && !blockedIds.Contains(c.RecipientId), cancellationToken);
var cheers = await socialGraphReader.ReadCheersPageAsync(
request.UserId,
isReceived,
since,
AppConstants.MaxCheersReturned,
cancellationToken);

var senderIds = cheers.Select(c => c.SenderId).ToHashSet();
var senders = await userRepository.FindAsync(u => senderIds.Contains(u.Id), cancellationToken);
var sendersById = senders.ToDictionary(u => u.Id);

var items = cheers
.OrderByDescending(c => c.CreatedAtUtc)
.Select(c =>
{
sendersById.TryGetValue(c.SenderId, out var sender);
Expand Down
19 changes: 4 additions & 15 deletions src/Orbit.Application/Social/Queries/GetFriendsQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ public record GetFriendsQuery(Guid UserId) : IRequest<Result<FriendsResponse>>;

public class GetFriendsQueryHandler(
SocialAccessGuard socialAccessGuard,
IGenericRepository<Friendship> friendshipRepository,
IGenericRepository<BlockedUser> blockedUserRepository,
ISocialGraphReader socialGraphReader,
IGenericRepository<User> userRepository) : IRequestHandler<GetFriendsQuery, Result<FriendsResponse>>
{
public async Task<Result<FriendsResponse>> Handle(GetFriendsQuery request, CancellationToken cancellationToken)
Expand All @@ -31,21 +30,11 @@ public async Task<Result<FriendsResponse>> Handle(GetFriendsQuery request, Cance
if (access.IsFailure)
return access.PropagateError<FriendsResponse>();

var friendships = await friendshipRepository.FindAsync(
f => f.RequesterId == request.UserId || f.AddresseeId == request.UserId,
var visible = await socialGraphReader.ReadVisibleFriendshipsAsync(
request.UserId,
AppConstants.MaxFriends,
cancellationToken);

var blocks = await blockedUserRepository.FindAsync(
b => b.BlockerId == request.UserId || b.BlockedId == request.UserId,
cancellationToken);
var blockedIds = blocks
.Select(b => b.BlockerId == request.UserId ? b.BlockedId : b.BlockerId)
.ToHashSet();

var visible = friendships
.Where(f => !blockedIds.Contains(OtherId(f, request.UserId)))
.ToList();

var otherIds = visible.Select(f => OtherId(f, request.UserId)).ToHashSet();
var users = await userRepository.FindAsync(u => otherIds.Contains(u.Id), cancellationToken);
var usersById = users.ToDictionary(u => u.Id);
Expand Down
36 changes: 36 additions & 0 deletions src/Orbit.Domain/Interfaces/ISocialGraphReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Orbit.Domain.Entities;

namespace Orbit.Domain.Interfaces;

/// <summary>
/// DB-side reads for the social surface that must exclude blocked counterparties and bound their result
/// set, so the handlers never materialize a user's full friendship or cheer history to filter and cap it
/// in memory. Blocked exclusion is done as an anti-join against <see cref="BlockedUser"/> (either
/// direction) inside the query; both reads are ordered and capped server-side.
/// </summary>
public interface ISocialGraphReader
{
/// <summary>
/// Loads a user's friendship rows (both directions), excluding those whose other participant the user
/// has blocked or been blocked by, ordered accepted-first then newest-first, capped at
/// <paramref name="limit"/>. Deactivated counterparties stay the caller's concern (resolved via the
/// deactivation-filtered <see cref="User"/> read), preserving the prior behavior.
/// </summary>
Task<IReadOnlyList<Friendship>> ReadVisibleFriendshipsAsync(
Guid userId,
int limit,
CancellationToken cancellationToken = default);

/// <summary>
/// Loads a page of cheers for <paramref name="userId"/> in the given direction, created on or after
/// <paramref name="since"/>, excluding blocked counterparties (either direction), newest-first, capped
/// at <paramref name="limit"/>. <paramref name="isReceived"/> selects cheers the user received; false
/// selects cheers the user sent.
/// </summary>
Task<IReadOnlyList<Cheer>> ReadCheersPageAsync(
Guid userId,
bool isReceived,
DateTime since,
int limit,
CancellationToken cancellationToken = default);
}
70 changes: 70 additions & 0 deletions src/Orbit.Infrastructure/Persistence/SocialGraphReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;

namespace Orbit.Infrastructure.Persistence;

public class SocialGraphReader(OrbitDbContext context) : ISocialGraphReader
{
public async Task<IReadOnlyList<Friendship>> ReadVisibleFriendshipsAsync(
Guid userId,
int limit,
CancellationToken cancellationToken = default)
{
return await BuildVisibleFriendships(context.Friendships.AsNoTracking(), context.BlockedUsers, userId, limit)
.ToListAsync(cancellationToken);
}

public async Task<IReadOnlyList<Cheer>> ReadCheersPageAsync(
Guid userId,
bool isReceived,
DateTime since,
int limit,
CancellationToken cancellationToken = default)
{
return await BuildVisibleCheers(context.Cheers.AsNoTracking(), context.BlockedUsers, userId, isReceived, since, limit)
.ToListAsync(cancellationToken);
}

internal static IQueryable<Friendship> BuildVisibleFriendships(
IQueryable<Friendship> friendships,
IQueryable<BlockedUser> blockedUsers,
Guid userId,
int limit)
{
return friendships
.Where(f => f.RequesterId == userId || f.AddresseeId == userId)
.Where(f => !blockedUsers.Any(b =>
(b.BlockerId == userId && (b.BlockedId == f.RequesterId || b.BlockedId == f.AddresseeId))
|| (b.BlockedId == userId && (b.BlockerId == f.RequesterId || b.BlockerId == f.AddresseeId))))
.OrderByDescending(f => f.Status == FriendshipStatus.Accepted)
.ThenByDescending(f => f.CreatedAtUtc)
.Take(limit);
}

internal static IQueryable<Cheer> BuildVisibleCheers(
IQueryable<Cheer> cheers,
IQueryable<BlockedUser> blockedUsers,
Guid userId,
bool isReceived,
DateTime since,
int limit)
{
var withinWindow = cheers.Where(c => c.CreatedAtUtc >= since);

var directed = isReceived
? withinWindow.Where(c => c.RecipientId == userId
&& !blockedUsers.Any(b =>
(b.BlockerId == userId && b.BlockedId == c.SenderId)
|| (b.BlockedId == userId && b.BlockerId == c.SenderId)))
: withinWindow.Where(c => c.SenderId == userId
&& !blockedUsers.Any(b =>
(b.BlockerId == userId && b.BlockedId == c.RecipientId)
|| (b.BlockedId == userId && b.BlockerId == c.RecipientId)));

return directed
.OrderByDescending(c => c.CreatedAtUtc)
.Take(limit);
}
}
30 changes: 0 additions & 30 deletions tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,34 +315,4 @@ public async Task Remove_NoFriendship_IsNoOpSuccess()
result.IsSuccess.Should().BeTrue();
_friendshipRepository.DidNotReceive().Remove(Arg.Any<Friendship>());
}

[Fact]
public async Task GetFriends_PartitionsAcceptedIncomingOutgoing_AndExcludesBlocked()
{
var caller = SocialTestHelpers.OptedInUser();
var friend = SocialTestHelpers.OptedInUser("Friend");
var incomingRequester = SocialTestHelpers.OptedInUser("Incoming");
var outgoingAddressee = SocialTestHelpers.OptedInUser("Outgoing");
var blockedFriend = SocialTestHelpers.OptedInUser("Blocked");

var accepted = Friendship.Create(caller.Id, friend.Id).Value;
accepted.Accept();
var incoming = Friendship.Create(incomingRequester.Id, caller.Id).Value;
var outgoing = Friendship.Create(caller.Id, outgoingAddressee.Id).Value;
var blockedAccepted = Friendship.Create(caller.Id, blockedFriend.Id).Value;
blockedAccepted.Accept();

SocialTestHelpers.StubUsers(_userRepository, caller, friend, incomingRequester, outgoingAddressee, blockedFriend);
SocialTestHelpers.StubFind(_friendshipRepository, accepted, incoming, outgoing, blockedAccepted);
SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(caller.Id, blockedFriend.Id).Value);

var handler = new GetFriendsQueryHandler(_guard, _friendshipRepository, _blockedUserRepository, _userRepository);
var result = await handler.Handle(new GetFriendsQuery(caller.Id), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Friends.Should().ContainSingle(f => f.UserId == friend.Id);
result.Value.Friends.Should().NotContain(f => f.UserId == blockedFriend.Id);
result.Value.IncomingRequests.Should().ContainSingle(r => r.UserId == incomingRequester.Id);
result.Value.OutgoingRequests.Should().ContainSingle(r => r.UserId == outgoingAddressee.Id);
}
}
73 changes: 47 additions & 26 deletions tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentAssertions;
using NSubstitute;
using Orbit.Application.Common;
using Orbit.Application.Social.Queries;
using Orbit.Application.Social.Services;
using Orbit.Domain.Entities;
Expand All @@ -10,67 +11,85 @@ namespace Orbit.Application.Tests.Social;
public class GetCheersQueryTests
{
private readonly IGenericRepository<User> _userRepository = Substitute.For<IGenericRepository<User>>();
private readonly IGenericRepository<Cheer> _cheerRepository = Substitute.For<IGenericRepository<Cheer>>();
private readonly IGenericRepository<BlockedUser> _blockedUserRepository = Substitute.For<IGenericRepository<BlockedUser>>();
private readonly ISocialGraphReader _reader = Substitute.For<ISocialGraphReader>();
private readonly TimeProvider _timeProvider = Substitute.For<TimeProvider>();
private readonly GetCheersQueryHandler _handler;

private readonly User _caller = SocialTestHelpers.OptedInUser("Caller");
private readonly User _friend = SocialTestHelpers.OptedInUser("Friend");
private readonly DateTime _now = new(2026, 7, 12, 10, 0, 0, DateTimeKind.Utc);

public GetCheersQueryTests()
{
var guard = new SocialAccessGuard(_userRepository);
_handler = new GetCheersQueryHandler(guard, _cheerRepository, _blockedUserRepository, _userRepository);
_handler = new GetCheersQueryHandler(guard, _reader, _userRepository, _timeProvider);
SocialTestHelpers.StubUsers(_userRepository, _caller, _friend);
SocialTestHelpers.StubFind(_blockedUserRepository);
_timeProvider.GetUtcNow().Returns(new DateTimeOffset(_now));
}

private void StubReader(bool isReceived, params Cheer[] cheers) =>
_reader.ReadCheersPageAsync(Arg.Any<Guid>(), isReceived, Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<Cheer>)cheers.ToList());

[Fact]
public async Task Received_ReturnsCheersWithSenderDisplayFields()
public async Task Received_MapsSenderDisplayFieldsFromReaderPage()
{
var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "proud of you").Value;
var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "go go").Value;
SocialTestHelpers.StubFind(_cheerRepository, received, sent);
StubReader(isReceived: true, received);

var result = await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Items.Should().ContainSingle();
var item = result.Value.Items[0];
var item = result.Value.Items.Should().ContainSingle().Subject;
item.SenderId.Should().Be(_friend.Id);
item.SenderDisplayName.Should().Be("Friend");
item.SenderHandle.Should().Be(_friend.Handle);
item.Note.Should().Be("proud of you");
}

[Fact]
public async Task Sent_ReturnsOnlyCheersTheCallerSent()
public async Task Sent_QueriesReaderWithSentDirection()
{
var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "a").Value;
var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "b").Value;
SocialTestHelpers.StubFind(_cheerRepository, received, sent);
var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "go go").Value;
StubReader(isReceived: false, sent);

var result = await _handler.Handle(new GetCheersQuery(_caller.Id, "sent"), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Items.Should().ContainSingle(c => c.Id == sent.Id);
await _reader.Received(1).ReadCheersPageAsync(
_caller.Id, false, Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task BlockedUser_CheersExcludedFromBothDirections()
public async Task Handle_QueriesReaderWithLookbackWindowAndPageCap()
{
var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "before block").Value;
var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "before block").Value;
SocialTestHelpers.StubFind(_cheerRepository, received, sent);
SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_caller.Id, _friend.Id).Value);

var receivedResult = await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None);
var sentResult = await _handler.Handle(new GetCheersQuery(_caller.Id, "sent"), CancellationToken.None);

receivedResult.IsSuccess.Should().BeTrue();
receivedResult.Value.Items.Should().BeEmpty();
sentResult.IsSuccess.Should().BeTrue();
sentResult.Value.Items.Should().BeEmpty();
StubReader(isReceived: true);

await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None);

await _reader.Received(1).ReadCheersPageAsync(
_caller.Id,
true,
_now.AddDays(-AppConstants.CheersLookbackDays),
AppConstants.MaxCheersReturned,
Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_UnknownSender_MapsEmptyDisplayFields()
{
var stranger = Guid.NewGuid();
var received = Cheer.Create(stranger, _caller.Id, null, null).Value;
StubReader(isReceived: true, received);

var result = await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
var item = result.Value.Items.Should().ContainSingle().Subject;
item.SenderId.Should().Be(stranger);
item.SenderHandle.Should().BeEmpty();
item.SenderDisplayName.Should().BeEmpty();
}

[Fact]
Expand All @@ -82,5 +101,7 @@ public async Task CallerOptedOut_ReturnsSocialDisabled()
var result = await _handler.Handle(new GetCheersQuery(optedOut.Id, "received"), CancellationToken.None);

result.IsFailure.Should().BeTrue();
await _reader.DidNotReceive().ReadCheersPageAsync(
Arg.Any<Guid>(), Arg.Any<bool>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}
Loading
Loading