GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED. GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1), ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4). All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply(). Startup sweep flips >7y gerbils to Deceased at next app restart. Migration StatusModel: Active→Breeding rename + backfill derived statuses. 10 new tests; 200/200 green; has-pending=No. Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale. FE contract: status field values updated (see Done-Report).
151 lines
6.4 KiB
C#
151 lines
6.4 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
|
|
namespace GerbilManager.Tests;
|
|
|
|
/// <summary>
|
|
/// SEARCH verification — every search variation Julian asked about, against the
|
|
/// REAL endpoints (SQLite host). Covers: substring/contains, case-insensitivity,
|
|
/// the no-trailing-* contains fix, separator-insensitive nameSearch (spaces ==
|
|
/// hyphens), umlauts, the OriginBreeder (Herkunft) filter, the /gerbils/breeders
|
|
/// distinct dropdown source, paged envelope, and a combined status+name filter.
|
|
/// Each test uses a unique token so the shared class DB doesn't cross-contaminate.
|
|
/// </summary>
|
|
public class SearchVariationsTests : IClassFixture<ApiFactory>
|
|
{
|
|
private readonly HttpClient _client;
|
|
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
|
|
|
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
|
{
|
|
var resp = await _client.PostAsJsonAsync("/gerbils",
|
|
new { name, gender = "female", status, originBreeder });
|
|
resp.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
/// <summary>GET /gerbils?filter=... → the list of returned names.</summary>
|
|
private async Task<List<string>> Names(string filter)
|
|
{
|
|
var url = $"/gerbils?filter={Uri.EscapeDataString(filter)}&page=1&pageSize=200";
|
|
var doc = JsonDocument.Parse(await _client.GetStringAsync(url));
|
|
return doc.RootElement.GetProperty("items").EnumerateArray()
|
|
.Select(x => x.GetProperty("name").GetString()!).ToList();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Contains_matches_substring_case_insensitively()
|
|
{
|
|
await Create("SuchSchokoEins");
|
|
await Create("SuchSchokoladeZwei");
|
|
await Create("SuchKeksDrei");
|
|
|
|
// lowercase query, capitalised names → exercises /i contains
|
|
var hits = await Names("name=*suchschoko/i");
|
|
Assert.Contains("SuchSchokoEins", hits);
|
|
Assert.Contains("SuchSchokoladeZwei", hits);
|
|
Assert.DoesNotContain("SuchKeksDrei", hits);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Contains_single_letter_returns_matches_not_the_trailing_star_bug()
|
|
{
|
|
// Regression guard for the contains hotfix (0ee2b53): the frontend used to
|
|
// build name=*VALUE* which Gridify read as literal "VALUE*" → 0 rows.
|
|
// Correct form name=*VALUE must return every name containing VALUE.
|
|
await Create("BugGuardAlpha");
|
|
await Create("BugGuardBeta");
|
|
var hits = await Names("name=*bugguard/i");
|
|
Assert.Equal(2, hits.Count(n => n.StartsWith("BugGuard")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NameSearch_is_separator_insensitive_spaces_equal_hyphens()
|
|
{
|
|
// The headline ask: "clan kleine chaoten" must match "clan-kleine-chaoten".
|
|
await Create("SepClan-Kleine-Chaoten");
|
|
await Create("SepClan Kleine Chaoten");
|
|
await Create("SepClanKleineChaoten");
|
|
await Create("SepUnrelatedMaus");
|
|
|
|
// Frontend normalizes the typed term the same way the backend stores it:
|
|
// lowercase + strip whitespace/.-_ → "sepclankleinechaoten".
|
|
var hits = await Names("nameSearch=*sepclankleinechaoten");
|
|
|
|
Assert.Contains("SepClan-Kleine-Chaoten", hits); // hyphens
|
|
Assert.Contains("SepClan Kleine Chaoten", hits); // spaces
|
|
Assert.Contains("SepClanKleineChaoten", hits); // none
|
|
Assert.DoesNotContain("SepUnrelatedMaus", hits);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NameSearch_dotted_and_underscored_separators_also_match()
|
|
{
|
|
await Create("SepDot.Von.Den.Chaoten");
|
|
await Create("SepDot_Von_Den_Chaoten");
|
|
var hits = await Names("nameSearch=*sepdotvondenchaoten");
|
|
Assert.Equal(2, hits.Count(n => n.StartsWith("SepDot")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NameSearch_handles_umlauts()
|
|
{
|
|
await Create("SuchKrümel von den Chaoten");
|
|
// nameSearch is stored pre-lowercased; the frontend sends a pre-lowercased
|
|
// normalized term, so this works without provider unicode case-folding.
|
|
var hits = await Names("nameSearch=*suchkrümelvondenchaoten");
|
|
Assert.Contains("SuchKrümel von den Chaoten", hits);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OriginBreeder_filter_returns_only_that_breeder()
|
|
{
|
|
await Create("HerkunftA1", originBreeder: "Zucht HerkunftTest Eins");
|
|
await Create("HerkunftA2", originBreeder: "Zucht HerkunftTest Eins");
|
|
await Create("HerkunftB1", originBreeder: "Zucht HerkunftTest Zwei");
|
|
|
|
var hits = await Names("originBreeder==Zucht HerkunftTest Eins");
|
|
Assert.Contains("HerkunftA1", hits);
|
|
Assert.Contains("HerkunftA2", hits);
|
|
Assert.DoesNotContain("HerkunftB1", hits);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Breeders_endpoint_returns_distinct_nonempty_sorted_values()
|
|
{
|
|
await Create("DistinctX1", originBreeder: "ZZZ Distinct Eins");
|
|
await Create("DistinctX2", originBreeder: "ZZZ Distinct Eins"); // duplicate breeder
|
|
await Create("DistinctX3", originBreeder: "ZZZ Distinct Zwei");
|
|
await Create("DistinctNoBreeder", originBreeder: null);
|
|
|
|
var list = JsonDocument.Parse(await _client.GetStringAsync("/gerbils/breeders"))
|
|
.RootElement.EnumerateArray().Select(x => x.GetString()!).ToList();
|
|
|
|
Assert.Contains("ZZZ Distinct Eins", list);
|
|
Assert.Contains("ZZZ Distinct Zwei", list);
|
|
Assert.Equal(1, list.Count(v => v == "ZZZ Distinct Eins")); // de-duplicated
|
|
Assert.DoesNotContain(list, string.IsNullOrWhiteSpace); // no empty/null
|
|
Assert.Equal(list.OrderBy(v => v, StringComparer.Ordinal).ToList(), list); // sorted
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_returns_paged_envelope()
|
|
{
|
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/gerbils?page=1&pageSize=5"));
|
|
var root = doc.RootElement;
|
|
Assert.True(root.TryGetProperty("items", out _));
|
|
Assert.True(root.TryGetProperty("totalCount", out _));
|
|
Assert.Equal(1, root.GetProperty("page").GetInt32());
|
|
Assert.Equal(5, root.GetProperty("pageSize").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Combined_status_and_name_filter()
|
|
{
|
|
await Create("KombiVerkauf", status: "ForSale");
|
|
await Create("KombiAktiv", status: "Breeding");
|
|
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
|
Assert.Contains("KombiVerkauf", hits);
|
|
Assert.DoesNotContain("KombiAktiv", hits);
|
|
}
|
|
}
|