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; /// /// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope, /// whatever other filters the board sends alongside. /// public class WorkOrderIdsFilterTests { private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .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.Scheduled }, Dispatchers = new List { "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 { "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().Subject; badRequest.Value.Should().BeOfType().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() .Which.Value.Should().BeOfType() .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(), Mock.Of()); return new WorkOrderBoardController( Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), search) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = AccountUser(1) } } }; } }