mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
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.
217 lines
7.4 KiB
C#
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) }
|
|
}
|
|
};
|
|
}
|
|
}
|