From 6c9da8e796ed3353eee7b417d7b82f66a47c7296 Mon Sep 17 00:00:00 2001 From: xxhen Date: Thu, 8 May 2025 21:34:12 -0400 Subject: [PATCH] Add project files. --- AccessQueueService.sln | 31 +++ AccessQueueService/AccessQueueService.csproj | 13 + AccessQueueService/AccessQueueService.http | 6 + .../Controllers/AccessController.cs | 32 +++ AccessQueueService/Models/AccessResponse.cs | 15 + AccessQueueService/Models/AccessTicket.cs | 9 + AccessQueueService/Program.cs | 29 ++ .../Properties/launchSettings.json | 41 +++ AccessQueueService/Services/AccessService.cs | 162 +++++++++++ AccessQueueService/Services/IAccessService.cs | 11 + AccessQueueService/WeatherForecast.cs | 13 + .../appsettings.Development.json | 8 + AccessQueueService/appsettings.json | 15 + .../AccessQueueServiceTests.csproj | 27 ++ AccessQueueServiceTests/AccessServiceTests.cs | 263 ++++++++++++++++++ 15 files changed, 675 insertions(+) create mode 100644 AccessQueueService.sln create mode 100644 AccessQueueService/AccessQueueService.csproj create mode 100644 AccessQueueService/AccessQueueService.http create mode 100644 AccessQueueService/Controllers/AccessController.cs create mode 100644 AccessQueueService/Models/AccessResponse.cs create mode 100644 AccessQueueService/Models/AccessTicket.cs create mode 100644 AccessQueueService/Program.cs create mode 100644 AccessQueueService/Properties/launchSettings.json create mode 100644 AccessQueueService/Services/AccessService.cs create mode 100644 AccessQueueService/Services/IAccessService.cs create mode 100644 AccessQueueService/WeatherForecast.cs create mode 100644 AccessQueueService/appsettings.Development.json create mode 100644 AccessQueueService/appsettings.json create mode 100644 AccessQueueServiceTests/AccessQueueServiceTests.csproj create mode 100644 AccessQueueServiceTests/AccessServiceTests.cs diff --git a/AccessQueueService.sln b/AccessQueueService.sln new file mode 100644 index 0000000..a3ecab9 --- /dev/null +++ b/AccessQueueService.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35931.197 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueService", "AccessQueueService\AccessQueueService.csproj", "{6E556254-63D4-4CEB-9CAF-E912C00E0C30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccessQueueServiceTests", "AccessQueueServiceTests\AccessQueueServiceTests.csproj", "{1DF48A19-A2B3-4B0C-B726-E65B8E023760}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E556254-63D4-4CEB-9CAF-E912C00E0C30}.Release|Any CPU.Build.0 = Release|Any CPU + {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DF48A19-A2B3-4B0C-B726-E65B8E023760}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0B4CB38D-5CDA-4E77-97C8-41A555171F52} + EndGlobalSection +EndGlobal diff --git a/AccessQueueService/AccessQueueService.csproj b/AccessQueueService/AccessQueueService.csproj new file mode 100644 index 0000000..5419ef0 --- /dev/null +++ b/AccessQueueService/AccessQueueService.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/AccessQueueService/AccessQueueService.http b/AccessQueueService/AccessQueueService.http new file mode 100644 index 0000000..499b4c0 --- /dev/null +++ b/AccessQueueService/AccessQueueService.http @@ -0,0 +1,6 @@ +@AccessQueueService_HostAddress = http://localhost:5199 + +GET {{AccessQueueService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/AccessQueueService/Controllers/AccessController.cs b/AccessQueueService/Controllers/AccessController.cs new file mode 100644 index 0000000..fdc63c4 --- /dev/null +++ b/AccessQueueService/Controllers/AccessController.cs @@ -0,0 +1,32 @@ +using AccessQueueService.Models; +using AccessQueueService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AccessQueueService.Controllers +{ + [ApiController] + [Route("access")] + public class AccessController : ControllerBase + { + private readonly IAccessService _accessService; + + public AccessController(IAccessService accessService) + { + _accessService = accessService; + } + + [HttpGet] + [Route("{id}")] + public async Task Get(Guid id) + { + return await _accessService.RequestAccess(id); + } + + [HttpDelete] + [Route("{id}")] + public async Task Delete(Guid id) + { + return await _accessService.RevokeAccess(id); + } + } +} diff --git a/AccessQueueService/Models/AccessResponse.cs b/AccessQueueService/Models/AccessResponse.cs new file mode 100644 index 0000000..101523a --- /dev/null +++ b/AccessQueueService/Models/AccessResponse.cs @@ -0,0 +1,15 @@ +namespace AccessQueueService.Models +{ + public class AccessResponse + { + public DateTime? ExpiresOn { get; set; } + public int RequestsAhead { get; set; } = 0; + public bool HasAccess + { + get + { + return ExpiresOn != null && ExpiresOn > DateTime.UtcNow; + } + } + } +} diff --git a/AccessQueueService/Models/AccessTicket.cs b/AccessQueueService/Models/AccessTicket.cs new file mode 100644 index 0000000..faf99e4 --- /dev/null +++ b/AccessQueueService/Models/AccessTicket.cs @@ -0,0 +1,9 @@ +namespace AccessQueueService.Models +{ + public class AccessTicket + { + public Guid UserId { get; set; } + public DateTime ExpiresOn { get; set; } + public DateTime LastActive { get; set; } + } +} diff --git a/AccessQueueService/Program.cs b/AccessQueueService/Program.cs new file mode 100644 index 0000000..700d2ab --- /dev/null +++ b/AccessQueueService/Program.cs @@ -0,0 +1,29 @@ +using AccessQueueService.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSingleton(); + + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/AccessQueueService/Properties/launchSettings.json b/AccessQueueService/Properties/launchSettings.json new file mode 100644 index 0000000..b49fa74 --- /dev/null +++ b/AccessQueueService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53481", + "sslPort": 44342 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7291;http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/AccessQueueService/Services/AccessService.cs b/AccessQueueService/Services/AccessService.cs new file mode 100644 index 0000000..dd9b042 --- /dev/null +++ b/AccessQueueService/Services/AccessService.cs @@ -0,0 +1,162 @@ +using AccessQueueService.Models; + +namespace AccessQueueService.Services +{ + public class AccessService : IAccessService + { + private readonly Dictionary _accessTickets = new(); + private readonly Queue _accessQueue = new(); + private static SemaphoreSlim _queueLock = new(1, 1); + private IConfiguration _configuration; + private readonly int EXP_SECONDS; + private readonly int ACT_SECONDS; + private readonly int CAPACITY_LIMIT; + private readonly bool ROLLING_EXPIRATION; + public AccessService(IConfiguration configuration) + { + _configuration = configuration; + EXP_SECONDS = _configuration.GetValue("AccessQueue:ExpirationSeconds"); + ACT_SECONDS = _configuration.GetValue("AccessQueue:ActivitySeconds"); + CAPACITY_LIMIT = _configuration.GetValue("AccessQueue:CapacityLimit"); + ROLLING_EXPIRATION = _configuration.GetValue("AccessQueue:RollingExpiration"); + } + public int UnexpiredTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow); + public int ActiveTicketsCount => _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > DateTime.UtcNow.AddSeconds(-_configuration.GetValue("AccessQueue:ActivitySeconds"))); + public int QueueCount => _accessQueue.Count; + public async Task RequestAccess(Guid userId) + { + await _queueLock.WaitAsync(); + try + { + var hasCapacity = !DidDequeueUntilFull(); + var existingTicket = _accessTickets.GetValueOrDefault(userId); + if (existingTicket != null && existingTicket.ExpiresOn > DateTime.UtcNow) + { + var expiresOn = existingTicket.ExpiresOn; + if (ROLLING_EXPIRATION) + { + expiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS); + } + _accessTickets[userId] = new AccessTicket + { + UserId = userId, + ExpiresOn = expiresOn, + LastActive = DateTime.UtcNow + }; + return new AccessResponse + { + ExpiresOn = expiresOn + }; + } + if (hasCapacity) + { + var accessTicket = new AccessTicket + { + UserId = userId, + ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS), + LastActive = DateTime.UtcNow + }; + _accessTickets[userId] = accessTicket; + return new AccessResponse + { + ExpiresOn = _accessTickets[userId].ExpiresOn, + RequestsAhead = _accessQueue.Count + }; + } + else + { + var indexOfTicket = IndexOfTicket(userId); + var requestsAhead = _accessQueue.Count - indexOfTicket - 1; + if (indexOfTicket == -1) + { + _accessQueue.Enqueue(new AccessTicket + { + UserId = userId, + LastActive = DateTime.UtcNow, + ExpiresOn = DateTime.MaxValue, + }); + } + return new AccessResponse + { + ExpiresOn = null, + RequestsAhead = requestsAhead + }; + } + } + finally + { + _queueLock.Release(); + } + } + + public async Task RevokeAccess(Guid userId) + { + await _queueLock.WaitAsync(); + try + { + return _accessTickets.Remove(userId); + } + finally + { + _queueLock.Release(); + } + } + + public int DeleteExpiredTickets() + { + var expiredTickets = _accessTickets.Where(t => t.Value.ExpiresOn < DateTime.UtcNow); + int count = 0; + foreach (var ticket in expiredTickets) + { + count++; + _accessTickets.Remove(ticket.Key); + } + return count; + } + + private bool DidDequeueUntilFull() + { + var activeCutoff = DateTime.UtcNow.AddSeconds(-ACT_SECONDS); + var numberOfActiveUsers = _accessTickets.Count(t => t.Value.ExpiresOn > DateTime.UtcNow && t.Value.LastActive > activeCutoff); + var openSpots = CAPACITY_LIMIT - numberOfActiveUsers; + int filledSpots = 0; + while (filledSpots < openSpots) + { + if (_accessQueue.TryDequeue(out var nextUser)) + { + if (nextUser.LastActive < activeCutoff) + { + // User is inactive, throw away their ticket + continue; + } + _accessTickets[nextUser.UserId] = new AccessTicket + { + UserId = nextUser.UserId, + ExpiresOn = DateTime.UtcNow.AddSeconds(EXP_SECONDS), + LastActive = DateTime.UtcNow + }; + filledSpots++; + } + else + { + break; + } + } + return filledSpots == openSpots; + } + + private int IndexOfTicket(Guid userId) + { + var index = 0; + foreach (var ticket in _accessQueue) + { + if (ticket.UserId == userId) + { + return index; + } + index++; + } + return -1; + } + } +} diff --git a/AccessQueueService/Services/IAccessService.cs b/AccessQueueService/Services/IAccessService.cs new file mode 100644 index 0000000..c11da36 --- /dev/null +++ b/AccessQueueService/Services/IAccessService.cs @@ -0,0 +1,11 @@ +using AccessQueueService.Models; + +namespace AccessQueueService.Services +{ + public interface IAccessService + { + public Task RequestAccess(Guid userId); + public Task RevokeAccess(Guid userId); + public int DeleteExpiredTickets(); + } +} diff --git a/AccessQueueService/WeatherForecast.cs b/AccessQueueService/WeatherForecast.cs new file mode 100644 index 0000000..d23d200 --- /dev/null +++ b/AccessQueueService/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace AccessQueueService +{ + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } + } +} diff --git a/AccessQueueService/appsettings.Development.json b/AccessQueueService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/AccessQueueService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AccessQueueService/appsettings.json b/AccessQueueService/appsettings.json new file mode 100644 index 0000000..6ba2637 --- /dev/null +++ b/AccessQueueService/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AccessQueue": { + "CapacityLimit": 100, // Maximum number of active users + "ActivitySeconds": 900, // Time since last access before a user is considered inactive + "ExpirationSeconds": 43200, // 12 hours - Time before a user access is revoked + "RollingExpiration": true // Whether to extend expiration time on access + }, + "AllowedHosts": "*" +} diff --git a/AccessQueueServiceTests/AccessQueueServiceTests.csproj b/AccessQueueServiceTests/AccessQueueServiceTests.csproj new file mode 100644 index 0000000..a1b7476 --- /dev/null +++ b/AccessQueueServiceTests/AccessQueueServiceTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/AccessQueueServiceTests/AccessServiceTests.cs b/AccessQueueServiceTests/AccessServiceTests.cs new file mode 100644 index 0000000..c97d654 --- /dev/null +++ b/AccessQueueServiceTests/AccessServiceTests.cs @@ -0,0 +1,263 @@ +namespace AccessQueueServiceTests +{ + using global::AccessQueueService.Services; + using Microsoft.Extensions.Configuration; + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Xunit; + + namespace AccessQueueService.Tests + { + public class AccessServiceTests + { + const int EXP_SECONDS = 5; + const int EXP_MILLIS = 1000 * EXP_SECONDS; + const int ACT_SECONDS = 1; + const int ACT_MILLIS = 1000 * ACT_SECONDS; + const int CAP_LIMIT = 5; + const int BULK_COUNT = 10000; + private readonly AccessService _accessService; + + public AccessServiceTests() + { + var inMemorySettings = new Dictionary + { + { "AccessQueue:ExpirationSeconds", $"{EXP_SECONDS}" }, + { "AccessQueue:ActivitySeconds", $"{ACT_SECONDS}" }, + { "AccessQueue:CapacityLimit", $"{CAP_LIMIT}" }, + { "AccessQueue:RollingExpiration", "true" } + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(inMemorySettings) // Add in-memory settings + .Build(); + + _accessService = new AccessService(configuration); + } + + [Fact] + public async Task RequestAccess_ShouldGrantAccess_WhenCapacityIsAvailable() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var response = await _accessService.RequestAccess(userId); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.ExpiresOn); + Assert.True(response.RequestsAhead == 0); + Assert.Equal(1, _accessService.UnexpiredTicketsCount); + Assert.Equal(1, _accessService.ActiveTicketsCount); + Assert.Equal(0, _accessService.QueueCount); + } + + [Fact] + public async Task RequestAccess_ShouldReturnAccessResponse_WhenUserAlreadyHasTicket() + { + // Arrange + var userId = Guid.NewGuid(); + await _accessService.RequestAccess(userId); // First request to create a ticket + + // Act + var response = await _accessService.RequestAccess(userId); // Second request for the same user + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.ExpiresOn); + Assert.True(response.RequestsAhead == 0); + Assert.Equal(1, _accessService.UnexpiredTicketsCount); + Assert.Equal(1, _accessService.ActiveTicketsCount); + Assert.Equal(0, _accessService.QueueCount); + } + + [Fact] + public async Task RequestAccess_ShouldQueueUser_WhenCapacityIsFull() + { + // Arrange + for (int i = 0; i < CAP_LIMIT * 2; i++) // Fill double capacity + { + await _accessService.RequestAccess(Guid.NewGuid()); + } + var userId = Guid.NewGuid(); + + // Act + var response = await _accessService.RequestAccess(userId); + + // Assert + Assert.NotNull(response); + Assert.Null(response.ExpiresOn); + Assert.True(response.RequestsAhead == 5); + Assert.Equal(5, _accessService.UnexpiredTicketsCount); + Assert.Equal(5, _accessService.ActiveTicketsCount); + Assert.Equal(6, _accessService.QueueCount); + } + + + [Fact] + public async Task RevokeAccess_ShouldReturnTrue_WhenUserHasAccess() + { + // Arrange + var userId = Guid.NewGuid(); + await _accessService.RequestAccess(userId); + + // Act + var result = await _accessService.RevokeAccess(userId); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task RevokeAccess_ShouldReturnFalse_WhenUserDoesNotHaveAccess() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _accessService.RevokeAccess(userId); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RequestAccess_ShouldQueueUser_AfterAccessRevoked() + { + var userId = Guid.NewGuid(); + await _accessService.RequestAccess(userId); + + for (int i = 0; i < CAP_LIMIT; i++) // Fill remaining slots + { + await _accessService.RequestAccess(Guid.NewGuid()); + } + + var response = await _accessService.RequestAccess(userId); // Request access before revoking + Assert.NotNull(response); + Assert.True(response.HasAccess); + + await _accessService.RevokeAccess(userId); // Revoke access + var responseAfterRevoke = await _accessService.RequestAccess(userId); // Request access again + Assert.NotNull(responseAfterRevoke); + Assert.False(responseAfterRevoke.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldNotQueueUser_WhenMultipleRequestsForOtherUsersMade() + { + for (int i = 0; i < CAP_LIMIT; i++) // Fill slots without awaiting + { + _ = _accessService.RequestAccess(Guid.NewGuid()); + } + var response = await _accessService.RequestAccess(Guid.NewGuid()); // Request access before revoking + Assert.NotNull(response); + Assert.False(response.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldUpdateExpirationTime_WhenRollingExpirationTrue() + { + var userId = Guid.NewGuid(); + var initialResponse = await _accessService.RequestAccess(userId); + await Task.Delay(ACT_MILLIS); + var updatedResponse = await _accessService.RequestAccess(userId); + Assert.True(updatedResponse.ExpiresOn > initialResponse.ExpiresOn); + } + + [Fact] + public async Task RequestAccess_ShouldGrantAccess_WhenUsersWithAccessInactive() + { + for (int i = 0; i < CAP_LIMIT; i++) + { + await _accessService.RequestAccess(Guid.NewGuid()); + } + var userId = Guid.NewGuid(); + var response = await _accessService.RequestAccess(userId); + Assert.False(response.HasAccess); + await Task.Delay(ACT_MILLIS); + response = await _accessService.RequestAccess(userId); + Assert.True(response.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldRevokeAccess_WhenExpired() + { + var userId = Guid.NewGuid(); + var response = await _accessService.RequestAccess(userId); + Assert.True(response.HasAccess); + await Task.Delay(EXP_MILLIS); + for (int i = 0; i < CAP_LIMIT; i++) + { + await _accessService.RequestAccess(Guid.NewGuid()); + } + response = await _accessService.RequestAccess(userId); + Assert.False(response.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldRetailAccess_WhenNotExpired() + { + var userId = Guid.NewGuid(); + var response = await _accessService.RequestAccess(userId); + Assert.True(response.HasAccess); + await Task.Delay(ACT_MILLIS); + for (int i = 0; i < CAP_LIMIT; i++) + { + response = await _accessService.RequestAccess(Guid.NewGuid()); + Assert.True(response.HasAccess); + } + response = await _accessService.RequestAccess(userId); + Assert.True(response.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldProcessBulkRequests() + { + var userId = Guid.NewGuid(); + await _accessService.RequestAccess(userId); + for (int i = 0; i < BULK_COUNT; i++) + { + _ = _accessService.RequestAccess(Guid.NewGuid()); + } + var response = await _accessService.RequestAccess(userId); + Assert.NotNull(response); + Assert.True(response.HasAccess); + } + + [Fact] + public async Task RequestAccess_ShouldReportLessInQueue_AsTicketsInactivate() + { + var start = DateTime.UtcNow; + for (int i = 0; i < CAP_LIMIT; i++) // Fill all slots + { + var elapsed = DateTime.UtcNow - start; + Console.WriteLine($"Elapsed time: {elapsed.TotalSeconds} s: Adding {i}"); + await _accessService.RequestAccess(Guid.NewGuid()); + await Task.Delay(ACT_MILLIS / CAP_LIMIT); + } + var users = new[] + { + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + }; + + await _accessService.RequestAccess(users[0]); + await _accessService.RequestAccess(users[1]); + var response = await _accessService.RequestAccess(users[2]); + + Assert.Equal(1, response.RequestsAhead); + await Task.Delay(ACT_MILLIS / CAP_LIMIT); + + await _accessService.RequestAccess(users[0]); + await _accessService.RequestAccess(users[1]); + response = await _accessService.RequestAccess(users[2]); + + Assert.Equal(0, response.RequestsAhead); + } + + } + } +} \ No newline at end of file