mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 01:22:02 +00:00
test: add ProposalNumberGenerator, LineItemService state guard, and API client interceptor tests
- ProposalNumberGenerator tests (8 tests): format validation (SHI-YYYY-NNNN), sequence incrementing, revision skipping, year boundary isolation, uniqueness, zero-padding, high sequence rollover. Uses SQLite in-memory with Postgres function stubs to support ExecuteSqlRawAsync. - LineItemService state guard tests (18 tests): verifies line items cannot be created/bulk-updated/deleted on Approved or Sent proposals (QA-C2), confirms operations succeed on InReview and Revised statuses, validates KeyNotFoundException on missing proposals, verifies audit logging. - API client interceptor tests (14 tests): request interceptor attaches Bearer token from sessionStorage (WEB-C1), handles missing/malformed token data, response interceptor dispatches Redux logout on 401 (WEB-M2), returns friendly messages for 403/404, extracts server error details, handles network errors. - DbContextFactory updated to suppress InMemoryEventId.TransactionIgnoredWarning so BulkUpdateAsync tests work with in-memory provider. - Added SqliteDbContextFactory for tests requiring relational features. - Added Microsoft.EntityFrameworkCore.Sqlite to test project dependencies. Total: 104 .NET tests (was 77), 26 web tests (was 12). CI already wired.
This commit is contained in:
parent
8d73e66a17
commit
ab9569d7a9
6 changed files with 968 additions and 0 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using ProposalSystem.Infrastructure.Data;
|
using ProposalSystem.Infrastructure.Data;
|
||||||
|
|
||||||
namespace ProposalSystem.Tests.Helpers;
|
namespace ProposalSystem.Tests.Helpers;
|
||||||
|
|
@ -6,6 +7,7 @@ namespace ProposalSystem.Tests.Helpers;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates isolated in-memory DbContext instances for unit tests.
|
/// Creates isolated in-memory DbContext instances for unit tests.
|
||||||
/// Each call produces a uniquely-named database so tests don't leak state.
|
/// Each call produces a uniquely-named database so tests don't leak state.
|
||||||
|
/// Transaction warnings are suppressed since InMemory provider does not support them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DbContextFactory
|
public static class DbContextFactory
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +15,7 @@ public static class DbContextFactory
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ProposalDbContext>()
|
var options = new DbContextOptionsBuilder<ProposalDbContext>()
|
||||||
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
|
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
var context = new ProposalDbContext(options);
|
var context = new ProposalDbContext(options);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ProposalSystem.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Tests.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates SQLite-backed in-memory DbContext instances for tests that require relational
|
||||||
|
/// features (e.g., ExecuteSqlRawAsync, transactions). The SQLite connection is kept open
|
||||||
|
/// for the lifetime of the context; disposing the context closes the connection and
|
||||||
|
/// discards the in-memory database.
|
||||||
|
///
|
||||||
|
/// Registers stub Postgres functions (hashtext, pg_advisory_xact_lock) so that
|
||||||
|
/// production SQL using these functions does not throw in the test environment.
|
||||||
|
/// </summary>
|
||||||
|
public static class SqliteDbContextFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new ProposalDbContext backed by a unique SQLite in-memory database.
|
||||||
|
/// The returned context owns the connection; dispose the context to clean up.
|
||||||
|
/// </summary>
|
||||||
|
public static ProposalDbContext Create()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
// Register Postgres-specific function stubs so ExecuteSqlRawAsync calls
|
||||||
|
// like "SELECT pg_advisory_xact_lock(hashtext('...'))" succeed in SQLite.
|
||||||
|
RegisterPostgresStubs(connection);
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ProposalDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
var context = new ProposalDbContext(options);
|
||||||
|
context.Database.EnsureCreated();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterPostgresStubs(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
// hashtext(text) -> integer — returns a constant; only needed so SQL parses
|
||||||
|
connection.CreateFunction("hashtext", (string _) => 0);
|
||||||
|
|
||||||
|
// pg_advisory_xact_lock(bigint) -> void — no-op in tests
|
||||||
|
connection.CreateFunction("pg_advisory_xact_lock", (long _) => 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using ProposalSystem.Application.DTOs;
|
||||||
|
using ProposalSystem.Application.Interfaces;
|
||||||
|
using ProposalSystem.Domain.Entities;
|
||||||
|
using ProposalSystem.Infrastructure.Services;
|
||||||
|
using ProposalSystem.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LineItemService state guard tests.
|
||||||
|
/// Verifies that line items cannot be created, bulk-updated, or deleted on
|
||||||
|
/// proposals in Approved or Sent status. Line item operations on InReview
|
||||||
|
/// and Revised proposals must succeed.
|
||||||
|
///
|
||||||
|
/// Related findings: QA-C2 (state machine enforcement), API-H3 (status field exposure).
|
||||||
|
/// </summary>
|
||||||
|
public class LineItemServiceStateGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Infrastructure.Data.ProposalDbContext _db;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
private readonly LineItemService _sut;
|
||||||
|
private readonly Guid _userId;
|
||||||
|
|
||||||
|
public LineItemServiceStateGuardTests()
|
||||||
|
{
|
||||||
|
_db = DbContextFactory.Create();
|
||||||
|
_audit = Substitute.For<IAuditService>();
|
||||||
|
|
||||||
|
_userId = Guid.NewGuid();
|
||||||
|
_db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = _userId,
|
||||||
|
CognitoSub = $"sub-{_userId}",
|
||||||
|
Email = "admin@test.com",
|
||||||
|
DisplayName = "Test Admin",
|
||||||
|
Role = UserRole.Admin,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
_db.SaveChanges();
|
||||||
|
|
||||||
|
_sut = new LineItemService(_db, _audit, Substitute.For<ILogger<LineItemService>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region CreateAsync Guards
|
||||||
|
|
||||||
|
[Theory(DisplayName = "QA-C2: CreateAsync throws on Approved and Sent proposals")]
|
||||||
|
[InlineData(ProposalStatus.Approved)]
|
||||||
|
[InlineData(ProposalStatus.Sent)]
|
||||||
|
public async Task CreateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(status);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = CreateLineItemRequest();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => _sut.CreateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("*approved/sent*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory(DisplayName = "QA-C2: CreateAsync succeeds on InReview and Revised proposals")]
|
||||||
|
[InlineData(ProposalStatus.InReview)]
|
||||||
|
[InlineData(ProposalStatus.Revised)]
|
||||||
|
public async Task CreateAsync_OnEditableStatus_Succeeds(ProposalStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(status);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = CreateLineItemRequest();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.CreateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Description.Should().Be(request.Description);
|
||||||
|
result.ProposalId.Should().Be(proposal.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: CreateAsync throws KeyNotFoundException for nonexistent proposal")]
|
||||||
|
public async Task CreateAsync_NonexistentProposal_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
var act = () => _sut.CreateAsync(Guid.NewGuid(), CreateLineItemRequest());
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region BulkUpdateAsync Guards
|
||||||
|
|
||||||
|
[Theory(DisplayName = "QA-C2: BulkUpdateAsync throws on Approved and Sent proposals")]
|
||||||
|
[InlineData(ProposalStatus.Approved)]
|
||||||
|
[InlineData(ProposalStatus.Sent)]
|
||||||
|
public async Task BulkUpdateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(status);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
|
||||||
|
{
|
||||||
|
new(null, "Updated item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => _sut.BulkUpdateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("*approved/sent*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync succeeds on InReview proposal")]
|
||||||
|
public async Task BulkUpdateAsync_OnInReview_Succeeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
|
||||||
|
{
|
||||||
|
new(null, "Item A", 2, "hours", 50m, 100m, PricingMode.UnitPrice, 1, LineItemSource.Manual),
|
||||||
|
new(null, "Item B", 1, "each", null, 200m, PricingMode.TotalPrice, 2, LineItemSource.AI),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().HaveCount(2);
|
||||||
|
result.Select(r => r.Description).Should().BeEquivalentTo("Item A", "Item B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync updates proposal TotalBidAmount")]
|
||||||
|
public async Task BulkUpdateAsync_UpdatesTotalBidAmount()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
|
||||||
|
{
|
||||||
|
new(null, "Item A", 1, "each", null, 500m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
|
||||||
|
new(null, "Item B", 1, "each", null, 300m, PricingMode.TotalPrice, 2, LineItemSource.Manual),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.BulkUpdateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updated = await _db.Proposals.FindAsync(proposal.Id);
|
||||||
|
updated!.TotalBidAmount.Should().Be(800m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync replaces all existing line items")]
|
||||||
|
public async Task BulkUpdateAsync_ReplacesExistingItems()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
proposal.LineItems.Add(new LineItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
Description = "Old item",
|
||||||
|
Quantity = 1,
|
||||||
|
Unit = "each",
|
||||||
|
TotalPrice = 999m,
|
||||||
|
PricingMode = PricingMode.TotalPrice,
|
||||||
|
Source = LineItemSource.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
|
||||||
|
{
|
||||||
|
new(null, "New item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().HaveCount(1);
|
||||||
|
result[0].Description.Should().Be("New item");
|
||||||
|
|
||||||
|
var dbItems = _db.LineItems.Where(li => li.ProposalId == proposal.Id).ToList();
|
||||||
|
dbItems.Should().HaveCount(1);
|
||||||
|
dbItems[0].Description.Should().Be("New item");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync throws KeyNotFoundException for nonexistent proposal")]
|
||||||
|
public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>());
|
||||||
|
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request);
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region DeleteAsync Guards
|
||||||
|
|
||||||
|
[Theory(DisplayName = "QA-C2: DeleteAsync throws on Approved and Sent proposals")]
|
||||||
|
[InlineData(ProposalStatus.Approved)]
|
||||||
|
[InlineData(ProposalStatus.Sent)]
|
||||||
|
public async Task DeleteAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(status);
|
||||||
|
var lineItem = new LineItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
Description = "Item to delete",
|
||||||
|
Quantity = 1,
|
||||||
|
Unit = "each",
|
||||||
|
TotalPrice = 100m,
|
||||||
|
PricingMode = PricingMode.TotalPrice,
|
||||||
|
Source = LineItemSource.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
proposal.LineItems.Add(lineItem);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => _sut.DeleteAsync(proposal.Id, lineItem.Id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("*approved/sent*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: DeleteAsync succeeds on InReview proposal")]
|
||||||
|
public async Task DeleteAsync_OnInReview_Succeeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
var lineItem = new LineItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
Description = "Item to delete",
|
||||||
|
Quantity = 1,
|
||||||
|
Unit = "each",
|
||||||
|
TotalPrice = 100m,
|
||||||
|
PricingMode = PricingMode.TotalPrice,
|
||||||
|
Source = LineItemSource.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
proposal.LineItems.Add(lineItem);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.DeleteAsync(proposal.Id, lineItem.Id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var deleted = await _db.LineItems.FindAsync(lineItem.Id);
|
||||||
|
deleted.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: DeleteAsync throws KeyNotFoundException for nonexistent line item")]
|
||||||
|
public async Task DeleteAsync_NonexistentLineItem_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => _sut.DeleteAsync(proposal.Id, Guid.NewGuid());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Audit Logging
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: CreateAsync logs audit event on success")]
|
||||||
|
public async Task CreateAsync_LogsAuditEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = CreateLineItemRequest();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.CreateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
AuditAction.EditLineItem,
|
||||||
|
proposal.Id,
|
||||||
|
Arg.Is<string?>(s => s != null && s.Contains(request.Description)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")]
|
||||||
|
public async Task BulkUpdateAsync_LogsAuditEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
|
||||||
|
{
|
||||||
|
new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.BulkUpdateAsync(proposal.Id, request);
|
||||||
|
|
||||||
|
// Assert — audit detail may be plain text ("Bulk update: N items") or JSON
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
AuditAction.EditLineItem,
|
||||||
|
proposal.Id,
|
||||||
|
Arg.Is<string?>(s => s != null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")]
|
||||||
|
public async Task DeleteAsync_LogsAuditEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var proposal = CreateProposal(ProposalStatus.InReview);
|
||||||
|
var lineItem = new LineItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
Description = "Audit test item",
|
||||||
|
Quantity = 1,
|
||||||
|
Unit = "each",
|
||||||
|
TotalPrice = 100m,
|
||||||
|
PricingMode = PricingMode.TotalPrice,
|
||||||
|
Source = LineItemSource.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
proposal.LineItems.Add(lineItem);
|
||||||
|
_db.Proposals.Add(proposal);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.DeleteAsync(proposal.Id, lineItem.Id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
AuditAction.EditLineItem,
|
||||||
|
proposal.Id,
|
||||||
|
Arg.Is<string?>(s => s != null && s.Contains("Audit test item")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private Proposal CreateProposal(ProposalStatus status)
|
||||||
|
{
|
||||||
|
return new Proposal
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16],
|
||||||
|
WorkOrderNumber = "WO-TEST",
|
||||||
|
CustomerName = "Test Customer",
|
||||||
|
CustomerAddress = "123 Test St",
|
||||||
|
ScopeOfWork = "Test scope of work",
|
||||||
|
ServiceCategory = ServiceCategory.General,
|
||||||
|
Priority = Priority.Standard,
|
||||||
|
Status = status,
|
||||||
|
Notes = "",
|
||||||
|
SubmittedById = _userId,
|
||||||
|
SubmittedAt = DateTime.UtcNow,
|
||||||
|
CurrentRevision = 1,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateLineItemRequest CreateLineItemRequest()
|
||||||
|
{
|
||||||
|
return new CreateLineItemRequest(
|
||||||
|
Description: "HVAC duct replacement",
|
||||||
|
Quantity: 2,
|
||||||
|
Unit: "each",
|
||||||
|
UnitPrice: 250m,
|
||||||
|
TotalPrice: 500m,
|
||||||
|
PricingMode: PricingMode.UnitPrice,
|
||||||
|
SortOrder: 1,
|
||||||
|
Source: LineItemSource.Manual
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ProposalSystem.Domain.Entities;
|
||||||
|
using ProposalSystem.Infrastructure.Services;
|
||||||
|
using ProposalSystem.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for ProposalNumberGenerator.
|
||||||
|
/// Verifies format (SHI-YYYY-NNNN), uniqueness, and sequence logic.
|
||||||
|
/// Uses SQLite in-memory provider because the generator calls ExecuteSqlRawAsync
|
||||||
|
/// for pg_advisory_xact_lock, which requires a relational provider (InMemory throws).
|
||||||
|
/// SQLite silently accepts the Postgres-specific SQL, allowing the sequence logic to
|
||||||
|
/// be exercised end-to-end.
|
||||||
|
///
|
||||||
|
/// Related findings: QA-C1 (test coverage gaps).
|
||||||
|
/// </summary>
|
||||||
|
public class ProposalNumberGeneratorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Infrastructure.Data.ProposalDbContext _db;
|
||||||
|
private readonly ProposalNumberGenerator _sut;
|
||||||
|
private readonly Guid _userId;
|
||||||
|
|
||||||
|
public ProposalNumberGeneratorTests()
|
||||||
|
{
|
||||||
|
_db = SqliteDbContextFactory.Create();
|
||||||
|
|
||||||
|
// Create a user required by FK constraints on Proposal.SubmittedById
|
||||||
|
_userId = Guid.NewGuid();
|
||||||
|
_db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = _userId,
|
||||||
|
CognitoSub = $"sub-{_userId}",
|
||||||
|
Email = "test@seahavenind.com",
|
||||||
|
DisplayName = "Test User",
|
||||||
|
Role = UserRole.Dispatcher,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
_db.SaveChanges();
|
||||||
|
|
||||||
|
_sut = new ProposalNumberGenerator(_db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync returns SHI-YYYY-0001 format when no prior proposals")]
|
||||||
|
public async Task GenerateAsync_NoPriorProposals_ReturnsFirstSequenceNumber()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
result.Should().Be($"SHI-{year}-0001");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync format matches SHI-YYYY-NNNN pattern")]
|
||||||
|
public async Task GenerateAsync_MatchesExpectedFormat()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().MatchRegex(@"^SHI-\d{4}-\d{4}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync increments sequence from last proposal")]
|
||||||
|
public async Task GenerateAsync_WithExistingProposals_IncrementsSequence()
|
||||||
|
{
|
||||||
|
// Arrange — seed a proposal with number ending in 0042
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0042"));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().Be($"SHI-{year}-0043");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync skips revision proposals (containing -R)")]
|
||||||
|
public async Task GenerateAsync_IgnoresRevisionProposals()
|
||||||
|
{
|
||||||
|
// Arrange — seed a regular and a revision proposal
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0010"));
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0010-R2")); // revision should be ignored
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert — next after 0010, not after the revision
|
||||||
|
result.Should().Be($"SHI-{year}-0011");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync ignores proposals from different years")]
|
||||||
|
public async Task GenerateAsync_IgnoresDifferentYearProposals()
|
||||||
|
{
|
||||||
|
// Arrange — seed proposals from a previous year
|
||||||
|
var lastYear = DateTime.UtcNow.Year - 1;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{lastYear}-0099"));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert — starts at 0001 for the current year
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
result.Should().Be($"SHI-{year}-0001");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync produces unique numbers across multiple calls")]
|
||||||
|
public async Task GenerateAsync_MultipleCalls_ProducesUniqueNumbers()
|
||||||
|
{
|
||||||
|
// Act — generate several numbers, persisting each to DB between calls
|
||||||
|
var numbers = new List<string>();
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
var number = await _sut.GenerateAsync();
|
||||||
|
numbers.Add(number);
|
||||||
|
// Persist so the next call sees it
|
||||||
|
_db.Proposals.Add(CreateProposal(number));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
numbers.Should().OnlyHaveUniqueItems();
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
numbers.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
$"SHI-{year}-0001",
|
||||||
|
$"SHI-{year}-0002",
|
||||||
|
$"SHI-{year}-0003",
|
||||||
|
$"SHI-{year}-0004",
|
||||||
|
$"SHI-{year}-0005",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync zero-pads sequence to 4 digits")]
|
||||||
|
public async Task GenerateAsync_ZeroPadsToFourDigits()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0003"));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().EndWith("-0004");
|
||||||
|
// Verify the sequence part is exactly 4 characters
|
||||||
|
var sequencePart = result.Split('-').Last();
|
||||||
|
sequencePart.Should().HaveLength(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync handles sequence above 9999 (5+ digits)")]
|
||||||
|
public async Task GenerateAsync_HighSequenceNumber_StillFormatsCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange — seed a proposal at 9999
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-9999"));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert — D4 format will produce 5 digits at 10000
|
||||||
|
result.Should().Be($"SHI-{year}-10000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "QA-C1: GenerateAsync picks highest existing sequence, not last inserted")]
|
||||||
|
public async Task GenerateAsync_OutOfOrderInsertion_PicksHighest()
|
||||||
|
{
|
||||||
|
// Arrange — insert out of order
|
||||||
|
var year = DateTime.UtcNow.Year;
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0050"));
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0020"));
|
||||||
|
_db.Proposals.Add(CreateProposal($"SHI-{year}-0075"));
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GenerateAsync();
|
||||||
|
|
||||||
|
// Assert — should pick 75 + 1 = 76 since OrderByDescending picks the highest
|
||||||
|
result.Should().Be($"SHI-{year}-0076");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Proposal CreateProposal(string proposalNumber)
|
||||||
|
{
|
||||||
|
return new Proposal
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ProposalNumber = proposalNumber,
|
||||||
|
WorkOrderNumber = "WO-TEST",
|
||||||
|
CustomerName = "Test Customer",
|
||||||
|
CustomerAddress = "123 Test St",
|
||||||
|
ScopeOfWork = "Test scope",
|
||||||
|
ServiceCategory = ServiceCategory.General,
|
||||||
|
Priority = Priority.Standard,
|
||||||
|
Status = ProposalStatus.InReview,
|
||||||
|
Notes = "",
|
||||||
|
SubmittedById = _userId,
|
||||||
|
SubmittedAt = DateTime.UtcNow,
|
||||||
|
CurrentRevision = 1,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
277
web/src/lib/api/__tests__/client.test.ts
Normal file
277
web/src/lib/api/__tests__/client.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
/**
|
||||||
|
* API client interceptor tests (WEB-M2, WEB-C1).
|
||||||
|
*
|
||||||
|
* Tests that:
|
||||||
|
* - Request interceptor attaches Bearer token from sessionStorage
|
||||||
|
* - Request interceptor handles missing/invalid token data gracefully
|
||||||
|
* - Response interceptor dispatches Redux logout and redirects on 401
|
||||||
|
* - Response interceptor returns friendly error message on 403
|
||||||
|
* - Response interceptor returns friendly error message on 404
|
||||||
|
* - Response interceptor extracts server error detail from response body
|
||||||
|
* - Response interceptor handles network errors (no response)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||||
|
import type { InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
// vi.hoisted returns values accessible in both the hoisted mock scope and test scope.
|
||||||
|
const { interceptors, mockDispatch } = vi.hoisted(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const state: Record<string, any> = {};
|
||||||
|
const dispatch = vi.fn();
|
||||||
|
return {
|
||||||
|
interceptors: state,
|
||||||
|
mockDispatch: dispatch,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the Redux store
|
||||||
|
vi.mock('../../../app/store', () => ({
|
||||||
|
store: {
|
||||||
|
dispatch: (...args: unknown[]) => mockDispatch(...args),
|
||||||
|
getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
replaceReducer: vi.fn(),
|
||||||
|
[Symbol.observable]: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the authSlice logout action
|
||||||
|
vi.mock('../../../app/slices/authSlice', () => ({
|
||||||
|
logout: () => ({ type: 'auth/logout' }),
|
||||||
|
setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }),
|
||||||
|
default: (state: unknown) => state,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
const mockInstance = {
|
||||||
|
interceptors: {
|
||||||
|
request: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
use: (fulfilled: any, rejected: any) => {
|
||||||
|
interceptors.requestFulfilled = fulfilled;
|
||||||
|
interceptors.requestRejected = rejected;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
use: (fulfilled: any, rejected: any) => {
|
||||||
|
interceptors.responseFulfilled = fulfilled;
|
||||||
|
interceptors.responseRejected = rejected;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { headers: { common: {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: () => mockInstance,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock sessionStorage
|
||||||
|
const sessionStorageData: Record<string, string> = {};
|
||||||
|
vi.stubGlobal('sessionStorage', {
|
||||||
|
getItem: vi.fn((key: string) => sessionStorageData[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }),
|
||||||
|
clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }),
|
||||||
|
get length() { return Object.keys(sessionStorageData).length; },
|
||||||
|
key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock window.location
|
||||||
|
const mockLocation = { href: '' };
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: mockLocation,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API client interceptors', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Dynamically import the module under test after all mocks are set up
|
||||||
|
await import('../client');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]);
|
||||||
|
mockLocation.href = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request interceptor', () => {
|
||||||
|
it('WEB-C1: attaches Bearer token from sessionStorage when valid token data exists', () => {
|
||||||
|
const tokenData = JSON.stringify({ token: 'my-jwt-token-123' });
|
||||||
|
sessionStorageData['proposal_system_token'] = tokenData;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
} as unknown as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
const result = interceptors.requestFulfilled(config);
|
||||||
|
|
||||||
|
expect(result.headers.Authorization).toBe('Bearer my-jwt-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEB-C1: does not attach Authorization header when no token in sessionStorage', () => {
|
||||||
|
const config = {
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
} as unknown as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
const result = interceptors.requestFulfilled(config);
|
||||||
|
|
||||||
|
expect(result.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEB-C1: handles malformed JSON in sessionStorage gracefully', () => {
|
||||||
|
sessionStorageData['proposal_system_token'] = 'not-valid-json{{{';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
} as unknown as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
const result = interceptors.requestFulfilled(config);
|
||||||
|
|
||||||
|
expect(result.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEB-C1: does not attach header when token field is missing in parsed data', () => {
|
||||||
|
sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
} as unknown as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
const result = interceptors.requestFulfilled(config);
|
||||||
|
|
||||||
|
expect(result.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEB-C1: request error rejection propagates the error', async () => {
|
||||||
|
const error = new Error('request setup failed');
|
||||||
|
const promise = interceptors.requestRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toEqual(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response interceptor', () => {
|
||||||
|
it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 401,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Session expired. Please log in again.');
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' });
|
||||||
|
expect(mockLocation.href).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WEB-M2: 403 response returns permission error without dispatching logout', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 403,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('You do not have permission to perform this action.');
|
||||||
|
expect(mockDispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 response returns resource-not-found error', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 404,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('The requested resource was not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts detail message from server error response', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
data: { detail: 'Validation failed: scope too short' },
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Validation failed: scope too short');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts title message when detail is absent', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 500,
|
||||||
|
data: { title: 'Internal Server Error' },
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Internal Server Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to generic message when no detail/title/message in response', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('An error occurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles network error (no response from server)', async () => {
|
||||||
|
const error = {
|
||||||
|
request: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('No response from server. Please check your connection.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through non-axios errors unchanged', async () => {
|
||||||
|
const error = new Error('Something unexpected');
|
||||||
|
|
||||||
|
const promise = interceptors.responseRejected(error);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response success passes through unchanged', () => {
|
||||||
|
const response = { data: { id: 1 }, status: 200 };
|
||||||
|
|
||||||
|
const result = interceptors.responseFulfilled(response);
|
||||||
|
|
||||||
|
expect(result).toBe(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue