diff --git a/src/DataAccess/src/SIL.DataAccess.Abstractions/IRepository.cs b/src/DataAccess/src/SIL.DataAccess.Abstractions/IRepository.cs index d817859a..2b52c629 100644 --- a/src/DataAccess/src/SIL.DataAccess.Abstractions/IRepository.cs +++ b/src/DataAccess/src/SIL.DataAccess.Abstractions/IRepository.cs @@ -27,6 +27,7 @@ Task UpdateAllAsync( Task DeleteAllAsync(Expression> filter, CancellationToken cancellationToken = default); Task> SubscribeAsync( Expression> filter, + IEnumerable<(Expression> Field, SortOrder SortOrder)>? sort = null, CancellationToken cancellationToken = default ); } diff --git a/src/DataAccess/src/SIL.DataAccess.Abstractions/SortOrder.cs b/src/DataAccess/src/SIL.DataAccess.Abstractions/SortOrder.cs new file mode 100644 index 00000000..1d32080a --- /dev/null +++ b/src/DataAccess/src/SIL.DataAccess.Abstractions/SortOrder.cs @@ -0,0 +1,7 @@ +namespace SIL.DataAccess; + +public enum SortOrder +{ + Ascending, + Descending, +} diff --git a/src/DataAccess/src/SIL.DataAccess/MemoryRepository.cs b/src/DataAccess/src/SIL.DataAccess/MemoryRepository.cs index e9d207fb..234a5d64 100644 --- a/src/DataAccess/src/SIL.DataAccess/MemoryRepository.cs +++ b/src/DataAccess/src/SIL.DataAccess/MemoryRepository.cs @@ -298,13 +298,33 @@ public async Task DeleteAllAsync( public async Task> SubscribeAsync( Expression> filter, + IEnumerable<(Expression> Field, SortOrder SortOrder)>? sort = null, CancellationToken cancellationToken = default ) { cancellationToken.ThrowIfCancellationRequested(); using (await _lock.LockAsync(cancellationToken)) { - T? initialEntity = Entities.AsQueryable().FirstOrDefault(filter); + IQueryable query = Entities.AsQueryable().Where(filter); + if (sort is not null && sort.Any()) + { + (Expression> firstField, SortOrder firstSortOrder) = sort.First(); + IOrderedQueryable orderedQuery = + firstSortOrder == SortOrder.Ascending + ? query.OrderBy(firstField) + : query.OrderByDescending(firstField); + + foreach ((Expression> field, SortOrder sortOrder) in sort.Skip(1)) + { + orderedQuery = + sortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(field) + : orderedQuery.ThenByDescending(field); + } + + query = orderedQuery; + } + T? initialEntity = query.FirstOrDefault(); var subscription = new MemorySubscription(initialEntity, RemoveSubscription); _subscriptions[subscription] = filter.Compile(); return subscription; diff --git a/src/DataAccess/src/SIL.DataAccess/MongoRepository.cs b/src/DataAccess/src/SIL.DataAccess/MongoRepository.cs index 69532b43..59e7730c 100644 --- a/src/DataAccess/src/SIL.DataAccess/MongoRepository.cs +++ b/src/DataAccess/src/SIL.DataAccess/MongoRepository.cs @@ -239,6 +239,7 @@ public async Task DeleteAllAsync( public async Task> SubscribeAsync( Expression> filter, + IEnumerable<(Expression> Field, SortOrder SortOrder)>? sort = null, CancellationToken cancellationToken = default ) { @@ -253,6 +254,21 @@ public async Task> SubscribeAsync( { "limit", 1 }, { "singleBatch", true }, }; + if (sort is not null && sort.Any()) + { + findCommand.Add( + "sort", + Builders + .Sort.Combine( + sort.Select(s => + s.SortOrder == SortOrder.Ascending + ? Builders.Sort.Ascending(s.Field) + : Builders.Sort.Descending(s.Field) + ) + ) + .Render(new RenderArgs(_collection.DocumentSerializer, _collection.Settings.SerializerRegistry)) + ); + } BsonDocument result; if (_context.Session is not null) { diff --git a/src/Machine/src/Serval.Machine.Shared/Services/DistributedReaderWriterLock.cs b/src/Machine/src/Serval.Machine.Shared/Services/DistributedReaderWriterLock.cs index 78da2e7c..848ef02e 100644 --- a/src/Machine/src/Serval.Machine.Shared/Services/DistributedReaderWriterLock.cs +++ b/src/Machine/src/Serval.Machine.Shared/Services/DistributedReaderWriterLock.cs @@ -69,7 +69,10 @@ public async Task ReaderLockAsync( (bool acquired, DateTime expiresAt) = await TryAcquireReaderLock(lockId, resolvedLifetime, cancellationToken); if (!acquired) { - using ISubscription sub = await _locks.SubscribeAsync(rwl => rwl.Id == _id, cancellationToken); + using ISubscription sub = await _locks.SubscribeAsync( + rwl => rwl.Id == _id, + cancellationToken: cancellationToken + ); do { RWLock? rwLock = sub.Change.Entity; @@ -134,7 +137,10 @@ await _locks.UpdateAsync( ); try { - using ISubscription sub = await _locks.SubscribeAsync(rwl => rwl.Id == _id, cancellationToken); + using ISubscription sub = await _locks.SubscribeAsync( + rwl => rwl.Id == _id, + cancellationToken: cancellationToken + ); do { RWLock? rwLock = sub.Change.Entity; diff --git a/src/Machine/src/Serval.Machine.Shared/Services/LocalBuildJobRunner.cs b/src/Machine/src/Serval.Machine.Shared/Services/LocalBuildJobRunner.cs index b12ad8ee..516afe32 100644 --- a/src/Machine/src/Serval.Machine.Shared/Services/LocalBuildJobRunner.cs +++ b/src/Machine/src/Serval.Machine.Shared/Services/LocalBuildJobRunner.cs @@ -103,14 +103,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) e.CurrentBuild != null && e.CurrentBuild.BuildJobRunner == BuildJobRunnerType.Local && e.CurrentBuild.JobState == BuildJobState.Pending, - stoppingToken + cancellationToken: stoppingToken ); using ISubscription wordAlignmentSub = await wordAlignmentEngines.SubscribeAsync( e => e.CurrentBuild != null && e.CurrentBuild.BuildJobRunner == BuildJobRunnerType.Local && e.CurrentBuild.JobState == BuildJobState.Pending, - stoppingToken + cancellationToken: stoppingToken ); await RecoverPendingJobsAsync(scope.ServiceProvider, stoppingToken); diff --git a/src/Serval/src/Serval.ApiServer/nswag.json b/src/Serval/src/Serval.ApiServer/nswag.json index 931d141e..75d0e206 100644 --- a/src/Serval/src/Serval.ApiServer/nswag.json +++ b/src/Serval/src/Serval.ApiServer/nswag.json @@ -46,7 +46,7 @@ "generateContractsOutput": false, "contractsNamespace": null, "contractsOutputFilePath": null, - "parameterDateTimeFormat": "u", + "parameterDateTimeFormat": "o", "parameterDateFormat": "yyyy-MM-dd", "generateUpdateJsonSerializerSettingsMethod": true, "useRequestAndResponseSerializationSettings": false, diff --git a/src/Serval/src/Serval.Client/Client.g.cs b/src/Serval/src/Serval.Client/Client.g.cs index a43d58fc..2f5891d2 100644 --- a/src/Serval/src/Serval.Client/Client.g.cs +++ b/src/Serval/src/Serval.Client/Client.g.cs @@ -7398,22 +7398,21 @@ public partial interface ITranslationBuildsClient /// /// Get all builds for your translation engines that are created after the specified date. /// - /// The date and time in UTC that the builds were created after (optional). + /// The date and time (either in UTC or with offset) that the builds were created after (optional). /// The engines /// A server side error occurred. System.Threading.Tasks.Task> GetAllBuildsCreatedAfterAsync(System.DateTimeOffset? createdAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get the next build that finished after the specified date and time. - ///
If not build has yet completed after that timestamp, - ///
Serval will wait until a build is finished after that date and time. + /// Get the next build that finishes after the specified build id. + ///
If no build has yet completed after that id, or you do not specify the id, + ///
Serval will wait until the next build is finished. ///
- /// The date and time in UTC that the next build should have finished after. - ///
You should use the finished timestamp of the build previously returned when calling this endpoint. - /// The engines + /// The id of the build that the next build must finish after (optional) + /// The build /// A server side error occurred. - System.Threading.Tasks.Task GetNextFinishedBuildAsync(System.DateTimeOffset? finishedAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task GetNextFinishedBuildAsync(string? finishedAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } @@ -7469,7 +7468,7 @@ public string BaseUrl /// /// Get all builds for your translation engines that are created after the specified date. /// - /// The date and time in UTC that the builds were created after (optional). + /// The date and time (either in UTC or with offset) that the builds were created after (optional). /// The engines /// A server side error occurred. public virtual async System.Threading.Tasks.Task> GetAllBuildsCreatedAfterAsync(System.DateTimeOffset? createdAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) @@ -7490,7 +7489,7 @@ public string BaseUrl urlBuilder_.Append('?'); if (createdAfter != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("created-after")).Append('=').Append(System.Uri.EscapeDataString(createdAfter.Value.ToString("u", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("created-after")).Append('=').Append(System.Uri.EscapeDataString(createdAfter.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } urlBuilder_.Length--; @@ -7566,15 +7565,14 @@ public string BaseUrl /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get the next build that finished after the specified date and time. - ///
If not build has yet completed after that timestamp, - ///
Serval will wait until a build is finished after that date and time. + /// Get the next build that finishes after the specified build id. + ///
If no build has yet completed after that id, or you do not specify the id, + ///
Serval will wait until the next build is finished. ///
- /// The date and time in UTC that the next build should have finished after. - ///
You should use the finished timestamp of the build previously returned when calling this endpoint. - /// The engines + /// The id of the build that the next build must finish after (optional) + /// The build /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetNextFinishedBuildAsync(System.DateTimeOffset? finishedAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task GetNextFinishedBuildAsync(string? finishedAfter = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -7592,7 +7590,7 @@ public string BaseUrl urlBuilder_.Append('?'); if (finishedAfter != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("finished-after")).Append('=').Append(System.Uri.EscapeDataString(finishedAfter.Value.ToString("u", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("finished-after")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(finishedAfter, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } urlBuilder_.Length--; diff --git a/src/Serval/src/Serval.Translation/Configuration/IServalConfiguratorExtensions.cs b/src/Serval/src/Serval.Translation/Configuration/IServalConfiguratorExtensions.cs index 22ea5aa5..7c4680d2 100644 --- a/src/Serval/src/Serval.Translation/Configuration/IServalConfiguratorExtensions.cs +++ b/src/Serval/src/Serval.Translation/Configuration/IServalConfiguratorExtensions.cs @@ -84,6 +84,10 @@ public static IServalConfigurator AddTranslationDataAccess(this IServalConfigura .Merge(c, new MergeStageOptions { WhenMatched = MergeStageWhenMatched.Replace }) .ToListAsync(), MongoMigrations.MigrateTargetQuoteConvention, + c => + c.Indexes.CreateOrUpdateAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(b => b.DateFinished)) + ), ] ); configurator.DataAccess.AddRepository( diff --git a/src/Serval/src/Serval.Translation/Features/Builds/GetAllBuildsCreatedAfter.cs b/src/Serval/src/Serval.Translation/Features/Builds/GetAllBuildsCreatedAfter.cs index f20de5b1..e0172cc8 100644 --- a/src/Serval/src/Serval.Translation/Features/Builds/GetAllBuildsCreatedAfter.cs +++ b/src/Serval/src/Serval.Translation/Features/Builds/GetAllBuildsCreatedAfter.cs @@ -38,7 +38,9 @@ public partial class TranslationBuildsController /// /// Get all builds for your translation engines that are created after the specified date. /// - /// The date and time in UTC that the builds were created after (optional). + /// + /// The date and time (either in UTC or with offset) that the builds were created after (optional). + /// /// /// The engines /// The client is not authenticated. diff --git a/src/Serval/src/Serval.Translation/Features/Builds/GetNextFinishedBuild.cs b/src/Serval/src/Serval.Translation/Features/Builds/GetNextFinishedBuild.cs index f39fd842..ff1f6afd 100644 --- a/src/Serval/src/Serval.Translation/Features/Builds/GetNextFinishedBuild.cs +++ b/src/Serval/src/Serval.Translation/Features/Builds/GetNextFinishedBuild.cs @@ -1,6 +1,6 @@ namespace Serval.Translation.Features.Builds; -public record GetNextFinishedBuild(string Owner, DateTime FinishedAfter) : IRequest; +public record GetNextFinishedBuild(string Owner, string? Id = null) : IRequest; public record GetNextFinishedBuildResponse( [property: MemberNotNullWhen(false, nameof(Build))] bool TimedOut, @@ -18,10 +18,14 @@ public async Task HandleAsync( CancellationToken cancellationToken = default ) { - DateTime finishedAfter = - request.FinishedAfter.Kind == DateTimeKind.Unspecified - ? DateTime.SpecifyKind(request.FinishedAfter, DateTimeKind.Utc) - : request.FinishedAfter.ToUniversalTime(); + DateTime dateFinished = DateTime.UtcNow; + string? id = request.Id; + if (id is not null) + { + Build? build = await builds.GetAsync(id, cancellationToken); + if (build is not null) + dateFinished = build.DateFinished ?? DateTime.UtcNow; + } (_, EntityChange change) = await TaskEx.Timeout( async ct => @@ -32,7 +36,10 @@ public async Task HandleAsync( && ( b.State == JobState.Completed || b.State == JobState.Canceled || b.State == JobState.Faulted ) - && b.DateFinished > finishedAfter, + && ( + b.DateFinished > dateFinished || (b.DateFinished == dateFinished && b.Id.CompareTo(id) > 0) + ), + [(b => b.DateFinished, SortOrder.Ascending), (b => b.Id, SortOrder.Ascending)], ct ); EntityChange curChange = subscription.Change; @@ -61,16 +68,13 @@ await subscription.WaitForChangeAsync( public partial class TranslationBuildsController { /// - /// Get the next build that finished after the specified date and time. - /// If not build has yet completed after that timestamp, - /// Serval will wait until a build is finished after that date and time. + /// Get the next build that finishes after the specified build id. + /// If no build has yet completed after that id, or you do not specify the id, + /// Serval will wait until the next build is finished. /// - /// - /// The date and time in UTC that the next build should have finished after. - /// You should use the finished timestamp of the build previously returned when calling this endpoint. - /// + /// The id of the build that the next build must finish after (optional) /// - /// The engines + /// The build /// The client is not authenticated. /// The authenticated client cannot perform the operation. /// The long polling request timed out. @@ -83,7 +87,7 @@ public partial class TranslationBuildsController [ProducesResponseType(typeof(void), StatusCodes.Status408RequestTimeout)] [ProducesResponseType(typeof(void), StatusCodes.Status503ServiceUnavailable)] public async Task> GetNextFinishedBuildAsync( - [FromQuery(Name = "finished-after")] DateTime finishedAfter, + [FromQuery(Name = "finished-after")] string? finishedAfter, [FromServices] IRequestHandler handler, CancellationToken cancellationToken ) diff --git a/src/Serval/src/Serval.Translation/Features/Engines/BuildRepositoryExtensions.cs b/src/Serval/src/Serval.Translation/Features/Engines/BuildRepositoryExtensions.cs index 594164bc..d319ef75 100644 --- a/src/Serval/src/Serval.Translation/Features/Engines/BuildRepositoryExtensions.cs +++ b/src/Serval/src/Serval.Translation/Features/Engines/BuildRepositoryExtensions.cs @@ -9,7 +9,10 @@ internal static async Task> GetNewerRevisionAsync( CancellationToken cancellationToken = default ) { - using ISubscription subscription = await repository.SubscribeAsync(filter, cancellationToken); + using ISubscription subscription = await repository.SubscribeAsync( + filter, + cancellationToken: cancellationToken + ); EntityChange curChange = subscription.Change; if (curChange.Type == EntityChangeType.Delete && minRevision > 1) return curChange; diff --git a/src/Serval/src/Serval.WordAlignment/Services/BuildService.cs b/src/Serval/src/Serval.WordAlignment/Services/BuildService.cs index c3069135..12863cb3 100644 --- a/src/Serval/src/Serval.WordAlignment/Services/BuildService.cs +++ b/src/Serval/src/Serval.WordAlignment/Services/BuildService.cs @@ -43,7 +43,10 @@ private async Task> GetNewerRevisionAsync( CancellationToken cancellationToken = default ) { - using ISubscription subscription = await Entities.SubscribeAsync(filter, cancellationToken); + using ISubscription subscription = await Entities.SubscribeAsync( + filter, + cancellationToken: cancellationToken + ); EntityChange curChange = subscription.Change; if (curChange.Type == EntityChangeType.Delete && minRevision > 1) return curChange; diff --git a/src/Serval/test/Serval.ApiServer.IntegrationTests/TranslationEngineTests.cs b/src/Serval/test/Serval.ApiServer.IntegrationTests/TranslationEngineTests.cs index 2d114bf7..f9f278f4 100644 --- a/src/Serval/test/Serval.ApiServer.IntegrationTests/TranslationEngineTests.cs +++ b/src/Serval/test/Serval.ApiServer.IntegrationTests/TranslationEngineTests.cs @@ -90,6 +90,7 @@ public class TranslationEngineTests private const string TARGET_CORPUS_ID = "cc0000000000000000000004"; private const string TARGET_CORPUS_ID_PT = "cc0000000000000000000005"; private const string EMPTY_CORPUS_ID = "cc0000000000000000000006"; + private const string BUILD1_ID = "b00000000000000000000001"; private const string DOES_NOT_EXIST_ENGINE_ID = "e00000000000000000000004"; private const string DOES_NOT_EXIST_CORPUS_ID = "c00000000000000000000001"; @@ -1470,29 +1471,43 @@ public async Task GetBuildByIdForEngineByIdAsync( } [Test] - [TestCase(new[] { Scopes.ReadTranslationEngines }, 200)] - [TestCase(new[] { Scopes.ReadTranslationEngines }, 408)] - [TestCase(new[] { Scopes.ReadFiles }, 403)] // Arbitrary unrelated privilege - public async Task GetNextFinishedBuildAsync(IEnumerable scope, int expectedStatusCode) + [TestCase(new[] { Scopes.ReadTranslationEngines }, 200, null)] + [TestCase(new[] { Scopes.ReadTranslationEngines }, 200, BUILD1_ID)] + [TestCase(new[] { Scopes.ReadTranslationEngines }, 408, null)] + [TestCase(new[] { Scopes.ReadFiles }, 403, null)] // Arbitrary unrelated privilege + public async Task GetNextFinishedBuildAsync( + IEnumerable scope, + int expectedStatusCode, + string? finishedAfter + ) { TranslationBuildsClient client = _env.CreateTranslationBuildsClient(scope); - Build? build = new Build + Build? build1 = new Build { + Id = BUILD1_ID, EngineRef = ECHO_ENGINE1_ID, Owner = "client1", - DateFinished = DateTime.UtcNow, + DateFinished = DateTime.UtcNow.AddHours(expectedStatusCode == 408 ? -1 : 1), State = Shared.Contracts.JobState.Completed, }; - await _env.Builds.InsertAsync(build); + await _env.Builds.InsertAsync(build1); + Build? build2 = new Build + { + EngineRef = ECHO_ENGINE2_ID, + Owner = "client1", + DateFinished = DateTime.UtcNow.AddHours(expectedStatusCode == 408 ? -2 : 2), + State = Shared.Contracts.JobState.Completed, + }; + await _env.Builds.InsertAsync(build2); switch (expectedStatusCode) { case 200: - TranslationBuild result = await client.GetNextFinishedBuildAsync(DateTime.UtcNow.AddDays(-2)); + TranslationBuild result = await client.GetNextFinishedBuildAsync(finishedAfter); Assert.That(result, Is.Not.Null); Assert.Multiple(() => { Assert.That(result.Revision, Is.EqualTo(1)); - Assert.That(result.Id, Is.EqualTo(build?.Id)); + Assert.That(result.Id, Is.EqualTo((finishedAfter is null ? build1 : build2)?.Id)); Assert.That(result.State, Is.EqualTo(Client.JobState.Completed)); }); break; @@ -1501,7 +1516,7 @@ public async Task GetNextFinishedBuildAsync(IEnumerable scope, int expec { ServalApiException? ex = Assert.ThrowsAsync(async () => { - await client.GetNextFinishedBuildAsync(DateTime.UtcNow.AddDays(2)); + await client.GetNextFinishedBuildAsync(finishedAfter); }); Assert.That(ex?.StatusCode, Is.EqualTo(expectedStatusCode)); break; @@ -1952,15 +1967,13 @@ public async Task CancelCurrentBuildForEngineByIdAsync( ) { TranslationEnginesClient client = _env.CreateTranslationEnginesClient(scope); - - string buildId = "b00000000000000000000000"; if (addBuild) { _env.EchoService.CancelBuildAsync(engineId, Arg.Any()) - .Returns(Task.FromResult(buildId)); + .Returns(Task.FromResult(BUILD1_ID)); var build = new Build { - Id = buildId, + Id = BUILD1_ID, EngineRef = engineId, Owner = "client1", }; @@ -1971,7 +1984,7 @@ public async Task CancelCurrentBuildForEngineByIdAsync( { case 200: TranslationBuild build = await client.CancelBuildAsync(engineId); - Assert.That(build.Id, Is.EqualTo("b00000000000000000000000")); + Assert.That(build.Id, Is.EqualTo(BUILD1_ID)); break; case 204: case 403: @@ -2266,7 +2279,7 @@ public async Task GetPretranslatedUsfmAsync_BookExists() await _env.Builds.InsertAsync( new Build { - Id = "b10000000000000000000000", + Id = BUILD1_ID, EngineRef = ECHO_ENGINE1_ID, Owner = "client1", Revision = 1, @@ -2358,7 +2371,7 @@ public async Task GetPretranslatedUsfmAsync_BookDoesNotExist() await _env.Builds.InsertAsync( new Build { - Id = "b10000000000000000000000", + Id = BUILD1_ID, EngineRef = ECHO_ENGINE1_ID, Owner = "client1", Revision = 1, diff --git a/src/Serval/test/Serval.Translation.Tests/Features/Builds/BuildsHandlersTests.cs b/src/Serval/test/Serval.Translation.Tests/Features/Builds/BuildsHandlersTests.cs index 5457832b..a2ab57d4 100644 --- a/src/Serval/test/Serval.Translation.Tests/Features/Builds/BuildsHandlersTests.cs +++ b/src/Serval/test/Serval.Translation.Tests/Features/Builds/BuildsHandlersTests.cs @@ -4,6 +4,7 @@ namespace Serval.Translation.Features.Builds; public class BuildsHandlersTests { const string BUILD1_ID = "b00000000000000000000001"; + const string BUILD2_ID = "b00000000000000000000002"; [Test] public async Task GetAllBuildsCreatedAfter_NoFilter_Success() @@ -90,12 +91,47 @@ await env.Builds.InsertAsync( } } + [Test] + public async Task GetNextFinishedBuild_FinishedAtSameTime() + { + TestEnvironment env = new(); + DateTime dateFinished = DateTime.UtcNow; + await env.Builds.InsertAsync( + new() + { + Id = BUILD1_ID, + EngineRef = "engine1", + Owner = "user1", + State = JobState.Completed, + DateFinished = dateFinished, + } + ); + await env.Builds.InsertAsync( + new() + { + Id = BUILD2_ID, + EngineRef = "engine1", + Owner = "user1", + State = JobState.Completed, + DateFinished = dateFinished, + } + ); + GetNextFinishedBuildHandler handler = new(env.Builds, env.DtoMapper, env.ApiOptions); + GetNextFinishedBuildResponse response = await handler.HandleAsync(new("user1", BUILD1_ID)); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.TimedOut, Is.False); + Assert.That(response.Build?.Id, Is.EqualTo(BUILD2_ID)); + Assert.That(response.Build?.State, Is.EqualTo(JobState.Completed)); + } + } + [Test] public async Task GetNextFinishedBuild_Insert() { TestEnvironment env = new(); GetNextFinishedBuildHandler handler = new(env.Builds, env.DtoMapper, env.ApiOptions); - Task task = handler.HandleAsync(new("user1", DateTime.UtcNow.AddMinutes(-1))); + Task task = handler.HandleAsync(new("user1", null)); await env.Builds.InsertAsync( new() @@ -115,6 +151,40 @@ await env.Builds.InsertAsync( } } + [Test] + public async Task GetNextFinishedBuild_PreviousBuild() + { + TestEnvironment env = new(); + await env.Builds.InsertAsync( + new() + { + Id = BUILD2_ID, + EngineRef = "engine1", + Owner = "user1", + State = JobState.Completed, + DateFinished = DateTime.UtcNow.AddMinutes(-2), + } + ); + await env.Builds.InsertAsync( + new() + { + Id = BUILD1_ID, + EngineRef = "engine1", + Owner = "user1", + State = JobState.Completed, + DateFinished = DateTime.UtcNow, + } + ); + GetNextFinishedBuildHandler handler = new(env.Builds, env.DtoMapper, env.ApiOptions); + GetNextFinishedBuildResponse response = await handler.HandleAsync(new("user1", BUILD2_ID)); + using (Assert.EnterMultipleScope()) + { + Assert.That(response.TimedOut, Is.False); + Assert.That(response.Build?.Id, Is.EqualTo(BUILD1_ID)); + Assert.That(response.Build?.State, Is.EqualTo(JobState.Completed)); + } + } + [Test] public async Task GetNextFinishedBuild_Update() { @@ -128,7 +198,7 @@ public async Task GetNextFinishedBuild_Update() await env.Builds.InsertAsync(build); GetNextFinishedBuildHandler handler = new(env.Builds, env.DtoMapper, env.ApiOptions); - Task task = handler.HandleAsync(new("user1", DateTime.UtcNow.AddMinutes(-1))); + Task task = handler.HandleAsync(new("user1", null)); await env.Builds.UpdateAsync( build,