Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/build-test-coverage.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Build, Test, Coverage & Check

on:
workflow_dispatch: {}
push:
branches: [ dev ]
pull_request:
Expand Down Expand Up @@ -35,18 +36,17 @@ jobs:
10.x.x

- name: Install SonarCloud Tools
if: ${{ secrets.SONAR_TOKEN != '' }}
continue-on-error: true
run: dotnet tool install --global dotnet-sonarscanner

- name: Prepare SonarCloud Scan
if: ${{ secrets.SONAR_TOKEN != '' }}
continue-on-error: true
run: dotnet sonarscanner begin /o:"baoduy2412" /k:"baoduy_DKNet" /d:sonar.cs.opencover.reportsPaths="**/coverage.*.xml" /d:sonar.scanner.scanAll=false /d:sonar.host.url="https://sonarcloud.io" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.scanner.skipJreProvisioning=true

- name: Restore dependencies
run: dotnet restore src/DKNet.FW.sln

- name: Build
continue-on-error: true
run: dotnet build src/DKNet.FW.sln --no-restore --configuration Release

- name: Test
Expand All @@ -73,7 +73,7 @@ jobs:
verbose: true

- name: Commit SonarCloud Results
if: ${{ secrets.SONAR_TOKEN != '' }}
continue-on-error: true
run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

- name: Verify Coverage Report
Expand Down
67 changes: 67 additions & 0 deletions src/AspNet/AspCore.Idempotency.Tests/Unit/CachedResponseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using DKNet.AspCore.Idempotency;

namespace AspCore.Idempotency.Tests.Unit;

