Files
OpenNest/OpenNest.Server.Tests/NestQueryTests.cs
T
aj 1fadf6c4d3 feat(server): add bounded nest query with SQL-side search
GET /api/nests/query filters name, customer, material, made by, comments
and status (stored and display names) with an escaped LIKE parameter,
orders newest saved first with an id tie-break, and returns one page of
at most 500 records plus the total match count, read in one database
hold. Unknown, repeated or out-of-range parameters return 400.

RemoteNestRepository.QueryAsync validates the same bounds before
sending, rejects oversized pages, and reports a 404 from an older server
as a server that needs updating. GET /api/nests stays the full
enumeration used by backup, restore checks and the container smoke.
2026-10-02 19:52:35 -04:00

282 lines
10 KiB
C#

using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
/// <summary>
/// Bounded, SQL-filtered browsing (<c>GET /api/nests/query</c>) through the production
/// routes, real SQLite and the desktop's <see cref="RemoteNestRepository"/>.
/// </summary>
public sealed class NestQueryTests : IDisposable
{
private readonly ServerFactory _factory = new();
private readonly HttpClient _client;
private readonly RemoteNestRepository _repository;
private readonly NestDatabase _database;
public NestQueryTests()
{
_client = _factory.CreateClient();
_repository = new RemoteNestRepository(_client, _client.BaseAddress!.ToString());
_database = _factory.Services.GetRequiredService<NestDatabase>();
}
[Fact]
public async Task Query_WithSelectiveSearch_ReturnsOnlyMatchesAndTheirTotal()
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => r.Customer == "Beta Works"));
var page = await _repository.QueryAsync(new NestQuery { Search = " BETA ", Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Length, page.Total);
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
Assert.All(page.Items, r => AssertSameMetadata(seeded.Single(s => s.Id == r.Id), r));
}
[Fact]
public async Task Query_WithSelectiveSearch_DoesNotTransferUnmatchedRows()
{
Seed(24);
var body = await _client.GetStringAsync("/api/nests/query?search=beta&limit=500");
Assert.Contains("Beta Works", body);
Assert.DoesNotContain("Alpha Fabrication", body);
Assert.DoesNotContain("Gamma 50% Shop", body);
Assert.DoesNotContain("Delta_Under", body);
Assert.DoesNotContain("\"file\"", body);
}
[Fact]
public async Task Query_WithNoMatch_ReturnsEmptyPageAndZeroTotal()
{
Seed(10);
var page = await _repository.QueryAsync(new NestQuery { Search = "no such synthetic text" });
Assert.Empty(page.Items);
Assert.Equal(0, page.Total);
}
[Fact]
public async Task Query_Pages_PartitionTheOrderedSetExactly()
{
var expected = Ordered(Seed(25)).Select(r => r.Id).ToArray();
var collected = new List<Guid>();
for (var offset = 0; offset < expected.Length; offset += 7)
{
var page = await _repository.QueryAsync(new NestQuery { Offset = offset, Limit = 7 });
Assert.Equal(25, page.Total);
Assert.Equal(offset, page.Offset);
Assert.Equal(7, page.Limit);
Assert.Equal(System.Math.Min(7, expected.Length - offset), page.Items.Count);
collected.AddRange(page.Items.Select(r => r.Id));
}
Assert.Equal(expected, collected);
}
[Theory]
[InlineData(25)]
[InlineData(1000)]
[InlineData(int.MaxValue)]
public async Task Query_OffsetPastTheEnd_ReturnsNoItemsWithTheTrueTotal(int offset)
{
Seed(25);
var page = await _repository.QueryAsync(new NestQuery { Offset = offset });
Assert.Empty(page.Items);
Assert.Equal(25, page.Total);
}
[Fact]
public async Task Query_DefaultLimit_BoundsAnUnfilteredRequest()
{
Seed(NestQuery.DefaultLimit + 5);
var page = await _repository.QueryAsync(new NestQuery());
var raw = JsonDocument.Parse(await _client.GetStringAsync("/api/nests/query"));
Assert.Equal(NestQuery.DefaultLimit, page.Items.Count);
Assert.Equal(NestQuery.DefaultLimit + 5, page.Total);
Assert.Equal(NestQuery.DefaultLimit, raw.RootElement.GetProperty("items").GetArrayLength());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Query_BlankSearch_EqualsTheUnfilteredOrder(string search)
{
var expected = Ordered(Seed(12)).Select(r => r.Id);
var page = await _repository.QueryAsync(new NestQuery { Search = search });
Assert.Equal(expected, page.Items.Select(r => r.Id));
Assert.Equal(12, page.Total);
}
[Theory]
[InlineData("0%", "Gamma 50% Shop")]
[InlineData("_", "Delta_Under")]
[InlineData(@"\", @"Path C:\jobs")]
public async Task Query_TreatsLikeWildcardsAndEscapeAsLiteralText(string search, string expectedText)
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => r.Customer == expectedText || r.Comments == expectedText));
Assert.NotEmpty(expected);
Assert.True(expected.Length < seeded.Count);
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
Assert.Equal(expected.Length, page.Total);
}
[Theory]
[InlineData("to be cut", new[] { NestStatus.ToBeCut })]
[InlineData("HAS BEEN", new[] { NestStatus.HasBeenCut })]
[InlineData("tobecut", new[] { NestStatus.ToBeCut })]
[InlineData("cut", new[] { NestStatus.ToBeCut, NestStatus.HasBeenCut })]
[InlineData("quote", new[] { NestStatus.Quote })]
public async Task Query_MatchesStoredAndDisplayedStatusNames(string search, NestStatus[] statuses)
{
var seeded = Seed(24);
var expected = Ordered(seeded.Where(r => statuses.Contains(r.Status)));
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
}
[Theory]
[InlineData("material", "Synthetic steel")]
[InlineData("madeBy", "Synthetic operator B")]
[InlineData("comments", "rush")]
[InlineData("name", "browse nest 07")]
public async Task Query_MatchesEachTextColumn(string column, string search)
{
var seeded = Seed(24);
Func<NestRecord, string> value = column switch
{
"material" => r => r.Material,
"madeBy" => r => r.MadeBy,
"comments" => r => r.Comments,
_ => r => r.Name,
};
var expected = Ordered(seeded.Where(r => value(r).Contains(search, StringComparison.OrdinalIgnoreCase)));
Assert.NotEmpty(expected);
Assert.True(expected.Length < seeded.Count);
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
}
[Fact]
public async Task Query_DoesNotMatchDatesOrNumbers()
{
Seed(12);
var thickness = await _repository.QueryAsync(new NestQuery { Search = "0.25" });
var date = await _repository.QueryAsync(new NestQuery { Search = "2026-01" });
Assert.Equal(0, thickness.Total);
Assert.Equal(0, date.Total);
}
[Theory]
[InlineData("limit=0")]
[InlineData("limit=501")]
[InlineData("limit=abc")]
[InlineData("limit=")]
[InlineData("limit=-1")]
[InlineData("limit=1.5")]
[InlineData("offset=-1")]
[InlineData("offset=99999999999")]
[InlineData("limit=1&limit=2")]
[InlineData("serach=beta")]
[InlineData("status=quote")]
public async Task Query_InvalidParameters_Return400WithoutItems(string queryString)
{
Seed(3);
using var response = await _client.GetAsync("/api/nests/query?" + queryString);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.DoesNotContain("items", body);
Assert.DoesNotContain("Synthetic browse nest", body);
}
[Fact]
public async Task Query_OverLongSearch_Returns400()
{
Seed(3);
var search = new string('x', NestQuery.MaxSearchLength + 1);
using var response = await _client.GetAsync("/api/nests/query?search=" + search);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task List_RemainsTheUnboundedFullEnumeration()
{
var seeded = Seed(NestQuery.DefaultLimit + 5);
var listed = await _repository.ListAsync();
Assert.Equal(seeded.Select(r => r.Id).Order(), listed.Select(r => r.Id).Order());
}
public void Dispose()
{
_repository.Dispose();
_client.Dispose();
_factory.Dispose();
}
/// <summary>Synthetic records with rotating customers/statuses; returns the stored rows.</summary>
private List<NestRecord> Seed(int count)
{
string[] customers = { "Alpha Fabrication", "Beta Works", "Gamma 50% Shop", "Delta_Under" };
var statuses = Enum.GetValues<NestStatus>();
var stored = new List<NestRecord>();
for (var index = 0; index < count; index++)
{
var record = new NestRecord
{
Name = $"Synthetic browse nest {index:D2}",
Customer = customers[index % customers.Length],
DateCreated = new DateTime(2026, 1, 1 + index % 28, 8, 0, 0, DateTimeKind.Unspecified),
DateModified = new DateTime(2026, 1, 2 + index % 27, 9, 0, 0, DateTimeKind.Unspecified),
Material = index % 3 == 0 ? "Synthetic steel" : "Synthetic alloy",
Thickness = 0.25,
Status = statuses[index % statuses.Length],
PlateCount = 1 + index % 4,
PartCount = 3 + index,
Comments = index % 5 == 0 ? "rush" : index % 7 == 1 ? @"Path C:\jobs" : "",
MadeBy = index % 2 == 0 ? "Synthetic operator A" : "Synthetic operator B",
};
stored.Add(_database.Insert(Guid.NewGuid(), record, new byte[] { (byte)index, 1, 2 }));
}
return stored;
}
/// <summary>Independent oracle for the browse order: newest saved first, then id.</summary>
private static NestRecord[] Ordered(IEnumerable<NestRecord> records) =>
records
.OrderByDescending(r => r.SavedAt)
.ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal)
.ToArray();
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual));
}