namespace WebApi.Test.Application;
///
/// BudgetApplication 单元测试
///
public class BudgetApplicationTest : BaseApplicationTest
{
private readonly IBudgetService _budgetService;
private readonly IBudgetRepository _budgetRepository;
private readonly ILogger _logger;
private readonly BudgetApplication _application;
public BudgetApplicationTest()
{
_budgetService = Substitute.For();
_budgetRepository = Substitute.For();
_logger = CreateMockLogger();
_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
{
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());
_budgetRepository.AddAsync(Arg.Any()).Returns(true);
// Act
var id = await _application.CreateAsync(request);
// Assert
id.Should().BeGreaterThan(0);
await _budgetRepository.Received(1).AddAsync(Arg.Is(
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(() => _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(() => _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()
};
// Act & Assert
await Assert.ThrowsAsync(() => _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());
// Act & Assert
var exception = await Assert.ThrowsAsync(
() => _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 { 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(
() => _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());
_budgetRepository.AddAsync(Arg.Any()).Returns(true);
// Act
await _application.CreateAsync(request);
// Assert
await _budgetRepository.Received(1).AddAsync(Arg.Is(
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());
_budgetRepository.AddAsync(Arg.Any()).Returns(false);
// Act & Assert
await Assert.ThrowsAsync(() => _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 { existingBudget });
_budgetRepository.UpdateAsync(Arg.Any()).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(
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(
() => _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(
() => _application.DeleteByIdAsync(999)
);
}
#endregion
}