public class CachedResponseTests
{
#region Methods

[Fact]
public void IsExpired_WhenExpiresAtIsInFuture_ReturnsFalse()
{
var response = new CachedResponse
{
StatusCode = 200,
Body = "{}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
response.IsExpired.ShouldBeFalse();
}

[Fact]
public void IsExpired_WhenExpiresAtIsInPast_ReturnsTrue()
{
var response = new CachedResponse
{
StatusCode = 200,
Body = "{}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow.AddHours(-2),
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(-1)
};
response.IsExpired.ShouldBeTrue();
}

[Fact]
public void IsExpired_WhenExpiresAtIsNull_ReturnsFalse()
{
var response = new CachedResponse
{
StatusCode = 200,
Body = "{}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = null
};
response.IsExpired.ShouldBeFalse();
}

[Fact]
public void CachedResponse_CanHaveNullBody()
{
var response = new CachedResponse
{
StatusCode = 204,
Body = null,
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
response.Body.ShouldBeNull();
response.StatusCode.ShouldBe(204);
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,39 @@ public async Task InvokeAsync_WithValidIdempotencyKey_IncludesResponseHeaders()
response.Headers.ShouldNotBeNull();
}

[Fact]
public async Task InvokeAsync_WhenKeyIsTooLong_Returns400BadRequest()
{
// Arrange
var client = fixture.CreateClient();
var json = JsonSerializer.Serialize(new { name = "test item" });
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/items") { Content = content };
request.Headers.Add("X-Idempotency-Key", new string('a', 256)); // Exceeds default 255 char limit

// Act
var response = await client.SendAsync(request);

// Assert
((int)response.StatusCode).ShouldBe((int)HttpStatusCode.BadRequest);
}

[Fact]
public async Task InvokeAsync_WhenKeyHasInvalidFormat_Returns400BadRequest()
{
// Arrange
var client = fixture.CreateClient();
var json = JsonSerializer.Serialize(new { name = "test item" });
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/items") { Content = content };
request.Headers.Add("X-Idempotency-Key", "invalid!@#$key"); // Contains invalid characters

// Act
var response = await client.SendAsync(request);

// Assert
((int)response.StatusCode).ShouldBe((int)HttpStatusCode.BadRequest);
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using DKNet.AspCore.Idempotency;
using DKNet.AspCore.Idempotency.Store;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

namespace AspCore.Idempotency.Tests.Unit;

/// <summary>
/// Tests for <see cref="IdempotencyDistributedCacheStore" /> edge cases not covered
/// by the main repository tests.
/// </summary>
public class IdempotencyStoreEdgeCaseTests
{
#region Fields

private readonly IDistributedCache _cache;
private readonly ILogger<IdempotencyEndpointFilter> _logger;

#endregion

#region Constructors

public IdempotencyStoreEdgeCaseTests()
{
_cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
_logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger<IdempotencyEndpointFilter>();
}

#endregion

#region Methods

private IdempotencyDistributedCacheStore CreateStore(IdempotencyOptions? options = null) =>
new(_cache, Options.Create(options ?? new IdempotencyOptions()), _logger);

private static IdempotentKeyInfo MakeKey(string key) =>
new() { IdempotentKey = key, Endpoint = "/api/test", Method = "POST" };

[Fact]
public async Task IsKeyProcessedAsync_WhenResponseIsAlreadyExpired_ReturnsFalseAndRemovesFromCache()
{
// Arrange: Store an already-expired response
var store = CreateStore();
var now = DateTimeOffset.UtcNow;
var response = new CachedResponse
{
StatusCode = 200,
Body = "{\"id\": 1}",
ContentType = "application/json",
CreatedAt = now.AddHours(-2),
ExpiresAt = now.AddSeconds(-1) // already expired
};

var key = Guid.NewGuid().ToString();
await store.MarkKeyAsProcessedAsync(MakeKey(key), response);

// Act
var result = await store.IsKeyProcessedAsync(MakeKey(key));

// Assert
result.processed.ShouldBeFalse();
result.response.ShouldBeNull();
}

[Fact]
public async Task IsKeyProcessedAsync_WhenResponseHasNoExpiration_NeverExpires()
{
// Arrange: Store a response with null ExpiresAt
var store = CreateStore();
var now = DateTimeOffset.UtcNow;
var response = new CachedResponse
{
StatusCode = 200,
Body = "{\"id\": 1}",
ContentType = "application/json",
CreatedAt = now,
ExpiresAt = null
};

var key = Guid.NewGuid().ToString();
await store.MarkKeyAsProcessedAsync(MakeKey(key), response);

// Act
var result = await store.IsKeyProcessedAsync(MakeKey(key));

// Assert
result.processed.ShouldBeTrue();
result.response.ShouldNotBeNull();
result.response!.ExpiresAt.ShouldBeNull();
}

[Fact]
public async Task MarkKeyAsProcessedAsync_DifferentEndpoints_StoredSeparately()
{
// Arrange: Same idempotency key but different endpoints
var store = CreateStore();
var idempotencyKey = Guid.NewGuid().ToString();

var keyInfo1 = new IdempotentKeyInfo { IdempotentKey = idempotencyKey, Endpoint = "/api/orders", Method = "POST" };
var keyInfo2 = new IdempotentKeyInfo { IdempotentKey = idempotencyKey, Endpoint = "/api/items", Method = "POST" };

var response1 = new CachedResponse
{
StatusCode = 201, Body = "{\"type\":\"order\"}", ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
var response2 = new CachedResponse
{
StatusCode = 201, Body = "{\"type\":\"item\"}", ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

// Act
await store.MarkKeyAsProcessedAsync(keyInfo1, response1);
await store.MarkKeyAsProcessedAsync(keyInfo2, response2);

var result1 = await store.IsKeyProcessedAsync(keyInfo1);
var result2 = await store.IsKeyProcessedAsync(keyInfo2);

// Assert: Both keys stored independently
result1.processed.ShouldBeTrue();
result2.processed.ShouldBeTrue();
result1.response!.Body.ShouldBe("{\"type\":\"order\"}");
result2.response!.Body.ShouldBe("{\"type\":\"item\"}");
}

[Fact]
public async Task MarkKeyAsProcessedAsync_DifferentMethods_StoredSeparately()
{
// Arrange: Same endpoint and key but different HTTP methods
var store = CreateStore();
var idempotencyKey = Guid.NewGuid().ToString();

var postKey = new IdempotentKeyInfo { IdempotentKey = idempotencyKey, Endpoint = "/api/orders", Method = "POST" };
var putKey = new IdempotentKeyInfo { IdempotentKey = idempotencyKey, Endpoint = "/api/orders", Method = "PUT" };

var response = new CachedResponse
{
StatusCode = 200, Body = "{}", ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

// Act
await store.MarkKeyAsProcessedAsync(postKey, response);

var postResult = await store.IsKeyProcessedAsync(postKey);
var putResult = await store.IsKeyProcessedAsync(putKey);

// Assert: POST key found, PUT key not found
postResult.processed.ShouldBeTrue();
putResult.processed.ShouldBeFalse();
}

#endregion
}
Loading
Loading