diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..15ebdcc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3894dc4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + pull_request: + branches: [ "main", "develop" ] + schedule: + - cron: '0 0 * * 1' + +jobs: + analyze: + name: Analyze (C#) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Build + working-directory: ./src + run: dotnet build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:csharp" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..4431e73 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,59 @@ +name: PR Validation + +on: + pull_request: + branches: [ "main", "develop" ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + working-directory: ./test/Unit + run: dotnet restore + + - name: Build + working-directory: ./test/Unit + run: dotnet build --no-restore + + - name: Test + working-directory: ./test/Unit + run: dotnet test --no-build --verbosity normal + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Start services + run: docker compose -f test/Integration/docker-compose.yml up -d --wait + + - name: Restore + working-directory: ./test/Integration + run: dotnet restore + + - name: Build + working-directory: ./test/Integration + run: dotnet build --no-restore + + - name: Test + working-directory: ./test/Integration + run: dotnet test -p:TestTfmsInParallel=false --no-build --verbosity normal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f2000d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + branches: + - 'release/**' + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Redis + uses: supercharge/redis-github-action@1.8.1 + with: + redis-version: 7 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Run unit tests + working-directory: ./test/Unit + run: dotnet test --verbosity normal + + - name: Run integration tests + working-directory: ./test/Integration + run: dotnet test --verbosity normal + + publish: + name: Pack and Publish + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract version from branch name + id: version + run: | + BRANCH="${GITHUB_REF#refs/heads/}" + VERSION="${BRANCH#release/v}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Invalid version format in branch name: $BRANCH (expected release/vX.Y.Z)" + exit 1 + fi + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Pack + working-directory: ./src + run: > + dotnet pack + --configuration Release + -p:Version=${{ steps.version.outputs.value }} + --output ./nupkg + + - name: Push to NuGet + working-directory: ./src + run: > + dotnet nuget push ./nupkg/*.nupkg + --api-key ${{ secrets.NUGET_API_KEY }} + --source https://api.nuget.org/v3/index.json + --skip-duplicate diff --git a/src/ActionCache.csproj b/src/ActionCache.csproj index 2049870..93ee78e 100644 --- a/src/ActionCache.csproj +++ b/src/ActionCache.csproj @@ -1,55 +1,60 @@ - - - - net8.0;net9.0 - enable - enable - true - - - - ActionCache - 0.0.9 - Joshua Zillwood - A simple yet powerful data caching library that adds an extra layer of caching to your ASP.NET Core applications. - - Icon.jpg - README.md - MIT - https://github.com/jzills/ActionCache - https://github.com/jzills/ActionCache.git - git - mvc;cache;azure;cosmos;sqlserver;redis;memory - Copyright © Joshua Zillwood - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net8.0;net10.0 + enable + enable + true + + + + ActionCache + 0.0.9 + Joshua Zillwood + A simple yet powerful data caching library that adds an extra layer of caching to your ASP.NET Core applications. + + Icon.jpg + README.md + MIT + https://github.com/jzills/ActionCache + https://github.com/jzills/ActionCache.git + git + mvc;cache;azure;cosmos;sqlserver;redis;memory + Copyright © Joshua Zillwood + + + + <_TargetVersion Condition="'$(TargetFramework)' == 'net8.0'">8.0.0 + <_TargetVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Common/Caching/ActionCacheEntryOptions.cs b/src/Common/Caching/ActionCacheEntryOptions.cs index 03da390..ad925ce 100644 --- a/src/Common/Caching/ActionCacheEntryOptions.cs +++ b/src/Common/Caching/ActionCacheEntryOptions.cs @@ -25,14 +25,14 @@ public class ActionCacheEntryOptions /// /// Gets or sets the duration for which the lock will remain valid once acquired. /// - /// The default is 200 milliseconds. - public TimeSpan LockDuration { get; set; } = TimeSpan.FromMilliseconds(200); + /// The default is 5 seconds. + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(5); /// /// Gets or sets the maximum amount of time to wait for acquiring the lock before timing out. /// - /// The default is 200 milliseconds. - public TimeSpan LockTimeout { get; set; } = TimeSpan.FromMilliseconds(200); + /// The default is 10 seconds. + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(10); /// /// Calculates the absolute expiration date and time based on , relative to the current UTC time. diff --git a/src/SqlServer/SqlServerActionCache.cs b/src/SqlServer/SqlServerActionCache.cs index 6b61c3b..d5410a3 100644 --- a/src/SqlServer/SqlServerActionCache.cs +++ b/src/SqlServer/SqlServerActionCache.cs @@ -91,9 +91,7 @@ await CacheLocker.WaitForLockThenAsync(Namespace, public override async Task RemoveAsync() { var keys = await GetKeysAsync(); - - await CacheLocker.WaitForLockThenAsync(Namespace, - () => Task.WhenAll(keys.Select(RemoveAsync))); + await Task.WhenAll(keys.Select(RemoveAsync)); } /// diff --git a/test/Integration/ActionCache/Test_ActionCache_Expiration_Absolute.cs b/test/Integration/ActionCache/Test_ActionCache_Expiration_Absolute.cs new file mode 100644 index 0000000..fd8fe48 --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_Expiration_Absolute.cs @@ -0,0 +1,61 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_Expiration_Absolute +{ + IActionCache Cache; + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test_GetAsync_Expires(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create(nameof(Test_GetAsync_Expires), TimeSpan.FromSeconds(5)); + + await Cache.SetAsync("Key_Expiration_1", "Value_1"); + var result = await Cache.GetAsync("Key_Expiration_1"); + var keys = await Cache.GetKeysAsync(); + + Assert.That(result, Is.EqualTo("Value_1")); + Assert.That(keys.Count(), Is.EqualTo(1)); + + Thread.Sleep(10000); + + result = await Cache.GetAsync("Key_Expiration_1"); + keys = await Cache.GetKeysAsync(); + + Assert.That(result, Is.Null); + Assert.That(keys.Count(), Is.EqualTo(0)); + } + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test_GetKeys_Expires(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create(nameof(Test_GetKeys_Expires), TimeSpan.FromSeconds(5)); + + await Cache.SetAsync("Key_Expiration_1", "Value_1"); + var result = await Cache.GetAsync("Key_Expiration_1"); + var keys = await Cache.GetKeysAsync(); + + Assert.That(result, Is.EqualTo("Value_1")); + Assert.That(keys.Count(), Is.EqualTo(1)); + + Thread.Sleep(10000); + + keys = await Cache.GetKeysAsync(); + result = await Cache.GetAsync("Key_Expiration_1"); + + Assert.That(result, Is.Null); + Assert.That(keys.Count(), Is.EqualTo(0)); + } + + [TearDown] + public async Task TearDown() + { + await Cache.RemoveAsync(); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_Expiration_Sliding.cs b/test/Integration/ActionCache/Test_ActionCache_Expiration_Sliding.cs new file mode 100644 index 0000000..571560e --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_Expiration_Sliding.cs @@ -0,0 +1,43 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_Expiration_Sliding +{ + IActionCache Cache; + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test_GetAsync_Expires(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create(nameof(Test_GetAsync_Expires), slidingExpiration: TimeSpan.FromSeconds(30)); + + await Cache.SetAsync("Key_Expiration_1", "Value_1"); + var result = await Cache.GetAsync("Key_Expiration_1"); + var keys = await Cache.GetKeysAsync(); + + Assert.That(result, Is.EqualTo("Value_1")); + Assert.That(keys.Count(), Is.EqualTo(1)); + + Thread.Sleep(10000); + + result = await Cache.GetAsync("Key_Expiration_1"); + keys = await Cache.GetKeysAsync(); + + Thread.Sleep(10000); + + result = await Cache.GetAsync("Key_Expiration_1"); + keys = await Cache.GetKeysAsync(); + + Assert.That(result, Is.Not.Null); + Assert.That(keys.Count(), Is.EqualTo(1)); + } + + [TearDown] + public async Task TearDown() + { + await Cache.RemoveAsync(); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_GetAsync.cs b/test/Integration/ActionCache/Test_ActionCache_GetAsync.cs new file mode 100644 index 0000000..60903fd --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_GetAsync.cs @@ -0,0 +1,38 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_GetAsync +{ + IActionCache Cache; + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create(nameof(Test_ActionCache_GetAsync))!; + await Cache.SetAsync("Foo", "Bar"); + + var result = await Cache.GetAsync("Foo"); + Assert.That(result, Is.EqualTo("Bar")); + } + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test_NullableInt_ReturnsNull(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create("Test")!; + + var result = await Cache.GetAsync("Foo_Not_Present"); + Assert.That(result, Is.EqualTo(null)); + } + + [TearDown] + public async Task TearDown() + { + await Cache.RemoveAsync(); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_GetKeysAsync.cs b/test/Integration/ActionCache/Test_ActionCache_GetKeysAsync.cs new file mode 100644 index 0000000..0b32101 --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_GetKeysAsync.cs @@ -0,0 +1,29 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_GetKeysAsync +{ + IActionCache Cache; + + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + Cache = cacheFactory.Create(nameof(Test_ActionCache_GetKeysAsync))!; + + await Cache.SetAsync("Foo", "Bar"); + await Cache.SetAsync("Biz", "Baz"); + + var result = await Cache.GetKeysAsync(); + Assert.That(result.Count(), Is.EqualTo(2)); + } + + [TearDown] + public async Task TearDown() + { + await Cache.RemoveAsync(); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_RemoveAsync.cs b/test/Integration/ActionCache/Test_ActionCache_RemoveAsync.cs new file mode 100644 index 0000000..2d8d521 --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_RemoveAsync.cs @@ -0,0 +1,21 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_RemoveAsync +{ + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + var cache = cacheFactory.Create(nameof(Test_ActionCache_RemoveAsync))!; + + await cache.SetAsync("Foo", "Bar"); + await cache.RemoveAsync("Foo"); + + var result = await cache.GetAsync("Foo"); + Assert.That(result, Is.Null); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_RemoveAsync_Namespace.cs b/test/Integration/ActionCache/Test_ActionCache_RemoveAsync_Namespace.cs new file mode 100644 index 0000000..e3f248b --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_RemoveAsync_Namespace.cs @@ -0,0 +1,28 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_RemoveAsync_Namespace +{ + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + var cache = cacheFactory.Create(nameof(Test_ActionCache_RemoveAsync_Namespace))!; + + await cache.SetAsync("Foo", "Bar"); + await cache.SetAsync("Biz", "Baz"); + await cache.SetAsync("Coz", "Doz"); + await cache.RemoveAsync(); + + string?[] result = [ + await cache.GetAsync("Foo"), + await cache.GetAsync("Biz"), + await cache.GetAsync("Coz") + ]; + + Assert.That(result, Is.All.Null); + } +} diff --git a/test/Integration/ActionCache/Test_ActionCache_SetAsync.cs b/test/Integration/ActionCache/Test_ActionCache_SetAsync.cs new file mode 100644 index 0000000..c63db75 --- /dev/null +++ b/test/Integration/ActionCache/Test_ActionCache_SetAsync.cs @@ -0,0 +1,23 @@ +using ActionCache; +using Integration.TestUtilities.Data; +using Microsoft.Extensions.DependencyInjection; + +[TestFixture] +public class Test_ActionCache_SetAsync +{ + [Test] + [TestCaseSource(typeof(TestData), nameof(TestData.GetServiceProviders))] + public async Task Test(IServiceProvider serviceProvider) + { + var cacheFactory = serviceProvider.GetRequiredService(); + var cache = cacheFactory.Create(nameof(Test_ActionCache_SetAsync)); + + Assert.That(cache, Is.Not.Null); + + await cache.SetAsync("Foo", "Bar"); + var result = await cache.GetAsync("Foo"); + await cache.RemoveAsync("Foo"); + + Assert.That(result, Is.EqualTo("Bar")); + } +} diff --git a/test/Integration/ActionCacheEndpointFilter/Test_ActionCacheEndpointFilter_Hit.cs b/test/Integration/ActionCacheEndpointFilter/Test_ActionCacheEndpointFilter_Hit.cs index 28cb371..7e2e231 100644 --- a/test/Integration/ActionCacheEndpointFilter/Test_ActionCacheEndpointFilter_Hit.cs +++ b/test/Integration/ActionCacheEndpointFilter/Test_ActionCacheEndpointFilter_Hit.cs @@ -41,7 +41,7 @@ public void Setup() [Test] public async Task Test() { - var route = "teams"; + var route = "teams/1"; var response = await Client.GetAsync(route); response.EnsureSuccessStatusCode(); diff --git a/test/Integration/Integration.csproj b/test/Integration/Integration.csproj index bb8013a..b222cf5 100644 --- a/test/Integration/Integration.csproj +++ b/test/Integration/Integration.csproj @@ -1,25 +1,34 @@ - - - - net8.0;net9.0 - enable - enable - - false - true - - - - - - - - - - - - - - - - + + + + net8.0;net10.0 + enable + enable + + false + true + $(MSBuildThisFileDirectory)integration.runsettings + + + + <_TargetVersion Condition="'$(TargetFramework)' == 'net8.0'">8.0.0 + <_TargetVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.0 + + + + + + + + + + + + + + + + + + + diff --git a/test/Integration/TestUtilities/Data/TestData.ServiceProvider.cs b/test/Integration/TestUtilities/Data/TestData.ServiceProvider.cs new file mode 100644 index 0000000..0c74576 --- /dev/null +++ b/test/Integration/TestUtilities/Data/TestData.ServiceProvider.cs @@ -0,0 +1,104 @@ +using ActionCache.Common.Extensions; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.DependencyInjection; + +namespace Integration.TestUtilities.Data; + +public static class TestData +{ + public static IEnumerable GetServiceProviders() => + GetRedisCacheServiceProvider().Concat( + GetSqlServerServiceProvider()).Concat( + GetAzureCosmosServiceProvider()).Concat( + GetMultipleCacheServiceProvider()); + + public static IEnumerable GetRedisCacheServiceProvider() + { + var services = new ServiceCollection(); + + services.AddMvc(); + services.AddActionCache(options => + { + options.UseEntryOptions(entryOptions => { }); + options.UseRedisCache(options => options.Configuration = "127.0.0.1:6379"); + }); + + var server = new TestServer(services.BuildServiceProvider()); + + return [server.Services]; + } + + public static IEnumerable GetSqlServerServiceProvider() + { + var services = new ServiceCollection(); + + services.AddMvc(); + services.AddActionCache(options => + { + options.UseEntryOptions(entryOptions => { }); + options.UseSqlServerCache(options => + { + options.ConnectionString = "Server=localhost;Database=ActionCache;User Id=sa;Password=Password1;Encrypt=True;TrustServerCertificate=True;"; + options.SchemaName = "dbo"; + options.TableName = "DistributedCache"; + }); + }); + + var server = new TestServer(services.BuildServiceProvider()); + + return [server.Services]; + } + + public static IEnumerable GetAzureCosmosServiceProvider() + { + var services = new ServiceCollection(); + + services.AddMvc(); + services.AddActionCache(options => + { + options.UseEntryOptions(entryOptions => { }); + options.UseAzureCosmosCache(options => + { + options.DatabaseId = "ActionCache"; + options.ConnectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + options.CosmosClientOptions = new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }) + }; + }); + }); + + var server = new TestServer(services.BuildServiceProvider()); + + return [server.Services]; + } + + public static IEnumerable GetMultipleCacheServiceProvider() + { + var services = new ServiceCollection(); + + services.AddMvc(); + services.AddActionCache(options => + { + options.UseEntryOptions(entryOptions => { }); + options.UseMemoryCache(options => options.SizeLimit = 1000); + options.UseRedisCache(options => options.Configuration = "127.0.0.1:6379"); + options.UseSqlServerCache(options => + { + options.ConnectionString = "Server=localhost;Database=ActionCache;User Id=sa;Password=Password1;Encrypt=True;TrustServerCertificate=True;"; + options.SchemaName = "dbo"; + options.TableName = "DistributedCache"; + }); + }); + + var server = new TestServer(services.BuildServiceProvider()); + + return [server.Services]; + } +} diff --git a/test/Integration/TestUtilities/Scripts/init-sql.sh b/test/Integration/TestUtilities/Scripts/init-sql.sh new file mode 100644 index 0000000..d6c6081 --- /dev/null +++ b/test/Integration/TestUtilities/Scripts/init-sql.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +/opt/mssql/bin/sqlservr & +pid=$! + +echo "Waiting for SQL Server..." +for i in $(seq 1 60); do + if /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -No -Q "SELECT 1" &>/dev/null 2>&1; then + echo "SQL Server ready" + break + fi + sleep 2 +done + +/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -No -Q " + IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'ActionCache') + CREATE DATABASE ActionCache +" + +/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -No -d ActionCache -Q " + IF NOT EXISTS ( + SELECT * FROM sys.objects + WHERE object_id = OBJECT_ID(N'[dbo].[DistributedCache]') AND type = N'U' + ) + CREATE TABLE [dbo].[DistributedCache] ( + [Id] nvarchar(449) COLLATE SQL_Latin1_General_CP1_CS_AS NOT NULL, + [Value] varbinary(MAX) NOT NULL, + [ExpiresAtTime] datetimeoffset(7) NOT NULL, + [SlidingExpirationInSeconds] bigint NULL, + [AbsoluteExpiration] datetimeoffset(7) NULL, + PRIMARY KEY CLUSTERED ([Id] ASC) + ) +" + +echo "Initialization complete" +wait $pid diff --git a/test/Integration/docker-compose.yml b/test/Integration/docker-compose.yml new file mode 100644 index 0000000..6cf65f9 --- /dev/null +++ b/test/Integration/docker-compose.yml @@ -0,0 +1,43 @@ +services: + redis: + image: redis:7 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + ports: + - "1433:1433" + environment: + SA_PASSWORD: Password1 + MSSQL_SA_PASSWORD: Password1 + ACCEPT_EULA: Y + MSSQL_PID: Developer + volumes: + - ./TestUtilities/Scripts/init-sql.sh:/init-sql.sh + entrypoint: bash /init-sql.sh + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Password1' -No -d ActionCache -Q 'SELECT TOP 1 * FROM dbo.DistributedCache'"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + + cosmos: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + ports: + - "8081:8081" + - "8080:8080" + environment: + PROTOCOL: https + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8081' 2>/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 60s diff --git a/test/Integration/integration.runsettings b/test/Integration/integration.runsettings new file mode 100644 index 0000000..4ec186d --- /dev/null +++ b/test/Integration/integration.runsettings @@ -0,0 +1,9 @@ + + + + 1 + + + 1 + + diff --git a/test/Unit/Common/ActionCacheEntryOptions/ActionCacheEntryOptionsTests.cs b/test/Unit/Common/ActionCacheEntryOptions/ActionCacheEntryOptionsTests.cs index 0556326..86d6569 100644 --- a/test/Unit/Common/ActionCacheEntryOptions/ActionCacheEntryOptionsTests.cs +++ b/test/Unit/Common/ActionCacheEntryOptions/ActionCacheEntryOptionsTests.cs @@ -154,14 +154,14 @@ public void Deconstruct_WithNoExpiration_AllZero() } [Test] - public void DefaultLockDuration_Is200Milliseconds() + public void DefaultLockDuration_Is5Seconds() { - new ActionCacheEntryOptions().LockDuration.Should().Be(TimeSpan.FromMilliseconds(200)); + new ActionCacheEntryOptions().LockDuration.Should().Be(TimeSpan.FromSeconds(5)); } [Test] - public void DefaultLockTimeout_Is200Milliseconds() + public void DefaultLockTimeout_Is10Seconds() { - new ActionCacheEntryOptions().LockTimeout.Should().Be(TimeSpan.FromMilliseconds(200)); + new ActionCacheEntryOptions().LockTimeout.Should().Be(TimeSpan.FromSeconds(10)); } } diff --git a/test/Unit/Redis/RedisExpiryServiceTests.cs b/test/Unit/Redis/RedisExpiryServiceTests.cs index eb08325..76f09bc 100644 --- a/test/Unit/Redis/RedisExpiryServiceTests.cs +++ b/test/Unit/Redis/RedisExpiryServiceTests.cs @@ -65,17 +65,19 @@ public async Task StartAsync_Always_SubscribesToKeyExpiryChannel() public async Task ExpiryCallback_WhenMessageIsEmpty_DoesNotCallSortedSetRemove() { Action? capturedHandler = null; + var handlerCaptured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _subscriberMock .Setup(subscriber => subscriber.SubscribeAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Callback, CommandFlags>( - (_, handler, _) => capturedHandler = handler) + (_, handler, _) => { capturedHandler = handler; handlerCaptured.SetResult(); }) .Returns(Task.CompletedTask); using var cts = new CancellationTokenSource(); await _sut.StartAsync(cts.Token); + await handlerCaptured.Task.WaitAsync(TimeSpan.FromSeconds(5)); capturedHandler!.Invoke(RedisChannel.Literal("__keyevent@0__:expired"), new RedisValue("")); @@ -89,17 +91,19 @@ public async Task ExpiryCallback_WhenMessageIsEmpty_DoesNotCallSortedSetRemove() public async Task ExpiryCallback_WhenMessageHasNoColon_DoesNotCallSortedSetRemove() { Action? capturedHandler = null; + var handlerCaptured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _subscriberMock .Setup(subscriber => subscriber.SubscribeAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Callback, CommandFlags>( - (_, handler, _) => capturedHandler = handler) + (_, handler, _) => { capturedHandler = handler; handlerCaptured.SetResult(); }) .Returns(Task.CompletedTask); using var cts = new CancellationTokenSource(); await _sut.StartAsync(cts.Token); + await handlerCaptured.Task.WaitAsync(TimeSpan.FromSeconds(5)); capturedHandler!.Invoke(RedisChannel.Literal("__keyevent@0__:expired"), new RedisValue("keywithnoseparator")); @@ -113,13 +117,14 @@ public async Task ExpiryCallback_WhenMessageHasNoColon_DoesNotCallSortedSetRemov public async Task ExpiryCallback_WhenMessageMatchesNamespaceKeyPattern_RemovesMemberFromSortedSet() { Action? capturedHandler = null; + var handlerCaptured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _subscriberMock .Setup(subscriber => subscriber.SubscribeAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Callback, CommandFlags>( - (_, handler, _) => capturedHandler = handler) + (_, handler, _) => { capturedHandler = handler; handlerCaptured.SetResult(); }) .Returns(Task.CompletedTask); _databaseMock @@ -129,6 +134,7 @@ public async Task ExpiryCallback_WhenMessageMatchesNamespaceKeyPattern_RemovesMe using var cts = new CancellationTokenSource(); await _sut.StartAsync(cts.Token); + await handlerCaptured.Task.WaitAsync(TimeSpan.FromSeconds(5)); capturedHandler!.Invoke(RedisChannel.Literal("__keyevent@0__:expired"), new RedisValue("mynamespace:mykey")); diff --git a/test/Unit/SqlServer/SqlServerActionCacheTests.cs b/test/Unit/SqlServer/SqlServerActionCacheTests.cs index a41cf5e..279fdeb 100644 --- a/test/Unit/SqlServer/SqlServerActionCacheTests.cs +++ b/test/Unit/SqlServer/SqlServerActionCacheTests.cs @@ -112,20 +112,7 @@ public async Task RemoveAsync_WithKey_RemovesFromCache() Times.AtLeastOnce); } - [Test] - public async Task RemoveAsync_NoKey_WhenNoKeys_CallsLocker() - { - _cacheMock.Setup(cache => cache.Get(It.IsAny())) - .Returns((byte[]?)null); - - await _sut.RemoveAsync(); - - _lockerMock.Verify( - locker => locker.WaitForLockThenAsync(It.IsAny(), It.IsAny>()), - Times.AtLeastOnce); - } - - [Test] +[Test] public async Task GetKeysAsync_WhenNoKeys_ReturnsEmpty() { _cacheMock.Setup(cache => cache.Get(It.IsAny())) diff --git a/test/Unit/Unit.csproj b/test/Unit/Unit.csproj index ec2ca47..a97270e 100644 --- a/test/Unit/Unit.csproj +++ b/test/Unit/Unit.csproj @@ -1,35 +1,35 @@ - - - - net8.0;net9.0 - enable - enable - - false - true - - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - + + + + net8.0;net10.0 + enable + enable + + false + true + + + + <_TargetVersion Condition="'$(TargetFramework)' == 'net8.0'">8.0.0 + <_TargetVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.0 + + + + + + + + + + + + + + + + + + + + +