From 24833c63a6648a9db95385bc15d0c9c1018ef690 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:09:33 +0000 Subject: [PATCH 1/4] Initial plan From b5db92153195efde7c1ea5b408940309722955d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:19:51 +0000 Subject: [PATCH 2/4] feat: add archive action to CategoriesPage and StatusesPage with bUnit tests Agent-Logs-Url: https://github.com/mpaulosky/IssueManager/sessions/e899df2f-9625-48a4-b467-5ae84e214857 Co-authored-by: mpaulosky <60372079+mpaulosky@users.noreply.github.com> --- .../Features/Statuses/StatusApiClient.cs | 13 +- .../Features/Statuses/StatusesPage.razor | 110 +++++------ .../Features/Statuses/StatusesPage.razor.cs | 46 +++-- .../Categories/CategoriesPageArchiveTests.cs | 172 ++++++++++++++++++ .../Statuses/StatusesPageArchiveTests.cs | 172 ++++++++++++++++++ 5 files changed, 431 insertions(+), 82 deletions(-) create mode 100644 tests/Web.Tests.Bunit/Components/Categories/CategoriesPageArchiveTests.cs create mode 100644 tests/Web.Tests.Bunit/Components/Statuses/StatusesPageArchiveTests.cs diff --git a/src/Web/Components/Features/Statuses/StatusApiClient.cs b/src/Web/Components/Features/Statuses/StatusApiClient.cs index dd5cc41..5cb2373 100644 --- a/src/Web/Components/Features/Statuses/StatusApiClient.cs +++ b/src/Web/Components/Features/Statuses/StatusApiClient.cs @@ -24,7 +24,7 @@ public interface IStatusApiClient /// Updates an existing status. Task UpdateAsync(string id, UpdateStatusCommand command, CancellationToken cancellationToken = default); - /// Archives a status by its identifier. + /// Archives (soft-deletes) a status by its identifier. Task ArchiveAsync(string id, CancellationToken cancellationToken = default); } @@ -76,15 +76,8 @@ public async Task> GetAllAsync(CancellationToken cancella /// public async Task ArchiveAsync(string id, CancellationToken cancellationToken = default) { - try - { - var response = await _httpClient.DeleteAsync($"/api/v1/statuses/{id}", cancellationToken).ConfigureAwait(false); - return response.IsSuccessStatusCode; - } - catch (HttpRequestException) - { - return false; - } + var response = await _httpClient.DeleteAsync($"/api/v1/statuses/{id}", cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; } } diff --git a/src/Web/Components/Features/Statuses/StatusesPage.razor b/src/Web/Components/Features/Statuses/StatusesPage.razor index 0fb5683..6b3ece5 100644 --- a/src/Web/Components/Features/Statuses/StatusesPage.razor +++ b/src/Web/Components/Features/Statuses/StatusesPage.razor @@ -5,60 +5,66 @@ Statuses — IssueManager
-
-

Statuses

- +
+

Statuses

