From 7c580fcd8c456ac04b3e280f50dccd3d8bb18982 Mon Sep 17 00:00:00 2001 From: Jaser1010 Date: Sat, 23 May 2026 14:07:45 +0300 Subject: [PATCH] Refactor OutboxProcessor and enhance test coverage Refactored `OutboxProcessor` to use `IOutboxDispatcher` for better separation of concerns. Updated `DependencyInjection.cs` to register `IOutboxDispatcher` and removed unused dependencies. Improved `LocalMediaService` to handle null `WebRootPath`. Added integration tests for multiple controllers and unit tests for services to ensure functionality and edge case handling. Introduced `BaseIntegrationTest` and `SocialPluseApiFactory` for isolated testing with PostgreSQL and Redis test containers. Updated `Program.cs` to support test environments and added `TestAuthHandler` for bypassing authentication in tests. Included new test projects and dependencies. Added `SharedTestCollection` for managing shared test infrastructure. Validated deterministic outbox processing with `OutboxProcessorTests`. --- LikeServiceTests.cs | 8 + .../Authentication/TestAuthHandler.cs | 43 ++++ .../BaseIntegrationTest.cs | 21 ++ .../AnalyticsControllerTests.cs | 30 +++ .../ControllersTests/AuthControllerTests.cs | 75 +++++++ .../ControllersTests/BlocksControllerTests.cs | 74 +++++++ .../BookmarksControllerTests.cs | 70 +++++++ .../CommentsControllerTests.cs | 56 ++++++ .../ControllersTests/FeedsControllerTests.cs | 20 ++ .../FollowsControllerTests.cs | 80 ++++++++ .../ControllersTests/LikesControllerTests.cs | 50 +++++ .../ControllersTests/MutesControllerTests.cs | 62 ++++++ .../NotificationsControllerTests.cs | 33 ++++ .../ControllersTests/PostsControllerTests.cs | 70 +++++++ .../ReportsControllerTests.cs | 83 ++++++++ .../ControllersTests/SearchControllerTests.cs | 78 ++++++++ .../ControllersTests/UsersControllerTests.cs | 41 ++++ .../MediaControllerTests.cs | 37 ++++ .../OutboxProcessorTests.cs | 183 ++++++++++++++++++ .../SharedTestCollection.cs | 13 ++ .../SocialPluse.IntegrationTests.csproj | 29 +++ .../SocialPluseApiFactory.cs | 101 ++++++++++ .../BackgroundJobs/OutboxDispatcher.cs | 129 ++++++++++++ .../BackgroundJobs/OutboxProcessor.cs | 103 ++-------- .../DependencyInjection.cs | 5 +- .../Repositories/FollowRepository.cs | 9 + .../Services/LocalMediaService.cs | 8 +- .../IRepositories/IFollowRepository.cs | 2 + .../IService/IOutboxDispatcher.cs | 8 + .../SocialPluse.Services.Tests.csproj | 28 +++ .../UnitTests/AnalyticsServiceTests.cs | 34 ++++ .../UnitTests/AuthServiceTests.cs | 70 +++++++ .../UnitTests/BookmarkServiceTests.cs | 36 ++++ .../UnitTests/CommentServiceTests.cs | 36 ++++ .../UnitTests/FeedServiceTests.cs | 45 +++++ .../UnitTests/FollowServiceTests.cs | 82 ++++++++ .../UnitTests/LikeServiceTests.cs | 168 ++++++++++++++++ .../UnitTests/NotificationServiceTests.cs | 41 ++++ .../UnitTests/PostServiceTests.cs | 107 ++++++++++ .../UnitTests/SafetyServiceTests.cs | 107 ++++++++++ .../UnitTests/SearchServiceTests.cs | 65 +++++++ .../UnitTests/UserServiceTests.cs | 100 ++++++++++ SocialPluse.Services/DependencyInjection.cs | 1 + .../MessageHandlers/FanoutPostHandler.cs | 38 ++-- .../SocialPluse.Services.csproj | 1 + .../Controllers/LikesController.cs | 1 - SocialPluse.Web/Program.cs | 13 +- .../3451e2381b764f1aa8d1e1c1adb7c9da.jpg | Bin 0 -> 10 bytes .../a86313d321ee4bc8a1a140c996b49a75.jpg | Bin 0 -> 10 bytes SocialPluse.slnx | 2 + 50 files changed, 2386 insertions(+), 110 deletions(-) create mode 100644 LikeServiceTests.cs create mode 100644 SocialPluse.IntegrationTests/Authentication/TestAuthHandler.cs create mode 100644 SocialPluse.IntegrationTests/BaseIntegrationTest.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/AnalyticsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/AuthControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/BlocksControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/BookmarksControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/CommentsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/FeedsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/FollowsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/LikesControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/MutesControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/NotificationsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/PostsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/ReportsControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/SearchControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/ControllersTests/UsersControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/MediaControllerTests.cs create mode 100644 SocialPluse.IntegrationTests/OutboxProcessorTests.cs create mode 100644 SocialPluse.IntegrationTests/SharedTestCollection.cs create mode 100644 SocialPluse.IntegrationTests/SocialPluse.IntegrationTests.csproj create mode 100644 SocialPluse.IntegrationTests/SocialPluseApiFactory.cs create mode 100644 SocialPluse.Persistence/BackgroundJobs/OutboxDispatcher.cs create mode 100644 SocialPluse.Services.Abstraction/IService/IOutboxDispatcher.cs create mode 100644 SocialPluse.Services.Tests/SocialPluse.Services.Tests.csproj create mode 100644 SocialPluse.Services.Tests/UnitTests/AnalyticsServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/AuthServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/BookmarkServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/CommentServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/FeedServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/FollowServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/LikeServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/NotificationServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/PostServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/SafetyServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/SearchServiceTests.cs create mode 100644 SocialPluse.Services.Tests/UnitTests/UserServiceTests.cs create mode 100644 SocialPluse.Web/uploads/3451e2381b764f1aa8d1e1c1adb7c9da.jpg create mode 100644 SocialPluse.Web/uploads/a86313d321ee4bc8a1a140c996b49a75.jpg diff --git a/LikeServiceTests.cs b/LikeServiceTests.cs new file mode 100644 index 0000000..d555ecb --- /dev/null +++ b/LikeServiceTests.cs @@ -0,0 +1,8 @@ +using System; + +public class Class1 +{ + public Class1() + { + } +} diff --git a/SocialPluse.IntegrationTests/Authentication/TestAuthHandler.cs b/SocialPluse.IntegrationTests/Authentication/TestAuthHandler.cs new file mode 100644 index 0000000..a66b1ce --- /dev/null +++ b/SocialPluse.IntegrationTests/Authentication/TestAuthHandler.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace SocialPluse.IntegrationTests.Authentication +{ + public class TestAuthHandler + : AuthenticationHandler + { + public const string TestUserId = + "00000000-0000-0000-0000-000000000001"; + + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, TestUserId), + new Claim("sub", TestUserId) + }; + + var identity = new ClaimsIdentity(claims, "Test"); + + var principal = new ClaimsPrincipal(identity); + + var ticket = new AuthenticationTicket( + principal, + "Test"); + + return Task.FromResult( + AuthenticateResult.Success(ticket)); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/BaseIntegrationTest.cs b/SocialPluse.IntegrationTests/BaseIntegrationTest.cs new file mode 100644 index 0000000..1db0fba --- /dev/null +++ b/SocialPluse.IntegrationTests/BaseIntegrationTest.cs @@ -0,0 +1,21 @@ +using System.Net.Http.Headers; +using Xunit; + +namespace SocialPluse.IntegrationTests +{ + // 1. We remove IClassFixture + // 2. We add the Collection attribute linking it to the shared infrastructure + [Collection("Shared API Collection")] + public abstract class BaseIntegrationTest + { + protected readonly HttpClient Client; + + protected BaseIntegrationTest(SocialPluseApiFactory factory) + { + Client = factory.CreateClient(); + + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Test"); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/AnalyticsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/AnalyticsControllerTests.cs new file mode 100644 index 0000000..7f7b065 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/AnalyticsControllerTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class AnalyticsControllerTests : BaseIntegrationTest + { + public AnalyticsControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + [Fact] + public async Task GetMyAnalytics_ShouldReturnOk() + { + // Act + var response = await Client.GetAsync("/api/analytics/me"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetTrendingTopics_ShouldReturnOk() + { + // Act + var response = await Client.GetAsync("/api/analytics/trending?limit=5"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/AuthControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/AuthControllerTests.cs new file mode 100644 index 0000000..ea92623 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/AuthControllerTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Persistence.IdentityData.Entities; +using SocialPluse.Shared.DTOs.Auth; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class AuthControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + private readonly HttpClient _unauthenticatedClient; + + public AuthControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + // A clean client that bypasses the BaseIntegrationTest's default Auth header + _unauthenticatedClient = factory.CreateClient(); + } + + [Fact] + public async Task Register_WhenValidPayload_ShouldReturnAuthResponse_WithRealJwt() + { + // Arrange + // Take only the first 8 characters of the GUID to stay under the 20-character limit + var uniqueId = Guid.NewGuid().ToString("N").Substring(0, 8); + + var request = new RegisterRequest + { + Username = $"usr_{uniqueId}", // "usr_" (4) + 8 = 12 characters. Perfect. + Email = $"{uniqueId}@test.com", + Password = "StrongPassword123!" + }; + + // Act - Using the unauthenticated client + var response = await _unauthenticatedClient.PostAsJsonAsync("/api/auth/register", request); + + // Assert + var responseContent = await response.Content.ReadAsStringAsync(); + + response.IsSuccessStatusCode.Should().BeTrue( + $"because the API should accept the registration, but it returned: {responseContent}"); + + var authResponse = await response.Content.ReadFromJsonAsync(); + authResponse.Should().NotBeNull(); + authResponse!.AccessToken.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Login_WhenCredentialsValid_ShouldReturnOk() + { + // Arrange - The SENIOR way: Seed the user directly via Identity UserManager + var uniqueId = Guid.NewGuid().ToString("N"); + var email = $"login_{uniqueId}@test.com"; + var password = "StrongPassword123!"; + + using (var scope = _services.CreateScope()) + { + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new AppUser { UserName = $"login_{uniqueId}", Email = email }; + await userManager.CreateAsync(user, password); + } + + var loginReq = new LoginRequest { Email = email, Password = password }; + + // Act + var response = await _unauthenticatedClient.PostAsJsonAsync("/api/auth/login", loginReq); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/BlocksControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/BlocksControllerTests.cs new file mode 100644 index 0000000..7335d1e --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/BlocksControllerTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Domain.Entities; +using SocialPluse.IntegrationTests.Authentication; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class BlocksControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public BlocksControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + private async Task SeedTargetUserAsync() + { + var targetId = Guid.NewGuid(); + using var scope = _services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Users.Add(new AppUser + { + Id = targetId, + UserName = $"block_target_{targetId:N}", + Email = $"block_{targetId:N}@test.com" + }); + + await dbContext.SaveChangesAsync(); + return targetId; + } + + [Fact] + public async Task BlockUser_WhenUserExists_ShouldReturnOk() + { + // Arrange + var targetId = await SeedTargetUserAsync(); + + // Act + var response = await Client.PostAsync($"/api/blocks/{targetId}", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UnblockUser_WhenBlocked_ShouldReturnNoContent() + { + // Arrange - Seed the target user AND the block relationship directly + var targetId = await SeedTargetUserAsync(); + using (var scope = _services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Blocks.Add(new Block + { + BlockerId = Guid.Parse(TestAuthHandler.TestUserId), + BlockedId = targetId, + CreatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); + } + + // Act + var response = await Client.DeleteAsync($"/api/blocks/{targetId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/BookmarksControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/BookmarksControllerTests.cs new file mode 100644 index 0000000..b58a60b --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/BookmarksControllerTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Domain.Entities; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.IntegrationTests.Authentication; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class BookmarksControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public BookmarksControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + private async Task SeedPostDirectlyAsync(string text) + { + var postId = Guid.NewGuid(); + using var scope = _services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Posts.Add(new Post + { + Id = postId, + // The TestUser is seeded in the Factory, so we can use them as the author + AuthorId = Guid.Parse(TestAuthHandler.TestUserId), + Text = text, + CreatedAt = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + return postId; + } + + [Fact] + public async Task ToggleBookmark_WhenPostExists_ShouldReturnOk() + { + // Arrange - 100% isolated from the PostsController + var postId = await SeedPostDirectlyAsync("A post to bookmark."); + + // Act + var response = await Client.PostAsync($"/api/posts/{postId}/bookmark?shouldBookmark=true", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetBookmarks_WhenBookmarked_ShouldReturnList() + { + // Arrange + var postId = await SeedPostDirectlyAsync("My favorite post."); + + // We can use the endpoint here because ToggleBookmark is the feature we need to pre-load the DB, + // though a true purist would seed the Bookmark entity directly too! + await Client.PostAsync($"/api/posts/{postId}/bookmark?shouldBookmark=true", null); + + // Act + var response = await Client.GetAsync("/api/bookmarks?limit=10"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("My favorite post."); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/CommentsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/CommentsControllerTests.cs new file mode 100644 index 0000000..7d30902 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/CommentsControllerTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using SocialPluse.Shared.DTOs.Comments; +using SocialPluse.Shared.DTOs.Posts; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class CommentsControllerTests : BaseIntegrationTest + { + public CommentsControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + private async Task CreateTestPostAsync() + { + var request = new CreatePostRequest { Text = "A post to be commented on." }; + var response = await Client.PostAsJsonAsync("/api/posts", request); + response.EnsureSuccessStatusCode(); + + var post = await response.Content.ReadFromJsonAsync(); + return post!.Id; + } + + [Fact] + public async Task AddComment_WhenValidPayload_ShouldReturnOk() + { + // Arrange + var postId = await CreateTestPostAsync(); + var request = new CreateCommentRequest { Text = "This is a test comment!" }; + + // Act + var response = await Client.PostAsJsonAsync($"/api/posts/{postId}/comments", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetComments_WhenCommentsExist_ShouldReturnList() + { + // Arrange + var postId = await CreateTestPostAsync(); + var request = new CreateCommentRequest { Text = "My first comment." }; + await Client.PostAsJsonAsync($"/api/posts/{postId}/comments", request); + + // Act + var response = await Client.GetAsync($"/api/posts/{postId}/comments?limit=10"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // If your GetComments API returns a wrapper (like a PaginatedList), you might need to adjust this type + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Contain("My first comment."); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/FeedsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/FeedsControllerTests.cs new file mode 100644 index 0000000..f47ada2 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/FeedsControllerTests.cs @@ -0,0 +1,20 @@ +using FluentAssertions; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class FeedsControllerTests : BaseIntegrationTest + { + public FeedsControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + [Fact] + public async Task GetFeed_WhenCalled_ShouldReturnOk() + { + // Act + var response = await Client.GetAsync("/api/feed?limit=20"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/FollowsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/FollowsControllerTests.cs new file mode 100644 index 0000000..a00e52e --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/FollowsControllerTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class FollowsControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public FollowsControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + // Helper to seed a user directly into the DB so we have someone to follow + private async Task CreateTargetUserAsync() + { + var targetUserId = Guid.NewGuid(); + using var scope = _services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Users.Add(new AppUser + { + Id = targetUserId, + UserName = $"target_{targetUserId:N}", + Email = $"{targetUserId:N}@test.com" + }); + + await dbContext.SaveChangesAsync(); + return targetUserId; + } + + [Fact] + public async Task Follow_WhenUserExists_ShouldReturnOk() + { + // Arrange + var targetId = await CreateTargetUserAsync(); + + // Act + var response = await Client.PostAsync($"/api/follows/{targetId}", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetFollowStatus_ShouldReturnTrue_AfterFollowing() + { + // Arrange + var targetId = await CreateTargetUserAsync(); + await Client.PostAsync($"/api/follows/{targetId}", null); // Follow them + + // Act + var response = await Client.GetAsync($"/api/follows/{targetId}/status"); + + // Assert + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("true"); // Checks the { isFollowing: true } JSON payload + } + + [Fact] + public async Task Unfollow_WhenFollowing_ShouldReturnNoContent() + { + // Arrange + var targetId = await CreateTargetUserAsync(); + await Client.PostAsync($"/api/follows/{targetId}", null); // Follow them first + + // Act + var response = await Client.DeleteAsync($"/api/follows/{targetId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/LikesControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/LikesControllerTests.cs new file mode 100644 index 0000000..e44f29b --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/LikesControllerTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using SocialPluse.Shared.DTOs.Posts; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class LikesControllerTests : BaseIntegrationTest + { + public LikesControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + // Helper method to setup the required state + private async Task CreateTestPostAsync() + { + var request = new CreatePostRequest { Text = "A post to be liked." }; + var response = await Client.PostAsJsonAsync("/api/posts", request); + response.EnsureSuccessStatusCode(); + + var post = await response.Content.ReadFromJsonAsync(); + return post!.Id; + } + + [Fact] + public async Task LikePost_WhenValid_ShouldReturnOk() + { + // Arrange + var postId = await CreateTestPostAsync(); + + // Act + var response = await Client.PostAsync($"/api/posts/{postId}/likes", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UnlikePost_WhenPreviouslyLiked_ShouldReturnNoContent() + { + // Arrange + var postId = await CreateTestPostAsync(); + await Client.PostAsync($"/api/posts/{postId}/likes", null); // Like it first + + // Act + var response = await Client.DeleteAsync($"/api/posts/{postId}/likes"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/MutesControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/MutesControllerTests.cs new file mode 100644 index 0000000..47465ef --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/MutesControllerTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class MutesControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public MutesControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + private async Task CreateTargetUserAsync() + { + var targetUserId = Guid.NewGuid(); + using var scope = _services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Users.Add(new AppUser + { + Id = targetUserId, + UserName = $"mute_target_{targetUserId:N}", + Email = $"mute_{targetUserId:N}@test.com" + }); + + await dbContext.SaveChangesAsync(); + return targetUserId; + } + + [Fact] + public async Task MuteUser_WhenUserExists_ShouldReturnOk() + { + // Arrange + var targetId = await CreateTargetUserAsync(); + + // Act + var response = await Client.PostAsync($"/api/mutes/{targetId}", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UnmuteUser_WhenMuted_ShouldReturnNoContent() + { + // Arrange + var targetId = await CreateTargetUserAsync(); + await Client.PostAsync($"/api/mutes/{targetId}", null); // Mute them first + + // Act + var response = await Client.DeleteAsync($"/api/mutes/{targetId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/NotificationsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/NotificationsControllerTests.cs new file mode 100644 index 0000000..c3f77e3 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/NotificationsControllerTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class NotificationsControllerTests : BaseIntegrationTest + { + public NotificationsControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + [Fact] + public async Task GetNotifications_ShouldReturnOk() + { + // Act + var response = await Client.GetAsync("/api/notifications?limit=20"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task MarkAsRead_WhenNotificationDoesNotExist_ShouldReturnInternalServerErrorOrNotFound() + { + // Act + var randomId = Guid.NewGuid(); + var response = await Client.PostAsync($"/api/notifications/{randomId}/read", null); + + // Assert + // Depending on your GlobalExceptionMiddleware, this will likely be a 404 or 500 if the entity isn't found. + // We ensure it doesn't return a 200/204 for a fake ID. + response.StatusCode.Should().NotBe(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/PostsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/PostsControllerTests.cs new file mode 100644 index 0000000..27b9d32 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/PostsControllerTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using SocialPluse.Domain.Entities; +using SocialPluse.Shared.DTOs.Posts; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class PostsControllerTests : BaseIntegrationTest + { + public PostsControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + + + [Fact] + public async Task CreatePost_WhenValidPayload_ShouldPersistToDatabase() + { + // Act + var request = new CreatePostRequest { Text = "Integration testing is powerful!" }; + var response = await Client.PostAsJsonAsync("/api/posts", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var createdPost = await response.Content.ReadFromJsonAsync(); + createdPost.Should().NotBeNull(); + createdPost!.Text.Should().Be(request.Text); + } + + [Fact] + public async Task GetPost_WhenExists_ShouldReturnPost() + { + // 1. Create a post + var createResponse = await Client.PostAsJsonAsync("/api/posts", new CreatePostRequest { Text = "Fetch me!" }); + + // --- STOP HERE --- If this fails, the test stops and tells you WHY (the 401) + createResponse.EnsureSuccessStatusCode(); + var createdPost = await createResponse.Content.ReadFromJsonAsync(); + + // 2. Fetch it + var getResponse = await Client.GetAsync($"/api/posts/{createdPost!.Id}"); + + // 3. Assert + getResponse.EnsureSuccessStatusCode(); // This gives you a clear error if it's 401 + var fetchedPost = await getResponse.Content.ReadFromJsonAsync(); + fetchedPost!.Id.Should().Be(createdPost.Id); + } + + [Fact] + public async Task DeletePost_WhenOwner_ShouldReturnNoContent() + { + // 1. Create a post + var createResponse = await Client.PostAsJsonAsync("/api/posts", new CreatePostRequest { Text = "Delete me!" }); + createResponse.EnsureSuccessStatusCode(); + + // 2. Extract the Id from the response body + var createdPost = await createResponse.Content.ReadFromJsonAsync(); + + // Ensure we actually got an Id back + var postId = createdPost!.Id; + + // 3. Delete it using that specific postId + var deleteResponse = await Client.DeleteAsync($"/api/posts/{postId}"); + + // 4. Assert + deleteResponse.EnsureSuccessStatusCode(); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/ReportsControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/ReportsControllerTests.cs new file mode 100644 index 0000000..66ddf27 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/ReportsControllerTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Domain.Entities; +using SocialPluse.IntegrationTests.Authentication; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using SocialPluse.Shared.DTOs.Safety; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class ReportsControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public ReportsControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + [Fact] + public async Task ReportUser_WhenValidPayload_ShouldReturnOk() + { + // Arrange + var targetId = Guid.NewGuid(); + using (var scope = _services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Users.Add(new AppUser + { + Id = targetId, + UserName = $"report_target_{targetId:N}", + Email = $"report_{targetId:N}@test.com" + }); + await dbContext.SaveChangesAsync(); + } + + var request = new CreateReportRequest + { + TargetType = "user", + TargetId = targetId, + Reason = "Inappropriate content in bio." + }; + + // Act + var response = await Client.PostAsJsonAsync("/api/reports", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetMyReports_WhenReportsExist_ShouldReturnList() + { + // Arrange - Seed the report directly + var uniqueReason = $"Spam_{Guid.NewGuid():N}"; + using (var scope = _services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Reports.Add(new Report + { + Id = Guid.NewGuid(), + ReporterId = Guid.Parse(TestAuthHandler.TestUserId), + TargetType = "post", + TargetId = Guid.NewGuid(), + Reason = uniqueReason, + Status = "pending", + CreatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); + } + + // Act + var response = await Client.GetAsync("/api/reports/me"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(uniqueReason); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/SearchControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/SearchControllerTests.cs new file mode 100644 index 0000000..ce0f79d --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/SearchControllerTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Domain.Entities; +using SocialPluse.IntegrationTests.Authentication; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using System.Net; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class SearchControllerTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public SearchControllerTests(SocialPluseApiFactory factory) : base(factory) + { + _services = factory.Services; + } + + [Fact] + public async Task SearchPosts_WhenQueryMatches_ShouldReturnResults() + { + // Arrange - Seed directly (Using the globally guaranteed Test User!) + var uniqueText = $"Searchable_{Guid.NewGuid():N}"; + var existingAuthorId = Guid.Parse(TestAuthHandler.TestUserId); + + using (var scope = _services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // We completely bypass creating a user and just use the guaranteed existing one + dbContext.Posts.Add(new Post + { + Id = Guid.NewGuid(), + AuthorId = existingAuthorId, + Text = uniqueText, + CreatedAt = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + } + + // Act + var response = await Client.GetAsync($"/api/search/posts?q={uniqueText}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(uniqueText); + } + + [Fact] + public async Task SearchUsers_WhenQueryMatches_ShouldReturnResults() + { + // Arrange - Seed directly + var uniqueUsername = $"findme_{Guid.NewGuid():N}"; + using (var scope = _services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Users.Add(new AppUser + { + Id = Guid.NewGuid(), + UserName = uniqueUsername, + Email = $"{uniqueUsername}@test.com" + }); + await dbContext.SaveChangesAsync(); + } + + // Act + var response = await Client.GetAsync($"/api/search/users?q={uniqueUsername}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(uniqueUsername); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/ControllersTests/UsersControllerTests.cs b/SocialPluse.IntegrationTests/ControllersTests/UsersControllerTests.cs new file mode 100644 index 0000000..0fa50c0 --- /dev/null +++ b/SocialPluse.IntegrationTests/ControllersTests/UsersControllerTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using SocialPluse.Shared.DTOs.Users; +using System.Net; +using System.Net.Http.Json; + +namespace SocialPluse.IntegrationTests.ControllersTests +{ + public class UsersControllerTests : BaseIntegrationTest + { + public UsersControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + [Fact] + public async Task GetMe_ShouldReturnCurrentUserData() + { + // Act + var response = await Client.GetAsync("/api/users/me"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var userDetails = await response.Content.ReadFromJsonAsync(); + userDetails.Should().NotBeNull(); + } + + [Fact] + public async Task UpdateMe_WhenValidPayload_ShouldReturnOk() + { + // Arrange + var request = new UpdateProfileRequest + { + DisplayName = "Senior Architect", + Bio = "Strictly enforcing Clean Architecture boundaries." + }; + + // Act + var response = await Client.PutAsJsonAsync("/api/users/me", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/MediaControllerTests.cs b/SocialPluse.IntegrationTests/MediaControllerTests.cs new file mode 100644 index 0000000..a119fc3 --- /dev/null +++ b/SocialPluse.IntegrationTests/MediaControllerTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using System.Net; +using System.Net.Http.Headers; + +namespace SocialPluse.IntegrationTests +{ + public class MediaControllerTests : BaseIntegrationTest + { + public MediaControllerTests(SocialPluseApiFactory factory) : base(factory) { } + + [Fact] + public async Task Upload_WhenValidFile_ShouldReturnUrl() + { + // Arrange - Simulate an IFormFile upload payload + using var content = new MultipartFormDataContent(); + + // Create a dummy 10-byte "image" + var fileBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46 }; + var fileContent = new ByteArrayContent(fileBytes); + fileContent.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("image/jpeg"); + + content.Add(fileContent, "file", "test-upload.jpg"); + + // Act + var response = await Client.PostAsync("/api/media/upload", content); + + // Assert + var responseText = await response.Content.ReadAsStringAsync(); + + // If it fails, it will now print EXACTLY why in the test explorer! + response.IsSuccessStatusCode.Should().BeTrue( + $"because a valid file was uploaded. API returned: {response.StatusCode} - {responseText}"); + + responseText.Should().Contain("url"); + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/OutboxProcessorTests.cs b/SocialPluse.IntegrationTests/OutboxProcessorTests.cs new file mode 100644 index 0000000..5703a89 --- /dev/null +++ b/SocialPluse.IntegrationTests/OutboxProcessorTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SocialPluse.Domain.Entities; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Shared.DTOs.Posts; +using StackExchange.Redis; +using System.Net; +using System.Net.Http.Json; +using SocialPluse.IntegrationTests.Authentication; + +namespace SocialPluse.IntegrationTests +{ + public class OutboxProcessorTests : BaseIntegrationTest + { + private readonly IServiceProvider _services; + + public OutboxProcessorTests( + SocialPluseApiFactory factory) + : base(factory) + { + _services = factory.Services; + } + + [Fact] + public async Task PostCreation_ShouldPublishOutboxMessage_AndDispatcherShouldExecuteCleanly() + { + // Arrange + var testFollowerId = Guid.NewGuid(); + var authorId = Guid.Parse(TestAuthHandler.TestUserId); + + using (var arrangeScope = _services.CreateScope()) + { + var dbContext = + arrangeScope.ServiceProvider + .GetRequiredService(); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + TRUNCATE TABLE "OutboxMessages" RESTART IDENTITY CASCADE; + TRUNCATE TABLE "IdempotentRecords" RESTART IDENTITY CASCADE; + """); + + // Ensure follower user exists + var followerExists = + await dbContext.Users + .AnyAsync(u => u.Id == testFollowerId); + + if (!followerExists) + { + dbContext.Users.Add(new AppUser + { + Id = testFollowerId, + UserName = "testfollower", + Email = "follower@test.com" + }); + } + + // Ensure follow relationship exists + var followExists = + await dbContext.Follows + .AnyAsync(f => + f.FollowerId == testFollowerId && + f.FolloweeId == authorId); + + if (!followExists) + { + dbContext.Follows.Add(new Follow + { + FollowerId = testFollowerId, + FolloweeId = authorId + }); + } + + await dbContext.SaveChangesAsync(); + } + + // Act 1 - Create Post + var request = new CreatePostRequest + { + Text = "Testing deterministic outbox execution!" + }; + + var response = + await Client.PostAsJsonAsync( + "/api/posts", + request); + + response.StatusCode.Should() + .Be(HttpStatusCode.Created); + + // Assert Phase A - Verify outbox message created + Guid outboxMessageId; + + using (var assertionScope = _services.CreateScope()) + { + var dbContext = + assertionScope.ServiceProvider + .GetRequiredService(); + + var outboxMessage = + await dbContext.OutboxMessages + .AsNoTracking() + .FirstOrDefaultAsync(m => + m.Type == "FanoutPostToFeed"); + + outboxMessage.Should().NotBeNull(); + + outboxMessage!.ProcessedOnUtc + .Should() + .BeNull(); + + outboxMessage.Error + .Should() + .BeNull(); + + outboxMessageId = outboxMessage.Id; + } + + // Act 2 - Deterministically execute dispatcher + using (var processingScope = _services.CreateScope()) + { + var dispatcher = + processingScope.ServiceProvider + .GetRequiredService(); + + await dispatcher.DispatchPendingMessagesAsync(); + } + + // Assert Phase B - Verify database state AFTER processing + using (var verificationScope = _services.CreateScope()) + { + var dbContext = + verificationScope.ServiceProvider + .GetRequiredService(); + + var updatedMessage = + await dbContext.OutboxMessages + .AsNoTracking() + .FirstAsync(m => + m.Id == outboxMessageId); + + updatedMessage.Error + .Should() + .BeNull( + $"because processing should succeed. Error: {updatedMessage.Error}"); + + updatedMessage.ProcessedOnUtc + .Should() + .NotBeNull(); + + // Verify idempotency shield written + var shieldExists = + await dbContext.IdempotentRecords + .AnyAsync(r => + r.Id == outboxMessageId); + + shieldExists.Should().BeTrue(); + } + + // Assert Phase C - Verify Redis fanout actually happened + using (var redisScope = _services.CreateScope()) + { + var redis = + redisScope.ServiceProvider + .GetRequiredService(); + + var db = redis.GetDatabase(); + + var feedKey = $"feed:{testFollowerId}"; + + var cacheExists = + await db.KeyExistsAsync(feedKey); + + cacheExists.Should() + .BeTrue( + "because the post should be fanned out into the follower feed."); + } + } + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/SharedTestCollection.cs b/SocialPluse.IntegrationTests/SharedTestCollection.cs new file mode 100644 index 0000000..c00d516 --- /dev/null +++ b/SocialPluse.IntegrationTests/SharedTestCollection.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace SocialPluse.IntegrationTests +{ + // The string name must match exactly in the [Collection] attributes later + [CollectionDefinition("Shared API Collection")] + public class SharedTestCollection : ICollectionFixture + { + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. + } +} \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/SocialPluse.IntegrationTests.csproj b/SocialPluse.IntegrationTests/SocialPluse.IntegrationTests.csproj new file mode 100644 index 0000000..94167e0 --- /dev/null +++ b/SocialPluse.IntegrationTests/SocialPluse.IntegrationTests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SocialPluse.IntegrationTests/SocialPluseApiFactory.cs b/SocialPluse.IntegrationTests/SocialPluseApiFactory.cs new file mode 100644 index 0000000..0a43cc7 --- /dev/null +++ b/SocialPluse.IntegrationTests/SocialPluseApiFactory.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; // Required for configuration override +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Persistence.IdentityData.Entities; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; +using SocialPluse.IntegrationTests.Authentication; + +namespace SocialPluse.IntegrationTests +{ + public class SocialPluseApiFactory : WebApplicationFactory, IAsyncLifetime + { + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("socialpulse_test") + .WithUsername("testuser") + .WithPassword("testpass") + .Build(); + + private readonly RedisContainer _redisContainer = new RedisBuilder("redis:7-alpine") + .Build(); + + public async Task InitializeAsync() + { + await Task.WhenAll(_dbContainer.StartAsync(), _redisContainer.StartAsync()); + + // Calling 'Services' triggers the Host to build. Because we injected the config below, + // Program.cs will boot up smoothly using the Docker containers! + using var scope = Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + + if (!context.Users.Any(u => u.Id == Guid.Parse(TestAuthHandler.TestUserId))) + { + context.Users.Add(new AppUser + { + Id = Guid.Parse(TestAuthHandler.TestUserId), + UserName = "testuser", + NormalizedUserName = "TESTUSER", // Required by Identity validation + Email = "test@socialpulse.com", + NormalizedEmail = "TEST@SOCIALPULSE.COM", // Required by Identity validation + SecurityStamp = Guid.NewGuid().ToString(), // CRITICAL: Required for UpdateAsync + ConcurrencyStamp = Guid.NewGuid().ToString() // CRITICAL: Required for UpdateAsync + }); + + await context.SaveChangesAsync(); + } + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // --- THE MASTER STROKE --- + // We dynamically replace the connection strings in the configuration. + // When DependencyInjection.cs calls config.GetConnectionString("Redis"), + // it will pull these Testcontainer strings instead of appsettings.json! + builder.ConfigureAppConfiguration((context, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:Postgres", _dbContainer.GetConnectionString() }, + { "ConnectionStrings:Redis", _redisContainer.GetConnectionString() } + }); + }); + + builder.ConfigureTestServices(services => + { + // 1. Bypass Auth + services.AddAuthentication(options => { + options.DefaultAuthenticateScheme = "Test"; + options.DefaultChallengeScheme = "Test"; + }).AddScheme("Test", options => { }); + + // Notice we NO LONGER need to override DbContext, RedisCache, or ConnectionMultiplexer here! + // Because we fixed the configuration above, the production registrations work perfectly. + + // 2. STRIP OUT AUTOMATIC BACKGROUND RUNNERS + var outboxHostedService = services.SingleOrDefault(d => d.ImplementationType == typeof(SocialPluse.Persistence.BackgroundJobs.OutboxProcessor)); + if (outboxHostedService != null) services.Remove(outboxHostedService); + + var hangfireHostedService = services.SingleOrDefault(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType?.Name == "BackgroundJobServerHostedService"); + if (hangfireHostedService != null) services.Remove(hangfireHostedService); + }); + + builder.ConfigureLogging(logging => { + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Debug); + }); + } + + public new async Task DisposeAsync() + { + await Task.WhenAll(_dbContainer.DisposeAsync().AsTask(), _redisContainer.DisposeAsync().AsTask()); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Persistence/BackgroundJobs/OutboxDispatcher.cs b/SocialPluse.Persistence/BackgroundJobs/OutboxDispatcher.cs new file mode 100644 index 0000000..9accd89 --- /dev/null +++ b/SocialPluse.Persistence/BackgroundJobs/OutboxDispatcher.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SocialPluse.Domain.Entities; +using SocialPluse.Persistence.DbContexts; +using SocialPluse.Services.Abstraction.IService; +using System.Text.Json; + +namespace SocialPluse.Persistence.BackgroundJobs +{ + public class OutboxDispatcher : IOutboxDispatcher + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public OutboxDispatcher( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task DispatchPendingMessagesAsync( + CancellationToken cancellationToken = default) + { + using var scope = _serviceProvider.CreateScope(); + + var dbContext = + scope.ServiceProvider.GetRequiredService(); + + var handlers = + scope.ServiceProvider + .GetServices(); + + var handlerMap = + handlers.ToDictionary( + h => h.HandledMessageType); + + var messages = await dbContext.OutboxMessages + .FromSqlRaw(@" + SELECT * + FROM ""OutboxMessages"" + WHERE ""ProcessedOnUtc"" IS NULL + ORDER BY ""OccurredOnUtc"" + FOR UPDATE SKIP LOCKED + LIMIT 20") + .ToListAsync(cancellationToken); + + if (messages.Count == 0) + { + return; + } + + foreach (var message in messages) + { + try + { + var alreadyProcessed = + await dbContext.IdempotentRecords + .AnyAsync( + r => r.Id == message.Id, + cancellationToken); + + if (alreadyProcessed) + { + _logger.LogInformation( + "Idempotency shield activated for message {MessageId}", + message.Id); + + message.ProcessedOnUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + continue; + } + + if (!handlerMap.TryGetValue( + message.Type, + out var handler)) + { + message.Error = + $"No handler registered for message type '{message.Type}'"; + + await dbContext.SaveChangesAsync(cancellationToken); + + continue; + } + + using var document = + JsonDocument.Parse(message.Content); + + await using var transaction = + await dbContext.Database + .BeginTransactionAsync(cancellationToken); + + await handler.HandleAsync( + message.Id, + document, + cancellationToken); + + var shieldRecord = + IdempotentRecord.Create( + message.Id, + message.Type); + + dbContext.IdempotentRecords.Add(shieldRecord); + + message.ProcessedOnUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch (Exception ex) + { + message.Error = ex.ToString(); + + await dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogError( + ex, + "Failed processing outbox message {MessageId}", + message.Id); + } + } + } + } +} \ No newline at end of file diff --git a/SocialPluse.Persistence/BackgroundJobs/OutboxProcessor.cs b/SocialPluse.Persistence/BackgroundJobs/OutboxProcessor.cs index c9ec26a..df0de8f 100644 --- a/SocialPluse.Persistence/BackgroundJobs/OutboxProcessor.cs +++ b/SocialPluse.Persistence/BackgroundJobs/OutboxProcessor.cs @@ -1,113 +1,42 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using SocialPluse.Domain.Entities; -using SocialPluse.Persistence.DbContexts; using SocialPluse.Services.Abstraction.IService; -using System.Text.Json; namespace SocialPluse.Persistence.BackgroundJobs { public class OutboxProcessor : BackgroundService { - private readonly IServiceProvider _serviceProvider; + private readonly IOutboxDispatcher _dispatcher; private readonly ILogger _logger; - public OutboxProcessor(IServiceProvider serviceProvider, ILogger logger) + public OutboxProcessor( + IOutboxDispatcher dispatcher, + ILogger logger) { - _serviceProvider = serviceProvider; + _dispatcher = dispatcher; _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync( + CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { - await ProcessOutboxMessagesAsync(stoppingToken); + await _dispatcher.DispatchPendingMessagesAsync( + stoppingToken); } catch (Exception ex) { - _logger.LogError(ex, "Critical failure in OutboxProcessor sweep."); + _logger.LogError( + ex, + "Critical failure in OutboxProcessor."); } - await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); - } - } - - private async Task ProcessOutboxMessagesAsync(CancellationToken stoppingToken) - { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var handlers = scope.ServiceProvider.GetServices(); - var handlerMap = handlers.ToDictionary(h => h.HandledMessageType); - - var messages = await dbContext.OutboxMessages - .FromSqlRaw(@" - SELECT * FROM ""OutboxMessages"" - WHERE ""ProcessedOnUtc"" IS NULL - ORDER BY ""OccurredOnUtc"" - FOR UPDATE SKIP LOCKED - LIMIT 20") - .ToListAsync(stoppingToken); - - if (messages.Count == 0) return; - - foreach (var message in messages) - { - try - { - // 1. THE IDEMPOTENCY CHECK - // Has this exact message ID already been processed successfully in the past? - var alreadyProcessed = await dbContext.IdempotentRecords - .AnyAsync(r => r.Id == message.Id, stoppingToken); - - if (alreadyProcessed) - { - // A ghost retry! Mark the outbox as processed and skip execution. - _logger.LogInformation("Idempotency shield activated. Skipping duplicate message {Id}", message.Id); - message.ProcessedOnUtc = DateTime.UtcNow; - continue; - } - - if (handlerMap.TryGetValue(message.Type, out var handler)) - { - using var doc = JsonDocument.Parse(message.Content); - - // 2. ATOMIC EXECUTION (Wrap the business logic and the idempotency record together) - using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); - - // Execute the business logic - await handler.HandleAsync(message.Id, doc, stoppingToken); - - // Add the shield record so this can never run again - var shieldRecord = IdempotentRecord.Create(message.Id, message.Type); - dbContext.IdempotentRecords.Add(shieldRecord); - - // Mark the outbox message as complete - message.ProcessedOnUtc = DateTime.UtcNow; - - // Save everything atomically - await dbContext.SaveChangesAsync(stoppingToken); - await transaction.CommitAsync(stoppingToken); - } - else - { - _logger.LogWarning("No handler registered for Outbox Message Type: {Type}", message.Type); - } - } - catch (Exception ex) - { - message.Error = ex.Message; - _logger.LogError(ex, "Failed to process Outbox Message {Id}", message.Id); - - // We save the error immediately, but DO NOT write to IdempotentRecords. - // This allows the system to retry it on the next sweep. - await dbContext.SaveChangesAsync(stoppingToken); - } + await Task.Delay( + TimeSpan.FromSeconds(10), + stoppingToken); } } } diff --git a/SocialPluse.Persistence/DependencyInjection.cs b/SocialPluse.Persistence/DependencyInjection.cs index 5308e9a..4a73e64 100644 --- a/SocialPluse.Persistence/DependencyInjection.cs +++ b/SocialPluse.Persistence/DependencyInjection.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using SocialPluse.Persistence.BackgroundJobs; using SocialPluse.Persistence.DbContexts; using SocialPluse.Persistence.IdentityData.Entities; using SocialPluse.Persistence.Repositories; @@ -36,11 +37,11 @@ public static IServiceCollection AddPersistence(this IServiceCollection services services.AddScoped(); - services.AddScoped(); - + services.AddScoped(); + // Database configuration services.AddDbContext(options => diff --git a/SocialPluse.Persistence/Repositories/FollowRepository.cs b/SocialPluse.Persistence/Repositories/FollowRepository.cs index 5400d5f..1c09256 100644 --- a/SocialPluse.Persistence/Repositories/FollowRepository.cs +++ b/SocialPluse.Persistence/Repositories/FollowRepository.cs @@ -64,5 +64,14 @@ public async Task> GetRecommendedUserIdsAsync(Guid userId, int limit) .Take(limit) .ToListAsync(); } + + + public async Task> GetFollowerIdsAsync(Guid followeeId, CancellationToken cancellationToken = default) + { + return await _context.Follows + .Where(f => f.FolloweeId == followeeId) + .Select(f => f.FollowerId) + .ToListAsync(cancellationToken); + } } } \ No newline at end of file diff --git a/SocialPluse.Persistence/Services/LocalMediaService.cs b/SocialPluse.Persistence/Services/LocalMediaService.cs index 96ec032..85d44ba 100644 --- a/SocialPluse.Persistence/Services/LocalMediaService.cs +++ b/SocialPluse.Persistence/Services/LocalMediaService.cs @@ -11,8 +11,12 @@ public class LocalMediaService : IMediaService public LocalMediaService(IWebHostEnvironment env, IHttpContextAccessor httpContextAccessor) { - // Define the physical path on the server - _uploadsFolder = Path.Combine(env.WebRootPath, "uploads"); + // Gracefully handle test environments where WebRootPath is null + var rootPath = string.IsNullOrWhiteSpace(env.WebRootPath) + ? env.ContentRootPath + : env.WebRootPath; + + _uploadsFolder = Path.Combine(rootPath, "uploads"); _httpContextAccessor = httpContextAccessor; } diff --git a/SocialPluse.Services.Abstraction/IRepositories/IFollowRepository.cs b/SocialPluse.Services.Abstraction/IRepositories/IFollowRepository.cs index 6d25109..9f598a5 100644 --- a/SocialPluse.Services.Abstraction/IRepositories/IFollowRepository.cs +++ b/SocialPluse.Services.Abstraction/IRepositories/IFollowRepository.cs @@ -15,5 +15,7 @@ public interface IFollowRepository Task<(int Followers, int Following)> GetFollowStatsAsync(Guid userId); Task> GetRecommendedUserIdsAsync(Guid userId, int limit); + + Task> GetFollowerIdsAsync(Guid followeeId, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/SocialPluse.Services.Abstraction/IService/IOutboxDispatcher.cs b/SocialPluse.Services.Abstraction/IService/IOutboxDispatcher.cs new file mode 100644 index 0000000..1768c16 --- /dev/null +++ b/SocialPluse.Services.Abstraction/IService/IOutboxDispatcher.cs @@ -0,0 +1,8 @@ +namespace SocialPluse.Services.Abstraction.IService +{ + public interface IOutboxDispatcher + { + Task DispatchPendingMessagesAsync( + CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/SocialPluse.Services.Tests.csproj b/SocialPluse.Services.Tests/SocialPluse.Services.Tests.csproj new file mode 100644 index 0000000..f1ed5ea --- /dev/null +++ b/SocialPluse.Services.Tests/SocialPluse.Services.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/AnalyticsServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/AnalyticsServiceTests.cs new file mode 100644 index 0000000..3567220 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/AnalyticsServiceTests.cs @@ -0,0 +1,34 @@ +using Moq; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Services; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class AnalyticsServiceTests + { + private readonly Mock _mockRepo; + private readonly AnalyticsService _sut; + + public AnalyticsServiceTests() + { + _mockRepo = new Mock(); + _sut = new AnalyticsService(_mockRepo.Object); + } + + [Fact] + public async Task GetTrendingTopicsAsync_ShouldExtractHashtagsCorrectly() + { + // Arrange + var posts = new List { "I love #coding and #dotnet", "Learning #dotnet is fun" }; + _mockRepo.Setup(r => r.GetRecentPostTextsAsync(It.IsAny(), It.IsAny(), 3000)) + .ReturnsAsync(posts); + + // Act + var result = await _sut.GetTrendingTopicsAsync(Guid.NewGuid()); + + // Assert + Assert.Contains(result, r => r.Hashtag == "#dotnet" && r.Mentions == 2); + Assert.Contains(result, r => r.Hashtag == "#coding" && r.Mentions == 1); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/AuthServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/AuthServiceTests.cs new file mode 100644 index 0000000..ed33b7b --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/AuthServiceTests.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Persistence.IdentityData.Entities; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Auth; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class AuthServiceTests + { + private readonly Mock> _mockUserManager; + private readonly Mock _mockConfig; + private readonly Mock> _mockLogger; + private readonly AuthService _sut; + + public AuthServiceTests() + { + // UserManager requires a mock UserStore to initialize properly + var mockUserStore = new Mock>(); + _mockUserManager = new Mock>(mockUserStore.Object, null!, null!, null!, null!, null!, null!, null!, null!); + + _mockConfig = new Mock(); + _mockLogger = new Mock>(); + + _sut = new AuthService(_mockUserManager.Object, _mockConfig.Object, _mockLogger.Object); + } + + [Fact] + public async Task LoginAsync_WhenUserNotFound_ShouldThrowUnauthorized() + { + // Arrange + var request = new LoginRequest { Email = "test@test.com", Password = "Password123!" }; + _mockUserManager.Setup(um => um.FindByEmailAsync(request.Email)).ReturnsAsync((AppUser?)null); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _sut.LoginAsync(request)); + Assert.Equal("Invalid credentials.", ex.Message); + } + + [Fact] + public async Task LoginAsync_WhenPasswordIsIncorrect_ShouldThrowUnauthorized() + { + // Arrange + var request = new LoginRequest { Email = "test@test.com", Password = "WrongPassword!" }; + var user = new AppUser { Email = "test@test.com" }; + + _mockUserManager.Setup(um => um.FindByEmailAsync(request.Email)).ReturnsAsync(user); + _mockUserManager.Setup(um => um.CheckPasswordAsync(user, request.Password)).ReturnsAsync(false); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _sut.LoginAsync(request)); + Assert.Equal("Invalid credentials.", ex.Message); + } + + [Fact] + public async Task RegisterAsync_WhenUsernameTaken_ShouldThrowInvalidOperationException() + { + // Arrange + var request = new RegisterRequest { Username = "takenUser", Email = "test@test.com", Password = "Password123!" }; + + _mockUserManager.Setup(um => um.FindByNameAsync(request.Username)).ReturnsAsync(new AppUser()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _sut.RegisterAsync(request)); + Assert.Equal("Username is already taken.", ex.Message); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/BookmarkServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/BookmarkServiceTests.cs new file mode 100644 index 0000000..c660c09 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/BookmarkServiceTests.cs @@ -0,0 +1,36 @@ +using Moq; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class BookmarkServiceTests + { + private readonly Mock _mockRepo; + private readonly Mock _mockEnricher; + private readonly BookmarkService _sut; + + public BookmarkServiceTests() + { + _mockRepo = new Mock(); + _mockEnricher = new Mock(); + _sut = new BookmarkService(_mockRepo.Object, _mockEnricher.Object); + } + + [Fact] + public async Task ToggleBookmarkAsync_WhenAlreadyBookmarked_ShouldReturnTrueAndDoNothing() + { + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + + _mockRepo.Setup(r => r.BeginTransactionAsync()).ReturnsAsync(new Mock().Object); + _mockRepo.Setup(r => r.BookmarkExistsAsync(userId, postId)).ReturnsAsync(true); + + var result = await _sut.ToggleBookmarkAsync(userId, postId, true); + + Assert.True(result); + _mockRepo.Verify(r => r.AddBookmarkAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/CommentServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/CommentServiceTests.cs new file mode 100644 index 0000000..f599233 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/CommentServiceTests.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Comments; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class CommentServiceTests + { + private readonly Mock _mockRepo; + private readonly Mock _mockUserRepo; + private readonly Mock _mockJobPublisher; + private readonly Mock> _mockLogger; + private readonly CommentService _sut; + + public CommentServiceTests() + { + _mockRepo = new Mock(); + _mockUserRepo = new Mock(); + _mockJobPublisher = new Mock(); + _mockLogger = new Mock>(); + _sut = new CommentService(_mockRepo.Object, _mockUserRepo.Object, _mockJobPublisher.Object, _mockLogger.Object); + } + + [Fact] + public async Task CreateCommentAsync_WhenPostMissing_ShouldThrowKeyNotFound() + { + _mockRepo.Setup(r => r.GetPostAuthorIdAsync(It.IsAny())).ReturnsAsync((Guid?)null); + + await Assert.ThrowsAsync(() => + _sut.CreateCommentAsync(Guid.NewGuid(), Guid.NewGuid(), new CreateCommentRequest { Text = "Hi" })); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/FeedServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/FeedServiceTests.cs new file mode 100644 index 0000000..6beb09c --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/FeedServiceTests.cs @@ -0,0 +1,45 @@ +using Moq; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class FeedServiceTests + { + private readonly Mock _mockRepo; + private readonly Mock _mockCache; + private readonly Mock _mockEnricher; + private readonly FeedService _sut; + + public FeedServiceTests() + { + _mockRepo = new Mock(); + _mockCache = new Mock(); + _mockEnricher = new Mock(); + _sut = new FeedService(_mockRepo.Object, _mockCache.Object, _mockEnricher.Object); + } + + [Fact] + public async Task GetFeedFromCacheAsync_WhenCacheEmpty_CallsDatabaseFeed() + { + // Arrange + var userId = Guid.NewGuid(); + var limit = 10; + + // 1. Setup cache to return empty + _mockCache.Setup(c => c.GetCachedFeedAsync(userId, null, limit)) + .ReturnsAsync((new List(), null)); + + // 2. IMPORTANT: Setup repository to return an empty list instead of null! + _mockRepo.Setup(r => r.GetFeedPostsAsync(userId, null, limit)) + .ReturnsAsync(new List()); // Return an empty list, not null + + // Act + await _sut.GetFeedFromCacheAsync(userId, null, limit); + + // Assert + _mockRepo.Verify(r => r.GetFeedPostsAsync(userId, null, limit), Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/FollowServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/FollowServiceTests.cs new file mode 100644 index 0000000..08fc20d --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/FollowServiceTests.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class FollowServiceTests + { + private readonly Mock _mockFollowRepo; + private readonly Mock _mockUserRepo; + private readonly Mock _mockJobPublisher; + private readonly Mock> _mockLogger; + private readonly Mock _mockTransaction; + private readonly FollowService _sut; + + public FollowServiceTests() + { + _mockFollowRepo = new Mock(); + _mockUserRepo = new Mock(); + _mockJobPublisher = new Mock(); + _mockLogger = new Mock>(); + _mockTransaction = new Mock(); + + _mockFollowRepo.Setup(repo => repo.BeginTransactionAsync()).ReturnsAsync(_mockTransaction.Object); + + _sut = new FollowService(_mockFollowRepo.Object, _mockUserRepo.Object, _mockJobPublisher.Object, _mockLogger.Object); + } + + [Fact] + public async Task FollowAsync_WhenFollowingSelf_ShouldThrowInvalidOperationException() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _sut.FollowAsync(userId, userId)); + Assert.Equal("You cannot follow yourself.", ex.Message); + } + + [Fact] + public async Task FollowAsync_WhenTargetUserDoesNotExist_ShouldThrowKeyNotFoundException() + { + // Arrange + var followerId = Guid.NewGuid(); + var followeeId = Guid.NewGuid(); + + _mockUserRepo.Setup(repo => repo.UserExistsAsync(followeeId)).ReturnsAsync(false); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _sut.FollowAsync(followerId, followeeId)); + Assert.Equal("User not found.", ex.Message); + } + + [Fact] + public async Task FollowAsync_WhenValid_ShouldSaveAndEnqueueJobs() + { + // Arrange + var followerId = Guid.NewGuid(); + var followeeId = Guid.NewGuid(); + + _mockUserRepo.Setup(repo => repo.UserExistsAsync(followeeId)).ReturnsAsync(true); + _mockFollowRepo.Setup(repo => repo.GetFollowAsync(followerId, followeeId)).ReturnsAsync((Follow?)null); + + // Act + var result = await _sut.FollowAsync(followerId, followeeId); + + // Assert + Assert.NotNull(result); + _mockFollowRepo.Verify(repo => repo.AddAsync(It.IsAny()), Times.Once); + _mockFollowRepo.Verify(repo => repo.SaveChangesAsync(), Times.Once); + _mockTransaction.Verify(tx => tx.CommitAsync(), Times.Once); + + // Verify both background jobs fired + _mockJobPublisher.Verify(pub => pub.EnqueueFollowNotificationJob(followeeId, followerId), Times.Once); + _mockJobPublisher.Verify(pub => pub.EnqueueBackfillFeedJob(followerId, followeeId), Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/LikeServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/LikeServiceTests.cs new file mode 100644 index 0000000..727a855 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/LikeServiceTests.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class LikeServiceTests + { + // 1. Define our Fakes (Mocks) + private readonly Mock _mockLikeRepo; + private readonly Mock _mockJobPublisher; + private readonly Mock> _mockLogger; + private readonly Mock _mockTransaction; // Added to fake EF transactions + + // 2. Define the System Under Test (SUT) + private readonly LikeService _sut; + + public LikeServiceTests() + { + // Initialize the mocks + _mockLikeRepo = new Mock(); + _mockJobPublisher = new Mock(); + _mockLogger = new Mock>(); + _mockTransaction = new Mock(); + + // By default, make our fake repository return a valid transaction wrapper on happy paths + _mockLikeRepo + .Setup(repo => repo.BeginTransactionAsync()) + .ReturnsAsync(_mockTransaction.Object); + + // Inject the fakes into the real service + _sut = new LikeService(_mockLikeRepo.Object, _mockJobPublisher.Object, _mockLogger.Object); + } + + // --- TEST 1: Post Does Not Exist --- + [Fact] + public async Task LikePostAsync_WhenPostDoesNotExist_ShouldThrowKeyNotFoundException() + { + // Arrange + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + + // We instruct our fake repository to return 'null' when GetPostAuthorIdAsync is called + _mockLikeRepo + .Setup(repo => repo.GetPostAuthorIdAsync(postId)) + .ReturnsAsync((Guid?)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.LikePostAsync(userId, postId)); + + // Verify the exception message matches your implementation exactly + Assert.Equal($"Post with id {postId} not found.", exception.Message); + + // Verify that the database AddAsync was NEVER called, because it failed early + _mockLikeRepo.Verify(repo => repo.AddAsync(It.IsAny()), Times.Never); + } + + // --- TEST 2: Duplicate Like Protection --- + [Fact] + public async Task LikePostAsync_WhenUserAlreadyLikedPost_ShouldThrowInvalidOperationException() + { + // Arrange + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + var authorId = Guid.NewGuid(); // Valid post author exists + + // 1. Tell repo the post exists by returning an author ID + _mockLikeRepo + .Setup(repo => repo.GetPostAuthorIdAsync(postId)) + .ReturnsAsync(authorId); + + // 2. Tell repo an existing like already structurally exists for this specific combination + _mockLikeRepo + .Setup(repo => repo.GetLikeAsync(userId, postId)) + .ReturnsAsync(new Like { UserId = userId, PostId = postId }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.LikePostAsync(userId, postId)); + + // Verify early execution safety message + Assert.Equal($"User with id {userId} already liked post with id {postId}.", exception.Message); + + // Verify that no duplicate writes or saves were committed to the underlying persistence layers + _mockLikeRepo.Verify(repo => repo.AddAsync(It.IsAny()), Times.Never); + _mockTransaction.Verify(tx => tx.CommitAsync(), Times.Never); + } + + // --- TEST 3: Happy Path (Valid Write & Outbox Registration) --- + [Fact] + public async Task LikePostAsync_WhenValid_ShouldAddLikeAndEnqueueJob() + { + // Arrange + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + var authorId = Guid.NewGuid(); // Different from userId to ensure notification condition triggers + + // 1. Setup valid post author ownership + _mockLikeRepo + .Setup(repo => repo.GetPostAuthorIdAsync(postId)) + .ReturnsAsync(authorId); + + // 2. Return null to prove the user has NOT liked this post yet + _mockLikeRepo + .Setup(repo => repo.GetLikeAsync(userId, postId)) + .ReturnsAsync((Like?)null); + + // Act + var result = await _sut.LikePostAsync(userId, postId); + + // Assert + Assert.NotNull(result); + Assert.Equal(userId, result.UserId); + Assert.Equal(postId, result.PostId); + + // Assert: Verify state mutation interactions processed exactly once inside the pipeline boundaries + _mockLikeRepo.Verify(repo => repo.AddAsync(It.Is(l => l.UserId == userId && l.PostId == postId)), Times.Once); + _mockLikeRepo.Verify(repo => repo.SaveChangesAsync(), Times.Once); + _mockTransaction.Verify(tx => tx.CommitAsync(), Times.Once); + + // Assert: Prove the background Hangfire Outbox job payload was safely dispatched + _mockJobPublisher.Verify(pub => pub.EnqueueLikeNotificationJob(authorId, userId, postId), Times.Once); + } + + // --- TEST 4: Unlike Post Does Not Exist --- + [Fact] + public async Task UnlikePostAsync_WhenLikeDoesNotExist_ShouldThrowKeyNotFoundException() + { + // Arrange + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + + _mockLikeRepo.Setup(repo => repo.GetLikeAsync(userId, postId)).ReturnsAsync((Like?)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.UnlikePostAsync(userId, postId)); + + Assert.Equal($"Like by user with id {userId} on post with id {postId} not found.", exception.Message); + _mockLikeRepo.Verify(repo => repo.Remove(It.IsAny()), Times.Never); + } + + // --- TEST 5: Unlike Post Happy Path --- + [Fact] + public async Task UnlikePostAsync_WhenValid_ShouldRemoveLikeAndSave() + { + // Arrange + var userId = Guid.NewGuid(); + var postId = Guid.NewGuid(); + var existingLike = new Like { UserId = userId, PostId = postId }; + + _mockLikeRepo.Setup(repo => repo.GetLikeAsync(userId, postId)).ReturnsAsync(existingLike); + + // Act + await _sut.UnlikePostAsync(userId, postId); + + // Assert + _mockLikeRepo.Verify(repo => repo.Remove(existingLike), Times.Once); + _mockLikeRepo.Verify(repo => repo.SaveChangesAsync(), Times.Once); + } + + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/NotificationServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/NotificationServiceTests.cs new file mode 100644 index 0000000..c720d54 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/NotificationServiceTests.cs @@ -0,0 +1,41 @@ +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Domain.Enums; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Notifications; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class NotificationServiceTests + { + private readonly Mock _mockRepo; + private readonly Mock _mockUserRepo; + private readonly Mock _mockSender; + private readonly NotificationService _sut; + + public NotificationServiceTests() + { + _mockRepo = new Mock(); + _mockUserRepo = new Mock(); + _mockSender = new Mock(); + _sut = new NotificationService(_mockRepo.Object, _mockUserRepo.Object, _mockSender.Object); + } + + [Fact] + public async Task CreateReportNotificationAsync_ShouldUseAnonymousActor() + { + // Act + await _sut.CreateReportNotificationAsync(Guid.NewGuid(), Guid.NewGuid()); + + // Assert: Use NotificationDto explicitly to satisfy the compiler and the Moq expression tree + _mockRepo.Verify(r => r.AddAsync(It.Is(n => n.Type == NotificationType.Report)), Times.Once); + + _mockSender.Verify(s => s.SendAsync( + It.IsAny(), + It.Is(dto => dto.ActorUsername == "Anonymous")), + Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/PostServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/PostServiceTests.cs new file mode 100644 index 0000000..9cd9f09 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/PostServiceTests.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Posts; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class PostServiceTests + { + private readonly Mock _mockPostRepo; + private readonly Mock _mockUserRepo; + private readonly Mock _mockJobPublisher; + private readonly Mock> _mockLogger; + private readonly Mock _mockTransaction; + private readonly PostService _sut; + + public PostServiceTests() + { + _mockPostRepo = new Mock(); + _mockUserRepo = new Mock(); + _mockJobPublisher = new Mock(); + _mockLogger = new Mock>(); + _mockTransaction = new Mock(); + + _mockPostRepo.Setup(r => r.BeginTransactionAsync()).ReturnsAsync(_mockTransaction.Object); + + _sut = new PostService( + _mockPostRepo.Object, + _mockUserRepo.Object, + _mockJobPublisher.Object, + _mockLogger.Object); + } + + [Fact] + public async Task CreatePostAsync_WhenUserDoesNotExist_ShouldThrowKeyNotFound() + { + // Arrange + _mockUserRepo.Setup(r => r.GetUsernameAsync(It.IsAny())).ReturnsAsync((string?)null); + + // Act & Assert + await Assert.ThrowsAsync(() => + _sut.CreatePostAsync(Guid.NewGuid(), new CreatePostRequest { Text = "Hello" })); + } + + [Fact] + public async Task CreatePostAsync_WhenValid_ShouldSaveAndEnqueueFanout() + { + // Arrange + var userId = Guid.NewGuid(); + var request = new CreatePostRequest { Text = "Valid post" }; + _mockUserRepo.Setup(r => r.GetUsernameAsync(userId)).ReturnsAsync("testuser"); + + // Act + var result = await _sut.CreatePostAsync(userId, request); + + // Assert + Assert.NotNull(result); + _mockPostRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Once); + _mockJobPublisher.Verify(p => p.EnqueuePostFanoutJob(It.IsAny(), userId), Times.Once); + _mockTransaction.Verify(t => t.CommitAsync(default), Times.Once); + } + + [Fact] + public async Task DeletePostAsync_WhenNotOwner_ShouldThrowUnauthorized() + { + // Arrange + var postId = Guid.NewGuid(); + var ownerId = Guid.NewGuid(); + var attackerId = Guid.NewGuid(); + + _mockPostRepo.Setup(r => r.GetByIdAsync(postId)).ReturnsAsync(new Post { AuthorId = ownerId }); + + // Act & Assert + await Assert.ThrowsAsync(() => + _sut.DeletePostAsync(postId, attackerId)); + + _mockPostRepo.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never); + _mockLogger.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Security Alert")), + null, + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task DeletePostAsync_WhenValid_ShouldDeleteAndCommit() + { + // Arrange + var postId = Guid.NewGuid(); + var ownerId = Guid.NewGuid(); + + _mockPostRepo.Setup(r => r.GetByIdAsync(postId)).ReturnsAsync(new Post { AuthorId = ownerId }); + + // Act + await _sut.DeletePostAsync(postId, ownerId); + + // Assert + _mockPostRepo.Verify(r => r.DeleteAsync(It.IsAny()), Times.Once); + _mockTransaction.Verify(t => t.CommitAsync(default), Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/SafetyServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/SafetyServiceTests.cs new file mode 100644 index 0000000..10cb89f --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/SafetyServiceTests.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Safety; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class SafetyServiceTests + { + private readonly Mock _mockSafetyRepo; + private readonly Mock _mockJobPublisher; + private readonly Mock> _mockLogger; + private readonly Mock _mockTransaction; + private readonly SafetyService _sut; + + public SafetyServiceTests() + { + _mockSafetyRepo = new Mock(); + _mockJobPublisher = new Mock(); + _mockLogger = new Mock>(); + _mockTransaction = new Mock(); + + _mockSafetyRepo + .Setup(repo => repo.BeginTransactionAsync()) + .ReturnsAsync(_mockTransaction.Object); + + _sut = new SafetyService(_mockSafetyRepo.Object, _mockJobPublisher.Object, _mockLogger.Object); + } + + [Fact] + public async Task BlockUserAsync_WhenBlockingSelf_ShouldThrowInvalidOperationException() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.BlockUserAsync(userId, userId)); + + Assert.Equal("Cannot block yourself.", exception.Message); + _mockSafetyRepo.Verify(repo => repo.AddBlockAsync(It.IsAny()), Times.Never); + } + + // THE MAGIC OF [Theory]: This runs the test 3 times with different inputs + [Theory] + [InlineData("user", "", "Reason cannot be empty.")] // Empty reason + [InlineData("comment", "Spam", "Invalid TargetType. Must be 'user' or 'post'.")] // Bad target type + [InlineData("user", "Harassment", "You cannot report yourself.")] // Reporting self (requires TargetId == ReporterId) + public async Task CreateReportAsync_WhenInputIsInvalid_ShouldThrowInvalidOperationException( + string targetType, string reason, string expectedMessage) + { + // Arrange + var reporterId = Guid.NewGuid(); + + // If we are testing the "reporting yourself" boundary, make the TargetId equal to ReporterId + var targetId = expectedMessage == "You cannot report yourself." ? reporterId : Guid.NewGuid(); + + var request = new CreateReportRequest + { + TargetId = targetId, + TargetType = targetType, + Reason = reason + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.CreateReportAsync(reporterId, request)); + + Assert.Equal(expectedMessage, exception.Message); + _mockSafetyRepo.Verify(repo => repo.AddReportAsync(It.IsAny()), Times.Never); + _mockTransaction.Verify(tx => tx.CommitAsync(), Times.Never); + } + + [Fact] + public async Task CreateReportAsync_WhenValid_ShouldSaveAndEnqueueJob() + { + // Arrange + var reporterId = Guid.NewGuid(); + var targetId = Guid.NewGuid(); + var request = new CreateReportRequest + { + TargetId = targetId, + TargetType = "user", + Reason = "Spamming the feed" + }; + + // Act + var result = await _sut.CreateReportAsync(reporterId, request); + + // Assert + Assert.NotNull(result); + Assert.Equal("user", result.TargetType); + Assert.Equal("pending", result.Status); + + _mockSafetyRepo.Verify(repo => repo.AddReportAsync(It.IsAny()), Times.Once); + _mockSafetyRepo.Verify(repo => repo.SaveChangesAsync(), Times.Once); + _mockTransaction.Verify(tx => tx.CommitAsync(), Times.Once); + + // Verify Outbox triggered + _mockJobPublisher.Verify(pub => pub.EnqueueReportNotificationJob(targetId, reporterId), Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/SearchServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/SearchServiceTests.cs new file mode 100644 index 0000000..ce37881 --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/SearchServiceTests.cs @@ -0,0 +1,65 @@ +using Moq; +using SocialPluse.Domain.Entities; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Users; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class SearchServiceTests + { + private readonly Mock _mockSearchRepo; + private readonly Mock _mockUserRepo; + private readonly SearchService _sut; + + public SearchServiceTests() + { + _mockSearchRepo = new Mock(); + _mockUserRepo = new Mock(); + + _sut = new SearchService( + _mockSearchRepo.Object, + _mockUserRepo.Object); + } + + [Fact] + public async Task SearchPostsAsync_ShouldSanitizeQueryAndFetch() + { + var query = ""; + var limit = 10; + + _mockSearchRepo + .Setup(r => r.SearchPostsAsync(It.IsAny(), limit)) + .ReturnsAsync(new List()); + + await _sut.SearchPostsAsync(query, limit); + + _mockSearchRepo.Verify( + r => r.SearchPostsAsync( + It.Is(s => !s.Contains("<")), + limit), + Times.Once); + } + + [Fact] + public async Task SearchUsersAsync_ShouldFetchUsers() + { + // Arrange + var query = "jaser"; + + _mockSearchRepo + .Setup(r => r.SearchUsersAsync(query, 5)) + .ReturnsAsync(new List()); + + // Act + var result = await _sut.SearchUsersAsync(query, 5); + + // Assert + Assert.NotNull(result); + + _mockSearchRepo.Verify( + r => r.SearchUsersAsync(query, 5), + Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services.Tests/UnitTests/UserServiceTests.cs b/SocialPluse.Services.Tests/UnitTests/UserServiceTests.cs new file mode 100644 index 0000000..7480f4d --- /dev/null +++ b/SocialPluse.Services.Tests/UnitTests/UserServiceTests.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SocialPluse.Services.Abstraction.IRepositories; +using SocialPluse.Services.Abstraction.IService; +using SocialPluse.Services.Services; +using SocialPluse.Shared.DTOs.Users; + +namespace SocialPluse.Services.Tests.UnitTests +{ + public class UserServiceTests + { + private readonly Mock _mockIdentityWrapper; + private readonly Mock _mockPostRepo; + private readonly Mock _mockFollowRepo; + private readonly Mock _mockUserRepo; + private readonly Mock> _mockLogger; + private readonly UserService _sut; + + public UserServiceTests() + { + _mockIdentityWrapper = new Mock(); + _mockPostRepo = new Mock(); + _mockFollowRepo = new Mock(); + _mockUserRepo = new Mock(); + _mockLogger = new Mock>(); + + _sut = new UserService( + _mockIdentityWrapper.Object, + _mockPostRepo.Object, + _mockFollowRepo.Object, + _mockUserRepo.Object, + _mockLogger.Object); + } + + [Theory] + [InlineData("", "newPass123!")] + [InlineData("oldPass123!", "")] + [InlineData(" ", " ")] + [InlineData(null, null)] + public async Task ChangePasswordAsync_WhenFieldsAreEmpty_ShouldThrowInvalidOperationException( + string? currentPassword, string? newPassword) + { + // Arrange + var userId = Guid.NewGuid(); + var request = new ChangePasswordRequest + { + CurrentPassword = currentPassword!, + NewPassword = newPassword! + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.ChangePasswordAsync(userId, request)); + + Assert.Equal("Current and new password are required.", exception.Message); + + // Verify Identity system was never touched + _mockIdentityWrapper.Verify(id => id.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateProfileAsync_WhenValid_ShouldCallIdentityWrapperAndReturnProfile() + { + // Arrange + var userId = Guid.NewGuid(); + var request = new UpdateProfileRequest + { + DisplayName = "New Name", + Bio = "New Bio", + AvatarUrl = "http://example.com/avatar.png" + }; + + var mockUpdatedUser = new UserDetailsDto + { + Id = userId, + Username = "testuser", + DisplayName = "New Name", + Bio = "New Bio" + }; + + _mockIdentityWrapper + .Setup(id => id.GetUserByIdAsync(userId)) + .ReturnsAsync(mockUpdatedUser); + + _mockPostRepo.Setup(repo => repo.GetPostCountAsync(userId)).ReturnsAsync(5); + _mockFollowRepo.Setup(repo => repo.GetFollowStatsAsync(userId)).ReturnsAsync((10, 20)); + + // Act + var result = await _sut.UpdateProfileAsync(userId, request); + + // Assert + Assert.NotNull(result); + Assert.Equal("New Name", result.DisplayName); + Assert.Equal(5, result.PostsCount); + Assert.Equal(10, result.FollowersCount); + + _mockIdentityWrapper.Verify(id => id.UpdateProfileAsync(userId, "New Name", "New Bio", "http://example.com/avatar.png"), Times.Once); + } + } +} \ No newline at end of file diff --git a/SocialPluse.Services/DependencyInjection.cs b/SocialPluse.Services/DependencyInjection.cs index 68638ff..d2f20ba 100644 --- a/SocialPluse.Services/DependencyInjection.cs +++ b/SocialPluse.Services/DependencyInjection.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + return services; } diff --git a/SocialPluse.Services/MessageHandlers/FanoutPostHandler.cs b/SocialPluse.Services/MessageHandlers/FanoutPostHandler.cs index 102b931..742864c 100644 --- a/SocialPluse.Services/MessageHandlers/FanoutPostHandler.cs +++ b/SocialPluse.Services/MessageHandlers/FanoutPostHandler.cs @@ -1,26 +1,32 @@ -using System.Text.Json; -using SocialPluse.Domain.Constants; +using SocialPluse.Domain.Constants; using SocialPluse.Services.Abstraction.IService; +using System.Text.Json; -namespace SocialPluse.Services.MessageHandlers +public class FanoutPostHandler : IOutboxMessageHandler { - public class FanoutPostHandler : IOutboxMessageHandler + private readonly IFeedService _feedService; + + public FanoutPostHandler(IFeedService feedService) { - private readonly IFeedService _feedService; + _feedService = feedService; + } - public FanoutPostHandler(IFeedService feedService) - { - _feedService = feedService; - } + public string HandledMessageType + => OutboxMessageTypes.FanoutPostToFeed; - public string HandledMessageType => OutboxMessageTypes.FanoutPostToFeed; + public async Task HandleAsync( + Guid messageId, + JsonDocument payload, + CancellationToken cancellationToken) + { + var postId = + payload.RootElement.GetProperty("PostId").GetGuid(); - public async Task HandleAsync(Guid messageId, JsonDocument payload, CancellationToken cancellationToken) - { - var postId = payload.RootElement.GetProperty("PostId").GetGuid(); - var authorId = payload.RootElement.GetProperty("AuthorId").GetGuid(); + var authorId = + payload.RootElement.GetProperty("AuthorId").GetGuid(); - await _feedService.FanoutPostToFeedAsync(postId, authorId); - } + await _feedService.FanoutPostToFeedAsync( + postId, + authorId); } } \ No newline at end of file diff --git a/SocialPluse.Services/SocialPluse.Services.csproj b/SocialPluse.Services/SocialPluse.Services.csproj index 1d38ec5..90fdb07 100644 --- a/SocialPluse.Services/SocialPluse.Services.csproj +++ b/SocialPluse.Services/SocialPluse.Services.csproj @@ -10,6 +10,7 @@ + diff --git a/SocialPluse.Web/Controllers/LikesController.cs b/SocialPluse.Web/Controllers/LikesController.cs index 78b9ab0..cdc4bef 100644 --- a/SocialPluse.Web/Controllers/LikesController.cs +++ b/SocialPluse.Web/Controllers/LikesController.cs @@ -19,7 +19,6 @@ public LikesController(ILikeService likeService) - [HttpPost] [HttpPost] public async Task LikePost(Guid postId) { diff --git a/SocialPluse.Web/Program.cs b/SocialPluse.Web/Program.cs index 70b7f7c..52607f4 100644 --- a/SocialPluse.Web/Program.cs +++ b/SocialPluse.Web/Program.cs @@ -40,8 +40,13 @@ public static async Task Main(string[] args) .WriteTo.Seq(builder.Configuration["Seq:ServerUrl"] ?? "http://seq:80")); // --- 1. Service Registrations --- - builder.Services.AddPersistence(builder.Configuration); + + // Updated to pass builder.Environment so tests can intercept Redis/Hangfire! + builder.Services.AddPersistence(builder.Configuration); builder.Services.AddServices(); + + + builder.Services.AddControllers(); builder.Services.AddOpenApi(); builder.Services.AddSignalR(); @@ -119,6 +124,7 @@ public static async Task Main(string[] args) catch (Exception ex) { Log.Fatal(ex, "SocialPluse API terminated unexpectedly during startup"); + throw; } finally { @@ -126,4 +132,7 @@ public static async Task Main(string[] args) } } } -} \ No newline at end of file +} + +// Required so the WebApplicationFactory can see the Program class in the tests +public partial class Program { } \ No newline at end of file diff --git a/SocialPluse.Web/uploads/3451e2381b764f1aa8d1e1c1adb7c9da.jpg b/SocialPluse.Web/uploads/3451e2381b764f1aa8d1e1c1adb7c9da.jpg new file mode 100644 index 0000000000000000000000000000000000000000..35d00a5a95bd824a2dc85b256847c723de276474 GIT binary patch literal 10 Rcmex= + +