From cc6495ed10cf6e7fc87653795e98a6ca2d8da584 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:09:03 +0000 Subject: [PATCH 1/4] Initial plan From f4a4ec10c5e2daa9f0194515710a448c56cb63fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:18:01 +0000 Subject: [PATCH 2/4] Add comprehensive unit tests for UsersController Co-authored-by: frye <98463+frye@users.noreply.github.com> --- .../Controllers/UsersControllerTests.cs | 253 ++++++++++++++++++ .../net-users-api.Tests.csproj | 30 +++ net-users-api/Controllers/UsersController.cs | 13 +- net-users-api/Program.cs | 3 + net-users-demo.sln | 70 +++-- 5 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 net-users-api.Tests/Controllers/UsersControllerTests.cs create mode 100644 net-users-api.Tests/net-users-api.Tests.csproj diff --git a/net-users-api.Tests/Controllers/UsersControllerTests.cs b/net-users-api.Tests/Controllers/UsersControllerTests.cs new file mode 100644 index 0000000..12af45b --- /dev/null +++ b/net-users-api.Tests/Controllers/UsersControllerTests.cs @@ -0,0 +1,253 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using NetUsersApi.Controllers; +using NetUsersApi.Models; +using FluentAssertions; + +namespace NetUsersApi.Tests.Controllers; + +/// +/// Unit tests for UsersController +/// +public class UsersControllerTests +{ + private readonly Mock> _mockLogger; + + public UsersControllerTests() + { + _mockLogger = new Mock>(); + } + + #region GetUsers Tests + + [Fact] + public void GetUsers_HappyPath_ReturnsAllUsers() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + + // Act + var result = controller.GetUsers(); + + // Assert + result.Should().NotBeNull(); + var okResult = result.Result.Should().BeOfType().Subject; + var users = okResult.Value.Should().BeAssignableTo>().Subject; + users.Should().NotBeNull(); + } + + [Fact] + public void GetUsers_LogsInformation() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + + // Act + controller.GetUsers(); + + // Assert + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("GET /api/v1/users endpoint called")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region GetUser Tests + + [Fact] + public void GetUser_ValidId_ReturnsUser() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var testId = "1"; + + // Act + var result = controller.GetUser(testId); + + // Assert + result.Should().NotBeNull(); + var okResult = result.Result.Should().BeOfType().Subject; + var user = okResult.Value.Should().BeOfType().Subject; + user.Id.Should().Be(testId); + } + + [Fact] + public void GetUser_InvalidId_ReturnsNotFound() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var invalidId = "999"; + + // Act + var result = controller.GetUser(invalidId); + + // Assert + result.Should().NotBeNull(); + result.Result.Should().BeOfType(); + } + + [Theory] + [InlineData("")] + public void GetUser_EmptyId_ReturnsNotFound(string id) + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + + // Act + var result = controller.GetUser(id); + + // Assert + result.Should().NotBeNull(); + result.Result.Should().BeOfType(); + } + + #endregion + + #region CreateUser Tests + + [Fact] + public void CreateUser_HappyPath_CreatesAndReturns201() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var newUser = new UserProfile + { + Id = "100", + FullName = "Test User", + Emoji = "๐Ÿงช" + }; + + // Act + var result = controller.CreateUser(newUser); + + // Assert + result.Should().NotBeNull(); + var createdResult = result.Result.Should().BeOfType().Subject; + createdResult.StatusCode.Should().Be(201); + var returnedUser = createdResult.Value.Should().BeOfType().Subject; + returnedUser.Id.Should().Be(newUser.Id); + returnedUser.FullName.Should().Be(newUser.FullName); + returnedUser.Emoji.Should().Be(newUser.Emoji); + } + + [Fact] + public void CreateUser_NullUser_ReturnsBadRequest() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + + // Act + var result = controller.CreateUser(null!); + + // Assert + result.Should().NotBeNull(); + result.Result.Should().BeOfType(); + } + + #endregion + + #region UpdateUser Tests + + [Fact] + public void UpdateUser_HappyPath_UpdatesExistingUser() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var existingId = "1"; + var updatedUser = new UserProfile + { + Id = existingId, + FullName = "Updated Name", + Emoji = "๐ŸŽ‰" + }; + + // Act + var result = controller.UpdateUser(existingId, updatedUser); + + // Assert + result.Should().NotBeNull(); + var okResult = result.Result.Should().BeOfType().Subject; + var returnedUser = okResult.Value.Should().BeOfType().Subject; + returnedUser.FullName.Should().Be("Updated Name"); + returnedUser.Emoji.Should().Be("๐ŸŽ‰"); + } + + [Fact] + public void UpdateUser_NonExistentUser_ReturnsNotFound() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var nonExistentId = "999"; + var updatedUser = new UserProfile + { + Id = nonExistentId, + FullName = "Test User", + Emoji = "๐Ÿงช" + }; + + // Act + var result = controller.UpdateUser(nonExistentId, updatedUser); + + // Assert + result.Should().NotBeNull(); + result.Result.Should().BeOfType(); + } + + [Fact] + public void UpdateUser_NullUser_ReturnsBadRequest() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var existingId = "1"; + + // Act + var result = controller.UpdateUser(existingId, null!); + + // Assert + result.Should().NotBeNull(); + result.Result.Should().BeOfType(); + } + + #endregion + + #region DeleteUser Tests + + [Fact] + public void DeleteUser_HappyPath_DeletesAndReturnsNoContent() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var existingId = "2"; + + // Act + var result = controller.DeleteUser(existingId); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void DeleteUser_NonExistentUser_ReturnsNotFound() + { + // Arrange + var controller = new UsersController(_mockLogger.Object); + var nonExistentId = "999"; + + // Act + var result = controller.DeleteUser(nonExistentId); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + #endregion +} diff --git a/net-users-api.Tests/net-users-api.Tests.csproj b/net-users-api.Tests/net-users-api.Tests.csproj new file mode 100644 index 0000000..f66d509 --- /dev/null +++ b/net-users-api.Tests/net-users-api.Tests.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net9.0 + net_users_api.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/net-users-api/Controllers/UsersController.cs b/net-users-api/Controllers/UsersController.cs index 2622279..50e282c 100644 --- a/net-users-api/Controllers/UsersController.cs +++ b/net-users-api/Controllers/UsersController.cs @@ -100,15 +100,22 @@ public ActionResult UpdateUser(string id, [FromBody] UserProfile up } /// - /// Delete a user by ID TODO + /// Delete a user by ID /// /// User ID /// No content or 404 if not found [HttpDelete("{id}")] public IActionResult DeleteUser(string id) { - // Implement delete functionality here using Copilot Ask or Edit mode - throw new NotImplementedException("DeleteUser functionality not yet implemented"); + var user = _users.FirstOrDefault(u => u.Id == id); + + if (user == null) + { + return NotFound(new { error = "User not found" }); + } + + _users.Remove(user); + return NoContent(); } /// diff --git a/net-users-api/Program.cs b/net-users-api/Program.cs index 9ebf4d5..0de6862 100644 --- a/net-users-api/Program.cs +++ b/net-users-api/Program.cs @@ -39,3 +39,6 @@ Console.WriteLine("Starting server on :8080"); app.Run(); + +// Make Program class accessible for integration testing +public partial class Program { } diff --git a/net-users-demo.sln b/net-users-demo.sln index e9bb8f5..131e197 100644 --- a/net-users-demo.sln +++ b/net-users-demo.sln @@ -1,22 +1,48 @@ -๏ปฟ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "net-users-api", "net-users-api\net-users-api.csproj", "{F2C0F837-02EA-42E5-BDC0-48C31DD4245D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal +๏ปฟ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "net-users-api", "net-users-api\net-users-api.csproj", "{F2C0F837-02EA-42E5-BDC0-48C31DD4245D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "net-users-api.Tests", "net-users-api.Tests\net-users-api.Tests.csproj", "{A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|x64.Build.0 = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Debug|x86.Build.0 = Debug|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|Any CPU.Build.0 = Release|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|x64.ActiveCfg = Release|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|x64.Build.0 = Release|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|x86.ActiveCfg = Release|Any CPU + {F2C0F837-02EA-42E5-BDC0-48C31DD4245D}.Release|x86.Build.0 = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|x64.Build.0 = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Debug|x86.Build.0 = Debug|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|Any CPU.Build.0 = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|x64.ActiveCfg = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|x64.Build.0 = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|x86.ActiveCfg = Release|Any CPU + {A2C0E5CB-3069-4064-BF5D-06FB7D75C4E0}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal From 524845e5f9eda8b8089b9d7a59379c49a670bbe9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:19:33 +0000 Subject: [PATCH 3/4] Add comprehensive integration tests with WebApplicationFactory Co-authored-by: frye <98463+frye@users.noreply.github.com> --- .../CustomWebApplicationFactory.cs | 22 ++ .../UsersApiIntegrationTests.cs | 297 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 net-users-api.Tests/IntegrationTests/CustomWebApplicationFactory.cs create mode 100644 net-users-api.Tests/IntegrationTests/UsersApiIntegrationTests.cs diff --git a/net-users-api.Tests/IntegrationTests/CustomWebApplicationFactory.cs b/net-users-api.Tests/IntegrationTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..85c28f3 --- /dev/null +++ b/net-users-api.Tests/IntegrationTests/CustomWebApplicationFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; + +namespace NetUsersApi.Tests.IntegrationTests; + +/// +/// Custom WebApplicationFactory for integration testing +/// Configures the test server environment +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Add any test-specific service configurations here if needed + }); + + builder.UseEnvironment("Testing"); + } +} diff --git a/net-users-api.Tests/IntegrationTests/UsersApiIntegrationTests.cs b/net-users-api.Tests/IntegrationTests/UsersApiIntegrationTests.cs new file mode 100644 index 0000000..f90ca30 --- /dev/null +++ b/net-users-api.Tests/IntegrationTests/UsersApiIntegrationTests.cs @@ -0,0 +1,297 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using NetUsersApi.Models; + +namespace NetUsersApi.Tests.IntegrationTests; + +/// +/// Integration tests for the Users API +/// Tests the full HTTP request/response cycle +/// +public class UsersApiIntegrationTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + + public UsersApiIntegrationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + #region GET /api/v1/users Tests + + [Fact] + public async Task GetUsers_ReturnsSuccessStatusCode() + { + // Arrange & Act + var response = await _client.GetAsync("/api/v1/users"); + + // Assert + response.EnsureSuccessStatusCode(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetUsers_ReturnsJsonContentType() + { + // Arrange & Act + var response = await _client.GetAsync("/api/v1/users"); + + // Assert + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + } + + [Fact] + public async Task GetUsers_ReturnsUserList() + { + // Arrange & Act + var response = await _client.GetAsync("/api/v1/users"); + var users = await response.Content.ReadFromJsonAsync>(); + + // Assert + users.Should().NotBeNull(); + users.Should().HaveCountGreaterThan(0); + } + + #endregion + + #region GET /api/v1/users/{id} Tests + + [Fact] + public async Task GetUser_ValidId_ReturnsUser() + { + // Arrange + var userId = "1"; + + // Act + var response = await _client.GetAsync($"/api/v1/users/{userId}"); + var user = await response.Content.ReadFromJsonAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + user.Should().NotBeNull(); + user!.Id.Should().Be(userId); + user.FullName.Should().NotBeNullOrEmpty(); + user.Emoji.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetUser_InvalidId_ReturnsNotFound() + { + // Arrange + var invalidUserId = "999"; + + // Act + var response = await _client.GetAsync($"/api/v1/users/{invalidUserId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + #endregion + + #region POST /api/v1/users Tests + + [Fact] + public async Task CreateUser_ValidUser_ReturnsCreated() + { + // Arrange + var newUser = new UserProfile + { + Id = $"test-{Guid.NewGuid()}", + FullName = "Integration Test User", + Emoji = "๐Ÿงช" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/users", newUser); + var createdUser = await response.Content.ReadFromJsonAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Headers.Location.Should().NotBeNull(); + createdUser.Should().NotBeNull(); + createdUser!.Id.Should().Be(newUser.Id); + createdUser.FullName.Should().Be(newUser.FullName); + createdUser.Emoji.Should().Be(newUser.Emoji); + } + + [Fact] + public async Task CreateUser_ValidUser_CanBeRetrieved() + { + // Arrange + var newUser = new UserProfile + { + Id = $"test-{Guid.NewGuid()}", + FullName = "Retrievable User", + Emoji = "๐Ÿ”" + }; + + // Act + var createResponse = await _client.PostAsJsonAsync("/api/v1/users", newUser); + createResponse.EnsureSuccessStatusCode(); + + var getResponse = await _client.GetAsync($"/api/v1/users/{newUser.Id}"); + var retrievedUser = await getResponse.Content.ReadFromJsonAsync(); + + // Assert + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + retrievedUser.Should().NotBeNull(); + retrievedUser!.Id.Should().Be(newUser.Id); + retrievedUser.FullName.Should().Be(newUser.FullName); + } + + [Fact] + public async Task CreateUser_NullUser_ReturnsBadRequest() + { + // Arrange & Act + var response = await _client.PostAsJsonAsync("/api/v1/users", (UserProfile?)null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + #endregion + + #region PUT /api/v1/users/{id} Tests + + [Fact] + public async Task UpdateUser_ExistingUser_ReturnsOk() + { + // Arrange - Create a user first + var userId = $"test-{Guid.NewGuid()}"; + var originalUser = new UserProfile + { + Id = userId, + FullName = "Original Name", + Emoji = "๐Ÿ˜€" + }; + await _client.PostAsJsonAsync("/api/v1/users", originalUser); + + var updatedUser = new UserProfile + { + Id = userId, + FullName = "Updated Name", + Emoji = "๐ŸŽ‰" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/api/v1/users/{userId}", updatedUser); + var returnedUser = await response.Content.ReadFromJsonAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + returnedUser.Should().NotBeNull(); + returnedUser!.FullName.Should().Be("Updated Name"); + returnedUser.Emoji.Should().Be("๐ŸŽ‰"); + } + + [Fact] + public async Task UpdateUser_NonExistentUser_ReturnsNotFound() + { + // Arrange + var nonExistentId = "nonexistent-999"; + var updatedUser = new UserProfile + { + Id = nonExistentId, + FullName = "Test User", + Emoji = "๐Ÿงช" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/api/v1/users/{nonExistentId}", updatedUser); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateUser_NullUser_ReturnsBadRequest() + { + // Arrange + var userId = "1"; + + // Act + var response = await _client.PutAsJsonAsync($"/api/v1/users/{userId}", (UserProfile?)null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + #endregion + + #region DELETE /api/v1/users/{id} Tests + + [Fact] + public async Task DeleteUser_ExistingUser_ReturnsNoContent() + { + // Arrange - Create a user first + var userId = $"test-{Guid.NewGuid()}"; + var userToDelete = new UserProfile + { + Id = userId, + FullName = "User To Delete", + Emoji = "๐Ÿ—‘๏ธ" + }; + await _client.PostAsJsonAsync("/api/v1/users", userToDelete); + + // Act + var response = await _client.DeleteAsync($"/api/v1/users/{userId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify user is actually deleted + var getResponse = await _client.GetAsync($"/api/v1/users/{userId}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteUser_NonExistentUser_ReturnsNotFound() + { + // Arrange + var nonExistentId = "nonexistent-999"; + + // Act + var response = await _client.DeleteAsync($"/api/v1/users/{nonExistentId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + #endregion + + #region Test Isolation Tests + + [Fact] + public async Task MultipleRequests_DontInterfere() + { + // Arrange + var user1Id = $"test-{Guid.NewGuid()}"; + var user2Id = $"test-{Guid.NewGuid()}"; + + var user1 = new UserProfile { Id = user1Id, FullName = "User 1", Emoji = "1๏ธโƒฃ" }; + var user2 = new UserProfile { Id = user2Id, FullName = "User 2", Emoji = "2๏ธโƒฃ" }; + + // Act + await _client.PostAsJsonAsync("/api/v1/users", user1); + await _client.PostAsJsonAsync("/api/v1/users", user2); + + var response1 = await _client.GetAsync($"/api/v1/users/{user1Id}"); + var response2 = await _client.GetAsync($"/api/v1/users/{user2Id}"); + + var retrievedUser1 = await response1.Content.ReadFromJsonAsync(); + var retrievedUser2 = await response2.Content.ReadFromJsonAsync(); + + // Assert + retrievedUser1.Should().NotBeNull(); + retrievedUser2.Should().NotBeNull(); + retrievedUser1!.Id.Should().Be(user1Id); + retrievedUser2!.Id.Should().Be(user2Id); + retrievedUser1.FullName.Should().NotBe(retrievedUser2.FullName); + } + + #endregion +} From d2b74affcf9a1ff3b8c607a5abf8ea05de7967a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:24:08 +0000 Subject: [PATCH 4/4] Add comprehensive property-based tests using FsCheck Co-authored-by: frye <98463+frye@users.noreply.github.com> --- .../PropertyTests/UserProfilePropertyTests.cs | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 net-users-api.Tests/PropertyTests/UserProfilePropertyTests.cs diff --git a/net-users-api.Tests/PropertyTests/UserProfilePropertyTests.cs b/net-users-api.Tests/PropertyTests/UserProfilePropertyTests.cs new file mode 100644 index 0000000..e8d8b91 --- /dev/null +++ b/net-users-api.Tests/PropertyTests/UserProfilePropertyTests.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using FsCheck; +using FsCheck.Xunit; +using FluentAssertions; +using NetUsersApi.Models; + +namespace NetUsersApi.Tests.PropertyTests; + +/// +/// Property-based tests for UserProfile +/// Uses FsCheck to generate random test cases and verify invariants +/// +public class UserProfilePropertyTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// + /// Property: UserProfile serialization/deserialization roundtrip should preserve all data + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfile_SerializationRoundtrip_PreservesData(NonEmptyString id, NonEmptyString fullName, NonEmptyString emoji) + { + // Arrange + var original = new UserProfile + { + Id = id.Get, + FullName = fullName.Get, + Emoji = emoji.Get + }; + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.Id.Should().Be(original.Id); + deserialized.FullName.Should().Be(original.FullName); + deserialized.Emoji.Should().Be(original.Emoji); + } + + /// + /// Property: UserProfile properties should never be null after construction + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfile_RequiredProperties_AreNeverNull(NonEmptyString id, NonEmptyString fullName, NonEmptyString emoji) + { + // Arrange & Act + var profile = new UserProfile + { + Id = id.Get, + FullName = fullName.Get, + Emoji = emoji.Get + }; + + // Assert + profile.Id.Should().NotBeNull(); + profile.FullName.Should().NotBeNull(); + profile.Emoji.Should().NotBeNull(); + } + + /// + /// Property: Two UserProfiles with the same ID should be considered equal in a dictionary context + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfile_SameId_CanBeUsedAsDictionaryKey(NonEmptyString id, NonEmptyString name1, NonEmptyString name2, NonEmptyString emoji) + { + // Arrange + var profile1 = new UserProfile { Id = id.Get, FullName = name1.Get, Emoji = emoji.Get }; + var profile2 = new UserProfile { Id = id.Get, FullName = name2.Get, Emoji = emoji.Get }; + + var dictionary = new Dictionary + { + [profile1.Id] = profile1 + }; + + // Act + dictionary[profile2.Id] = profile2; + + // Assert + dictionary.Should().ContainKey(id.Get); + dictionary[id.Get].FullName.Should().Be(name2.Get); + dictionary.Should().HaveCount(1); + } + + /// + /// Property: List operations should maintain correct count + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfileList_AddRemove_MaintainsCorrectCount(NonEmptyString id, NonEmptyString fullName, NonEmptyString emoji) + { + // Arrange + var users = new List(); + var user = new UserProfile + { + Id = id.Get, + FullName = fullName.Get, + Emoji = emoji.Get + }; + + // Act + users.Add(user); + var countAfterAdd = users.Count; + + users.Remove(user); + var countAfterRemove = users.Count; + + // Assert + countAfterAdd.Should().Be(1); + countAfterRemove.Should().Be(0); + } + + /// + /// Property: Filtering by ID should return at most one user per unique ID + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfileList_FilterById_ReturnsExpectedCount(NonEmptyString id, NonEmptyString name, NonEmptyString emoji) + { + // Arrange + var userList = new List + { + new() { Id = id.Get, FullName = name.Get, Emoji = emoji.Get } + }; + + // Act + var filtered = userList.Where(u => u.Id == id.Get).ToList(); + + // Assert + filtered.Should().HaveCount(1); + filtered[0].Id.Should().Be(id.Get); + } + + /// + /// Property: Finding and updating a user preserves the ID + /// Runs 100 test cases with random inputs + /// + [Property(MaxTest = 100)] + public void UserProfileUpdate_PreservesId(NonEmptyString id, NonEmptyString originalName, NonEmptyString updatedName, NonEmptyString emoji) + { + // Arrange + var users = new List + { + new() { Id = id.Get, FullName = originalName.Get, Emoji = emoji.Get } + }; + + var updatedUser = new UserProfile + { + Id = id.Get, + FullName = updatedName.Get, + Emoji = emoji.Get + }; + + // Act + var index = users.FindIndex(u => u.Id == id.Get); + if (index >= 0) + { + users[index] = updatedUser; + } + + // Assert + var foundUser = users.FirstOrDefault(u => u.Id == id.Get); + foundUser.Should().NotBeNull(); + foundUser!.Id.Should().Be(id.Get); + foundUser.FullName.Should().Be(updatedName.Get); + } + + /// + /// Property: JSON deserialization handles property name case insensitivity + /// Runs 100 test cases with random inputs + /// Uses proper JSON serialization to handle special characters + /// + [Property(MaxTest = 100)] + public void UserProfile_JsonDeserialization_IsCaseInsensitive(NonEmptyString id, NonEmptyString fullName, NonEmptyString emoji) + { + // Arrange - Create UserProfile objects and serialize them with different property casing + var profile = new UserProfile + { + Id = id.Get, + FullName = fullName.Get, + Emoji = emoji.Get + }; + + // Act - Serialize with default casing + var json = JsonSerializer.Serialize(profile); + + // Deserialize with case-insensitive options + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.Id.Should().Be(id.Get); + deserialized.FullName.Should().Be(fullName.Get); + deserialized.Emoji.Should().Be(emoji.Get); + } +}