+ +
+ + @if (_isLoading) + { +
Loading...
+ } + else + { +
+ + + + + + + + + + + + + + + + + + + + + + +
+ }
-@if (_isLoading) +@if (!string.IsNullOrEmpty(_errorMessage)) { -
Loading...
+
@_errorMessage
} -else -{ -
- - - - - - - - - - - - - - - - - - - - - - -
-} -
- - + diff --git a/src/Web/Components/Features/Statuses/StatusesPage.razor.cs b/src/Web/Components/Features/Statuses/StatusesPage.razor.cs index 363d0e3..9304e01 100644 --- a/src/Web/Components/Features/Statuses/StatusesPage.razor.cs +++ b/src/Web/Components/Features/Statuses/StatusesPage.razor.cs @@ -27,10 +27,11 @@ public partial class StatusesPage : ComponentBase private StatusEditModel? _editingStatus; private bool _isLoading = true; - // Archive dialog state - private bool _showArchiveDialog = false; - private string? _statusToArchiveId = null; - private string? _statusToArchiveName = null; + private StatusEditModel? _archiveTarget; + + private bool _showArchiveConfirm; + + private string? _errorMessage; protected override async Task OnInitializedAsync() { @@ -108,32 +109,37 @@ private async Task OnUpdateRow(StatusEditModel status) await StatusClient.UpdateAsync(status.Id, command); } - private void ShowArchiveDialog(string id, string name) + private void InitiateArchive(StatusEditModel status) { - _statusToArchiveId = id; - _statusToArchiveName = name; - _showArchiveDialog = true; + _archiveTarget = status; + _showArchiveConfirm = true; } - private async Task HandleArchiveConfirm() + private async Task ConfirmArchive() { - _showArchiveDialog = false; - if (string.IsNullOrEmpty(_statusToArchiveId)) return; + _showArchiveConfirm = false; + + if (_archiveTarget is null) return; + + var success = await StatusClient.ArchiveAsync(_archiveTarget.Id); - var success = await StatusClient.ArchiveAsync(_statusToArchiveId); if (success) { - _statuses = _statuses.Where(s => s.Id != _statusToArchiveId).ToList(); - await InvokeAsync(StateHasChanged); + _statuses = _statuses.Where(s => s != _archiveTarget).ToList(); + if (_grid is not null) + await _grid.Reload(); } - _statusToArchiveId = null; - _statusToArchiveName = null; + else + { + _errorMessage = "Failed to archive the status. Please try again."; + } + + _archiveTarget = null; } - private void HandleArchiveCancel() + private void CancelArchive() { - _showArchiveDialog = false; - _statusToArchiveId = null; - _statusToArchiveName = null; + _showArchiveConfirm = false; + _archiveTarget = null; } } diff --git a/tests/Web.Tests.Bunit/Components/Categories/CategoriesPageArchiveTests.cs b/tests/Web.Tests.Bunit/Components/Categories/CategoriesPageArchiveTests.cs new file mode 100644 index 0000000..89dfe26 --- /dev/null +++ b/tests/Web.Tests.Bunit/Components/Categories/CategoriesPageArchiveTests.cs @@ -0,0 +1,172 @@ +// ============================================= +// Copyright (c) 2026. All rights reserved. +// File Name : CategoriesPageArchiveTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : Web.Tests.Bunit +// ============================================= + +namespace Web.Components.Features.Categories; + +/// +/// bUnit tests verifying archive action behavior in . +/// +[ExcludeFromCodeCoverage] +public class CategoriesPageArchiveTests : IDisposable +{ + private readonly BunitContext _ctx; + private readonly ICategoryApiClient _mockCategoryClient; + + /// + /// Initializes a new instance of the class. + /// + public CategoriesPageArchiveTests() + { + _ctx = new BunitContext(); + _ctx.JSInterop.Mode = JSRuntimeMode.Loose; + _mockCategoryClient = Substitute.For(); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + _ctx.Services.AddSingleton(_mockCategoryClient); + } + + /// + public void Dispose() + { + _ctx.Dispose(); + GC.SuppressFinalize(this); + } + + private static CategoryDto MakeCategory(string name = "Bug", string description = "Bug fixes") => new( + ObjectId.GenerateNewId(), + name, + description, + DateTime.UtcNow, + null, + false, + UserDto.Empty); + + // ─── Admin Guard ───────────────────────────────────────────────────────────── + + [Fact] + public void ArchiveButton_AdminUser_IsVisible() + { + // Arrange + var category = MakeCategory("Performance"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + + // Act + var cut = _ctx.Render(); + + // Assert + cut.Find($"#archive-{category.Id}").Should().NotBeNull(); + } + + [Fact] + public void ArchiveButton_NonAdminUser_IsNotVisible() + { + // Arrange + var category = MakeCategory("Performance"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _ctx.AddAuthorization().SetAuthorized("Regular User"); + + // Act + var cut = _ctx.Render(); + + // Assert — archive button must not be present for non-admin users + cut.FindAll($"#archive-{category.Id}").Should().BeEmpty(); + } + + // ─── Confirmation Dialog ────────────────────────────────────────────────────── + + [Fact] + public async Task ArchiveButton_Clicked_ShowsConfirmDialog() + { + // Arrange + var category = MakeCategory("Security"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Act + await cut.Find($"#archive-{category.Id}").ClickAsync(new MouseEventArgs()); + + // Assert + cut.Find("[role='dialog']").Should().NotBeNull(); + } + + [Fact] + public async Task ConfirmDialog_Confirmed_CallsArchiveApiAndRemovesRow() + { + // Arrange + var category = MakeCategory("Obsolete"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _mockCategoryClient.ArchiveAsync(category.Id.ToString(), Arg.Any()) + .Returns(Task.FromResult(true)); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog + await cut.Find($"#archive-{category.Id}").ClickAsync(new MouseEventArgs()); + + // Act — click the confirm button + var confirmButton = cut.FindAll("button").First(b => b.TextContent.Contains("Yes, Archive")); + await confirmButton.ClickAsync(new MouseEventArgs()); + + // Assert + await _mockCategoryClient.Received(1).ArchiveAsync(category.Id.ToString(), Arg.Any()); + cut.Markup.Should().NotContain("Obsolete"); + } + + [Fact] + public async Task ConfirmDialog_Cancelled_DoesNotCallApi() + { + // Arrange + var category = MakeCategory("Keep Me"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog + await cut.Find($"#archive-{category.Id}").ClickAsync(new MouseEventArgs()); + + // Act — click the cancel button + var cancelButton = cut.FindAll("button").First(b => b.TextContent.Contains("Cancel")); + await cancelButton.ClickAsync(new MouseEventArgs()); + + // Assert — API must not have been called and dialog must be hidden + await _mockCategoryClient.DidNotReceive().ArchiveAsync(Arg.Any(), Arg.Any()); + cut.FindAll("[role='dialog']").Should().BeEmpty(); + } + + // ─── Error Handling ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ArchiveApi_ReturnsError_ShowsErrorMessage() + { + // Arrange + var category = MakeCategory("Failing"); + _mockCategoryClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([category])); + _mockCategoryClient.ArchiveAsync(category.Id.ToString(), Arg.Any()) + .Returns(Task.FromResult(false)); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog and confirm + await cut.Find($"#archive-{category.Id}").ClickAsync(new MouseEventArgs()); + var confirmButton = cut.FindAll("button").First(b => b.TextContent.Contains("Yes, Archive")); + await confirmButton.ClickAsync(new MouseEventArgs()); + + // Assert — error element is shown + cut.Find("#archive-error").Should().NotBeNull(); + cut.Find("#archive-error").TextContent.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/tests/Web.Tests.Bunit/Components/Statuses/StatusesPageArchiveTests.cs b/tests/Web.Tests.Bunit/Components/Statuses/StatusesPageArchiveTests.cs new file mode 100644 index 0000000..ae20520 --- /dev/null +++ b/tests/Web.Tests.Bunit/Components/Statuses/StatusesPageArchiveTests.cs @@ -0,0 +1,172 @@ +// ============================================= +// Copyright (c) 2026. All rights reserved. +// File Name : StatusesPageArchiveTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : Web.Tests.Bunit +// ============================================= + +namespace Web.Components.Features.Statuses; + +/// +/// bUnit tests verifying archive action behavior in . +/// +[ExcludeFromCodeCoverage] +public class StatusesPageArchiveTests : IDisposable +{ + private readonly BunitContext _ctx; + private readonly IStatusApiClient _mockStatusClient; + + /// + /// Initializes a new instance of the class. + /// + public StatusesPageArchiveTests() + { + _ctx = new BunitContext(); + _ctx.JSInterop.Mode = JSRuntimeMode.Loose; + _mockStatusClient = Substitute.For(); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([])); + _ctx.Services.AddSingleton(_mockStatusClient); + } + + /// + public void Dispose() + { + _ctx.Dispose(); + GC.SuppressFinalize(this); + } + + private static StatusDto MakeStatus(string name = "Open", string description = "Issue is open") => new( + ObjectId.GenerateNewId(), + name, + description, + DateTime.UtcNow, + null, + false, + UserDto.Empty); + + // ─── Admin Guard ───────────────────────────────────────────────────────────── + + [Fact] + public void ArchiveButton_AdminUser_IsVisible() + { + // Arrange + var status = MakeStatus("In Progress"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + + // Act + var cut = _ctx.Render(); + + // Assert + cut.Find($"#archive-{status.Id}").Should().NotBeNull(); + } + + [Fact] + public void ArchiveButton_NonAdminUser_IsNotVisible() + { + // Arrange + var status = MakeStatus("In Progress"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _ctx.AddAuthorization().SetAuthorized("Regular User"); + + // Act + var cut = _ctx.Render(); + + // Assert — archive button must not be present for non-admin users + cut.FindAll($"#archive-{status.Id}").Should().BeEmpty(); + } + + // ─── Confirmation Dialog ────────────────────────────────────────────────────── + + [Fact] + public async Task ArchiveButton_Clicked_ShowsConfirmDialog() + { + // Arrange + var status = MakeStatus("Watching"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Act + await cut.Find($"#archive-{status.Id}").ClickAsync(new MouseEventArgs()); + + // Assert + cut.Find("[role='dialog']").Should().NotBeNull(); + } + + [Fact] + public async Task ConfirmDialog_Confirmed_CallsArchiveApiAndRemovesRow() + { + // Arrange + var status = MakeStatus("Dismissed"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _mockStatusClient.ArchiveAsync(status.Id.ToString(), Arg.Any()) + .Returns(Task.FromResult(true)); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog + await cut.Find($"#archive-{status.Id}").ClickAsync(new MouseEventArgs()); + + // Act — click the confirm button + var confirmButton = cut.FindAll("button").First(b => b.TextContent.Contains("Yes, Archive")); + await confirmButton.ClickAsync(new MouseEventArgs()); + + // Assert + await _mockStatusClient.Received(1).ArchiveAsync(status.Id.ToString(), Arg.Any()); + cut.Markup.Should().NotContain("Dismissed"); + } + + [Fact] + public async Task ConfirmDialog_Cancelled_DoesNotCallApi() + { + // Arrange + var status = MakeStatus("Keep Me"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog + await cut.Find($"#archive-{status.Id}").ClickAsync(new MouseEventArgs()); + + // Act — click the cancel button + var cancelButton = cut.FindAll("button").First(b => b.TextContent.Contains("Cancel")); + await cancelButton.ClickAsync(new MouseEventArgs()); + + // Assert — API must not have been called and dialog must be hidden + await _mockStatusClient.DidNotReceive().ArchiveAsync(Arg.Any(), Arg.Any()); + cut.FindAll("[role='dialog']").Should().BeEmpty(); + } + + // ─── Error Handling ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ArchiveApi_ReturnsError_ShowsErrorMessage() + { + // Arrange + var status = MakeStatus("Failing"); + _mockStatusClient.GetAllAsync(Arg.Any()) + .Returns(Task.FromResult>([status])); + _mockStatusClient.ArchiveAsync(status.Id.ToString(), Arg.Any()) + .Returns(Task.FromResult(false)); + _ctx.AddAuthorization().SetAuthorized("Admin User").SetRoles("Admin"); + var cut = _ctx.Render(); + + // Open dialog and confirm + await cut.Find($"#archive-{status.Id}").ClickAsync(new MouseEventArgs()); + var confirmButton = cut.FindAll("button").First(b => b.TextContent.Contains("Yes, Archive")); + await confirmButton.ClickAsync(new MouseEventArgs()); + + // Assert — error element is shown + cut.Find("#archive-error").Should().NotBeNull(); + cut.Find("#archive-error").TextContent.Should().NotBeNullOrWhiteSpace(); + } +} From 8ec309605ba633f0b17f411a1605d6329308dee3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:22:56 +0000 Subject: [PATCH 3/4] fix: update archive dialog messages to accurately describe soft-delete behavior Agent-Logs-Url: https://github.com/mpaulosky/IssueManager/sessions/e899df2f-9625-48a4-b467-5ae84e214857 Co-authored-by: mpaulosky <60372079+mpaulosky@users.noreply.github.com> --- src/Web/Components/Features/Statuses/StatusesPage.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Web/Components/Features/Statuses/StatusesPage.razor b/src/Web/Components/Features/Statuses/StatusesPage.razor index 6b3ece5..1906283 100644 --- a/src/Web/Components/Features/Statuses/StatusesPage.razor +++ b/src/Web/Components/Features/Statuses/StatusesPage.razor @@ -63,7 +63,7 @@ Date: Wed, 15 Apr 2026 10:25:41 -0700 Subject: [PATCH 4/4] fix(CategoriesPage): align archive UI with test expectations - Add id attribute to archive button: id="archive-{cat.Id}" - Change AuthorizeView from Policy="Admin" to Roles="Admin" - Change ConfirmText from "Archive" to "Yes, Archive" - Add archive-error div with conditional display - Add _errorMessage field and error handling in HandleArchiveConfirm Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Features/Categories/CategoriesPage.razor | 11 ++++++++--- .../Features/Categories/CategoriesPage.razor.cs | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Web/Components/Features/Categories/CategoriesPage.razor b/src/Web/Components/Features/Categories/CategoriesPage.razor index 1e4aabe..e98d92a 100644 --- a/src/Web/Components/Features/Categories/CategoriesPage.razor +++ b/src/Web/Components/Features/Categories/CategoriesPage.razor @@ -37,9 +37,9 @@