mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
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.
This commit is contained in:
parent
06eae2fb02
commit
3019e71093
6 changed files with 287 additions and 11 deletions
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal file
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,26 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
if (query.AccountId.HasValue)
|
if (query.AccountId.HasValue)
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
|
||||||
|
|
||||||
|
// An exact id set (a link from an alert or a linked-work-orders list) shows those work
|
||||||
|
// orders whatever their date, status or owner; only the base and tenant scope still apply.
|
||||||
|
baseQuery = query.Ids is { Count: > 0 } ids
|
||||||
|
? baseQuery.Where(w => ids.Contains(w.Id))
|
||||||
|
: ApplyFilters(baseQuery, query);
|
||||||
|
|
||||||
|
var totalCount = await baseQuery.CountAsync();
|
||||||
|
|
||||||
|
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
|
||||||
|
var paged = sorted
|
||||||
|
.Skip(query.Page * query.PageSize)
|
||||||
|
.Take(query.PageSize);
|
||||||
|
|
||||||
|
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
|
||||||
|
return new WorkOrderAdvancedSearchResult(rows, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<WorkOrder> ApplyFilters(IQueryable<WorkOrder> baseQuery, WorkOrderAdvancedSearchQuery query)
|
||||||
|
{
|
||||||
if (query.UnscheduledOnly)
|
if (query.UnscheduledOnly)
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
|
||||||
else
|
else
|
||||||
|
|
@ -47,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
if (normalizedSearch != null)
|
if (normalizedSearch != null)
|
||||||
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
|
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
|
||||||
|
|
||||||
var totalCount = await baseQuery.CountAsync();
|
return baseQuery;
|
||||||
|
|
||||||
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
|
|
||||||
var paged = sorted
|
|
||||||
.Skip(query.Page * query.PageSize)
|
|
||||||
.Take(query.PageSize);
|
|
||||||
|
|
||||||
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
|
|
||||||
return new WorkOrderAdvancedSearchResult(rows, totalCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
|
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
string SortBy,
|
string SortBy,
|
||||||
string SortDir,
|
string SortDir,
|
||||||
int? AccountId = null,
|
int? AccountId = null,
|
||||||
bool IncludeDateless = false);
|
bool IncludeDateless = false,
|
||||||
|
IReadOnlyList<int>? Ids = null);
|
||||||
|
|
||||||
public record WorkOrderAdvancedSearchResult(
|
public record WorkOrderAdvancedSearchResult(
|
||||||
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,12 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool HasUplift { get; set; }
|
public bool HasUplift { get; set; }
|
||||||
public List<string>? UpliftStatuses { get; set; }
|
public List<string>? UpliftStatuses { get; set; }
|
||||||
public bool MyWorkOrders { get; set; }
|
public bool MyWorkOrders { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Comma-separated work-order ids, at most <see cref="WorkOrderIdSet.MaxCount"/> distinct.
|
||||||
|
/// When present the result is exactly those work orders inside the caller's tenant scope,
|
||||||
|
/// and every other filter (dates, statuses, dispatchers, facets, search) is ignored.
|
||||||
|
/// </summary>
|
||||||
|
public string? Ids { get; set; }
|
||||||
public string? SortBy { get; set; }
|
public string? SortBy { get; set; }
|
||||||
public string? SortDir { get; set; }
|
public string? SortDir { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
SeaHaven.Services/Helpers/WorkOrderIdSet.cs
Normal file
38
SeaHaven.Services/Helpers/WorkOrderIdSet.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The board's exact work-order id filter: comma-separated positive integers, deduplicated,
|
||||||
|
/// at most <see cref="MaxCount"/> distinct ids.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderIdSet
|
||||||
|
{
|
||||||
|
public const int MaxCount = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Null when <paramref name="raw"/> is absent or blank. Throws <see cref="ArgumentException"/>
|
||||||
|
/// (a 400 on the board) for any token that is not a positive integer, or too many ids.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<int>? ParseOrThrow(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ids = new List<int>();
|
||||||
|
var seen = new HashSet<int>();
|
||||||
|
foreach (var token in raw.Split(','))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(token.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
|
||||||
|
throw new ArgumentException("ids must be a comma-separated list of positive work order ids.");
|
||||||
|
if (seen.Add(id))
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.Count > MaxCount)
|
||||||
|
throw new ArgumentException($"ids accepts at most {MaxCount} work orders.");
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.DateTo);
|
query.DateTo);
|
||||||
|
|
||||||
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
|
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
|
||||||
|
var ids = WorkOrderIdSet.ParseOrThrow(query.Ids);
|
||||||
|
|
||||||
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
||||||
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
||||||
|
|
@ -77,7 +78,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.IncludeDateless,
|
query.IncludeDateless,
|
||||||
query.DatePreset,
|
query.DatePreset,
|
||||||
query.DateFrom,
|
query.DateFrom,
|
||||||
query.DateTo));
|
query.DateTo),
|
||||||
|
ids);
|
||||||
|
|
||||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||||
var utcNow = DateTime.UtcNow;
|
var utcNow = DateTime.UtcNow;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue