using System.Collections.Concurrent; using System.Linq.Expressions; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using Polly; using Xunit; namespace ZymonicServices.Performance.Tests; public class SearchTests { [Theory] [InlineData(true)] [InlineData(false)] public async Task ApiActivityIsBalancedAndLocalSearchDoesNotReportNetworkWork(bool localOnly) { using var fixture = new SearchFixture(); var activity = new ConcurrentQueue(); var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var task = fixture.Filter.Search(new Search { Term = "missing" }, localOnly: localOnly, apiActivityChanged: active => { activity.Enqueue(active); if (!active) finished.TrySetResult(); }).ToTask(); if (localOnly) { await task; Assert.Empty(activity); } else { await Assert.ThrowsAsync(() => task); await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(new[] { true, false }, activity.ToArray()); } } [Fact] public async Task RepeatedCacheReadsRetainItemsButPublishNewDatabaseRevisions() { using var fixture = new SearchFixture(); var updated = DateTime.UtcNow; using (var context = fixture.Factory.CreateDbContext()) { context.Set().Single(x => x.uid == "apple").dateUpdated = updated; context.SaveChanges(); } int changes = 0; using var subscription = fixture.Filter.Connect().Subscribe(batch => changes += batch.Count); await fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true).ToTask(); Assert.Equal(1, changes); await fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true).ToTask(); Assert.Equal(1, changes); using (var context = fixture.Factory.CreateDbContext()) { context.Set().Single(x => x.uid == "apple").dateUpdated = updated.AddSeconds(1); context.SaveChanges(); } await fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true).ToTask(); Assert.Equal(2, changes); } [Fact] public async Task BackgroundSearchDoesNotCreateContextsOnTheSubscriberThread() { using var fixture = new SearchFixture(); int subscriberThread = Environment.CurrentManagedThreadId; using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); fixture.Factory.OnCreate = () => { entered.TrySetResult(Environment.CurrentManagedThreadId); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); }; // Subscription must return while synchronous database preparation is blocked. var search = fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true, runInBackground: true).ToTask(); try { Assert.NotEqual(subscriberThread, await entered.Task.WaitAsync(TimeSpan.FromSeconds(5))); Assert.False(search.IsCompleted); } finally { release.Set(); } Assert.Equal(1, (await search.WaitAsync(TimeSpan.FromSeconds(5))).ResultsRetrieved); } [Fact] public async Task SearchSnapshotsParametersBeforeSubscription() { using var fixture = new SearchFixture(); var parameters = new Search { Term = "apple" }; var search = fixture.Filter.Search(parameters, localOnly: true, runInBackground: true); parameters.Term = "pear"; var progress = await search.ToTask().WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(1, progress.TotalResults); Assert.Equal("apple", fixture.Filter.searchParameters.Term); Assert.Equal(new[] { "apple" }, fixture.LiveKeys()); } [Fact] public async Task OverlappingSearchesKeepTheirCountsAndRejectOlderResults() { using var fixture = new SearchFixture(); using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int contexts = 0; fixture.Factory.OnCreate = () => { if (Interlocked.Increment(ref contexts) == 1) { entered.SetResult(); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); } }; var older = fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true, runInBackground: true).ToTask(); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var newer = await fixture.Filter.Search(new Search { Term = "pear" }, localOnly: true, runInBackground: true) .ToTask().WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(2, newer.TotalResults); } finally { release.Set(); } Assert.Equal(1, (await older.WaitAsync(TimeSpan.FromSeconds(5))).TotalResults); Assert.Equal(new[] { "pear1", "pear2" }, fixture.LiveKeys()); } [Fact] public async Task CancelledSearchDoesNotPublishAfterDatabaseWorkReturns() { using var fixture = new SearchFixture(); using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int contexts = 0; fixture.Factory.OnCreate = () => { // Block inside RefreshCache, after its initial cancellation check. if (Interlocked.Increment(ref contexts) == 2) { entered.TrySetResult(); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); } }; fixture.Factory.OnDispose = () => finished.TrySetResult(); var progress = new ConcurrentQueue(); using var subscription = fixture.Filter.Search(new Search { Term = "apple" }, localOnly: true, runInBackground: true) .Subscribe(progress.Enqueue); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); subscription.Dispose(); } finally { release.Set(); } await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Empty(progress); Assert.Empty(fixture.LiveKeys()); } [Fact] public async Task DatabaseFailureIsReportedInsteadOfLeavingSearchPending() { using var fixture = new SearchFixture(); fixture.Factory.OnCreate = () => throw new InvalidOperationException("Test database failure"); var error = await Assert.ThrowsAsync(() => fixture.Filter.Search(new Search(), localOnly: true, runInBackground: true) .ToTask().WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal("Test database failure", error.Message); } [Fact] public async Task LegacyParameterlessCacheDoesNotSuppressSpecificSearches() { using var fixture = new SearchFixture(); using (var context = fixture.Factory.CreateDbContext()) { context.Set().Add(new ZymonicFilterStatus { ZName = nameof(TestFilter), CacheOwner = "test", SearchSignature = "full-cache", searchIsParameterless = true, SearchCompleted = DateTime.UtcNow }); context.SaveChanges(); } var error = await Assert.ThrowsAsync(() => fixture.Filter.Search(new Search { Term = "missing" }, runInBackground: true) .ToTask().WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal("Local tests must not access the network.", error.Message); } [Theory] [InlineData("missing", "missing", 0, 0, 0, false, true)] [InlineData("apple", "apple", 0, 0, 0, false, true)] [InlineData("missing", "missing", 10, 10, 0, false, true)] [InlineData("", "missing", 10, 0, 0, false, false)] [InlineData("pear", "missing", 0, 0, 0, false, false)] [InlineData("missing", "missing", 10, 0, 0, false, false)] [InlineData("missing", "missing", 0, 0, -60, false, false)] [InlineData("missing", "missing", 0, 0, 0, true, false)] public async Task FreshnessRequiresTheSameCompletedSearchAndDownloadLimit( string cachedTerm, string requestedTerm, int cachedLimit, int requestedLimit, int completedMinutesAgo, bool interrupted, bool reuse) { using var fixture = new SearchFixture(); var cachedSearch = new Search { Term = cachedTerm, SearchIsParameterless = cachedTerm.Length == 0 }; // Seed the persisted status using the production key, then exercise Search end to end. var signatureMethod = fixture.Filter.GetType().BaseType!.GetMethod("SearchParamSignature", BindingFlags.Instance | BindingFlags.NonPublic)!; var signature = (string)signatureMethod.Invoke(fixture.Filter, new object?[] { cachedSearch, cachedLimit, null })!; var completed = DateTime.UtcNow.AddMinutes(completedMinutesAgo); using (var context = fixture.Factory.CreateDbContext()) { context.Set().Add(new ZymonicFilterStatus { ZName = nameof(TestFilter), CacheOwner = "test", SearchSignature = signature, searchIsParameterless = cachedSearch.SearchIsParameterless, SearchCompleted = completed, SearchStarted = interrupted ? completed.AddSeconds(1) : completed.AddSeconds(-1) }); context.SaveChanges(); } var task = fixture.Filter.Search(new Search { Term = requestedTerm }, maxResults: requestedLimit, runInBackground: true).ToTask().WaitAsync(TimeSpan.FromSeconds(5)); if (reuse) { Assert.Equal(requestedTerm == "apple" ? 1 : 0, (await task).TotalResults); } else { var error = await Assert.ThrowsAsync(() => task); Assert.Equal("Local tests must not access the network.", error.Message); } } [Fact] public async Task OneCachedResultDoesNotBypassExistingFreshnessRule() { using var fixture = new SearchFixture(); // SufficientResults = 1 has historically required MORE than one cached row. // Keep that ticket-sensitive behaviour in this scheduling-only change. var error = await Assert.ThrowsAsync(() => fixture.Filter.Search(new Search { Term = "apple" }, SufficientResults: 1, runInBackground: true) .ToTask().WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal("Local tests must not access the network.", error.Message); } [Theory] [InlineData(1, 1)] [InlineData(0, 0)] [InlineData(-1, 0)] public async Task CacheLimitIsAppliedBeforeEntityMaterialisationAndKeepsFullCount(int limit, int expectedLoaded) { using var fixture = new SearchFixture(); using (var context = fixture.Factory.CreateDbContext()) { context.Set().AddRange(Enumerable.Range(0, 200).Select(i => new Result { uid = $"pear-extra-{i:D4}", Name = "pear", CacheOwner = "test", ZZid = new ResultId { Id = i } })); context.SaveChanges(); } fixture.Filter.CacheResultLimit = limit; var materialisation = new MaterialisationCounter(); fixture.Factory.Materialisation = materialisation; var progress = await fixture.Filter.Search(new Search { Term = "pear" }, localOnly: true, runInBackground: true).ToTask(); Assert.Equal(202, progress.TotalResults); Assert.Equal(expectedLoaded, materialisation.Results); Assert.Equal(expectedLoaded, fixture.LiveKeys().Length); if (expectedLoaded > 0) Assert.Equal("pear-extra-0000", fixture.LiveKeys()[0]); } private sealed class MaterialisationCounter : IMaterializationInterceptor { public int Results; public object InitializedInstance(MaterializationInterceptionData data, object entity) { if (entity is Result) Interlocked.Increment(ref Results); return entity; } } private sealed class SearchFixture : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), $"zymonic-search-{Guid.NewGuid():N}.sqlite"); public TestFactory Factory { get; } public TestFilter Filter { get; } public SearchFixture() { Factory = new TestFactory(_path); using var context = Factory.CreateDbContext(); context.Database.EnsureCreated(); context.Set().AddRange( new Result { uid = "apple", Name = "apple", CacheOwner = "test" }, new Result { uid = "pear1", Name = "pear", CacheOwner = "test" }, new Result { uid = "pear2", Name = "pear", CacheOwner = "test" }, new Result { uid = "other", Name = "apple", CacheOwner = "other" }); context.SaveChanges(); Filter = new TestFilter(Factory); } public string[] LiveKeys() { string[] keys = []; using var subscription = Filter.Connect().Subscribe(changes => keys = changes.Select(c => c.Key).Order().ToArray()); return keys; } public void Dispose() => File.Delete(_path); } private sealed class TestFactory(string path) : IZymonicDbContextFactory { public MaterialisationCounter? Materialisation { get; set; } public Action? OnCreate { get; set; } public Action? OnDispose { get; set; } public IZymonicDbContext CreateDbContext() { OnCreate?.Invoke(); return new TestContext(path, OnDispose, Materialisation); } public void RegisterEntityAssembly(Assembly assembly) { } } private sealed class TestContext(string path, Action? onDispose, MaterialisationCounter? materialisation) : DbContext, IZymonicDbContext { protected override void OnConfiguring(DbContextOptionsBuilder options) { options.UseSqlite($"Data Source={path};Pooling=False"); if (materialisation != null) options.AddInterceptors(materialisation); } protected override void OnModelCreating(ModelBuilder model) { model.Entity().HasKey(x => x.uid); model.Entity().OwnsOne(x => x.ZZid); model.Entity(); } public void StartupTasks() { } public override void Dispose() { base.Dispose(); onDispose?.Invoke(); } } private sealed class TestFilter(TestFactory factory) : ZymonicFilter( new ZymonicLogger(NullLogger.Instance), factory, new Settings(), null!, Policy.BulkheadAsync(2, 10), "test") { protected override Expression> whereClause(Search search) => result => result.Name == search.Term; } public sealed class Search : ZymonicFilterSearch { public string Term { get; set; } = ""; public override bool isEmpty() => Term.Length == 0; } public sealed class Request : ZymonicFilterApiRequest { public override Search? searchFields { get; set; } } public sealed class Result : IZymonicFilterResult { public string uid { get; set; } = ""; public string Name { get; set; } = ""; public string CacheOwner { get; set; } = ""; public DateTime dateUpdated { get; set; } public bool UpdateComplete { get; set; } public ResultId? ZZid { get; set; } public override string ToString() => uid; } public sealed class ResultId { public int Id { get; set; } } public sealed class Results : ZymonicFilterResults { } public sealed class Response : ZymonicFilterApiResponse { public override Results? filterResults { get; set; } } private sealed class Settings : IZymonicSettings { public string? Setting(string key, string? value = null, bool noCache = false) => null; public string SystemName() => "test"; public string BaseUri() => throw new InvalidOperationException("Local tests must not access the network."); public void ClearSetting(string key) { } } }