diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 0b3f4a0e..53560b42 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -92,6 +92,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => new Orbit.Application.Challenges.Services.ChallengeProgressRepositories( diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index bb5859d3..99810ffb 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -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; diff --git a/src/Orbit.Application/Social/Queries/GetCheersQuery.cs b/src/Orbit.Application/Social/Queries/GetCheersQuery.cs index fe3cfe8a..74e125e2 100644 --- a/src/Orbit.Application/Social/Queries/GetCheersQuery.cs +++ b/src/Orbit.Application/Social/Queries/GetCheersQuery.cs @@ -23,9 +23,9 @@ public record GetCheersQuery(Guid UserId, string Direction) : IRequest cheerRepository, - IGenericRepository blockedUserRepository, - IGenericRepository userRepository) : IRequestHandler> + ISocialGraphReader socialGraphReader, + IGenericRepository userRepository, + TimeProvider timeProvider) : IRequestHandler> { public const string ReceivedDirection = "received"; @@ -35,25 +35,21 @@ public async Task> Handle(GetCheersQuery request, Cancellatio if (access.IsFailure) return access.PropagateError(); - 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); diff --git a/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs b/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs index 8158cbed..ad4cf829 100644 --- a/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs +++ b/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs @@ -21,8 +21,7 @@ public record GetFriendsQuery(Guid UserId) : IRequest>; public class GetFriendsQueryHandler( SocialAccessGuard socialAccessGuard, - IGenericRepository friendshipRepository, - IGenericRepository blockedUserRepository, + ISocialGraphReader socialGraphReader, IGenericRepository userRepository) : IRequestHandler> { public async Task> Handle(GetFriendsQuery request, CancellationToken cancellationToken) @@ -31,21 +30,11 @@ public async Task> Handle(GetFriendsQuery request, Cance if (access.IsFailure) return access.PropagateError(); - 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); diff --git a/src/Orbit.Domain/Interfaces/ISocialGraphReader.cs b/src/Orbit.Domain/Interfaces/ISocialGraphReader.cs new file mode 100644 index 00000000..9cd255fb --- /dev/null +++ b/src/Orbit.Domain/Interfaces/ISocialGraphReader.cs @@ -0,0 +1,36 @@ +using Orbit.Domain.Entities; + +namespace Orbit.Domain.Interfaces; + +/// +/// 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 (either +/// direction) inside the query; both reads are ordered and capped server-side. +/// +public interface ISocialGraphReader +{ + /// + /// 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 + /// . Deactivated counterparties stay the caller's concern (resolved via the + /// deactivation-filtered read), preserving the prior behavior. + /// + Task> ReadVisibleFriendshipsAsync( + Guid userId, + int limit, + CancellationToken cancellationToken = default); + + /// + /// Loads a page of cheers for in the given direction, created on or after + /// , excluding blocked counterparties (either direction), newest-first, capped + /// at . selects cheers the user received; false + /// selects cheers the user sent. + /// + Task> ReadCheersPageAsync( + Guid userId, + bool isReceived, + DateTime since, + int limit, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Infrastructure/Persistence/SocialGraphReader.cs b/src/Orbit.Infrastructure/Persistence/SocialGraphReader.cs new file mode 100644 index 00000000..82d59276 --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/SocialGraphReader.cs @@ -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> ReadVisibleFriendshipsAsync( + Guid userId, + int limit, + CancellationToken cancellationToken = default) + { + return await BuildVisibleFriendships(context.Friendships.AsNoTracking(), context.BlockedUsers, userId, limit) + .ToListAsync(cancellationToken); + } + + public async Task> 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 BuildVisibleFriendships( + IQueryable friendships, + IQueryable 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 BuildVisibleCheers( + IQueryable cheers, + IQueryable 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); + } +} diff --git a/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs b/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs index 357c7f4e..f2e090a0 100644 --- a/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs +++ b/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs @@ -315,34 +315,4 @@ public async Task Remove_NoFriendship_IsNoOpSuccess() result.IsSuccess.Should().BeTrue(); _friendshipRepository.DidNotReceive().Remove(Arg.Any()); } - - [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); - } } diff --git a/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs b/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs index 52dc040c..697bbe9a 100644 --- a/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs +++ b/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs @@ -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; @@ -10,33 +11,36 @@ namespace Orbit.Application.Tests.Social; public class GetCheersQueryTests { private readonly IGenericRepository _userRepository = Substitute.For>(); - private readonly IGenericRepository _cheerRepository = Substitute.For>(); - private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly ISocialGraphReader _reader = Substitute.For(); + private readonly TimeProvider _timeProvider = Substitute.For(); 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(), isReceived, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((IReadOnlyList)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); @@ -44,33 +48,48 @@ public async Task Received_ReturnsCheersWithSenderDisplayFields() } [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(), Arg.Any(), Arg.Any()); } [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()); + } + + [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] @@ -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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/Orbit.Application.Tests/Social/GetFriendsQueryTests.cs b/tests/Orbit.Application.Tests/Social/GetFriendsQueryTests.cs new file mode 100644 index 00000000..aa5163af --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/GetFriendsQueryTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class GetFriendsQueryTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly ISocialGraphReader _reader = Substitute.For(); + private readonly GetFriendsQueryHandler _handler; + + private readonly User _caller = SocialTestHelpers.OptedInUser("Caller"); + + public GetFriendsQueryTests() + { + var guard = new SocialAccessGuard(_userRepository); + _handler = new GetFriendsQueryHandler(guard, _reader, _userRepository); + } + + private void StubReader(params Friendship[] friendships) => + _reader.ReadVisibleFriendshipsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((IReadOnlyList)friendships.ToList()); + + private static Friendship Accepted(Guid requester, Guid addressee) + { + var friendship = Friendship.Create(requester, addressee).Value; + friendship.Accept(); + return friendship; + } + + [Fact] + public async Task PartitionsAcceptedIncomingAndOutgoing() + { + var friend = SocialTestHelpers.OptedInUser("Friend"); + var incomingRequester = SocialTestHelpers.OptedInUser("Incoming"); + var outgoingAddressee = SocialTestHelpers.OptedInUser("Outgoing"); + SocialTestHelpers.StubUsers(_userRepository, _caller, friend, incomingRequester, outgoingAddressee); + StubReader( + Accepted(_caller.Id, friend.Id), + Friendship.Create(incomingRequester.Id, _caller.Id).Value, + Friendship.Create(_caller.Id, outgoingAddressee.Id).Value); + + 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.IncomingRequests.Should().ContainSingle(r => r.UserId == incomingRequester.Id); + result.Value.OutgoingRequests.Should().ContainSingle(r => r.UserId == outgoingAddressee.Id); + } + + [Fact] + public async Task QueriesReaderCappedAtMaxFriends() + { + SocialTestHelpers.StubUsers(_userRepository, _caller); + StubReader(); + + await _handler.Handle(new GetFriendsQuery(_caller.Id), CancellationToken.None); + + await _reader.Received(1).ReadVisibleFriendshipsAsync( + _caller.Id, AppConstants.MaxFriends, Arg.Any()); + } + + [Fact] + public async Task FriendsOrderedByDisplayName() + { + var zed = SocialTestHelpers.OptedInUser("Zed"); + var amy = SocialTestHelpers.OptedInUser("Amy"); + SocialTestHelpers.StubUsers(_userRepository, _caller, zed, amy); + StubReader(Accepted(_caller.Id, zed.Id), Accepted(_caller.Id, amy.Id)); + + var result = await _handler.Handle(new GetFriendsQuery(_caller.Id), CancellationToken.None); + + result.Value.Friends.Select(f => f.DisplayName).Should().ContainInOrder("Amy", "Zed"); + } + + [Fact] + public async Task DropsFriendshipWhoseOtherUserIsNotResolvable() + { + var deactivated = SocialTestHelpers.OptedInUser("Ghost"); + SocialTestHelpers.StubUsers(_userRepository, _caller); + StubReader(Accepted(_caller.Id, deactivated.Id)); + + var result = await _handler.Handle(new GetFriendsQuery(_caller.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Friends.Should().BeEmpty(); + } + + [Fact] + public async Task CallerOptedOut_ReturnsSocialDisabled() + { + var optedOut = SocialTestHelpers.OptedOutUser(); + SocialTestHelpers.StubUsers(_userRepository, optedOut); + + var result = await _handler.Handle(new GetFriendsQuery(optedOut.Id), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + await _reader.DidNotReceive().ReadVisibleFriendshipsAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/SocialGraphReaderTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/SocialGraphReaderTests.cs new file mode 100644 index 00000000..82035266 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/SocialGraphReaderTests.cs @@ -0,0 +1,176 @@ +using FluentAssertions; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Tests.Persistence; + +public class SocialGraphReaderTests +{ + private static readonly DateTime Now = new(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc); + + private static T WithCreatedAt(T entity, DateTime createdAtUtc) + { + typeof(T).GetProperty(nameof(Cheer.CreatedAtUtc))!.SetValue(entity, createdAtUtc); + return entity; + } + + private static Friendship Accepted(Guid requester, Guid addressee) + { + var friendship = Friendship.Create(requester, addressee).Value; + friendship.Accept(); + return friendship; + } + + [Fact] + public void VisibleFriendships_ExcludeBlockedCounterparty_InEitherDirection() + { + var caller = Guid.NewGuid(); + var friend = Guid.NewGuid(); + var iBlocked = Guid.NewGuid(); + var blockedMe = Guid.NewGuid(); + + var friendships = new[] + { + Accepted(caller, friend), + Accepted(caller, iBlocked), + Accepted(blockedMe, caller) + }.AsQueryable(); + var blocks = new[] + { + BlockedUser.Create(caller, iBlocked).Value, + BlockedUser.Create(blockedMe, caller).Value + }.AsQueryable(); + + var result = SocialGraphReader.BuildVisibleFriendships(friendships, blocks, caller, 100).ToList(); + + result.Should().HaveCount(1); + var kept = result.Single(); + (kept.RequesterId == friend || kept.AddresseeId == friend).Should().BeTrue(); + } + + [Fact] + public void VisibleFriendships_IgnoreRowsNotInvolvingTheUser() + { + var caller = Guid.NewGuid(); + var mine = Accepted(caller, Guid.NewGuid()); + var strangers = Accepted(Guid.NewGuid(), Guid.NewGuid()); + + var result = SocialGraphReader + .BuildVisibleFriendships(new[] { mine, strangers }.AsQueryable(), Array.Empty().AsQueryable(), caller, 100) + .ToList(); + + result.Should().ContainSingle().Which.Should().Be(mine); + } + + [Fact] + public void VisibleFriendships_OrderAcceptedFirstThenNewest() + { + var caller = Guid.NewGuid(); + var oldAccepted = WithCreatedAt(Accepted(caller, Guid.NewGuid()), Now.AddDays(-10)); + var newAccepted = WithCreatedAt(Accepted(caller, Guid.NewGuid()), Now.AddDays(-1)); + var pending = WithCreatedAt(Friendship.Create(caller, Guid.NewGuid()).Value, Now); + + var result = SocialGraphReader + .BuildVisibleFriendships(new[] { pending, oldAccepted, newAccepted }.AsQueryable(), Array.Empty().AsQueryable(), caller, 100) + .ToList(); + + result.Should().ContainInOrder(newAccepted, oldAccepted, pending); + } + + [Fact] + public void VisibleFriendships_CapAtLimit() + { + var caller = Guid.NewGuid(); + var rows = Enumerable.Range(0, 5) + .Select(i => WithCreatedAt(Accepted(caller, Guid.NewGuid()), Now.AddDays(-i))) + .ToArray(); + + var result = SocialGraphReader + .BuildVisibleFriendships(rows.AsQueryable(), Array.Empty().AsQueryable(), caller, 3) + .ToList(); + + result.Should().HaveCount(3); + result.Should().ContainInOrder(rows[0], rows[1], rows[2]); + } + + [Fact] + public void VisibleCheers_Received_ExcludeBlockedSenders() + { + var caller = Guid.NewGuid(); + var friend = Guid.NewGuid(); + var iBlocked = Guid.NewGuid(); + var blockedMe = Guid.NewGuid(); + + var cheers = new[] + { + Cheer.Create(friend, caller, null, "hi").Value, + Cheer.Create(iBlocked, caller, null, "x").Value, + Cheer.Create(blockedMe, caller, null, "y").Value, + Cheer.Create(caller, friend, null, "outbound").Value + }.AsQueryable(); + var blocks = new[] + { + BlockedUser.Create(caller, iBlocked).Value, + BlockedUser.Create(blockedMe, caller).Value + }.AsQueryable(); + + var result = SocialGraphReader + .BuildVisibleCheers(cheers, blocks, caller, isReceived: true, Now.AddDays(-90), 100) + .ToList(); + + result.Should().ContainSingle().Which.SenderId.Should().Be(friend); + } + + [Fact] + public void VisibleCheers_Sent_ExcludeBlockedRecipients() + { + var caller = Guid.NewGuid(); + var friend = Guid.NewGuid(); + var iBlocked = Guid.NewGuid(); + + var cheers = new[] + { + Cheer.Create(caller, friend, null, "a").Value, + Cheer.Create(caller, iBlocked, null, "b").Value, + Cheer.Create(friend, caller, null, "inbound").Value + }.AsQueryable(); + var blocks = new[] { BlockedUser.Create(caller, iBlocked).Value }.AsQueryable(); + + var result = SocialGraphReader + .BuildVisibleCheers(cheers, blocks, caller, isReceived: false, Now.AddDays(-90), 100) + .ToList(); + + result.Should().ContainSingle().Which.RecipientId.Should().Be(friend); + } + + [Fact] + public void VisibleCheers_IncludeBoundary_ExcludeOlderThanLookback() + { + var caller = Guid.NewGuid(); + var since = Now.AddDays(-90); + var atBoundary = WithCreatedAt(Cheer.Create(Guid.NewGuid(), caller, null, "edge").Value, since); + var justOlder = WithCreatedAt(Cheer.Create(Guid.NewGuid(), caller, null, "stale").Value, since.AddTicks(-1)); + + var result = SocialGraphReader + .BuildVisibleCheers(new[] { atBoundary, justOlder }.AsQueryable(), Array.Empty().AsQueryable(), caller, isReceived: true, since, 100) + .ToList(); + + result.Should().ContainSingle().Which.Note.Should().Be("edge"); + } + + [Fact] + public void VisibleCheers_OrderNewestFirst_AndCapAtLimit() + { + var caller = Guid.NewGuid(); + var newest = WithCreatedAt(Cheer.Create(Guid.NewGuid(), caller, null, "n0").Value, Now); + var middle = WithCreatedAt(Cheer.Create(Guid.NewGuid(), caller, null, "n1").Value, Now.AddHours(-1)); + var oldest = WithCreatedAt(Cheer.Create(Guid.NewGuid(), caller, null, "n2").Value, Now.AddHours(-2)); + + var result = SocialGraphReader + .BuildVisibleCheers(new[] { middle, oldest, newest }.AsQueryable(), Array.Empty().AsQueryable(), caller, isReceived: true, Now.AddDays(-90), 2) + .ToList(); + + result.Should().HaveCount(2); + result.Select(c => c.Note).Should().ContainInOrder("n0", "n1"); + } +}