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
}