shoc-backend/Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Alexandre Brandizzi 3019e71093 feat(workorders): filter board search to an exact work-order id set
GET /board/search accepts ids=1,2,3 (positive ints, deduplicated, at most
200). When present the result is exactly those work orders inside the
caller's tenant and base scope; date, status, dispatcher, facet and text
filters are ignored so none of them can hide a listed work order.
Malformed or oversized lists are a 400.
2026-09-25 10:56:47 -03:00

217 lines
7.4 KiB
C#

using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope,
/// whatever other filters the board sends alongside.
/// </summary>
public class WorkOrderIdsFilterTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(
new WorkOrderAdvancedSearchDataService(ctx),
new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx)));
private static ClaimsPrincipal AccountUser(int accountId)
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
int accountId = 1,
string? assignTo = "disp-1",
LifecycleStatus? status = LifecycleStatus.Scheduled,
bool? deleted = null,
bool template = false)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"3000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
LifecycleStatus = status,
IsDeleted = deleted,
istemplate = template
};
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)),
Wo(2, null), // no schedule date
Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed),
Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"),
Wo(5, new DateTime(2026, 9, 22)), // not requested
Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant
Wo(7, new DateTime(2026, 9, 22), deleted: true),
Wo(8, new DateTime(2026, 9, 22), template: true));
ctx.SaveChanges();
}
[Fact]
public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "4,1,2,3",
// Filters the board may still carry; none of them may hide a listed work order.
DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek,
Statuses = new List<LifecycleStatus> { LifecycleStatus.Scheduled },
Dispatchers = new List<string> { "disp-1" },
Search = "no match anywhere",
PageSize = 200
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(4);
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 });
}
[Fact]
public async Task Ids_NeverWidenTenantOrBaseScope()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "1,6,7,8",
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().Equal(1);
result.TotalCount.Should().Be(1);
}
[Fact]
public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "6"
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task NoIds_KeepsTheExistingFilters()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 9, 21),
DateTo = new DateOnly(2026, 9, 25),
Dispatchers = new List<string> { "disp-1" },
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 });
}
[Theory]
[InlineData("1,abc")]
[InlineData("0")]
[InlineData("-3")]
[InlineData("1,,2")]
[InlineData("1.5")]
[InlineData("99999999999")]
public async Task MalformedIds_AreABadRequest(string ids)
{
var controller = NewController();
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<Response>().Which.Message.Should().Contain("ids");
}
[Fact]
public async Task MoreThanTheLimit_IsABadRequest()
{
var controller = NewController();
var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1));
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
result.Should().BeOfType<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<Response>()
.Which.Message.Should().Contain("200");
}
[Fact]
public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder()
{
var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 }));
WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount);
WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_AbsentOrBlank_IsNoFilter(string? ids)
{
WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull();
}
private static WorkOrderBoardController NewController()
{
var search = new WorkOrderAdvancedSearchService(
Mock.Of<SeaHaven.DataServices.Interfaces.IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
return new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
search)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = AccountUser(1) }
}
};
}
}