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 { /// /// ApiService 属性测试 /// Feature: work-camera-2.0.1 /// Property 10: Token 自动刷新 /// Property 11: 请求重试机制 /// Validates: Requirements 10.3, 11.2 /// 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(); } /// /// Property 10: Token 自动刷新 /// For any API 请求,如果 Token 即将过期(剩余时间小于阈值),应在请求前自动刷新 Token。 /// 测试:当 Token 即将过期时,IsTokenExpiringSoon 应返回 true /// [Property(MaxTest = 100)] public Property TokenExpiringSoon_WhenExpireTimeWithinThreshold_ShouldReturnTrue() { return Prop.ForAll( Arb.From(), (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"); }); } /// /// Property 10 扩展: Token 未过期时不应标记为即将过期 /// [Property(MaxTest = 100)] public Property TokenNotExpiringSoon_WhenExpireTimeOutsideThreshold_ShouldReturnFalse() { return Prop.ForAll( Arb.From(), (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"); }); } /// /// Property 10 扩展: 设置 Token 后应能正确获取 /// [Property(MaxTest = 100)] public Property TokenRoundTrip_SetThenGet_ShouldReturnSameValue() { return Prop.ForAll( Arb.From(), (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"); }); } /// /// Property 11: 请求重试机制 /// For any 失败的 API 请求,应自动重试,重试次数不超过 3 次。 /// 测试:当请求失败时,应重试指定次数 /// [Property(MaxTest = 20)] public Property RetryMechanism_OnTransientFailure_ShouldRetryUpToMaxTimes() { return Prop.ForAll( Arb.From(), (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)"); }); } /// /// Property 11 扩展: 成功请求不应重试 /// [Property(MaxTest = 20)] public Property RetryMechanism_OnSuccess_ShouldNotRetry() { return Prop.ForAll( Arb.From(), (_) => { // 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"); }); } /// /// Property 11 扩展: 401 错误不应重试 /// [Property(MaxTest = 20)] public Property RetryMechanism_On401Error_ShouldNotRetry() { return Prop.ForAll( Arb.From(), (_) => { // 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 /// /// 基础 Mock HTTP 处理器 /// public class MockHttpMessageHandler : HttpMessageHandler { public int RequestCount { get; protected set; } public List Requests { get; } = new(); protected override Task 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); } } /// /// 计数重试的处理器 - 前 N 次返回 500 错误 /// public class RetryCountingHandler : HttpMessageHandler { private readonly int _failUntilCount; public int RequestCount { get; private set; } public RetryCountingHandler(int failUntilCount) { _failUntilCount = failUntilCount; } protected override Task 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); } } /// /// 成功响应处理器 /// public class SuccessHandler : HttpMessageHandler { public int RequestCount { get; private set; } protected override Task 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); } } /// /// 401 未授权响应处理器 /// public class UnauthorizedHandler : HttpMessageHandler { public int RequestCount { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestCount++; var response = new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("{\"code\":401,\"msg\":\"Unauthorized\"}") }; return Task.FromResult(response); } } #endregion }