WorkCamera/client/WorkCameraExport.Tests/ApiServicePropertyTests.cs
2026-01-05 23:58:56 +08:00

316 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}