fix
This commit is contained in:
152
WebApi.Test/Application/AuthApplicationTest.cs
Normal file
152
WebApi.Test/Application/AuthApplicationTest.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Service.AppSettingModel;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// AuthApplication 单元测试
|
||||
/// </summary>
|
||||
public class AuthApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly AuthSettings _authSettings;
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<AuthApplication> _logger;
|
||||
private readonly AuthApplication _application;
|
||||
|
||||
public AuthApplicationTest()
|
||||
{
|
||||
// 配置测试用的设置
|
||||
_authSettings = new AuthSettings { Password = "test-password-123" };
|
||||
_jwtSettings = new JwtSettings
|
||||
{
|
||||
SecretKey = "test-secret-key-minimum-32-characters-long-for-hmacsha256",
|
||||
Issuer = "TestIssuer",
|
||||
Audience = "TestAudience",
|
||||
ExpirationHours = 24
|
||||
};
|
||||
|
||||
var authOptions = Options.Create(_authSettings);
|
||||
var jwtOptions = Options.Create(_jwtSettings);
|
||||
_logger = CreateMockLogger<AuthApplication>();
|
||||
|
||||
_application = new AuthApplication(authOptions, jwtOptions, _logger);
|
||||
}
|
||||
|
||||
#region Login Tests
|
||||
|
||||
[Fact]
|
||||
public void Login_有效密码_应返回Token和过期时间()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "test-password-123" };
|
||||
|
||||
// Act
|
||||
var response = _application.Login(request);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response.Token.Should().NotBeEmpty();
|
||||
response.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
|
||||
response.ExpiresAt.Should().BeOnOrBefore(DateTime.UtcNow.AddHours(25)); // 允许1小时误差
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_空密码_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "" };
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ValidationException>(() => _application.Login(request));
|
||||
exception.Message.Should().Contain("密码不能为空");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_错误密码_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "wrong-password" };
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ValidationException>(() => _application.Login(request));
|
||||
exception.Message.Should().Contain("密码错误");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_多次调用_应生成不同Token()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "test-password-123" };
|
||||
|
||||
// Act
|
||||
var response1 = _application.Login(request);
|
||||
System.Threading.Thread.Sleep(10); // 确保时间戳不同
|
||||
var response2 = _application.Login(request);
|
||||
|
||||
// Assert
|
||||
response1.Token.Should().NotBe(response2.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_生成的Token_应为有效JWT格式()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "test-password-123" };
|
||||
|
||||
// Act
|
||||
var response = _application.Login(request);
|
||||
|
||||
// Assert
|
||||
response.Token.Should().NotBeEmpty();
|
||||
var parts = response.Token.Split('.');
|
||||
parts.Should().HaveCount(3); // JWT格式: header.payload.signature
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_成功登录_应记录日志()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "test-password-123" };
|
||||
|
||||
// Act
|
||||
_application.Login(request);
|
||||
|
||||
// Assert
|
||||
// 验证LogInformation被调用过
|
||||
_logger.Received().Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString()!.Contains("登录成功")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>()!
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_密码错误_应记录警告日志()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LoginRequest { Password = "wrong-password" };
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
_application.Login(request);
|
||||
}
|
||||
catch (ValidationException)
|
||||
{
|
||||
// 预期异常
|
||||
}
|
||||
|
||||
// Assert
|
||||
// 验证LogWarning被调用过
|
||||
_logger.Received().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString()!.Contains("密码错误")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>()!
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
23
WebApi.Test/Application/BaseApplicationTest.cs
Normal file
23
WebApi.Test/Application/BaseApplicationTest.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Application层测试基类
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 提供通用的测试基础设施,如Mock对象、测试数据构造等
|
||||
/// </remarks>
|
||||
public class BaseApplicationTest : BaseTest
|
||||
{
|
||||
protected BaseApplicationTest()
|
||||
{
|
||||
// 继承BaseTest的ID生成器初始化
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建Mock Logger
|
||||
/// </summary>
|
||||
protected ILogger<T> CreateMockLogger<T>()
|
||||
{
|
||||
return Substitute.For<ILogger<T>>();
|
||||
}
|
||||
}
|
||||
321
WebApi.Test/Application/BudgetApplicationTest.cs
Normal file
321
WebApi.Test/Application/BudgetApplicationTest.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// BudgetApplication 单元测试
|
||||
/// </summary>
|
||||
public class BudgetApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly IBudgetService _budgetService;
|
||||
private readonly IBudgetRepository _budgetRepository;
|
||||
private readonly ILogger<BudgetApplication> _logger;
|
||||
private readonly BudgetApplication _application;
|
||||
|
||||
public BudgetApplicationTest()
|
||||
{
|
||||
_budgetService = Substitute.For<IBudgetService>();
|
||||
_budgetRepository = Substitute.For<IBudgetRepository>();
|
||||
_logger = CreateMockLogger<BudgetApplication>();
|
||||
_application = new BudgetApplication(_budgetService, _budgetRepository, _logger);
|
||||
}
|
||||
|
||||
#region GetListAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_应返回排序后的预算列表()
|
||||
{
|
||||
// Arrange
|
||||
var referenceDate = new DateTime(2026, 2, 10);
|
||||
var testData = new List<BudgetResult>
|
||||
{
|
||||
new() { Id = 1, Name = "餐饮", Category = BudgetCategory.Expense, IsMandatoryExpense = false, Limit = 1000, Current = 500 },
|
||||
new() { Id = 2, Name = "房租", Category = BudgetCategory.Expense, IsMandatoryExpense = true, Limit = 3000, Current = 3000 }
|
||||
};
|
||||
_budgetService.GetListAsync(referenceDate).Returns(testData);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Should().HaveCount(2);
|
||||
result[0].Name.Should().Be("房租"); // 刚性支出优先
|
||||
result[1].Name.Should().Be("餐饮");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_有效请求_应返回新预算ID()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "测试预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮", "交通" },
|
||||
NoLimit = false,
|
||||
IsMandatoryExpense = false
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord>());
|
||||
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var id = await _application.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
id.Should().BeGreaterThan(0);
|
||||
await _budgetRepository.Received(1).AddAsync(Arg.Is<BudgetRecord>(
|
||||
b => b.Name == "测试预算" && b.Limit == 1000
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_空名称_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮" }
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_金额为0且非不记额_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "测试预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 0,
|
||||
NoLimit = false,
|
||||
SelectedCategories = new[] { "餐饮" }
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_未选择分类_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "测试预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = Array.Empty<string>()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_不记额预算非年度_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "错误预算",
|
||||
Type = BudgetPeriodType.Month, // 月度
|
||||
Category = BudgetCategory.Expense,
|
||||
NoLimit = true, // 不记额
|
||||
SelectedCategories = new[] { "其他" }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord>());
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(
|
||||
() => _application.CreateAsync(request)
|
||||
);
|
||||
exception.Message.Should().Contain("不记额预算只能设置为年度预算");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_分类冲突_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var existingBudget = new BudgetRecord
|
||||
{
|
||||
Id = 1,
|
||||
Name = "现有预算",
|
||||
Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "餐饮,交通"
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord> { existingBudget });
|
||||
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "新预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮" }, // 冲突
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(
|
||||
() => _application.CreateAsync(request)
|
||||
);
|
||||
exception.Message.Should().Contain("存在分类冲突");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_不记额预算_应将Limit设为0()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "不记额预算",
|
||||
Type = BudgetPeriodType.Year,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 999, // 即使传入金额
|
||||
NoLimit = true,
|
||||
SelectedCategories = new[] { "其他" }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord>());
|
||||
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _budgetRepository.Received(1).AddAsync(Arg.Is<BudgetRecord>(
|
||||
b => b.NoLimit && b.Limit == 0
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Repository添加失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateBudgetRequest
|
||||
{
|
||||
Name = "测试预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮" }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord>());
|
||||
_budgetRepository.AddAsync(Arg.Any<BudgetRecord>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_有效请求_应成功更新()
|
||||
{
|
||||
// Arrange
|
||||
var existingBudget = new BudgetRecord
|
||||
{
|
||||
Id = 1,
|
||||
Name = "旧名称",
|
||||
Limit = 500
|
||||
};
|
||||
|
||||
_budgetRepository.GetByIdAsync(1).Returns(existingBudget);
|
||||
_budgetRepository.GetAllAsync().Returns(new List<BudgetRecord> { existingBudget });
|
||||
_budgetRepository.UpdateAsync(Arg.Any<BudgetRecord>()).Returns(true);
|
||||
|
||||
var request = new UpdateBudgetRequest
|
||||
{
|
||||
Id = 1,
|
||||
Name = "新名称",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮" },
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
// Act
|
||||
await _application.UpdateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _budgetRepository.Received(1).UpdateAsync(Arg.Is<BudgetRecord>(
|
||||
b => b.Id == 1 && b.Name == "新名称" && b.Limit == 1000
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_预算不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_budgetRepository.GetByIdAsync(999).Returns((BudgetRecord?)null);
|
||||
|
||||
var request = new UpdateBudgetRequest
|
||||
{
|
||||
Id = 999,
|
||||
Name = "不存在的预算",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Category = BudgetCategory.Expense,
|
||||
Limit = 1000,
|
||||
SelectedCategories = new[] { "餐饮" }
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => _application.UpdateAsync(request)
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_成功删除_不应抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
_budgetRepository.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.DeleteByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
await _budgetRepository.Received(1).DeleteAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_删除失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_budgetRepository.DeleteAsync(999).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(
|
||||
() => _application.DeleteByIdAsync(999)
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
130
WebApi.Test/Application/ConfigApplicationTest.cs
Normal file
130
WebApi.Test/Application/ConfigApplicationTest.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigApplication 单元测试
|
||||
/// </summary>
|
||||
public class ConfigApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly ILogger<ConfigApplication> _logger;
|
||||
private readonly ConfigApplication _application;
|
||||
|
||||
public ConfigApplicationTest()
|
||||
{
|
||||
_configService = Substitute.For<IConfigService>();
|
||||
_logger = CreateMockLogger<ConfigApplication>();
|
||||
_application = new ConfigApplication(_configService, _logger);
|
||||
}
|
||||
|
||||
#region GetConfigAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigAsync_有效Key_应返回配置值()
|
||||
{
|
||||
// Arrange
|
||||
const string key = "test-key";
|
||||
const string expectedValue = "test-value";
|
||||
_configService.GetConfigByKeyAsync<string>(key).Returns(expectedValue);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetConfigAsync(key);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedValue);
|
||||
await _configService.Received(1).GetConfigByKeyAsync<string>(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigAsync_配置不存在_应返回空字符串()
|
||||
{
|
||||
// Arrange
|
||||
const string key = "non-existent-key";
|
||||
_configService.GetConfigByKeyAsync<string>(key).Returns((string?)null);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetConfigAsync(key);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigAsync_空Key_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.GetConfigAsync(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigAsync_空白Key_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.GetConfigAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SetConfigAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfigAsync_有效参数_应成功设置()
|
||||
{
|
||||
// Arrange
|
||||
const string key = "test-key";
|
||||
const string value = "test-value";
|
||||
_configService.SetConfigByKeyAsync(key, value).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.SetConfigAsync(key, value);
|
||||
|
||||
// Assert
|
||||
await _configService.Received(1).SetConfigByKeyAsync(key, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfigAsync_空Key_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.SetConfigAsync(string.Empty, "value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfigAsync_设置失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
const string key = "test-key";
|
||||
const string value = "test-value";
|
||||
_configService.SetConfigByKeyAsync(key, value).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BusinessException>(() =>
|
||||
_application.SetConfigAsync(key, value));
|
||||
exception.Message.Should().Contain(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfigAsync_成功设置_应记录日志()
|
||||
{
|
||||
// Arrange
|
||||
const string key = "test-key";
|
||||
const string value = "test-value";
|
||||
_configService.SetConfigByKeyAsync(key, value).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.SetConfigAsync(key, value);
|
||||
|
||||
// Assert
|
||||
_logger.Received().Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString()!.Contains(key)),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>()!
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
189
WebApi.Test/Application/EmailMessageApplicationTest.cs
Normal file
189
WebApi.Test/Application/EmailMessageApplicationTest.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using Application.Dto.Email;
|
||||
using Service.EmailServices;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EmailMessageApplication 单元测试
|
||||
/// </summary>
|
||||
public class EmailMessageApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly IEmailMessageRepository _emailRepository;
|
||||
private readonly ITransactionRecordRepository _transactionRepository;
|
||||
private readonly IEmailHandleService _emailHandleService;
|
||||
private readonly IEmailSyncService _emailSyncService;
|
||||
private readonly ILogger<EmailMessageApplication> _logger;
|
||||
private readonly EmailMessageApplication _application;
|
||||
|
||||
public EmailMessageApplicationTest()
|
||||
{
|
||||
_emailRepository = Substitute.For<IEmailMessageRepository>();
|
||||
_transactionRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
_emailHandleService = Substitute.For<IEmailHandleService>();
|
||||
_emailSyncService = Substitute.For<IEmailSyncService>();
|
||||
_logger = CreateMockLogger<EmailMessageApplication>();
|
||||
_application = new EmailMessageApplication(
|
||||
_emailRepository,
|
||||
_transactionRepository,
|
||||
_emailHandleService,
|
||||
_emailSyncService,
|
||||
_logger);
|
||||
}
|
||||
|
||||
#region GetListAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_正常查询_应返回邮件列表()
|
||||
{
|
||||
// Arrange
|
||||
var emailList = new List<EmailMessage>
|
||||
{
|
||||
new() { Id = 1, Subject = "测试邮件1", From = "test@example.com", ReceivedDate = DateTime.Now },
|
||||
new() { Id = 2, Subject = "测试邮件2", From = "test2@example.com", ReceivedDate = DateTime.Now }
|
||||
};
|
||||
|
||||
_emailRepository.GetPagedListAsync(Arg.Any<DateTime?>(), Arg.Any<long?>())
|
||||
.Returns((emailList, DateTime.Now, 2L));
|
||||
_emailRepository.GetTotalCountAsync().Returns(2L);
|
||||
_transactionRepository.GetCountByEmailIdAsync(Arg.Any<long>()).Returns(5);
|
||||
|
||||
var request = new EmailQueryRequest();
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Data.Should().HaveCount(2);
|
||||
result.Total.Should().Be(2);
|
||||
result.Data[0].TransactionCount.Should().Be(5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_存在的邮件_应返回邮件详情()
|
||||
{
|
||||
// Arrange
|
||||
var email = new EmailMessage
|
||||
{
|
||||
Id = 1,
|
||||
Subject = "测试邮件",
|
||||
From = "test@example.com",
|
||||
Body = "邮件内容",
|
||||
To = "接收人 <receiver@example.com>",
|
||||
ReceivedDate = DateTime.Now
|
||||
};
|
||||
|
||||
_emailRepository.GetByIdAsync(1).Returns(email);
|
||||
_transactionRepository.GetCountByEmailIdAsync(1).Returns(3);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1);
|
||||
result.Subject.Should().Be("测试邮件");
|
||||
result.TransactionCount.Should().Be(3);
|
||||
result.ToName.Should().Be("接收人");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_邮件不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_emailRepository.GetByIdAsync(999).Returns((EmailMessage?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.GetByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_成功删除_应不抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
_emailRepository.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.DeleteByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
await _emailRepository.Received(1).DeleteAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_删除失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_emailRepository.DeleteAsync(999).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.DeleteByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RefreshTransactionRecordsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTransactionRecordsAsync_邮件存在且刷新成功_应不抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
var email = new EmailMessage { Id = 1, Subject = "测试邮件" };
|
||||
_emailRepository.GetByIdAsync(1).Returns(email);
|
||||
_emailHandleService.RefreshTransactionRecordsAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.RefreshTransactionRecordsAsync(1);
|
||||
|
||||
// Assert
|
||||
await _emailHandleService.Received(1).RefreshTransactionRecordsAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTransactionRecordsAsync_邮件不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_emailRepository.GetByIdAsync(999).Returns((EmailMessage?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
_application.RefreshTransactionRecordsAsync(999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTransactionRecordsAsync_刷新失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var email = new EmailMessage { Id = 1, Subject = "测试邮件" };
|
||||
_emailRepository.GetByIdAsync(1).Returns(email);
|
||||
_emailHandleService.RefreshTransactionRecordsAsync(1).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() =>
|
||||
_application.RefreshTransactionRecordsAsync(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SyncEmailsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SyncEmailsAsync_应调用邮件同步服务()
|
||||
{
|
||||
// Act
|
||||
await _application.SyncEmailsAsync();
|
||||
|
||||
// Assert
|
||||
await _emailSyncService.Received(1).SyncEmailsAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
170
WebApi.Test/Application/ImportApplicationTest.cs
Normal file
170
WebApi.Test/Application/ImportApplicationTest.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// ImportApplication 单元测试
|
||||
/// </summary>
|
||||
public class ImportApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly IImportService _importService;
|
||||
private readonly ILogger<ImportApplication> _logger;
|
||||
private readonly ImportApplication _application;
|
||||
|
||||
public ImportApplicationTest()
|
||||
{
|
||||
_importService = Substitute.For<IImportService>();
|
||||
_logger = CreateMockLogger<ImportApplication>();
|
||||
_application = new ImportApplication(_importService, _logger);
|
||||
}
|
||||
|
||||
#region ImportAlipayAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAlipayAsync_有效文件_应返回成功消息()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".csv",
|
||||
FileName = "test.csv",
|
||||
FileSize = 100
|
||||
};
|
||||
_importService.ImportAlipayAsync(Arg.Any<MemoryStream>(), ".csv")
|
||||
.Returns((true, "成功导入1条记录"));
|
||||
|
||||
// Act
|
||||
var response = await _application.ImportAlipayAsync(request);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response.Message.Should().Contain("成功");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAlipayAsync_空文件_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream();
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".csv",
|
||||
FileName = "test.csv",
|
||||
FileSize = 0
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ImportAlipayAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAlipayAsync_不支持的文件格式_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".txt",
|
||||
FileName = "test.txt",
|
||||
FileSize = 100
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ImportAlipayAsync(request));
|
||||
exception.Message.Should().Contain("CSV 或 Excel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAlipayAsync_文件过大_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".csv",
|
||||
FileName = "test.csv",
|
||||
FileSize = 11 * 1024 * 1024 // 11MB
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ImportAlipayAsync(request));
|
||||
exception.Message.Should().Contain("10MB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAlipayAsync_导入失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".csv",
|
||||
FileName = "test.csv",
|
||||
FileSize = 100
|
||||
};
|
||||
_importService.ImportAlipayAsync(Arg.Any<MemoryStream>(), ".csv")
|
||||
.Returns((false, "解析失败"));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BusinessException>(() =>
|
||||
_application.ImportAlipayAsync(request));
|
||||
exception.Message.Should().Contain("解析失败");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ImportWeChatAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ImportWeChatAsync_有效文件_应返回成功消息()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".xlsx",
|
||||
FileName = "test.xlsx",
|
||||
FileSize = 100
|
||||
};
|
||||
_importService.ImportWeChatAsync(Arg.Any<MemoryStream>(), ".xlsx")
|
||||
.Returns((true, "成功导入2条记录"));
|
||||
|
||||
// Act
|
||||
var response = await _application.ImportWeChatAsync(request);
|
||||
|
||||
// Assert
|
||||
response.Should().NotBeNull();
|
||||
response.Message.Should().Contain("成功");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportWeChatAsync_导入失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
var request = new ImportRequest
|
||||
{
|
||||
FileStream = stream,
|
||||
FileExtension = ".xlsx",
|
||||
FileName = "test.xlsx",
|
||||
FileSize = 100
|
||||
};
|
||||
_importService.ImportWeChatAsync(Arg.Any<MemoryStream>(), ".xlsx")
|
||||
.Returns((false, "数据格式错误"));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BusinessException>(() =>
|
||||
_application.ImportWeChatAsync(request));
|
||||
exception.Message.Should().Contain("数据格式错误");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
208
WebApi.Test/Application/JobApplicationTest.cs
Normal file
208
WebApi.Test/Application/JobApplicationTest.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using Quartz;
|
||||
using Quartz.Impl.Matchers;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// JobApplication 单元测试
|
||||
/// </summary>
|
||||
public class JobApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly IScheduler _scheduler;
|
||||
private readonly ILogger<JobApplication> _logger;
|
||||
private readonly JobApplication _application;
|
||||
|
||||
public JobApplicationTest()
|
||||
{
|
||||
_schedulerFactory = Substitute.For<ISchedulerFactory>();
|
||||
_scheduler = Substitute.For<IScheduler>();
|
||||
_logger = CreateMockLogger<JobApplication>();
|
||||
|
||||
_schedulerFactory.GetScheduler().Returns(_scheduler);
|
||||
_application = new JobApplication(_schedulerFactory, _logger);
|
||||
}
|
||||
|
||||
#region GetJobsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetJobsAsync_有任务_应返回任务列表()
|
||||
{
|
||||
// Arrange
|
||||
var jobKey1 = new JobKey("Job1");
|
||||
var jobKey2 = new JobKey("Job2");
|
||||
var jobKeys = new List<JobKey> { jobKey1, jobKey2 };
|
||||
|
||||
var jobDetail1 = Substitute.For<IJobDetail>();
|
||||
jobDetail1.Description.Returns("测试任务1");
|
||||
var jobDetail2 = Substitute.For<IJobDetail>();
|
||||
jobDetail2.Description.Returns("测试任务2");
|
||||
|
||||
var trigger1 = Substitute.For<ITrigger>();
|
||||
trigger1.Key.Returns(new TriggerKey("Trigger1"));
|
||||
trigger1.Description.Returns("每天运行");
|
||||
trigger1.GetNextFireTimeUtc().Returns(DateTimeOffset.Now.AddHours(1));
|
||||
|
||||
var trigger2 = Substitute.For<ITrigger>();
|
||||
trigger2.Key.Returns(new TriggerKey("Trigger2"));
|
||||
trigger2.Description.Returns("每小时运行");
|
||||
trigger2.GetNextFireTimeUtc().Returns(DateTimeOffset.Now.AddMinutes(30));
|
||||
|
||||
_scheduler.GetJobKeys(Arg.Any<GroupMatcher<JobKey>>()).Returns(jobKeys);
|
||||
_scheduler.GetJobDetail(jobKey1).Returns(jobDetail1);
|
||||
_scheduler.GetJobDetail(jobKey2).Returns(jobDetail2);
|
||||
_scheduler.GetTriggersOfJob(jobKey1).Returns(new List<ITrigger> { trigger1 });
|
||||
_scheduler.GetTriggersOfJob(jobKey2).Returns(new List<ITrigger> { trigger2 });
|
||||
_scheduler.GetTriggerState(trigger1.Key).Returns(TriggerState.Normal);
|
||||
_scheduler.GetTriggerState(trigger2.Key).Returns(TriggerState.Normal);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetJobsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result[0].Name.Should().Be("Job1");
|
||||
result[0].JobDescription.Should().Be("测试任务1");
|
||||
result[0].Status.Should().Be("Normal");
|
||||
result[1].Name.Should().Be("Job2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJobsAsync_无任务_应返回空列表()
|
||||
{
|
||||
// Arrange
|
||||
_scheduler.GetJobKeys(Arg.Any<GroupMatcher<JobKey>>()).Returns(new List<JobKey>());
|
||||
|
||||
// Act
|
||||
var result = await _application.GetJobsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJobsAsync_任务无触发器_应显示Unknown状态()
|
||||
{
|
||||
// Arrange
|
||||
var jobKey = new JobKey("TestJob");
|
||||
var jobDetail = Substitute.For<IJobDetail>();
|
||||
jobDetail.Description.Returns("测试任务");
|
||||
|
||||
_scheduler.GetJobKeys(Arg.Any<GroupMatcher<JobKey>>()).Returns(new List<JobKey> { jobKey });
|
||||
_scheduler.GetJobDetail(jobKey).Returns(jobDetail);
|
||||
_scheduler.GetTriggersOfJob(jobKey).Returns(new List<ITrigger>());
|
||||
|
||||
// Act
|
||||
var result = await _application.GetJobsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Status.Should().Be("Unknown");
|
||||
result[0].NextRunTime.Should().Be("无");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_有效任务名_应触发任务执行()
|
||||
{
|
||||
// Arrange
|
||||
var jobName = "TestJob";
|
||||
|
||||
// Act
|
||||
var result = await _application.ExecuteAsync(jobName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _scheduler.Received(1).TriggerJob(Arg.Is<JobKey>(k => k.Name == jobName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_空任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.ExecuteAsync(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_空白任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.ExecuteAsync(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Null任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.ExecuteAsync(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PauseAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAsync_有效任务名_应暂停任务()
|
||||
{
|
||||
// Arrange
|
||||
var jobName = "TestJob";
|
||||
|
||||
// Act
|
||||
var result = await _application.PauseAsync(jobName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _scheduler.Received(1).PauseJob(Arg.Is<JobKey>(k => k.Name == jobName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAsync_空任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.PauseAsync(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAsync_空白任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.PauseAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResumeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_有效任务名_应恢复任务()
|
||||
{
|
||||
// Arrange
|
||||
var jobName = "TestJob";
|
||||
|
||||
// Act
|
||||
var result = await _application.ResumeAsync(jobName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _scheduler.Received(1).ResumeJob(Arg.Is<JobKey>(k => k.Name == jobName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_空任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.ResumeAsync(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_空白任务名_应抛出ValidationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.ResumeAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
96
WebApi.Test/Application/MessageRecordApplicationTest.cs
Normal file
96
WebApi.Test/Application/MessageRecordApplicationTest.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Service.Message;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// MessageRecordApplication 单元测试
|
||||
/// </summary>
|
||||
public class MessageRecordApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly IMessageService _messageService;
|
||||
private readonly ILogger<MessageRecordApplication> _logger;
|
||||
private readonly MessageRecordApplication _application;
|
||||
|
||||
public MessageRecordApplicationTest()
|
||||
{
|
||||
_messageService = Substitute.For<IMessageService>();
|
||||
_logger = CreateMockLogger<MessageRecordApplication>();
|
||||
_application = new MessageRecordApplication(_messageService, _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_正常查询_应返回消息列表()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<MessageRecord>
|
||||
{
|
||||
new() { Id = 1, Title = "消息1", Content = "内容1", IsRead = false },
|
||||
new() { Id = 2, Title = "消息2", Content = "内容2", IsRead = true }
|
||||
};
|
||||
|
||||
_messageService.GetPagedListAsync(1, 20).Returns((messages, 2L));
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(1, 20);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Data.Should().HaveCount(2);
|
||||
result.Total.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnreadCountAsync_应返回未读数量()
|
||||
{
|
||||
// Arrange
|
||||
_messageService.GetUnreadCountAsync().Returns(5L);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetUnreadCountAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkAsReadAsync_应调用服务标记已读()
|
||||
{
|
||||
// Arrange
|
||||
_messageService.MarkAsReadAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.MarkAsReadAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _messageService.Received(1).MarkAsReadAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkAllAsReadAsync_应调用服务全部标记已读()
|
||||
{
|
||||
// Arrange
|
||||
_messageService.MarkAllAsReadAsync().Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.MarkAllAsReadAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _messageService.Received(1).MarkAllAsReadAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_应调用服务删除消息()
|
||||
{
|
||||
// Arrange
|
||||
_messageService.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.DeleteAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
await _messageService.Received(1).DeleteAsync(1);
|
||||
}
|
||||
}
|
||||
153
WebApi.Test/Application/NotificationApplicationTest.cs
Normal file
153
WebApi.Test/Application/NotificationApplicationTest.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using Service.Message;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// NotificationApplication 单元测试
|
||||
/// </summary>
|
||||
public class NotificationApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ILogger<NotificationApplication> _logger;
|
||||
private readonly NotificationApplication _application;
|
||||
|
||||
public NotificationApplicationTest()
|
||||
{
|
||||
_notificationService = Substitute.For<INotificationService>();
|
||||
_logger = CreateMockLogger<NotificationApplication>();
|
||||
_application = new NotificationApplication(_notificationService, _logger);
|
||||
}
|
||||
|
||||
#region GetVapidPublicKeyAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetVapidPublicKeyAsync_应返回公钥()
|
||||
{
|
||||
// Arrange
|
||||
var expectedKey = "BM5wX9Y8Z_test_vapid_public_key";
|
||||
_notificationService.GetVapidPublicKeyAsync().Returns(expectedKey);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetVapidPublicKeyAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedKey);
|
||||
await _notificationService.Received(1).GetVapidPublicKeyAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVapidPublicKeyAsync_返回空字符串_应正常处理()
|
||||
{
|
||||
// Arrange
|
||||
_notificationService.GetVapidPublicKeyAsync().Returns(string.Empty);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetVapidPublicKeyAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SubscribeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_有效订阅信息_应成功订阅()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new PushSubscription
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test_p256dh_key",
|
||||
Auth = "test_auth_key"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _application.SubscribeAsync(subscription);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(1).SubscribeAsync(Arg.Is<PushSubscription>(
|
||||
s => s.Endpoint == subscription.Endpoint &&
|
||||
s.P256DH == subscription.P256DH &&
|
||||
s.Auth == subscription.Auth
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_多次订阅同一端点_应正常处理()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new PushSubscription
|
||||
{
|
||||
Endpoint = "https://fcm.googleapis.com/fcm/send/test",
|
||||
P256DH = "test_key",
|
||||
Auth = "test_auth"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _application.SubscribeAsync(subscription);
|
||||
await _application.SubscribeAsync(subscription);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(2).SubscribeAsync(Arg.Any<PushSubscription>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SendNotificationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_有效消息_应成功发送()
|
||||
{
|
||||
// Arrange
|
||||
var message = "测试通知消息";
|
||||
|
||||
// Act
|
||||
await _application.SendNotificationAsync(message);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(1).SendNotificationAsync(message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_空消息_应调用服务()
|
||||
{
|
||||
// Arrange
|
||||
var message = string.Empty;
|
||||
|
||||
// Act
|
||||
await _application.SendNotificationAsync(message);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(1).SendNotificationAsync(message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_长消息_应成功发送()
|
||||
{
|
||||
// Arrange
|
||||
var longMessage = new string('测', 500);
|
||||
|
||||
// Act
|
||||
await _application.SendNotificationAsync(longMessage);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(1).SendNotificationAsync(longMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotificationAsync_特殊字符消息_应成功发送()
|
||||
{
|
||||
// Arrange
|
||||
var specialMessage = "测试<html> 特殊\"字符\"消息";
|
||||
|
||||
// Act
|
||||
await _application.SendNotificationAsync(specialMessage);
|
||||
|
||||
// Assert
|
||||
await _notificationService.Received(1).SendNotificationAsync(specialMessage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
772
WebApi.Test/Application/TransactionApplicationTest.cs
Normal file
772
WebApi.Test/Application/TransactionApplicationTest.cs
Normal file
@@ -0,0 +1,772 @@
|
||||
using Application.Dto.Transaction;
|
||||
using Service.AI;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// TransactionApplication 单元测试
|
||||
/// </summary>
|
||||
public class TransactionApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly ITransactionRecordRepository _transactionRepository;
|
||||
private readonly ISmartHandleService _smartHandleService;
|
||||
private readonly ILogger<TransactionApplication> _logger;
|
||||
private readonly TransactionApplication _application;
|
||||
|
||||
public TransactionApplicationTest()
|
||||
{
|
||||
_transactionRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
_smartHandleService = Substitute.For<ISmartHandleService>();
|
||||
_logger = CreateMockLogger<TransactionApplication>();
|
||||
_application = new TransactionApplication(_transactionRepository, _smartHandleService, _logger);
|
||||
}
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_存在的记录_应返回交易详情()
|
||||
{
|
||||
// Arrange
|
||||
var record = new TransactionRecord
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "测试交易",
|
||||
Amount = 100,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_transactionRepository.GetByIdAsync(1).Returns(record);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1);
|
||||
result.Reason.Should().Be("测试交易");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_不存在的记录_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.GetByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_有效请求_应成功创建()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateTransactionRequest
|
||||
{
|
||||
OccurredAt = "2026-02-10",
|
||||
Reason = "测试支出",
|
||||
Amount = 100,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
};
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(
|
||||
t => t.Reason == "测试支出" && t.Amount == 100
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_无效日期格式_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateTransactionRequest
|
||||
{
|
||||
OccurredAt = "invalid-date",
|
||||
Reason = "测试",
|
||||
Amount = 100,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Repository添加失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateTransactionRequest
|
||||
{
|
||||
OccurredAt = "2026-02-10",
|
||||
Reason = "测试",
|
||||
Amount = 100,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_transactionRepository.AddAsync(Arg.Any<TransactionRecord>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_有效请求_应成功更新()
|
||||
{
|
||||
// Arrange
|
||||
var existingRecord = new TransactionRecord
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "旧原因",
|
||||
Amount = 50
|
||||
};
|
||||
_transactionRepository.GetByIdAsync(1).Returns(existingRecord);
|
||||
_transactionRepository.UpdateAsync(Arg.Any<TransactionRecord>()).Returns(true);
|
||||
|
||||
var request = new UpdateTransactionRequest
|
||||
{
|
||||
Id = 1,
|
||||
Reason = "新原因",
|
||||
Amount = 100,
|
||||
Balance = 0,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _application.UpdateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).UpdateAsync(Arg.Is<TransactionRecord>(
|
||||
t => t.Id == 1 && t.Reason == "新原因" && t.Amount == 100
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_记录不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null);
|
||||
|
||||
var request = new UpdateTransactionRequest
|
||||
{
|
||||
Id = 999,
|
||||
Amount = 100,
|
||||
Balance = 0,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.UpdateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_成功删除_不应抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.DeleteByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).DeleteAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_删除失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.DeleteAsync(999).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.DeleteByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetListAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_基本查询_应返回分页结果()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TransactionQueryRequest
|
||||
{
|
||||
PageIndex = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
var transactions = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, Reason = "测试1", Amount = 100, Type = TransactionType.Expense },
|
||||
new() { Id = 2, Reason = "测试2", Amount = 200, Type = TransactionType.Income }
|
||||
};
|
||||
_transactionRepository.QueryAsync(
|
||||
pageIndex: 1, pageSize: 10).Returns(transactions);
|
||||
_transactionRepository.CountAsync().Returns(2);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Data.Should().HaveCount(2);
|
||||
result.Total.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_按分类筛选_应返回过滤结果()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TransactionQueryRequest
|
||||
{
|
||||
Classify = "餐饮,交通",
|
||||
PageIndex = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
var transactions = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, Reason = "午餐", Amount = 50, Type = TransactionType.Expense, Classify = "餐饮" }
|
||||
};
|
||||
_transactionRepository.QueryAsync(
|
||||
classifies: Arg.Is<string[]>(c => c != null && c.Contains("餐饮")),
|
||||
pageIndex: 1,
|
||||
pageSize: 10).Returns(transactions);
|
||||
_transactionRepository.CountAsync(
|
||||
classifies: Arg.Is<string[]>(c => c != null && c.Contains("餐饮"))).Returns(1);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Data.Should().HaveCount(1);
|
||||
result.Data[0].Classify.Should().Be("餐饮");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_按类型筛选_应返回对应类型()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TransactionQueryRequest
|
||||
{
|
||||
Type = (int)TransactionType.Expense,
|
||||
PageIndex = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
var transactions = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, Amount = 100, Type = TransactionType.Expense }
|
||||
};
|
||||
_transactionRepository.QueryAsync(
|
||||
type: TransactionType.Expense,
|
||||
pageIndex: 1,
|
||||
pageSize: 10).Returns(transactions);
|
||||
_transactionRepository.CountAsync(
|
||||
type: TransactionType.Expense).Returns(1);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Data.Should().HaveCount(1);
|
||||
result.Data[0].Type.Should().Be(TransactionType.Expense);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByEmailIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByEmailIdAsync_有关联记录_应返回列表()
|
||||
{
|
||||
// Arrange
|
||||
var emailId = 100L;
|
||||
var transactions = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, EmailMessageId = emailId, Amount = 100 },
|
||||
new() { Id = 2, EmailMessageId = emailId, Amount = 200 }
|
||||
};
|
||||
_transactionRepository.GetByEmailIdAsync(emailId).Returns(transactions);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByEmailIdAsync(emailId);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
await _transactionRepository.Received(1).GetByEmailIdAsync(emailId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByEmailIdAsync_无关联记录_应返回空列表()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.GetByEmailIdAsync(999).Returns(new List<TransactionRecord>());
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByEmailIdAsync(999);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByDateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDateAsync_指定日期_应返回当天记录()
|
||||
{
|
||||
// Arrange
|
||||
var date = new DateTime(2026, 2, 10);
|
||||
var expectedStart = date.Date;
|
||||
var expectedEnd = expectedStart.AddDays(1);
|
||||
var transactions = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, OccurredAt = date, Amount = 100 }
|
||||
};
|
||||
_transactionRepository.QueryAsync(
|
||||
startDate: expectedStart,
|
||||
endDate: expectedEnd).Returns(transactions);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByDateAsync(date);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].OccurredAt.Date.Should().Be(date.Date);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetUnconfirmedListAsync and GetUnconfirmedCountAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnconfirmedListAsync_有未确认记录_应返回列表()
|
||||
{
|
||||
// Arrange
|
||||
var unconfirmedRecords = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, Amount = 100, UnconfirmedClassify = "待确认分类" },
|
||||
new() { Id = 2, Amount = 200, UnconfirmedType = TransactionType.Expense }
|
||||
};
|
||||
_transactionRepository.GetUnconfirmedRecordsAsync().Returns(unconfirmedRecords);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetUnconfirmedListAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnconfirmedCountAsync_应返回未确认记录数量()
|
||||
{
|
||||
// Arrange
|
||||
var unconfirmedRecords = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, UnconfirmedClassify = "待确认" },
|
||||
new() { Id = 2, UnconfirmedClassify = "待确认" }
|
||||
};
|
||||
_transactionRepository.GetUnconfirmedRecordsAsync().Returns(unconfirmedRecords);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetUnconfirmedCountAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetUnclassifiedCountAsync and GetUnclassifiedAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnclassifiedCountAsync_应返回未分类数量()
|
||||
{
|
||||
// Arrange
|
||||
_transactionRepository.CountAsync().Returns(5);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetUnclassifiedCountAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnclassifiedAsync_指定页大小_应返回未分类记录()
|
||||
{
|
||||
// Arrange
|
||||
var pageSize = 10;
|
||||
var unclassifiedRecords = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id = 1, Amount = 100, Classify = string.Empty },
|
||||
new() { Id = 2, Amount = 200, Classify = string.Empty }
|
||||
};
|
||||
_transactionRepository.GetUnclassifiedAsync(pageSize).Returns(unclassifiedRecords);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetUnclassifiedAsync(pageSize);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ConfirmAllUnconfirmedAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmAllUnconfirmedAsync_有效ID列表_应返回确认数量()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new long[] { 1, 2, 3 };
|
||||
_transactionRepository.ConfirmAllUnconfirmedAsync(ids).Returns(3);
|
||||
|
||||
// Act
|
||||
var result = await _application.ConfirmAllUnconfirmedAsync(ids);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(3);
|
||||
await _transactionRepository.Received(1).ConfirmAllUnconfirmedAsync(ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmAllUnconfirmedAsync_空ID列表_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var emptyIds = Array.Empty<long>();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ConfirmAllUnconfirmedAsync(emptyIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmAllUnconfirmedAsync_NullID列表_应抛出ValidationException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ConfirmAllUnconfirmedAsync(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SmartClassifyAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SmartClassifyAsync_有效ID列表_应调用Service()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new long[] { 1, 2 };
|
||||
var chunkReceived = false;
|
||||
Action<(string, string)> onChunk = chunk => { chunkReceived = true; };
|
||||
|
||||
// Act
|
||||
await _application.SmartClassifyAsync(ids, onChunk);
|
||||
|
||||
// Assert
|
||||
await _smartHandleService.Received(1).SmartClassifyAsync(ids, onChunk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmartClassifyAsync_空ID列表_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var emptyIds = Array.Empty<long>();
|
||||
Action<(string, string)> onChunk = _ => { };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.SmartClassifyAsync(emptyIds, onChunk));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmartClassifyAsync_NullID列表_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
Action<(string, string)> onChunk = _ => { };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.SmartClassifyAsync(null!, onChunk));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseOneLineAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ParseOneLineAsync_有效文本_应返回解析结果()
|
||||
{
|
||||
// Arrange
|
||||
var text = "午餐花了50块";
|
||||
var parseResult = new TransactionParseResult(
|
||||
OccurredAt: DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
Classify: "餐饮",
|
||||
Amount: 50,
|
||||
Reason: "午餐",
|
||||
Type: TransactionType.Expense
|
||||
);
|
||||
_smartHandleService.ParseOneLineBillAsync(text).Returns(parseResult);
|
||||
|
||||
// Act
|
||||
var result = await _application.ParseOneLineAsync(text);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Amount.Should().Be(50);
|
||||
result.Classify.Should().Be("餐饮");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseOneLineAsync_空文本_应抛出ValidationException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ParseOneLineAsync(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseOneLineAsync_空白文本_应抛出ValidationException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.ParseOneLineAsync(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseOneLineAsync_解析失败返回null_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_smartHandleService.ParseOneLineBillAsync(Arg.Any<string>()).Returns((TransactionParseResult?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() =>
|
||||
_application.ParseOneLineAsync("测试文本"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AnalyzeBillAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeBillAsync_有效输入_应调用Service()
|
||||
{
|
||||
// Arrange
|
||||
var userInput = "本月支出分析";
|
||||
var chunkReceived = false;
|
||||
Action<string> onChunk = chunk => { chunkReceived = true; };
|
||||
|
||||
// Act
|
||||
await _application.AnalyzeBillAsync(userInput, onChunk);
|
||||
|
||||
// Assert
|
||||
await _smartHandleService.Received(1).AnalyzeBillAsync(userInput, onChunk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeBillAsync_空输入_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
Action<string> onChunk = _ => { };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.AnalyzeBillAsync(string.Empty, onChunk));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeBillAsync_空白输入_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
Action<string> onChunk = _ => { };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.AnalyzeBillAsync(" ", onChunk));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BatchUpdateClassifyAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateClassifyAsync_有效项目列表_应返回成功数量()
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<BatchUpdateClassifyItem>
|
||||
{
|
||||
new() { Id = 1, Classify = "餐饮", Type = TransactionType.Expense },
|
||||
new() { Id = 2, Classify = "交通", Type = TransactionType.Expense }
|
||||
};
|
||||
var record1 = new TransactionRecord { Id = 1, Amount = 100 };
|
||||
var record2 = new TransactionRecord { Id = 2, Amount = 200 };
|
||||
|
||||
_transactionRepository.GetByIdAsync(1).Returns(record1);
|
||||
_transactionRepository.GetByIdAsync(2).Returns(record2);
|
||||
_transactionRepository.UpdateAsync(Arg.Any<TransactionRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.BatchUpdateClassifyAsync(items);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2);
|
||||
await _transactionRepository.Received(2).UpdateAsync(Arg.Any<TransactionRecord>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateClassifyAsync_部分记录不存在_应只更新存在的记录()
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<BatchUpdateClassifyItem>
|
||||
{
|
||||
new() { Id = 1, Classify = "餐饮" },
|
||||
new() { Id = 999, Classify = "交通" }
|
||||
};
|
||||
var record1 = new TransactionRecord { Id = 1, Amount = 100 };
|
||||
|
||||
_transactionRepository.GetByIdAsync(1).Returns(record1);
|
||||
_transactionRepository.GetByIdAsync(999).Returns((TransactionRecord?)null);
|
||||
_transactionRepository.UpdateAsync(Arg.Any<TransactionRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.BatchUpdateClassifyAsync(items);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateClassifyAsync_空列表_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var emptyList = new List<BatchUpdateClassifyItem>();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.BatchUpdateClassifyAsync(emptyList));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateClassifyAsync_Null列表_应抛出ValidationException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.BatchUpdateClassifyAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateClassifyAsync_更新应清除待确认状态()
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<BatchUpdateClassifyItem>
|
||||
{
|
||||
new() { Id = 1, Classify = "餐饮", Type = TransactionType.Expense }
|
||||
};
|
||||
var record = new TransactionRecord
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 100,
|
||||
UnconfirmedClassify = "待确认",
|
||||
UnconfirmedType = TransactionType.Income
|
||||
};
|
||||
|
||||
_transactionRepository.GetByIdAsync(1).Returns(record);
|
||||
_transactionRepository.UpdateAsync(Arg.Any<TransactionRecord>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.BatchUpdateClassifyAsync(items);
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.Received(1).UpdateAsync(Arg.Is<TransactionRecord>(
|
||||
r => r.UnconfirmedClassify == null && r.UnconfirmedType == null
|
||||
));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BatchUpdateByReasonAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateByReasonAsync_有效请求_应返回更新数量()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchUpdateByReasonRequest
|
||||
{
|
||||
Reason = "午餐",
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
};
|
||||
_transactionRepository.BatchUpdateByReasonAsync("午餐", TransactionType.Expense, "餐饮")
|
||||
.Returns(5);
|
||||
|
||||
// Act
|
||||
var result = await _application.BatchUpdateByReasonAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateByReasonAsync_空摘要_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchUpdateByReasonRequest
|
||||
{
|
||||
Reason = string.Empty,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.BatchUpdateByReasonAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateByReasonAsync_空分类_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchUpdateByReasonRequest
|
||||
{
|
||||
Reason = "午餐",
|
||||
Type = TransactionType.Expense,
|
||||
Classify = string.Empty
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.BatchUpdateByReasonAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateByReasonAsync_空白摘要_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchUpdateByReasonRequest
|
||||
{
|
||||
Reason = " ",
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "餐饮"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||
_application.BatchUpdateByReasonAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
447
WebApi.Test/Application/TransactionCategoryApplicationTest.cs
Normal file
447
WebApi.Test/Application/TransactionCategoryApplicationTest.cs
Normal file
@@ -0,0 +1,447 @@
|
||||
using Application.Dto.Category;
|
||||
using Service.AI;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// TransactionCategoryApplication 单元测试
|
||||
/// </summary>
|
||||
public class TransactionCategoryApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly ITransactionCategoryRepository _categoryRepository;
|
||||
private readonly ITransactionRecordRepository _transactionRepository;
|
||||
private readonly IBudgetRepository _budgetRepository;
|
||||
private readonly ISmartHandleService _smartHandleService;
|
||||
private readonly ILogger<TransactionCategoryApplication> _logger;
|
||||
private readonly TransactionCategoryApplication _application;
|
||||
|
||||
public TransactionCategoryApplicationTest()
|
||||
{
|
||||
_categoryRepository = Substitute.For<ITransactionCategoryRepository>();
|
||||
_transactionRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
_budgetRepository = Substitute.For<IBudgetRepository>();
|
||||
_smartHandleService = Substitute.For<ISmartHandleService>();
|
||||
_logger = CreateMockLogger<TransactionCategoryApplication>();
|
||||
_application = new TransactionCategoryApplication(
|
||||
_categoryRepository,
|
||||
_transactionRepository,
|
||||
_budgetRepository,
|
||||
_smartHandleService,
|
||||
_logger);
|
||||
}
|
||||
|
||||
#region GetListAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_无类型筛选_应返回所有分类()
|
||||
{
|
||||
// Arrange
|
||||
var categories = new List<TransactionCategory>
|
||||
{
|
||||
new() { Id = 1, Name = "餐饮", Type = TransactionType.Expense },
|
||||
new() { Id = 2, Name = "工资", Type = TransactionType.Income }
|
||||
};
|
||||
_categoryRepository.GetAllAsync().Returns(categories);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(c => c.Name == "餐饮");
|
||||
result.Should().Contain(c => c.Name == "工资");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_指定类型_应返回该类型分类()
|
||||
{
|
||||
// Arrange
|
||||
var expenseCategories = new List<TransactionCategory>
|
||||
{
|
||||
new() { Id = 1, Name = "餐饮", Type = TransactionType.Expense },
|
||||
new() { Id = 2, Name = "交通", Type = TransactionType.Expense }
|
||||
};
|
||||
_categoryRepository.GetCategoriesByTypeAsync(TransactionType.Expense).Returns(expenseCategories);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(TransactionType.Expense);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().AllSatisfy(c => c.Type.Should().Be(TransactionType.Expense));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_存在的分类_应返回分类详情()
|
||||
{
|
||||
// Arrange
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "餐饮",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(category);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1);
|
||||
result.Name.Should().Be("餐饮");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_不存在的分类_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_categoryRepository.GetByIdAsync(999).Returns((TransactionCategory?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.GetByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_有效请求_应成功创建()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateCategoryRequest
|
||||
{
|
||||
Name = "新分类",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_categoryRepository.GetByNameAndTypeAsync("新分类", TransactionType.Expense).Returns((TransactionCategory?)null);
|
||||
_categoryRepository.AddAsync(Arg.Any<TransactionCategory>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().BeGreaterThan(0);
|
||||
await _categoryRepository.Received(1).AddAsync(Arg.Is<TransactionCategory>(
|
||||
c => c.Name == "新分类" && c.Type == TransactionType.Expense
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_同名分类已存在_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var existingCategory = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "餐饮",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var request = new CreateCategoryRequest
|
||||
{
|
||||
Name = "餐饮",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_categoryRepository.GetByNameAndTypeAsync("餐饮", TransactionType.Expense).Returns(existingCategory);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(() => _application.CreateAsync(request));
|
||||
exception.Message.Should().Contain("已存在相同名称的分类");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Repository添加失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateCategoryRequest
|
||||
{
|
||||
Name = "测试分类",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_categoryRepository.GetByNameAndTypeAsync("测试分类", TransactionType.Expense).Returns((TransactionCategory?)null);
|
||||
_categoryRepository.AddAsync(Arg.Any<TransactionCategory>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_有效请求_应成功更新()
|
||||
{
|
||||
// Arrange
|
||||
var existingCategory = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "旧名称",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var request = new UpdateCategoryRequest
|
||||
{
|
||||
Id = 1,
|
||||
Name = "新名称"
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(existingCategory);
|
||||
_categoryRepository.GetByNameAndTypeAsync("新名称", TransactionType.Expense).Returns((TransactionCategory?)null);
|
||||
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.UpdateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _categoryRepository.Received(1).UpdateAsync(Arg.Is<TransactionCategory>(
|
||||
c => c.Id == 1 && c.Name == "新名称"
|
||||
));
|
||||
await _transactionRepository.Received(1).UpdateCategoryNameAsync("旧名称", "新名称", TransactionType.Expense);
|
||||
await _budgetRepository.Received(1).UpdateBudgetCategoryNameAsync("旧名称", "新名称", TransactionType.Expense);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_分类不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateCategoryRequest
|
||||
{
|
||||
Id = 999,
|
||||
Name = "新名称"
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(999).Returns((TransactionCategory?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.UpdateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_新名称已存在_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var existingCategory = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "旧名称",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var conflictingCategory = new TransactionCategory
|
||||
{
|
||||
Id = 2,
|
||||
Name = "冲突名称",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var request = new UpdateCategoryRequest
|
||||
{
|
||||
Id = 1,
|
||||
Name = "冲突名称"
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(existingCategory);
|
||||
_categoryRepository.GetByNameAndTypeAsync("冲突名称", TransactionType.Expense).Returns(conflictingCategory);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.UpdateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_名称未改变_不应同步更新关联数据()
|
||||
{
|
||||
// Arrange
|
||||
var existingCategory = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "相同名称",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var request = new UpdateCategoryRequest
|
||||
{
|
||||
Id = 1,
|
||||
Name = "相同名称"
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(existingCategory);
|
||||
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.UpdateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _transactionRepository.DidNotReceive().UpdateCategoryNameAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TransactionType>());
|
||||
await _budgetRepository.DidNotReceive().UpdateBudgetCategoryNameAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TransactionType>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_未被使用的分类_应成功删除()
|
||||
{
|
||||
// Arrange
|
||||
_categoryRepository.IsCategoryInUseAsync(1).Returns(false);
|
||||
_categoryRepository.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.DeleteAsync(1);
|
||||
|
||||
// Assert
|
||||
await _categoryRepository.Received(1).DeleteAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_已被使用的分类_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
_categoryRepository.IsCategoryInUseAsync(1).Returns(true);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ValidationException>(() => _application.DeleteAsync(1));
|
||||
exception.Message.Should().Contain("已被使用");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_删除失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_categoryRepository.IsCategoryInUseAsync(1).Returns(false);
|
||||
_categoryRepository.DeleteAsync(1).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.DeleteAsync(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BatchCreateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BatchCreateAsync_有效请求列表_应返回创建数量()
|
||||
{
|
||||
// Arrange
|
||||
var requests = new List<CreateCategoryRequest>
|
||||
{
|
||||
new() { Name = "分类1", Type = TransactionType.Expense },
|
||||
new() { Name = "分类2", Type = TransactionType.Expense }
|
||||
};
|
||||
_categoryRepository.AddRangeAsync(Arg.Any<List<TransactionCategory>>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.BatchCreateAsync(requests);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2);
|
||||
await _categoryRepository.Received(1).AddRangeAsync(Arg.Is<List<TransactionCategory>>(
|
||||
list => list.Count == 2
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchCreateAsync_Repository添加失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var requests = new List<CreateCategoryRequest>
|
||||
{
|
||||
new() { Name = "分类1", Type = TransactionType.Expense }
|
||||
};
|
||||
_categoryRepository.AddRangeAsync(Arg.Any<List<TransactionCategory>>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.BatchCreateAsync(requests));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSelectedIconAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSelectedIconAsync_有效索引_应更新选中图标()
|
||||
{
|
||||
// Arrange
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "测试",
|
||||
Type = TransactionType.Expense,
|
||||
Icon = """["<svg>icon1</svg>","<svg>icon2</svg>","<svg>icon3</svg>"]"""
|
||||
};
|
||||
var request = new UpdateSelectedIconRequest
|
||||
{
|
||||
CategoryId = 1,
|
||||
SelectedIndex = 2
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(category);
|
||||
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.UpdateSelectedIconAsync(request);
|
||||
|
||||
// Assert
|
||||
await _categoryRepository.Received(1).UpdateAsync(Arg.Is<TransactionCategory>(
|
||||
c => c.Id == 1 && c.Icon != null && c.Icon.Contains("icon3")
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSelectedIconAsync_分类不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateSelectedIconRequest
|
||||
{
|
||||
CategoryId = 999,
|
||||
SelectedIndex = 0
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(999).Returns((TransactionCategory?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.UpdateSelectedIconAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSelectedIconAsync_分类无图标_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "测试",
|
||||
Type = TransactionType.Expense,
|
||||
Icon = null
|
||||
};
|
||||
var request = new UpdateSelectedIconRequest
|
||||
{
|
||||
CategoryId = 1,
|
||||
SelectedIndex = 0
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(category);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.UpdateSelectedIconAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSelectedIconAsync_索引超出范围_应抛出ValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = 1,
|
||||
Name = "测试",
|
||||
Type = TransactionType.Expense,
|
||||
Icon = """["<svg>icon1</svg>"]"""
|
||||
};
|
||||
var request = new UpdateSelectedIconRequest
|
||||
{
|
||||
CategoryId = 1,
|
||||
SelectedIndex = 5
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(1).Returns(category);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => _application.UpdateSelectedIconAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
352
WebApi.Test/Application/TransactionPeriodicApplicationTest.cs
Normal file
352
WebApi.Test/Application/TransactionPeriodicApplicationTest.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
using Application.Dto.Periodic;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// TransactionPeriodicApplication 单元测试
|
||||
/// </summary>
|
||||
public class TransactionPeriodicApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly ITransactionPeriodicRepository _periodicRepository;
|
||||
private readonly ITransactionPeriodicService _periodicService;
|
||||
private readonly ILogger<TransactionPeriodicApplication> _logger;
|
||||
private readonly TransactionPeriodicApplication _application;
|
||||
|
||||
public TransactionPeriodicApplicationTest()
|
||||
{
|
||||
_periodicRepository = Substitute.For<ITransactionPeriodicRepository>();
|
||||
_periodicService = Substitute.For<ITransactionPeriodicService>();
|
||||
_logger = CreateMockLogger<TransactionPeriodicApplication>();
|
||||
_application = new TransactionPeriodicApplication(_periodicRepository, _periodicService, _logger);
|
||||
}
|
||||
|
||||
#region GetListAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_有数据_应返回分页结果()
|
||||
{
|
||||
// Arrange
|
||||
var pageIndex = 1;
|
||||
var pageSize = 10;
|
||||
var periodics = new List<TransactionPeriodic>
|
||||
{
|
||||
new() { Id = 1, Amount = 100, Type = TransactionType.Expense, Classify = "房租", IsEnabled = true },
|
||||
new() { Id = 2, Amount = 5000, Type = TransactionType.Income, Classify = "工资", IsEnabled = true }
|
||||
};
|
||||
_periodicRepository.GetPagedListAsync(pageIndex, pageSize, null).Returns(periodics);
|
||||
_periodicRepository.GetTotalCountAsync(null).Returns(2L);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(pageIndex, pageSize);
|
||||
|
||||
// Assert
|
||||
result.Data.Should().HaveCount(2);
|
||||
result.Total.Should().Be(2);
|
||||
result.Data[0].Amount.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListAsync_带搜索关键词_应过滤结果()
|
||||
{
|
||||
// Arrange
|
||||
var keyword = "房租";
|
||||
var periodics = new List<TransactionPeriodic>
|
||||
{
|
||||
new() { Id = 1, Amount = 1000, Type = TransactionType.Expense, Classify = "房租", IsEnabled = true }
|
||||
};
|
||||
_periodicRepository.GetPagedListAsync(1, 20, keyword).Returns(periodics);
|
||||
_periodicRepository.GetTotalCountAsync(keyword).Returns(1L);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetListAsync(1, 20, keyword);
|
||||
|
||||
// Assert
|
||||
result.Data.Should().HaveCount(1);
|
||||
result.Total.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_存在的记录_应返回详情()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "房租",
|
||||
Reason = "每月房租",
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(periodic);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1);
|
||||
result.Amount.Should().Be(1000);
|
||||
result.Classify.Should().Be("房租");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_不存在的记录_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_periodicRepository.GetByIdAsync(999).Returns((TransactionPeriodic?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.GetByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_有效请求_应成功创建()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreatePeriodicRequest
|
||||
{
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "1",
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "房租",
|
||||
Reason = "每月房租"
|
||||
};
|
||||
var nextExecuteTime = DateTime.Now.AddDays(1);
|
||||
_periodicService.CalculateNextExecuteTime(Arg.Any<TransactionPeriodic>(), Arg.Any<DateTime>())
|
||||
.Returns(nextExecuteTime);
|
||||
_periodicRepository.AddAsync(Arg.Any<TransactionPeriodic>()).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _application.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Amount.Should().Be(1000);
|
||||
result.IsEnabled.Should().BeTrue();
|
||||
await _periodicRepository.Received(1).AddAsync(Arg.Is<TransactionPeriodic>(
|
||||
p => p.Amount == 1000 && p.Classify == "房租"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Repository添加失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreatePeriodicRequest
|
||||
{
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
_periodicService.CalculateNextExecuteTime(Arg.Any<TransactionPeriodic>(), Arg.Any<DateTime>())
|
||||
.Returns(DateTime.Now.AddDays(1));
|
||||
_periodicRepository.AddAsync(Arg.Any<TransactionPeriodic>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.CreateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_有效请求_应成功更新()
|
||||
{
|
||||
// Arrange
|
||||
var existingPeriodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "房租",
|
||||
IsEnabled = true
|
||||
};
|
||||
var request = new UpdatePeriodicRequest
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
PeriodicConfig = "1",
|
||||
Amount = 1200,
|
||||
Type = TransactionType.Expense,
|
||||
Classify = "房租",
|
||||
Reason = "房租涨价",
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(existingPeriodic);
|
||||
_periodicService.CalculateNextExecuteTime(Arg.Any<TransactionPeriodic>(), Arg.Any<DateTime>())
|
||||
.Returns(DateTime.Now.AddMonths(1));
|
||||
_periodicRepository.UpdateAsync(Arg.Any<TransactionPeriodic>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.UpdateAsync(request);
|
||||
|
||||
// Assert
|
||||
await _periodicRepository.Received(1).UpdateAsync(Arg.Is<TransactionPeriodic>(
|
||||
p => p.Id == 1 && p.Amount == 1200 && p.Reason == "房租涨价"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_记录不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdatePeriodicRequest
|
||||
{
|
||||
Id = 999,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(999).Returns((TransactionPeriodic?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.UpdateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Repository更新失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var existingPeriodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
var request = new UpdatePeriodicRequest
|
||||
{
|
||||
Id = 1,
|
||||
PeriodicType = PeriodicType.Monthly,
|
||||
Amount = 1200,
|
||||
Type = TransactionType.Expense,
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(existingPeriodic);
|
||||
_periodicService.CalculateNextExecuteTime(Arg.Any<TransactionPeriodic>(), Arg.Any<DateTime>())
|
||||
.Returns(DateTime.Now.AddMonths(1));
|
||||
_periodicRepository.UpdateAsync(Arg.Any<TransactionPeriodic>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.UpdateAsync(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_成功删除_不应抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
_periodicRepository.DeleteAsync(1).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.DeleteByIdAsync(1);
|
||||
|
||||
// Assert
|
||||
await _periodicRepository.Received(1).DeleteAsync(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByIdAsync_删除失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
_periodicRepository.DeleteAsync(999).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.DeleteByIdAsync(999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToggleEnabledAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleEnabledAsync_禁用账单_应成功更新状态()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(periodic);
|
||||
_periodicRepository.UpdateAsync(Arg.Any<TransactionPeriodic>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.ToggleEnabledAsync(1, false);
|
||||
|
||||
// Assert
|
||||
await _periodicRepository.Received(1).UpdateAsync(Arg.Is<TransactionPeriodic>(
|
||||
p => p.Id == 1 && p.IsEnabled == false
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleEnabledAsync_启用账单_应成功更新状态()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
IsEnabled = false
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(periodic);
|
||||
_periodicRepository.UpdateAsync(Arg.Any<TransactionPeriodic>()).Returns(true);
|
||||
|
||||
// Act
|
||||
await _application.ToggleEnabledAsync(1, true);
|
||||
|
||||
// Assert
|
||||
await _periodicRepository.Received(1).UpdateAsync(Arg.Is<TransactionPeriodic>(
|
||||
p => p.Id == 1 && p.IsEnabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleEnabledAsync_记录不存在_应抛出NotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
_periodicRepository.GetByIdAsync(999).Returns((TransactionPeriodic?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _application.ToggleEnabledAsync(999, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleEnabledAsync_更新失败_应抛出BusinessException()
|
||||
{
|
||||
// Arrange
|
||||
var periodic = new TransactionPeriodic
|
||||
{
|
||||
Id = 1,
|
||||
Amount = 1000,
|
||||
Type = TransactionType.Expense,
|
||||
IsEnabled = true
|
||||
};
|
||||
_periodicRepository.GetByIdAsync(1).Returns(periodic);
|
||||
_periodicRepository.UpdateAsync(Arg.Any<TransactionPeriodic>()).Returns(false);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BusinessException>(() => _application.ToggleEnabledAsync(1, false));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
328
WebApi.Test/Application/TransactionStatisticsApplicationTest.cs
Normal file
328
WebApi.Test/Application/TransactionStatisticsApplicationTest.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
using Application.Dto.Statistics;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Application;
|
||||
|
||||
/// <summary>
|
||||
/// TransactionStatisticsApplication 单元测试
|
||||
/// </summary>
|
||||
public class TransactionStatisticsApplicationTest : BaseApplicationTest
|
||||
{
|
||||
private readonly ITransactionStatisticsService _statisticsService;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly ILogger<TransactionStatisticsApplication> _logger;
|
||||
private readonly TransactionStatisticsApplication _application;
|
||||
|
||||
public TransactionStatisticsApplicationTest()
|
||||
{
|
||||
_statisticsService = Substitute.For<ITransactionStatisticsService>();
|
||||
_configService = Substitute.For<IConfigService>();
|
||||
_logger = CreateMockLogger<TransactionStatisticsApplication>();
|
||||
_application = new TransactionStatisticsApplication(_statisticsService, _configService, _logger);
|
||||
}
|
||||
|
||||
#region GetBalanceStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetBalanceStatisticsAsync_有效数据_应返回累计余额统计()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 2;
|
||||
var savingClassify = "储蓄";
|
||||
var dailyStats = new Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>
|
||||
{
|
||||
{ "2026-02-01", (2, 500m, 1000m, 0m) },
|
||||
{ "2026-02-02", (1, 200m, 0m, 0m) },
|
||||
{ "2026-02-03", (2, 300m, 2000m, 0m) }
|
||||
};
|
||||
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(savingClassify);
|
||||
_statisticsService.GetDailyStatisticsAsync(year, month, savingClassify).Returns(dailyStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetBalanceStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result[0].Day.Should().Be(1);
|
||||
result[0].CumulativeBalance.Should().Be(500m); // 1000 - 500
|
||||
result[1].Day.Should().Be(2);
|
||||
result[1].CumulativeBalance.Should().Be(300m); // 500 + (0 - 200)
|
||||
result[2].Day.Should().Be(3);
|
||||
result[2].CumulativeBalance.Should().Be(2000m); // 300 + (2000 - 300)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBalanceStatisticsAsync_无数据_应返回空列表()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 2;
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("储蓄");
|
||||
_statisticsService.GetDailyStatisticsAsync(year, month, "储蓄")
|
||||
.Returns(new Dictionary<string, (int, decimal, decimal, decimal)>());
|
||||
|
||||
// Act
|
||||
var result = await _application.GetBalanceStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetDailyStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_有效数据_应返回每日统计()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 2;
|
||||
var dailyStats = new Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>
|
||||
{
|
||||
{ "2026-02-10", (3, 500m, 1000m, 100m) },
|
||||
{ "2026-02-11", (5, 800m, 2000m, 200m) }
|
||||
};
|
||||
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("储蓄");
|
||||
_statisticsService.GetDailyStatisticsAsync(year, month, "储蓄").Returns(dailyStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetDailyStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(s => s.Day == 10 && s.Income == 1000m && s.Expense == 500m && s.Count == 3 && s.Saving == 100m);
|
||||
result.Should().Contain(s => s.Day == 11 && s.Income == 2000m && s.Expense == 800m && s.Count == 5 && s.Saving == 200m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetWeeklyStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeeklyStatisticsAsync_有效日期范围_应返回周统计()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = new DateTime(2026, 2, 1);
|
||||
var endDate = new DateTime(2026, 2, 7);
|
||||
var weeklyStats = new Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>
|
||||
{
|
||||
{ "2026-02-01", (2, 200m, 500m, 50m) },
|
||||
{ "2026-02-07", (3, 300m, 800m, 100m) }
|
||||
};
|
||||
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("储蓄");
|
||||
_statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, "储蓄").Returns(weeklyStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetWeeklyStatisticsAsync(startDate, endDate);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(s => s.Day == 1 && s.Income == 500m && s.Expense == 200m);
|
||||
result.Should().Contain(s => s.Day == 7 && s.Income == 800m && s.Expense == 300m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRangeStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRangeStatisticsAsync_有效日期范围_应返回汇总统计()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = new DateTime(2026, 2, 1);
|
||||
var endDate = new DateTime(2026, 2, 28);
|
||||
var rangeStats = new Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>
|
||||
{
|
||||
{ "2026-02-01", (3, 500m, 1000m, 0m) },
|
||||
{ "2026-02-02", (4, 800m, 2000m, 0m) },
|
||||
{ "2026-02-03", (2, 300m, 0m, 0m) }
|
||||
};
|
||||
|
||||
_statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, null).Returns(rangeStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetRangeStatisticsAsync(startDate, endDate);
|
||||
|
||||
// Assert
|
||||
result.Year.Should().Be(2026);
|
||||
result.Month.Should().Be(2);
|
||||
result.TotalIncome.Should().Be(3000m);
|
||||
result.TotalExpense.Should().Be(1600m);
|
||||
result.Balance.Should().Be(1400m);
|
||||
result.TotalCount.Should().Be(9);
|
||||
result.ExpenseCount.Should().Be(3);
|
||||
result.IncomeCount.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMonthlyStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetMonthlyStatisticsAsync_有效年月_应返回月度统计()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 2;
|
||||
var monthlyStats = new MonthlyStatistics
|
||||
{
|
||||
Year = year,
|
||||
Month = month,
|
||||
TotalIncome = 5000m,
|
||||
TotalExpense = 3000m,
|
||||
Balance = 2000m,
|
||||
IncomeCount = 10,
|
||||
ExpenseCount = 15,
|
||||
TotalCount = 25
|
||||
};
|
||||
|
||||
_statisticsService.GetMonthlyStatisticsAsync(year, month).Returns(monthlyStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetMonthlyStatisticsAsync(year, month);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Year.Should().Be(year);
|
||||
result.Month.Should().Be(month);
|
||||
result.TotalIncome.Should().Be(5000m);
|
||||
result.TotalExpense.Should().Be(3000m);
|
||||
result.Balance.Should().Be(2000m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategoryStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStatisticsAsync_有效参数_应返回分类统计()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 2;
|
||||
var type = TransactionType.Expense;
|
||||
var categoryStats = new List<CategoryStatistics>
|
||||
{
|
||||
new() { Classify = "餐饮", Amount = 1000m, Count = 10 },
|
||||
new() { Classify = "交通", Amount = 500m, Count = 5 }
|
||||
};
|
||||
|
||||
_statisticsService.GetCategoryStatisticsAsync(year, month, type).Returns(categoryStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetCategoryStatisticsAsync(year, month, type);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(s => s.Classify == "餐饮" && s.Amount == 1000m);
|
||||
result.Should().Contain(s => s.Classify == "交通" && s.Amount == 500m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCategoryStatisticsByDateRangeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStatisticsByDateRangeAsync_有效日期字符串_应返回分类统计()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = "2026-02-01";
|
||||
var endDate = "2026-02-28";
|
||||
var type = TransactionType.Expense;
|
||||
var categoryStats = new List<CategoryStatistics>
|
||||
{
|
||||
new() { Classify = "餐饮", Amount = 1500m, Count = 15 }
|
||||
};
|
||||
|
||||
_statisticsService.GetCategoryStatisticsByDateRangeAsync(
|
||||
DateTime.Parse(startDate),
|
||||
DateTime.Parse(endDate),
|
||||
type
|
||||
).Returns(categoryStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetCategoryStatisticsByDateRangeAsync(startDate, endDate, type);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Classify.Should().Be("餐饮");
|
||||
result[0].Amount.Should().Be(1500m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCategoryStatisticsByDateRangeAsync_无效日期格式_应抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = "invalid-date";
|
||||
var endDate = "2026-02-28";
|
||||
var type = TransactionType.Expense;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<FormatException>(() =>
|
||||
_application.GetCategoryStatisticsByDateRangeAsync(startDate, endDate, type));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetTrendStatisticsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetTrendStatisticsAsync_有效参数_应返回趋势统计()
|
||||
{
|
||||
// Arrange
|
||||
var startYear = 2026;
|
||||
var startMonth = 1;
|
||||
var monthCount = 3;
|
||||
var trendStats = new List<TrendStatistics>
|
||||
{
|
||||
new() { Year = 2026, Month = 1, Income = 5000m, Expense = 3000m },
|
||||
new() { Year = 2026, Month = 2, Income = 6000m, Expense = 3500m },
|
||||
new() { Year = 2026, Month = 3, Income = 5500m, Expense = 3200m }
|
||||
};
|
||||
|
||||
_statisticsService.GetTrendStatisticsAsync(startYear, startMonth, monthCount).Returns(trendStats);
|
||||
|
||||
// Act
|
||||
var result = await _application.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result[0].Year.Should().Be(2026);
|
||||
result[0].Month.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetReasonGroupsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetReasonGroupsAsync_有效分页参数_应返回分组数据()
|
||||
{
|
||||
// Arrange
|
||||
var pageIndex = 1;
|
||||
var pageSize = 10;
|
||||
var reasonGroups = new List<ReasonGroupDto>
|
||||
{
|
||||
new() { Reason = "餐饮", Count = 20, TotalAmount = 1500m },
|
||||
new() { Reason = "交通", Count = 15, TotalAmount = 800m }
|
||||
};
|
||||
var total = 50;
|
||||
|
||||
_statisticsService.GetReasonGroupsAsync(pageIndex, pageSize).Returns((reasonGroups, total));
|
||||
|
||||
// Act
|
||||
var result = await _application.GetReasonGroupsAsync(pageIndex, pageSize);
|
||||
|
||||
// Assert
|
||||
result.list.Should().HaveCount(2);
|
||||
result.total.Should().Be(50);
|
||||
result.list[0].Reason.Should().Be("餐饮");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Budget;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Service.AI;
|
||||
using Service.Message;
|
||||
using Service.Transaction;
|
||||
@@ -11,7 +10,7 @@ public class BudgetStatsTest : BaseTest
|
||||
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
|
||||
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
private readonly ITransactionStatisticsService _transactionStatisticsService = Substitute.For<ITransactionStatisticsService>();
|
||||
private readonly IOpenAiService _openAiService = Substitute.For<IOpenAiService>();
|
||||
private readonly ISmartHandleService _smartHandleService = Substitute.For<ISmartHandleService>();
|
||||
private readonly IMessageService _messageService = Substitute.For<IMessageService>();
|
||||
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
|
||||
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
|
||||
@@ -35,7 +34,7 @@ public class BudgetStatsTest : BaseTest
|
||||
_budgetArchiveRepository,
|
||||
_transactionsRepository,
|
||||
_transactionStatisticsService,
|
||||
_openAiService,
|
||||
_smartHandleService,
|
||||
_messageService,
|
||||
_logger,
|
||||
_budgetSavingsService,
|
||||
|
||||
@@ -7,4 +7,9 @@ global using Service.Budget;
|
||||
global using Xunit;
|
||||
global using Yitter.IdGenerator;
|
||||
global using WebApi.Test.Basic;
|
||||
global using Common;
|
||||
global using Common;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Application;
|
||||
global using Application.Exceptions;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Application.Dto;
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Service.Transaction;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Transaction;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\Application.csproj" />
|
||||
<ProjectReference Include="..\Service\Service.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user