316 lines
12 KiB
C#
316 lines
12 KiB
C#
using FsCheck;
|
||
using FsCheck.Xunit;
|
||
using System.Net;
|
||
using System.Net.Http;
|
||
using WorkCameraExport.Models;
|
||
using WorkCameraExport.Services;
|
||
using WorkCameraExport.Services.Interfaces;
|
||
|
||
namespace WorkCameraExport.Tests
|
||
{
|
||
/// <summary>
|
||
/// ApiService 属性测试
|
||
/// Feature: work-camera-2.0.1
|
||
/// Property 10: Token 自动刷新
|
||
/// Property 11: 请求重试机制
|
||
/// Validates: Requirements 10.3, 11.2
|
||
/// </summary>
|
||
public class ApiServicePropertyTests : IDisposable
|
||
{
|
||
private readonly MockHttpMessageHandler _mockHandler;
|
||
private readonly HttpClient _httpClient;
|
||
private readonly ApiService _apiService;
|
||
|
||
public ApiServicePropertyTests()
|
||
{
|
||
_mockHandler = new MockHttpMessageHandler();
|
||
_httpClient = new HttpClient(_mockHandler);
|
||
_apiService = new ApiService(_httpClient, null);
|
||
_apiService.SetBaseUrl("http://test.local");
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_httpClient.Dispose();
|
||
_apiService.Dispose();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 10: Token 自动刷新
|
||
/// For any API 请求,如果 Token 即将过期(剩余时间小于阈值),应在请求前自动刷新 Token。
|
||
/// 测试:当 Token 即将过期时,IsTokenExpiringSoon 应返回 true
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property TokenExpiringSoon_WhenExpireTimeWithinThreshold_ShouldReturnTrue()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(minutesOffset) =>
|
||
{
|
||
// Arrange: 设置 Token 过期时间在阈值内(5分钟内)
|
||
var minutesWithinThreshold = minutesOffset.Get % 5; // 0-4 分钟
|
||
var expireTime = DateTime.Now.AddMinutes(minutesWithinThreshold);
|
||
|
||
_apiService.SetToken("test-token", expireTime);
|
||
_apiService.SetRefreshToken("test-refresh-token");
|
||
|
||
// Assert: IsTokenExpiringSoon 应返回 true
|
||
return _apiService.IsTokenExpiringSoon
|
||
.Label($"Token expiring in {minutesWithinThreshold} minutes should be marked as expiring soon");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 10 扩展: Token 未过期时不应标记为即将过期
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property TokenNotExpiringSoon_WhenExpireTimeOutsideThreshold_ShouldReturnFalse()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(minutesOffset) =>
|
||
{
|
||
// Arrange: 设置 Token 过期时间在阈值外(超过5分钟)
|
||
var minutesOutsideThreshold = (minutesOffset.Get % 100) + 6; // 6-105 分钟
|
||
var expireTime = DateTime.Now.AddMinutes(minutesOutsideThreshold);
|
||
|
||
_apiService.SetToken("test-token", expireTime);
|
||
|
||
// Assert: IsTokenExpiringSoon 应返回 false
|
||
return (!_apiService.IsTokenExpiringSoon)
|
||
.Label($"Token expiring in {minutesOutsideThreshold} minutes should NOT be marked as expiring soon");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 10 扩展: 设置 Token 后应能正确获取
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property TokenRoundTrip_SetThenGet_ShouldReturnSameValue()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<NonEmptyString>(),
|
||
(token) =>
|
||
{
|
||
// 过滤控制字符
|
||
var safeToken = new string(token.Get.Where(c => !char.IsControl(c)).ToArray());
|
||
if (string.IsNullOrWhiteSpace(safeToken))
|
||
{
|
||
return true.Label("Skipped: empty after filtering");
|
||
}
|
||
|
||
// Act
|
||
_apiService.SetToken(safeToken);
|
||
|
||
// Assert
|
||
return (_apiService.GetToken() == safeToken)
|
||
.Label("Token should match after set and get");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 11: 请求重试机制
|
||
/// For any 失败的 API 请求,应自动重试,重试次数不超过 3 次。
|
||
/// 测试:当请求失败时,应重试指定次数
|
||
/// </summary>
|
||
[Property(MaxTest = 20)]
|
||
public Property RetryMechanism_OnTransientFailure_ShouldRetryUpToMaxTimes()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(failCount) =>
|
||
{
|
||
// Arrange: 设置失败次数(1-5次)
|
||
var actualFailCount = (failCount.Get % 5) + 1;
|
||
var handler = new RetryCountingHandler(actualFailCount);
|
||
using var client = new HttpClient(handler);
|
||
using var service = new ApiService(client, null);
|
||
service.SetBaseUrl("http://test.local");
|
||
service.SetToken("test-token");
|
||
|
||
// Act: 尝试请求
|
||
try
|
||
{
|
||
var task = service.GetStatisticsAsync();
|
||
task.Wait(TimeSpan.FromSeconds(30));
|
||
}
|
||
catch
|
||
{
|
||
// 忽略异常,我们只关心重试次数
|
||
}
|
||
|
||
// Assert: 重试次数应不超过 3 次(总请求次数不超过 3)
|
||
var expectedMaxAttempts = Math.Min(actualFailCount, 3);
|
||
return (handler.RequestCount <= 3)
|
||
.Label($"Request count {handler.RequestCount} should be <= 3 (max retries)");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 11 扩展: 成功请求不应重试
|
||
/// </summary>
|
||
[Property(MaxTest = 20)]
|
||
public Property RetryMechanism_OnSuccess_ShouldNotRetry()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(_) =>
|
||
{
|
||
// Arrange: 设置成功响应
|
||
var handler = new SuccessHandler();
|
||
using var client = new HttpClient(handler);
|
||
using var service = new ApiService(client, null);
|
||
service.SetBaseUrl("http://test.local");
|
||
service.SetToken("test-token");
|
||
|
||
// Act
|
||
try
|
||
{
|
||
var task = service.GetStatisticsAsync();
|
||
task.Wait(TimeSpan.FromSeconds(5));
|
||
}
|
||
catch
|
||
{
|
||
// 忽略异常
|
||
}
|
||
|
||
// Assert: 只应请求一次
|
||
return (handler.RequestCount == 1)
|
||
.Label($"Successful request should only be made once, but was made {handler.RequestCount} times");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 11 扩展: 401 错误不应重试
|
||
/// </summary>
|
||
[Property(MaxTest = 20)]
|
||
public Property RetryMechanism_On401Error_ShouldNotRetry()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(_) =>
|
||
{
|
||
// Arrange: 设置 401 响应
|
||
var handler = new UnauthorizedHandler();
|
||
using var client = new HttpClient(handler);
|
||
using var service = new ApiService(client, null);
|
||
service.SetBaseUrl("http://test.local");
|
||
service.SetToken("test-token");
|
||
|
||
// Act
|
||
try
|
||
{
|
||
var task = service.GetStatisticsAsync();
|
||
task.Wait(TimeSpan.FromSeconds(5));
|
||
}
|
||
catch
|
||
{
|
||
// 忽略异常
|
||
}
|
||
|
||
// Assert: 401 错误不应重试,只请求一次
|
||
return (handler.RequestCount == 1)
|
||
.Label($"401 error should not trigger retry, but request was made {handler.RequestCount} times");
|
||
});
|
||
}
|
||
}
|
||
|
||
#region Mock HTTP Handlers
|
||
|
||
/// <summary>
|
||
/// 基础 Mock HTTP 处理器
|
||
/// </summary>
|
||
public class MockHttpMessageHandler : HttpMessageHandler
|
||
{
|
||
public int RequestCount { get; protected set; }
|
||
public List<HttpRequestMessage> Requests { get; } = new();
|
||
|
||
protected override Task<HttpResponseMessage> SendAsync(
|
||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
{
|
||
RequestCount++;
|
||
Requests.Add(request);
|
||
|
||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||
{
|
||
Content = new StringContent("{\"code\":200,\"msg\":\"success\",\"data\":{}}")
|
||
};
|
||
return Task.FromResult(response);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计数重试的处理器 - 前 N 次返回 500 错误
|
||
/// </summary>
|
||
public class RetryCountingHandler : HttpMessageHandler
|
||
{
|
||
private readonly int _failUntilCount;
|
||
public int RequestCount { get; private set; }
|
||
|
||
public RetryCountingHandler(int failUntilCount)
|
||
{
|
||
_failUntilCount = failUntilCount;
|
||
}
|
||
|
||
protected override Task<HttpResponseMessage> SendAsync(
|
||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
{
|
||
RequestCount++;
|
||
|
||
if (RequestCount <= _failUntilCount)
|
||
{
|
||
// 返回 500 错误触发重试
|
||
throw new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError);
|
||
}
|
||
|
||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||
{
|
||
Content = new StringContent("{\"code\":200,\"msg\":\"success\",\"data\":{}}")
|
||
};
|
||
return Task.FromResult(response);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 成功响应处理器
|
||
/// </summary>
|
||
public class SuccessHandler : HttpMessageHandler
|
||
{
|
||
public int RequestCount { get; private set; }
|
||
|
||
protected override Task<HttpResponseMessage> SendAsync(
|
||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
{
|
||
RequestCount++;
|
||
|
||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||
{
|
||
Content = new StringContent("{\"code\":200,\"msg\":\"success\",\"data\":{\"monthRecordCount\":10}}")
|
||
};
|
||
return Task.FromResult(response);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 401 未授权响应处理器
|
||
/// </summary>
|
||
public class UnauthorizedHandler : HttpMessageHandler
|
||
{
|
||
public int RequestCount { get; private set; }
|
||
|
||
protected override Task<HttpResponseMessage> SendAsync(
|
||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
{
|
||
RequestCount++;
|
||
|
||
var response = new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||
{
|
||
Content = new StringContent("{\"code\":401,\"msg\":\"Unauthorized\"}")
|
||
};
|
||
return Task.FromResult(response);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|