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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+