From 5aec2aa82a774a1061da7940914dd3b614da9a28 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 6 Jul 2026 14:08:09 +1200 Subject: [PATCH] SF-3819 Add support for draft rate limiting --- .../draft-generation.service.spec.ts | 93 +++++++++++++- .../draft-generation.service.ts | 38 ++++-- .../src/assets/i18n/non_checking_en.json | 1 + .../Controllers/MachineApiController.cs | 5 + .../Services/MachineApiService.cs | 57 +++++++++ .../Services/MachineBackgroundService.cs | 15 ++- .../DataAccessServiceCollectionExtensions.cs | 2 +- src/SIL.XForge/Models/SiteConfig.cs | 35 +++++ src/SIL.XForge/Services/EventMetricService.cs | 6 + .../Services/LimitExceededException.cs | 9 ++ .../Controllers/MachineApiControllerTests.cs | 22 ++++ .../Services/MachineApiServiceTests.cs | 121 +++++++++++++++++- .../Services/MachineBackgroundServiceTests.cs | 2 +- .../Services/EventMetricServiceTests.cs | 19 +++ 14 files changed, 405 insertions(+), 20 deletions(-) create mode 100644 src/SIL.XForge/Services/LimitExceededException.cs diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts index dfa5c55767c..d042ae6ed13 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts @@ -1,4 +1,4 @@ -import { HttpStatusCode } from '@angular/common/http'; +import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { HttpTestingController } from '@angular/common/http/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { Canon } from '@sillsdev/scripture'; @@ -600,6 +600,97 @@ describe('DraftGenerationService', () => { tick(); })); + it('should fail on a Serval outage error', fakeAsync(() => { + const spyGetBuildProgress = spyOn(service, 'getBuildProgress').and.returnValue(of(undefined)); + + // SUT + service + .startBuildOrGetActiveBuild(buildConfig) + .pipe(first()) + .subscribe(result => { + expect(result).toBeUndefined(); + verify(mockNoticeService.showError(anything())).once(); + expect(spyGetBuildProgress).toHaveBeenCalledWith(projectId); + }); + tick(); + + // Setup the HTTP request + const req = httpTestingController.expectOne(`${MACHINE_API_BASE_URL}translation/pretranslations`); + expect(req.request.method).toEqual('POST'); + expect(req.request.body).toEqual(buildConfig); + req.flush(null, { status: HttpStatusCode.ServiceUnavailable, statusText: 'Serval Down' }); + tick(); + })); + + it('should fail on a rate limit error', fakeAsync(() => { + const spyGetBuildProgress = spyOn(service, 'getBuildProgress').and.returnValue(of(undefined)); + + // SUT + service + .startBuildOrGetActiveBuild(buildConfig) + .pipe(first()) + .subscribe(result => { + expect(result).toBeUndefined(); + verify(mockNoticeService.showError(anything())).once(); + expect(spyGetBuildProgress).toHaveBeenCalledWith(projectId); + }); + tick(); + + // Setup the HTTP request + const req = httpTestingController.expectOne(`${MACHINE_API_BASE_URL}translation/pretranslations`); + expect(req.request.method).toEqual('POST'); + expect(req.request.body).toEqual(buildConfig); + req.flush(null, { status: HttpStatusCode.TooManyRequests, statusText: 'Too many requests' }); + tick(); + })); + + it('should fail on an forbidden error', fakeAsync(() => { + const spyGetBuildProgress = spyOn(service, 'getBuildProgress').and.returnValue(of(undefined)); + + // SUT + service + .startBuildOrGetActiveBuild(buildConfig) + .pipe(first()) + .subscribe(result => { + expect(result).toBeUndefined(); + verify(mockNoticeService.showError(anything())).never(); + expect(spyGetBuildProgress).toHaveBeenCalledWith(projectId); + }); + tick(); + + // Setup the HTTP request + const req = httpTestingController.expectOne(`${MACHINE_API_BASE_URL}translation/pretranslations`); + expect(req.request.method).toEqual('POST'); + expect(req.request.body).toEqual(buildConfig); + req.flush(null, { status: HttpStatusCode.Forbidden, statusText: 'Forbidden' }); + tick(); + })); + + it('should rethrow an unauthorized error', fakeAsync(() => { + const spyGetBuildProgress = spyOn(service, 'getBuildProgress').and.returnValue(of(undefined)); + + // SUT + service + .startBuildOrGetActiveBuild(buildConfig) + .pipe(first()) + .subscribe({ + next: () => fail('Expected an error'), + error: (err: HttpErrorResponse) => { + expect(err.status).toBe(HttpStatusCode.Unauthorized); + verify(mockNoticeService.showError(anything())).never(); + expect(spyGetBuildProgress).toHaveBeenCalledWith(projectId); + } + }); + tick(); + + // Setup the HTTP request + const req = httpTestingController.expectOne(`${MACHINE_API_BASE_URL}translation/pretranslations`); + expect(req.request.method).toEqual('POST'); + expect(req.request.body).toEqual(buildConfig); + req.flush(null, { status: HttpStatusCode.Unauthorized, statusText: 'Unauthorized' }); + tick(); + })); + it('should return already active build job', fakeAsync(() => { const spyGetBuildProgress = spyOn(service, 'getBuildProgress').and.returnValue(of(buildDto)); const spyPollBuildProgress = spyOn(service, 'pollBuildProgress').and.returnValue(of(buildDto)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts index 73bce9f702b..549127044fc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts @@ -259,9 +259,32 @@ export class DraftGenerationService { } // Otherwise, start build and then poll - return this.startBuild(buildConfig).pipe( - // No errors means build successfully started, so start polling - switchMap(() => this.pollBuildProgress(buildConfig.projectId)) + return this.httpClient.post(`translation/pretranslations`, buildConfig).pipe( + map(() => true), + catchError(err => { + if (err.status === 401) { + // Expired Paratext credentials. Rethrow to be caught by DraftGenerationComponent.startBuild() + throw err; + } + + if (err.status === 403 || err.status === 404) { + return of(false); + } + + if (err.status === 429) { + this.noticeService.showError(this.i18n.translateStatic('draft_generation.quota_exceeded')); + return of(false); + } + + this.noticeService.showError(this.i18n.translateStatic('draft_generation.temporarily_unavailable')); + return of(false); + }), + switchMap(started => { + if (!started) return of(undefined); + + // No error means build successfully started, so start polling + return this.pollBuildProgress(buildConfig.projectId); + }) ); }) ); @@ -525,13 +548,4 @@ export class DraftGenerationService { }); }); } - - /** - * Calls the machine api to start a pre-translation build job. - * This should only be called if no build is currently active. - * @param buildConfig The build configuration. - */ - private startBuild(buildConfig: BuildConfig): Observable { - return this.httpClient.post(`translation/pretranslations`, buildConfig).pipe(map(res => res.data)); - } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index 1986c97aabb..813c9ebec19 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -221,6 +221,7 @@ "preview_last_draft_button": "Preview last draft", "preview_last_draft_detail": "The last draft build did not complete, but you can still view the most recent successful draft.", "preview_last_draft_header": "Preview last draft", + "quota_exceeded": "Generating drafts is temporarily unavailable for this project as its quota has been exceeded", "report_problem": "Report problem", "signup_already_submitted": "{{ name }} submitted a request to sign up for drafting on {{ date }}. A team member will contact you within {{ min }} to {{ max }} business days to discuss your project and next steps.", "sign_up_for_drafting": "Sign up for drafting", diff --git a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs index 828ae61a642..cf2cee56b01 100644 --- a/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs +++ b/src/SIL.XForge.Scripture/Controllers/MachineApiController.cs @@ -1038,6 +1038,7 @@ public async Task StartBuildAsync([FromBody] string sfProjectId, C /// Your Paratext tokens have expired, and you must log in again. /// You do not have permission to build this project. /// The project does not exist or is not configured on the ML server. + /// The project's build quota has been exceeded. /// The ML server is temporarily unavailable or unresponsive. /// /// If a JSON string is passed in the format "project_id", then a default build configuration will be created for @@ -1071,6 +1072,10 @@ await _machineApiService.StartPreTranslationBuildAsync( { return Forbid(); } + catch (LimitExceededException e) + { + return StatusCode(StatusCodes.Status429TooManyRequests, e.Message); + } catch (UnauthorizedAccessException) { return Unauthorized(); diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index be7a58dfd7b..beb380977ed 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -56,6 +56,7 @@ public class MachineApiService( ISFProjectRights projectRights, ISFProjectService projectService, IRealtimeService realtimeService, + IRepository siteConfigs, IOptions siteOptions, ISyncService syncService, ITranslationBuildsClient translationBuildsClient, @@ -2794,6 +2795,62 @@ CancellationToken cancellationToken throw new ForbiddenException(); } + // See if there is a build limit enforced + SiteConfig? siteConfig = await siteConfigs + .Query() + .FirstOrDefaultAsync(s => s.Name == siteOptions.Value.Id, cancellationToken); + + // Check the build quota for the last day + if (siteConfig is not null && siteConfig.BuildQuotaPerDay > 0) + { + // Get all build events during the last day + QueryResults eventMetrics = await eventMetricService.GetEventMetricsAsync( + buildConfig.ProjectId, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)], + fromDate: DateTime.UtcNow.AddDays(-1), + pageSize: 0 // Use a zero page size as we only want the count not the results + ); + if (eventMetrics.UnpagedCount >= siteConfig.BuildQuotaPerDay) + { + throw new LimitExceededException("Build quota exceeded."); + } + } + + // Check the build quota for the last week + if (siteConfig is not null && siteConfig.BuildQuotaPerWeek > 0) + { + // Get all build events during the last week + QueryResults eventMetrics = await eventMetricService.GetEventMetricsAsync( + buildConfig.ProjectId, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)], + fromDate: DateTime.UtcNow.AddDays(-7), + pageSize: 0 // Use a zero page size as we only want the count not the results + ); + if (eventMetrics.UnpagedCount >= siteConfig.BuildQuotaPerWeek) + { + throw new LimitExceededException("Build quota exceeded."); + } + } + + // Check the build quota for the last month + if (siteConfig is not null && siteConfig.BuildQuotaPerMonth > 0) + { + // Get all build events during the last month + QueryResults eventMetrics = await eventMetricService.GetEventMetricsAsync( + buildConfig.ProjectId, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)], + fromDate: DateTime.UtcNow.AddMonths(-1), + pageSize: 0 // Use a zero page size as we only want the count not the results + ); + if (eventMetrics.UnpagedCount >= siteConfig.BuildQuotaPerMonth) + { + throw new LimitExceededException("Build quota exceeded."); + } + } + // Save the selected books await projectDoc.SubmitJson0OpAsync(op => { diff --git a/src/SIL.XForge.Scripture/Services/MachineBackgroundService.cs b/src/SIL.XForge.Scripture/Services/MachineBackgroundService.cs index 1a514be06fc..6f23442affc 100644 --- a/src/SIL.XForge.Scripture/Services/MachineBackgroundService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineBackgroundService.cs @@ -47,9 +47,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { // If we do not have it already, load the site_config document by name, creating a new one if missing - siteConfig ??= - await siteConfigs.Query().FirstOrDefaultAsync(s => s.Name == siteName, stoppingToken) - ?? new SiteConfig { Id = ObjectId.GenerateNewId().ToString(), Name = siteName }; + siteConfig ??= await siteConfigs.Query().FirstOrDefaultAsync(s => s.Name == siteName, stoppingToken); + if (siteConfig is null) + { + siteConfig = new SiteConfig { Id = ObjectId.GenerateNewId().ToString(), Name = siteName }; + await siteConfigs.InsertAsync(siteConfig, stoppingToken); + } // Poll for the latest build TranslationBuild build = await translationBuildsClient.GetNextFinishedBuildAsync( @@ -66,7 +69,11 @@ await siteConfigs.Query().FirstOrDefaultAsync(s => s.Name == siteName, stoppingT // On failure throw an exception, so the next iteration of the loop will begin. // This is OK, as we will record the last build finished on the next iteration of the loop siteConfig.LastFinishedBuildId = build.Id; - await siteConfigs.ReplaceAsync(siteConfig, upsert: true, stoppingToken); + await siteConfigs.UpdateAsync( + siteConfig.Id, + u => u.Set(sc => sc.LastFinishedBuildId, build.Id), + cancellationToken: stoppingToken + ); // Reset the consecutive failure count consecutiveFailures = 0; diff --git a/src/SIL.XForge/DataAccess/DataAccessServiceCollectionExtensions.cs b/src/SIL.XForge/DataAccess/DataAccessServiceCollectionExtensions.cs index a93dc68912d..794402f34a0 100644 --- a/src/SIL.XForge/DataAccess/DataAccessServiceCollectionExtensions.cs +++ b/src/SIL.XForge/DataAccess/DataAccessServiceCollectionExtensions.cs @@ -64,7 +64,7 @@ public static IServiceCollection AddDataAccess(this IServiceCollection services, ); services.AddMongoRepository( "site_configs", - cm => cm.MapIdProperty(sm => sm.Id), + cm => cm.MapIdProperty(sc => sc.Id), im => im.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(sc => sc.Name))) ); diff --git a/src/SIL.XForge/Models/SiteConfig.cs b/src/SIL.XForge/Models/SiteConfig.cs index c89fac05620..7fda02ae999 100644 --- a/src/SIL.XForge/Models/SiteConfig.cs +++ b/src/SIL.XForge/Models/SiteConfig.cs @@ -1,8 +1,43 @@ namespace SIL.XForge.Models; +/// +/// The sitewide configuration stored in MongoDB. +/// public class SiteConfig : IIdentifiable { + /// + /// The object identifier. + /// public required string Id { get; set; } + + /// + /// The site name. + /// + /// + /// This corresponds to . + /// public required string Name { get; set; } + + /// + /// The last finished build identifier. + /// + /// + /// This is used by the Machine Background Service. + /// public string? LastFinishedBuildId { get; set; } + + /// + /// The number of builds allowed per day for every project. + /// + public int BuildQuotaPerDay { get; set; } + + /// + /// The number of builds allowed per week for every project. + /// + public int BuildQuotaPerWeek { get; set; } + + /// + /// The number of builds allowed per month for every project. + /// + public int BuildQuotaPerMonth { get; set; } } diff --git a/src/SIL.XForge/Services/EventMetricService.cs b/src/SIL.XForge/Services/EventMetricService.cs index a59314bb4b6..ee25f4ab207 100644 --- a/src/SIL.XForge/Services/EventMetricService.cs +++ b/src/SIL.XForge/Services/EventMetricService.cs @@ -69,6 +69,12 @@ public async Task> GetEventMetricsAsync( results = await orderedQuery.ToListAsync(); unpagedCount = results.Count; } + else if (pageSize == 0) + { + // We only want the unpaginated count + results = []; + unpagedCount = await query.CountAsync(); + } else { // Execute count and paged results diff --git a/src/SIL.XForge/Services/LimitExceededException.cs b/src/SIL.XForge/Services/LimitExceededException.cs new file mode 100644 index 00000000000..b77dd9a37be --- /dev/null +++ b/src/SIL.XForge/Services/LimitExceededException.cs @@ -0,0 +1,9 @@ +using System; + +namespace SIL.XForge.Services; + +/// +/// A rate limit has been exceeded. +/// +/// The error message. +public class LimitExceededException(string message) : Exception(message); diff --git a/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs b/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs index 29aa57c8df7..b2f31630746 100644 --- a/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Controllers/MachineApiControllerTests.cs @@ -2573,6 +2573,28 @@ public async Task StartPreTranslationBuildAsync_NoProject() Assert.IsInstanceOf(actual); } + [Test] + public async Task StartPreTranslationBuildAsync_RateLimitExceeded() + { + // Set up test environment + var env = new TestEnvironment(); + env.MachineApiService.StartPreTranslationBuildAsync( + User01, + Arg.Is(p => p.ProjectId == Project01), + CancellationToken.None + ) + .Throws(new LimitExceededException("Too many drafts")); + + // SUT + ActionResult actual = await env.Controller.StartPreTranslationBuildAsync( + new BuildConfig { ProjectId = Project01 }, + CancellationToken.None + ); + + Assert.IsInstanceOf(actual); + Assert.AreEqual(StatusCodes.Status429TooManyRequests, (actual as ObjectResult)?.StatusCode); + } + [Test] public async Task StartPreTranslationBuildAsync_Success() { diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 6e43f970f6d..3996e9fdd80 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -4641,6 +4641,115 @@ public void StartPreTranslationBuildAsync_NoProject() ); } + [TestCase(1, 0, 0)] + [TestCase(0, 1, 0)] + [TestCase(0, 0, 1)] + public async Task StartPreTranslationBuildAsync_RateLimitExceeded(int dayLimit, int weekLimit, int monthLimit) + { + // Set up test environment + var env = new TestEnvironment(); + env.EventMetricService.GetEventMetricsAsync( + Project01, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)] + ) + .ReturnsForAnyArgs( + Task.FromResult( + new QueryResults { Results = [], UnpagedCount = dayLimit + weekLimit + monthLimit + 1 } + ) + ); + await env.SiteConfigs.UpdateAsync( + sc => sc.Name == env.SiteOptions.Value.Id, + u => + { + u.Set(sc => sc.BuildQuotaPerDay, dayLimit); + u.Set(sc => sc.BuildQuotaPerWeek, weekLimit); + u.Set(sc => sc.BuildQuotaPerMonth, monthLimit); + } + ); + + // SUT + Assert.ThrowsAsync(() => + env.Service.StartPreTranslationBuildAsync( + User01, + new BuildConfig { ProjectId = Project01 }, + CancellationToken.None + ) + ); + } + + [Test] + public async Task StartPreTranslationBuildAsync_RateLimitNotConfigured() + { + // Set up test environment + var env = new TestEnvironment(); + await env.SiteConfigs.UpdateAsync( + sc => sc.Name == env.SiteOptions.Value.Id, + u => + { + u.Set(sc => sc.BuildQuotaPerDay, 0); + u.Set(sc => sc.BuildQuotaPerWeek, 0); + u.Set(sc => sc.BuildQuotaPerMonth, 0); + } + ); + + // SUT + await env.Service.StartPreTranslationBuildAsync( + User01, + new BuildConfig { ProjectId = Project01 }, + CancellationToken.None + ); + + await env + .EventMetricService.DidNotReceiveWithAnyArgs() + .GetEventMetricsAsync( + Project01, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)] + ); + await env.ProjectService.Received(1).SyncAsync(User01, Project01); + env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task StartPreTranslationBuildAsync_RateLimitNotExceeded() + { + // Set up test environment + var env = new TestEnvironment(); + env.EventMetricService.GetEventMetricsAsync( + Project01, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)] + ) + .ReturnsForAnyArgs(Task.FromResult(new QueryResults { Results = [], UnpagedCount = 0 })); + await env.SiteConfigs.UpdateAsync( + sc => sc.Name == env.SiteOptions.Value.Id, + u => + { + u.Set(sc => sc.BuildQuotaPerDay, 5); + u.Set(sc => sc.BuildQuotaPerWeek, 20); + u.Set(sc => sc.BuildQuotaPerMonth, 75); + } + ); + + // SUT + await env.Service.StartPreTranslationBuildAsync( + User01, + new BuildConfig { ProjectId = Project01 }, + CancellationToken.None + ); + + await env + .EventMetricService.ReceivedWithAnyArgs() + .GetEventMetricsAsync( + Project01, + scopes: [EventScope.Drafting], + eventTypes: [nameof(MachineProjectService.BuildProjectAsync)] + ); + await env.ProjectService.Received(1).SyncAsync(User01, Project01); + env.BackgroundJobClient.Received(1).Create(Arg.Any(), Arg.Any()); + } + [Test] public async Task StartPreTranslationBuildAsync_SuccessNoTrainingOrTranslationScriptureRanges() { @@ -5459,8 +5568,16 @@ public TestEnvironment() RealtimeService.AddRepository("text_documents", OTType.Json0, TextDocuments); RealtimeService.AddRepository("texts", OTType.RichText, Texts); SiteOptions = Options.Create( - new SiteOptions { IssuesEmail = "help@scriptureforge.org", Origin = "https://scriptureforge.org" } + new SiteOptions + { + Id = "sf", + IssuesEmail = "help@scriptureforge.org", + Origin = "https://scriptureforge.org", + } ); + SiteConfigs = new MemoryRepository([ + new SiteConfig { Id = ObjectId.GenerateNewId().ToString(), Name = SiteOptions.Value.Id }, + ]); SyncService = Substitute.For(); SyncService.SyncAsync(Arg.Any()).Returns(Task.FromResult(HangfireJobId)); TranslationBuildsClient = Substitute.For(); @@ -5501,6 +5618,7 @@ public TestEnvironment() ProjectRights, ProjectService, RealtimeService, + SiteConfigs, SiteOptions, SyncService, TranslationBuildsClient, @@ -5521,6 +5639,7 @@ public TestEnvironment() public MemoryRepository DraftMetrics { get; } public MemoryRepository Projects { get; } public MemoryRepository ProjectSecrets { get; } + public MemoryRepository SiteConfigs { get; } public MemoryRepository TextDocuments { get; } public MemoryRepository Texts { get; } public ISFProjectRights ProjectRights { get; } diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineBackgroundServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineBackgroundServiceTests.cs index 57a72b0c53e..aefc736043f 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineBackgroundServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineBackgroundServiceTests.cs @@ -170,7 +170,7 @@ await env private class TestEnvironment { - private const string SiteId = "SF"; + private const string SiteId = "sf"; public TestEnvironment() { diff --git a/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs b/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs index b78b8b1665f..30a491decdb 100644 --- a/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs +++ b/test/SIL.XForge.Tests/Services/EventMetricServiceTests.cs @@ -139,6 +139,25 @@ public async Task GetEventMetricsAsync_GetAllForProject() Assert.AreEqual(3, actual.UnpagedCount); } + [Test] + public async Task GetEventMetricsAsync_GetCountOnlyForProject() + { + var env = new TestEnvironment(); + Assert.AreEqual(4, env.EventMetrics.Query().Count()); + + // SUT + QueryResults actual = await env.Service.GetEventMetricsAsync( + Project01, + scopes: null, + eventTypes: null, + pageSize: 0 + ); + + // Skip the one event metric without a project identifier + Assert.AreEqual(0, actual.Results.Count()); + Assert.AreEqual(3, actual.UnpagedCount); + } + [Test] public async Task GetEventMetricsAsync_SupportsPagination() {