This commit is contained in:
SunCheng
2026-02-10 17:49:19 +08:00
parent 3e18283e52
commit d052ae5197
104 changed files with 10369 additions and 3000 deletions

View 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
}

View 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>>();
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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);
}
}

View 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>&nbsp;特殊\"字符\"消息";
// Act
await _application.SendNotificationAsync(specialMessage);
// Assert
await _notificationService.Received(1).SendNotificationAsync(specialMessage);
}
#endregion
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
using NSubstitute.ReturnsExtensions;
using NSubstitute.ReturnsExtensions;
using Service.Transaction;
namespace WebApi.Test.Budget;

View File

@@ -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,

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
using Service.Transaction;
using Service.Transaction;
namespace WebApi.Test.Transaction;

View File

@@ -17,6 +17,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
<ProjectReference Include="..\Service\Service.csproj" />
</ItemGroup>
</Project>