From 16016d0df773a4921f917a7704de127d82dac5c1 Mon Sep 17 00:00:00 2001 From: haoliang <821644@qq.com> Date: Tue, 28 Apr 2026 22:52:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9ECncWebApi.Tests=EF=BC=9A14?= =?UTF-8?q?=E4=B8=AA=E6=8E=A7=E5=88=B6=E5=99=A8127=E4=B8=AA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ControllerFactory:封装14个Controller的创建(含完整Repository→Service→Controller依赖链) - TestDb:测试数据库辅助(TruncateAll+SeedData+SetRealPasswordHash+辅助查询) - DatabaseCollection:xUnit串行测试集合(共享cnc_test库) - 14个Controller测试文件覆盖所有API端点: Health(1) + Auth(6) + Brand(15) + Machine(10) + CollectAddress(10) + Worker(13) + Dashboard(9) + Settings(15) + Production(6) + Alert(7) + Log(4) + ScreenConfig(12) + Screen(11) + Option(8) = 127个测试 - 直接实例化Controller调用方法,不经过HTTP管线 - 使用真实数据库(cnc_test库),与Service.Tests共享同一测试库 --- tests/CncWebApi.Tests/AlertControllerTests.cs | 145 +++++++++ tests/CncWebApi.Tests/AuthControllerTests.cs | 151 +++++++++ tests/CncWebApi.Tests/BrandControllerTests.cs | 303 ++++++++++++++++++ .../CollectAddressControllerTests.cs | 194 +++++++++++ tests/CncWebApi.Tests/ControllerFactory.cs | 135 ++++++++ .../DashboardControllerTests.cs | 164 ++++++++++ tests/CncWebApi.Tests/DatabaseCollection.cs | 10 + .../CncWebApi.Tests/HealthControllerTests.cs | 37 +++ tests/CncWebApi.Tests/LogControllerTests.cs | 85 +++++ .../CncWebApi.Tests/MachineControllerTests.cs | 204 ++++++++++++ .../CncWebApi.Tests/OptionControllerTests.cs | 156 +++++++++ .../ProductionControllerTests.cs | 146 +++++++++ .../ScreenConfigControllerTests.cs | 241 ++++++++++++++ .../CncWebApi.Tests/ScreenControllerTests.cs | 186 +++++++++++ .../SettingsControllerTests.cs | 245 ++++++++++++++ tests/CncWebApi.Tests/TestDb.cs | 131 ++++++++ .../CncWebApi.Tests/WorkerControllerTests.cs | 193 +++++++++++ 17 files changed, 2726 insertions(+) create mode 100644 tests/CncWebApi.Tests/AlertControllerTests.cs create mode 100644 tests/CncWebApi.Tests/AuthControllerTests.cs create mode 100644 tests/CncWebApi.Tests/BrandControllerTests.cs create mode 100644 tests/CncWebApi.Tests/CollectAddressControllerTests.cs create mode 100644 tests/CncWebApi.Tests/ControllerFactory.cs create mode 100644 tests/CncWebApi.Tests/DashboardControllerTests.cs create mode 100644 tests/CncWebApi.Tests/DatabaseCollection.cs create mode 100644 tests/CncWebApi.Tests/HealthControllerTests.cs create mode 100644 tests/CncWebApi.Tests/LogControllerTests.cs create mode 100644 tests/CncWebApi.Tests/MachineControllerTests.cs create mode 100644 tests/CncWebApi.Tests/OptionControllerTests.cs create mode 100644 tests/CncWebApi.Tests/ProductionControllerTests.cs create mode 100644 tests/CncWebApi.Tests/ScreenConfigControllerTests.cs create mode 100644 tests/CncWebApi.Tests/ScreenControllerTests.cs create mode 100644 tests/CncWebApi.Tests/SettingsControllerTests.cs create mode 100644 tests/CncWebApi.Tests/TestDb.cs create mode 100644 tests/CncWebApi.Tests/WorkerControllerTests.cs diff --git a/tests/CncWebApi.Tests/AlertControllerTests.cs b/tests/CncWebApi.Tests/AlertControllerTests.cs new file mode 100644 index 0000000..4b4e111 --- /dev/null +++ b/tests/CncWebApi.Tests/AlertControllerTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using CncModels.Dto; +using CncModels.Dto.Alert; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// AlertController单元测试 + /// 告警CRUD + 统计 + 批量处理 + /// + [Collection("Database")] + public class AlertControllerTests + { + private readonly AlertController _controller; + + public AlertControllerTests() + { + TestDb.TruncateAll(); + // 预置机床(告警外键依赖) + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at) + VALUES ('CNC001', '机床1', 1, 1, '192.168.1.100', 1, 1, NOW(), NOW())"); + _controller = ControllerFactory.CreateAlertController(); + } + + #region 辅助方法 + + /// + /// 插入一条告警 + /// + private int InsertAlert(string type = "offline", string title = "告警测试") + { + TestDb.Execute(@"INSERT INTO cnc_alert (machine_id, alert_type, title, is_resolved, created_at) + VALUES (1, @type, @title, 0, NOW())", new { type, title }); + return TestDb.QuerySingle("SELECT MAX(id) FROM cnc_alert"); + } + + #endregion + + #region GetList - 告警列表 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetList(new AlertQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:有告警数据时返回列表 + /// + [Fact] + public void GetList_WithData_ShouldReturnItems() + { + InsertAlert(); + var result = _controller.GetList(new AlertQuery()); + var response = ControllerFactory.Extract>(result); + Assert.NotEmpty(response.Data.Items); + } + + #endregion + + #region GetStatistics - 告警统计 + + /// + /// 测试:告警统计数据 + /// + [Fact] + public void GetStatistics_ShouldReturnStats() + { + InsertAlert(); + var result = _controller.GetStatistics(); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region Resolve - 处理告警 + + /// + /// 测试:处理单条告警成功 + /// + [Fact] + public void Resolve_Existing_ShouldSuccess() + { + int alertId = InsertAlert(); + var result = _controller.Resolve(alertId); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + // 验证已处理 + int isResolved = TestDb.QuerySingle("SELECT is_resolved FROM cnc_alert WHERE id = @id", new { id = alertId }); + Assert.Equal(1, isResolved); + } + + /// + /// 测试:处理不存在的告警不抛异常(Service层影响0行) + /// + [Fact] + public void Resolve_NotExisting_ShouldNotThrow() + { + var result = _controller.Resolve(999); + Assert.NotNull(result); + } + + #endregion + + #region BatchResolve - 批量处理 + + /// + /// 测试:批量处理告警成功 + /// + [Fact] + public void BatchResolve_ValidIds_ShouldSuccess() + { + int id1 = InsertAlert("offline", "告警1"); + int id2 = InsertAlert("offline", "告警2"); + + var result = _controller.BatchResolve(new BatchResolveRequest { Ids = new[] { id1, id2 } }); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + } + + /// + /// 测试:空ID数组抛出异常 + /// + [Fact] + public void BatchResolve_EmptyIds_ShouldThrow() + { + Assert.Throws(() => _controller.BatchResolve(new BatchResolveRequest { Ids = new int[0] })); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/AuthControllerTests.cs b/tests/CncWebApi.Tests/AuthControllerTests.cs new file mode 100644 index 0000000..dbb2b60 --- /dev/null +++ b/tests/CncWebApi.Tests/AuthControllerTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Web.Http.Results; +using CncModels.Constants; +using CncModels.Dto; +using CncModels.Dto.Login; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// AuthController单元测试 + /// 登录接口,无JWT过滤,验证登录成功/失败场景 + /// + [Collection("Database")] + public class AuthControllerTests + { + private readonly AuthController _controller; + private const string TestPassword = "admin123"; + + public AuthControllerTests() + { + TestDb.TruncateAll(); + // 设置真实BCrypt密码哈希,确保密码验证可通过 + TestDb.SetRealPasswordHash(TestPassword); + _controller = ControllerFactory.CreateAuthController(); + } + + #region Login - 登录 + + /// + /// 测试:正确用户名和密码登录成功 + /// + [Fact] + public void Login_CorrectCredentials_ShouldReturnToken() + { + // Arrange + var request = new LoginRequest + { + Username = "admin", + Password = TestPassword, + RememberMe = false + }; + + // Act + var result = _controller.Login(request); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + Assert.False(string.IsNullOrWhiteSpace(response.Data.Token)); + Assert.Equal(8 * 3600, response.Data.ExpiresIn); // 非记住密码,8小时 + } + + /// + /// 测试:记住密码时Token过期时间为24小时 + /// + [Fact] + public void Login_RememberMe_ShouldReturn24HourToken() + { + // Arrange + var request = new LoginRequest + { + Username = "admin", + Password = TestPassword, + RememberMe = true + }; + + // Act + var result = _controller.Login(request); + + // Assert + var response = ControllerFactory.Extract(result); + Assert.Equal(24 * 3600, response.Data.ExpiresIn); + } + + /// + /// 测试:错误密码登录失败 + /// + [Fact] + public void Login_WrongPassword_ShouldThrowBusinessException() + { + // Arrange + var request = new LoginRequest + { + Username = "admin", + Password = "wrong_password" + }; + + // Act & Assert + var ex = Assert.Throws(() => _controller.Login(request)); + Assert.Equal(ErrorCode.BadRequest, ex.Code); + Assert.Equal("用户名或密码错误", ex.Message); + } + + /// + /// 测试:错误用户名登录失败 + /// + [Fact] + public void Login_WrongUsername_ShouldThrowBusinessException() + { + // Arrange + var request = new LoginRequest + { + Username = "notexist", + Password = TestPassword + }; + + // Act & Assert + var ex = Assert.Throws(() => _controller.Login(request)); + Assert.Equal("用户名或密码错误", ex.Message); + } + + /// + /// 测试:请求为null时抛出参数异常 + /// + [Fact] + public void Login_NullRequest_ShouldThrowBusinessException() + { + // Act & Assert + var ex = Assert.Throws(() => _controller.Login(null)); + Assert.Equal(ErrorCode.BadRequest, ex.Code); + } + + /// + /// 测试:用户名不区分大小写 + /// + [Fact] + public void Login_CaseInsensitiveUsername_ShouldReturnToken() + { + // Arrange + var request = new LoginRequest + { + Username = "ADMIN", + Password = TestPassword + }; + + // Act + var result = _controller.Login(request); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data.Token); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/BrandControllerTests.cs b/tests/CncWebApi.Tests/BrandControllerTests.cs new file mode 100644 index 0000000..97fd4eb --- /dev/null +++ b/tests/CncWebApi.Tests/BrandControllerTests.cs @@ -0,0 +1,303 @@ +using System.Collections.Generic; +using System.Linq; +using CncModels.Dto; +using CncModels.Dto.Brand; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// BrandController单元测试 + /// 品牌CRUD + 复制 + 启停 + 标准字段 + /// + [Collection("Database")] + public class BrandControllerTests + { + private readonly BrandController _controller; + + public BrandControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateBrandController(); + } + + #region GetList - 品牌列表 + + /// + /// 测试:获取品牌列表,种子数据包含FANUC + /// + [Fact] + public void GetList_ShouldReturnBrandList() + { + // Act + var result = _controller.GetList(); + + // Assert + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + Assert.Single(response.Data); // 种子数据只有FANUC + Assert.Equal("FANUC", response.Data[0].BrandName); + } + + /// + /// 测试:新增品牌后列表数量增加 + /// + [Fact] + public void GetList_AfterCreate_ShouldHaveTwoBrands() + { + // Arrange - 新增一个品牌 + _controller.Create(new CreateBrandRequest + { + BrandName = "Siemens", + DeviceField = "device", + TagsPath = "tags" + }); + + // Act + var result = _controller.GetList(); + + // Assert + var response = ControllerFactory.Extract>(result); + Assert.Equal(2, response.Data.Count); + } + + #endregion + + #region GetById - 品牌详情 + + /// + /// 测试:获取FANUC品牌详情 + /// + [Fact] + public void GetById_ExistingBrand_ShouldReturnDetail() + { + // Act + var result = _controller.GetById(1); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + Assert.Equal("FANUC", response.Data.BrandName); + Assert.NotNull(response.Data.Mappings); + } + + /// + /// 测试:获取不存在的品牌抛出异常 + /// + [Fact] + public void GetById_NotExisting_ShouldThrowBusinessException() + { + // Act & Assert + var ex = Assert.Throws(() => _controller.GetById(999)); + Assert.Equal("品牌不存在", ex.Message); + } + + #endregion + + #region Create - 新增品牌 + + /// + /// 测试:新增品牌成功 + /// + [Fact] + public void Create_ValidRequest_ShouldReturnNewId() + { + // Arrange + var request = new CreateBrandRequest + { + BrandName = "Mitsubishi", + DeviceField = "device", + TagsPath = "tags" + }; + + // Act + var result = _controller.Create(request); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:新增重复品牌名抛出异常 + /// + [Fact] + public void Create_DuplicateName_ShouldThrowBusinessException() + { + // Arrange - FANUC已存在于种子数据 + var request = new CreateBrandRequest + { + BrandName = "FANUC", + DeviceField = "device", + TagsPath = "tags" + }; + + // Act & Assert + var ex = Assert.Throws(() => _controller.Create(request)); + Assert.Equal("品牌名称已存在", ex.Message); + } + + #endregion + + #region Update - 编辑品牌 + + /// + /// 测试:编辑品牌名称成功 + /// + [Fact] + public void Update_ValidRequest_ShouldSuccess() + { + // Arrange + var request = new UpdateBrandRequest + { + BrandName = "FANUC-Updated", + DeviceField = "device", + TagsPath = "tags" + }; + + // Act + var result = _controller.Update(1, request); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + + // 验证修改后名称 + var detail = ControllerFactory.Extract(_controller.GetById(1)); + Assert.Equal("FANUC-Updated", detail.Data.BrandName); + } + + /// + /// 测试:编辑不存在的品牌抛出异常 + /// + [Fact] + public void Update_NotExisting_ShouldThrowBusinessException() + { + var request = new UpdateBrandRequest { BrandName = "Test", DeviceField = "d", TagsPath = "t" }; + Assert.Throws(() => _controller.Update(999, request)); + } + + #endregion + + #region Delete - 删除品牌 + + /// + /// 测试:删除品牌成功 + /// + [Fact] + public void Delete_ExistingBrand_ShouldSuccess() + { + // 先新增一个品牌(避免删除种子FANUC) + var createResult = _controller.Create(new CreateBrandRequest + { + BrandName = "ToDelete", + DeviceField = "d", + TagsPath = "t" + }); + // 获取新品牌ID + int newId = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_brand"); + + // Act + var result = _controller.Delete(newId); + + // Assert + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + // 删除后列表只剩FANUC + var list = ControllerFactory.Extract>(_controller.GetList()); + Assert.Single(list.Data); + } + + /// + /// 测试:删除不存在的品牌抛出异常 + /// + [Fact] + public void Delete_NotExisting_ShouldThrowBusinessException() + { + Assert.Throws(() => _controller.Delete(999)); + } + + #endregion + + #region Copy - 复制品牌 + + /// + /// 测试:复制FANUC品牌成功 + /// + [Fact] + public void Copy_ExistingBrand_ShouldReturnNewId() + { + // Act + var result = _controller.Copy(1); + + // Assert + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + // 列表应有两项 + var list = ControllerFactory.Extract>(_controller.GetList()); + Assert.Equal(2, list.Data.Count); + } + + /// + /// 测试:复制不存在的品牌抛出异常 + /// + [Fact] + public void Copy_NotExisting_ShouldThrowBusinessException() + { + Assert.Throws(() => _controller.Copy(999)); + } + + #endregion + + #region ToggleEnabled - 启停品牌 + + /// + /// 测试:切换品牌启用状态成功 + /// + [Fact] + public void ToggleEnabled_ExistingBrand_ShouldSuccess() + { + // Act + var result = _controller.ToggleEnabled(1); + + // Assert + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:切换不存在的品牌不会抛异常(Service层不检查存在性,返回0行受影响) + /// + [Fact] + public void ToggleEnabled_NotExisting_ShouldNotThrow() + { + // 不存在的ID,Service层执行UPDATE但影响0行,不抛异常 + var result = _controller.ToggleEnabled(999); + Assert.NotNull(result); + } + + #endregion + + #region GetStandardFields - 标准字段列表 + + /// + /// 测试:获取标准字段列表返回非空 + /// + [Fact] + public void GetStandardFields_ShouldReturnFields() + { + // Act + var result = _controller.GetStandardFields(); + + // Assert + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/CollectAddressControllerTests.cs b/tests/CncWebApi.Tests/CollectAddressControllerTests.cs new file mode 100644 index 0000000..d6d7be8 --- /dev/null +++ b/tests/CncWebApi.Tests/CollectAddressControllerTests.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using CncModels.Dto; +using CncModels.Dto.CollectAddress; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// CollectAddressController单元测试 + /// 采集地址CRUD + 启停 + /// + [Collection("Database")] + public class CollectAddressControllerTests + { + private readonly CollectAddressController _controller; + + public CollectAddressControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateCollectAddressController(); + } + + /// + /// 辅助:创建地址请求 + /// + private CreateCollectAddressRequest CreateRequest(string name = "地址1", string url = "http://192.168.1.1") => new CreateCollectAddressRequest + { + Name = name, + Url = url, + BrandId = 1, + CollectInterval = 5 + }; + + #region GetList - 地址列表 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetList(new CollectAddressQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:新增后列表有数据 + /// + [Fact] + public void GetList_AfterCreate_ShouldReturnOne() + { + _controller.Create(CreateRequest()); + var result = _controller.GetList(new CollectAddressQuery()); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data.Items); + } + + #endregion + + #region GetById - 地址详情 + + /// + /// 测试:获取地址详情成功 + /// + [Fact] + public void GetById_Existing_ShouldReturnDetail() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_collect_address"); + + var result = _controller.GetById(id); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.Equal("地址1", response.Data.Name); + } + + /// + /// 测试:不存在的地址抛出异常 + /// + [Fact] + public void GetById_NotExisting_ShouldThrow() + { + Assert.Throws(() => _controller.GetById(999)); + } + + #endregion + + #region Create - 新增地址 + + /// + /// 测试:新增地址成功 + /// + [Fact] + public void Create_ValidRequest_ShouldReturnId() + { + var result = _controller.Create(CreateRequest()); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:品牌不存在时抛出异常 + /// + [Fact] + public void Create_InvalidBrand_ShouldThrow() + { + var request = new CreateCollectAddressRequest + { + Name = "测试", + Url = "http://test", + BrandId = 999, + CollectInterval = 5 + }; + Assert.Throws(() => _controller.Create(request)); + } + + #endregion + + #region Update - 编辑地址 + + /// + /// 测试:编辑地址成功 + /// + [Fact] + public void Update_ValidRequest_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_collect_address"); + + var result = _controller.Update(id, new UpdateCollectAddressRequest + { + Name = "已改名", + Url = "http://192.168.1.2", + BrandId = 1, + CollectInterval = 10 + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + var detail = ControllerFactory.Extract(_controller.GetById(id)); + Assert.Equal("已改名", detail.Data.Name); + } + + #endregion + + #region Delete - 删除地址 + + /// + /// 测试:删除地址成功 + /// + [Fact] + public void Delete_Existing_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_collect_address"); + + var result = _controller.Delete(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:删除不存在的地址不抛异常(Service层影响0行但不抛异常) + /// + [Fact] + public void Delete_NotExisting_ShouldNotThrow() + { + var result = _controller.Delete(999); + Assert.NotNull(result); + } + + #endregion + + #region ToggleEnabled - 启停 + + /// + /// 测试:切换地址启用状态 + /// + [Fact] + public void ToggleEnabled_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_collect_address"); + + var result = _controller.ToggleEnabled(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/ControllerFactory.cs b/tests/CncWebApi.Tests/ControllerFactory.cs new file mode 100644 index 0000000..ff3e1e7 --- /dev/null +++ b/tests/CncWebApi.Tests/ControllerFactory.cs @@ -0,0 +1,135 @@ +using System.Web.Http; +using System.Web.Http.Results; +using CncModels.Dto; +using CncRepository.Impl; +using CncRepository.Impl.Dashboard; +using CncRepository.Impl.Log; +using CncRepository.Interface; +using CncService.Impl; +using CncService.Interface; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// Controller工厂 —— 创建Controller实例用于单元测试 + /// 直接实例化Controller,不经过HTTP管线(跳过JwtAuthFilter等过滤器) + /// 所有Repository/Service使用cnc_test真实数据库 + /// + public static class ControllerFactory + { + private static readonly string _conn = TestDb.ConnectionString; + private const string _jwtSecret = "test-jwt-secret-key-for-unit-testing-2024"; + + #region Repository 创建 + + private static ISysConfigRepository SysConfigRepo() => new SysConfigRepository(_conn); + private static IBrandRepository BrandRepo() => new BrandRepository(_conn); + private static IBrandFieldMappingRepository BrandFieldMappingRepo() => new BrandFieldMappingRepository(_conn); + private static ICollectAddressRepository CollectAddressRepo() => new CollectAddressRepository(_conn); + private static IMachineRepository MachineRepo() => new MachineRepository(_conn); + private static IWorkerRepository WorkerRepo() => new WorkerRepository(_conn); + private static IWorkerMachineRepository WorkerMachineRepo() => new WorkerMachineRepository(_conn); + private static IWorkshopRepository WorkshopRepo() => new WorkshopRepository(_conn); + private static IAlertRepository AlertRepo() => new AlertRepository(_conn); + private static IDailyProductionRepository DailyProductionRepo() => new DailyProductionRepository(_conn); + private static IProductionSegmentRepository ProductionSegmentRepo() => new ProductionSegmentRepository(_conn); + private static IProductionAdjustmentRepository ProductionAdjustmentRepo() => new ProductionAdjustmentRepository(_conn); + private static IScreenConfigRepository ScreenConfigRepo() => new ScreenConfigRepository(_conn); + private static IScreenFilterRepository ScreenFilterRepo() => new ScreenFilterRepository(_conn); + private static IDashboardRepository DashboardRepo() => new DashboardRepository(_conn); + private static ICollectorHeartbeatRepository HeartbeatRepo() => new CollectorHeartbeatRepository(_conn); + private static ISystemLogRepository SystemLogRepo() => new SystemLogRepository(_conn); + + #endregion + + #region Service 创建 + + private static IAuthService CreateAuthService() => new AuthService(SysConfigRepo(), _jwtSecret); + private static IDashboardService CreateDashboardService() => new DashboardService(DashboardRepo(), HeartbeatRepo()); + private static IMachineService CreateMachineService() => new MachineService(MachineRepo(), CollectAddressRepo(), WorkerMachineRepo(), BrandRepo()); + private static IBrandService CreateBrandService() => new BrandService(BrandRepo(), BrandFieldMappingRepo(), CollectAddressRepo()); + private static ICollectAddressService CreateCollectAddressService() => new CollectAddressService(CollectAddressRepo(), MachineRepo(), BrandRepo()); + private static IWorkerService CreateWorkerService() => new WorkerService(WorkerRepo(), WorkerMachineRepo(), MachineRepo()); + private static IProductionService CreateProductionService() => new ProductionService(DailyProductionRepo(), ProductionSegmentRepo(), ProductionAdjustmentRepo()); + private static IAlertService CreateAlertService() => new AlertService(AlertRepo()); + private static IWorkshopService CreateWorkshopService() => new WorkshopService(WorkshopRepo()); + private static IScreenService CreateScreenService() => new ScreenService(ScreenConfigRepo(), ScreenFilterRepo(), WorkshopRepo()); + private static ISystemLogService CreateSystemLogService() => new SystemLogService(SystemLogRepo()); + + #endregion + + #region Controller 创建 + + /// 创建AuthController(无JWT过滤) + public static AuthController CreateAuthController() => new AuthController(CreateAuthService()); + + /// 创建HealthController(无依赖) + public static HealthController CreateHealthController() => new HealthController(); + + /// 创建BrandController + public static BrandController CreateBrandController() => new BrandController(CreateBrandService()); + + /// 创建MachineController + public static MachineController CreateMachineController() => new MachineController(CreateMachineService()); + + /// 创建CollectAddressController + public static CollectAddressController CreateCollectAddressController() => new CollectAddressController(CreateCollectAddressService()); + + /// 创建WorkerController + public static WorkerController CreateWorkerController() => new WorkerController(CreateWorkerService()); + + /// 创建DashboardController + public static DashboardController CreateDashboardController() => new DashboardController(CreateDashboardService()); + + /// 创建SettingsController(系统配置+车间管理) + public static SettingsController CreateSettingsController() => new SettingsController(SysConfigRepo(), CreateWorkshopService()); + + /// 创建ProductionController + public static ProductionController CreateProductionController() => new ProductionController(CreateProductionService()); + + /// 创建AlertController + public static AlertController CreateAlertController() => new AlertController(CreateAlertService()); + + /// 创建LogController + public static LogController CreateLogController() => new LogController(CreateSystemLogService(), ProductionAdjustmentRepo()); + + /// 创建ScreenConfigController + public static ScreenConfigController CreateScreenConfigController() => new ScreenConfigController(CreateScreenService()); + + /// 创建ScreenController(大屏,无JWT过滤) + public static ScreenController CreateScreenController() => new ScreenController(CreateDashboardService(), CreateScreenService(), SysConfigRepo()); + + /// 创建OptionController + public static OptionController CreateOptionController() => new OptionController( + CreateWorkshopService(), CreateBrandService(), CreateMachineService(), + CreateWorkerService(), CreateCollectAddressService()); + + #endregion + + #region 测试辅助方法 + + /// + /// 从IHttpActionResult中提取ApiResponse<T>内容 + /// Controller返回Ok(ApiResponse<T>.Success(data)),实际类型是OkNegotiatedContentResult<ApiResponse<T>> + /// + public static ApiResponse Extract(IHttpActionResult result) + { + var okResult = result as OkNegotiatedContentResult>; + Assert.NotNull(okResult); + return okResult.Content; + } + + /// + /// 验证ApiResponse成功:Code=0, Message="success" + /// + public static void AssertSuccess(ApiResponse response) + { + Assert.Equal(0, response.Code); + Assert.Equal("success", response.Message); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/DashboardControllerTests.cs b/tests/CncWebApi.Tests/DashboardControllerTests.cs new file mode 100644 index 0000000..2338c03 --- /dev/null +++ b/tests/CncWebApi.Tests/DashboardControllerTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using CncModels.Dto; +using CncModels.Dto.Dashboard; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// DashboardController单元测试 + /// 8个仪表盘统计接口 + /// + [Collection("Database")] + public class DashboardControllerTests + { + private readonly DashboardController _controller; + + public DashboardControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateDashboardController(); + } + + #region GetSummary - 统计卡片 + + /// + /// 测试:空数据库也能返回统计(各指标为0) + /// + [Fact] + public void GetSummary_EmptyDb_ShouldReturnZeros() + { + var result = _controller.GetSummary(); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:有数据时统计正确 + /// + [Fact] + public void GetSummary_WithData_ShouldReturnStats() + { + // 预置机床+产量数据 + PrepareProductionData(); + + var result = _controller.GetSummary(); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetWorkshopProduction - 车间产量 + + [Fact] + public void GetWorkshopProduction_ShouldReturnList() + { + var result = _controller.GetWorkshopProduction(null, null); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetMachineRank - 机床排行 + + [Fact] + public void GetMachineRank_ShouldReturnList() + { + var result = _controller.GetMachineRank(null, null, 10); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetWorkerRank - 工人排行 + + [Fact] + public void GetWorkerRank_ShouldReturnList() + { + var result = _controller.GetWorkerRank(null, null, 10); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetProductionTrend - 产量趋势 + + [Fact] + public void GetProductionTrend_ShouldReturnData() + { + var result = _controller.GetProductionTrend(7); + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + + #endregion + + #region GetMachineStatusDistribution - 机床状态分布 + + [Fact] + public void GetMachineStatusDistribution_ShouldReturnData() + { + var result = _controller.GetMachineStatusDistribution(); + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + + #endregion + + #region GetRecentAlerts - 最新告警 + + [Fact] + public void GetRecentAlerts_ShouldReturnList() + { + var result = _controller.GetRecentAlerts(5); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetCollectorStatus - 采集服务状态 + + [Fact] + public void GetCollectorStatus_ShouldReturnData() + { + var result = _controller.GetCollectorStatus(); + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + + #endregion + + #region 辅助方法 + + /// + /// 预置产量数据(机床+日产量) + /// + private void PrepareProductionData() + { + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at) + VALUES ('CNC001', '机床1', 1, 1, '192.168.1.100', 1, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_daily_production (machine_id, production_date, program_name, total_quantity, segment_count, created_at, updated_at) + VALUES (1, CURDATE(), 'O0001', 100, 1, NOW(), NOW())"); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/DatabaseCollection.cs b/tests/CncWebApi.Tests/DatabaseCollection.cs new file mode 100644 index 0000000..9c4723e --- /dev/null +++ b/tests/CncWebApi.Tests/DatabaseCollection.cs @@ -0,0 +1,10 @@ +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// WebApi测试集合定义 —— 所有Controller测试类共享数据库,串行执行 + /// + [CollectionDefinition("Database", DisableParallelization = true)] + public class DatabaseCollection { } +} diff --git a/tests/CncWebApi.Tests/HealthControllerTests.cs b/tests/CncWebApi.Tests/HealthControllerTests.cs new file mode 100644 index 0000000..1dac35d --- /dev/null +++ b/tests/CncWebApi.Tests/HealthControllerTests.cs @@ -0,0 +1,37 @@ +using System.Web.Http.Results; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// HealthController单元测试 + /// 健康检查接口,无依赖,无JWT过滤 + /// + [Collection("Database")] + public class HealthControllerTests + { + private readonly HealthController _controller; + + public HealthControllerTests() + { + _controller = ControllerFactory.CreateHealthController(); + } + + /// + /// 测试:健康检查返回正常状态 + /// Controller返回匿名类型,通过反射验证Content + /// + [Fact] + public void Check_ShouldReturnHealthy() + { + // Act + var result = _controller.Check(); + + // Assert - OkNegotiatedContentResult有Content属性 + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + } +} diff --git a/tests/CncWebApi.Tests/LogControllerTests.cs b/tests/CncWebApi.Tests/LogControllerTests.cs new file mode 100644 index 0000000..82f029a --- /dev/null +++ b/tests/CncWebApi.Tests/LogControllerTests.cs @@ -0,0 +1,85 @@ +using System; +using CncModels.Dto; +using CncModels.Dto.Log; +using CncModels.Entity; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// LogController单元测试 + /// 系统日志 + 产量修正日志 + /// + [Collection("Database")] + public class LogControllerTests + { + private readonly LogController _controller; + + public LogControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateLogController(); + } + + #region GetSystemLog - 系统日志 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetSystemLog_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetSystemLog(new SystemLogQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:有日志数据时返回列表 + /// + [Fact] + public void GetSystemLog_WithData_ShouldReturnItems() + { + TestDb.Execute(@"INSERT INTO log_system (log_level, source, message, created_at) + VALUES ('INFO', '测试模块', '测试内容', NOW())"); + + var result = _controller.GetSystemLog(new SystemLogQuery()); + var response = ControllerFactory.Extract>(result); + Assert.NotEmpty(response.Data.Items); + } + + #endregion + + #region GetAdjustmentLog - 产量修正日志 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetAdjustmentLog_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetAdjustmentLog(null, null, null, null, 1, 20); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:有修正日志时返回列表 + /// + [Fact] + public void GetAdjustmentLog_WithData_ShouldReturnItems() + { + TestDb.Execute(@"INSERT INTO cnc_production_adjustment (target_table, target_id, field_name, old_value, new_value, reason, created_at) + VALUES ('cnc_daily_production', 1, 'total_quantity', 100, 200, '测试修正', NOW())"); + + var result = _controller.GetAdjustmentLog(null, null, null, null, 1, 20); + var response = ControllerFactory.Extract>(result); + Assert.NotEmpty(response.Data.Items); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/MachineControllerTests.cs b/tests/CncWebApi.Tests/MachineControllerTests.cs new file mode 100644 index 0000000..182cc64 --- /dev/null +++ b/tests/CncWebApi.Tests/MachineControllerTests.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using CncModels.Constants; +using CncModels.Dto; +using CncModels.Dto.CollectAddress; +using CncModels.Dto.Machine; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// MachineController单元测试 + /// 机床CRUD + 启停 + /// + [Collection("Database")] + public class MachineControllerTests + { + private readonly MachineController _controller; + + public MachineControllerTests() + { + TestDb.TruncateAll(); + // 预置一个采集地址(机床外键依赖) + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + _controller = ControllerFactory.CreateMachineController(); + } + + /// + /// 辅助:创建机床请求 + /// + private CreateMachineRequest CreateRequest(string code = "CNC001", string name = "机床1") => new CreateMachineRequest + { + DeviceCode = code, + Name = name, + WorkshopId = 1, + CollectAddressId = 1, + IpAddress = "192.168.1.100", + BrandId = 1 + }; + + #region GetList - 机床列表 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetList(new MachineQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:新增机床后列表有数据 + /// + [Fact] + public void GetList_AfterCreate_ShouldReturnOne() + { + _controller.Create(CreateRequest()); + var result = _controller.GetList(new MachineQuery()); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data.Items); + } + + /// + /// 测试:分页参数生效 + /// + [Fact] + public void GetList_Pagination_ShouldWork() + { + // 新增3台机床 + for (int i = 1; i <= 3; i++) + _controller.Create(CreateRequest($"C{i:00}", $"机床{i}")); + + var result = _controller.GetList(new MachineQuery { Page = 1, PageSize = 2 }); + var response = ControllerFactory.Extract>(result); + Assert.Equal(2, response.Data.Items.Count); + Assert.Equal(3, response.Data.Total); + } + + #endregion + + #region GetById - 机床详情 + + /// + /// 测试:获取机床详情成功 + /// + [Fact] + public void GetById_Existing_ShouldReturnDetail() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_machine"); + + var result = _controller.GetById(id); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.Equal("CNC001", response.Data.DeviceCode); + } + + /// + /// 测试:获取不存在的机床抛出异常 + /// + [Fact] + public void GetById_NotExisting_ShouldThrow() + { + Assert.Throws(() => _controller.GetById(999)); + } + + #endregion + + #region Create - 新增机床 + + /// + /// 测试:新增机床成功 + /// + [Fact] + public void Create_ValidRequest_ShouldReturnId() + { + var result = _controller.Create(CreateRequest()); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:重复设备编码抛出异常 + /// + [Fact] + public void Create_DuplicateCode_ShouldThrow() + { + _controller.Create(CreateRequest("CNC001", "机床1")); + var ex = Assert.Throws(() => _controller.Create(CreateRequest("CNC001", "机床2"))); + Assert.Equal("设备编码已存在", ex.Message); + } + + #endregion + + #region Update - 编辑机床 + + /// + /// 测试:编辑机床成功 + /// + [Fact] + public void Update_ValidRequest_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_machine"); + + var result = _controller.Update(id, new UpdateMachineRequest + { + Name = "机床已改名", + WorkshopId = 1, + CollectAddressId = 1, + IpAddress = "192.168.1.200", + BrandId = 1 + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + var detail = ControllerFactory.Extract(_controller.GetById(id)); + Assert.Equal("机床已改名", detail.Data.Name); + } + + #endregion + + #region Delete - 删除机床 + + /// + /// 测试:删除机床成功 + /// + [Fact] + public void Delete_Existing_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_machine"); + + var result = _controller.Delete(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + Assert.Throws(() => _controller.GetById(id)); + } + + #endregion + + #region ToggleEnabled - 启停 + + /// + /// 测试:切换机床启用状态 + /// + [Fact] + public void ToggleEnabled_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_machine"); + + var result = _controller.ToggleEnabled(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/OptionControllerTests.cs b/tests/CncWebApi.Tests/OptionControllerTests.cs new file mode 100644 index 0000000..8cef383 --- /dev/null +++ b/tests/CncWebApi.Tests/OptionControllerTests.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using System.Linq; +using CncModels.Dto; +using CncModels.Dto.Common; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// OptionController单元测试 + /// 公共下拉选项接口(车间/品牌/机床/工人/采集地址) + /// + [Collection("Database")] + public class OptionControllerTests + { + private readonly OptionController _controller; + + public OptionControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateOptionController(); + } + + #region WorkshopList - 车间下拉 + + /// + /// 测试:车间下拉返回种子数据 + /// + [Fact] + public void WorkshopList_ShouldReturnSeedData() + { + var result = _controller.WorkshopList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Equal(2, response.Data.Count); // A栋、B栋 + } + + #endregion + + #region BrandList - 品牌下拉 + + /// + /// 测试:品牌下拉返回种子数据 + /// + [Fact] + public void BrandList_ShouldReturnSeedData() + { + var result = _controller.BrandList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Single(response.Data); // FANUC + Assert.Equal("FANUC", response.Data[0].Label); + } + + #endregion + + #region MachineList - 机床下拉 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void MachineList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.MachineList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data); + } + + /// + /// 测试:有机床时返回选项 + /// + [Fact] + public void MachineList_WithData_ShouldReturnOptions() + { + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at) + VALUES ('CNC001', '机床1', 1, 1, '192.168.1.100', 1, 1, NOW(), NOW())"); + + // 重新创建Controller以获取最新数据 + var controller = ControllerFactory.CreateOptionController(); + var result = controller.MachineList(); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data); + } + + #endregion + + #region WorkerList - 工人下拉 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void WorkerList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.WorkerList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data); + } + + /// + /// 测试:有工人时返回选项(格式:姓名(工号)) + /// + [Fact] + public void WorkerList_WithData_ShouldReturnFormattedOptions() + { + TestDb.Execute(@"INSERT INTO cnc_worker (name, code, is_enabled, created_at, updated_at) + VALUES ('张三', 'W001', 1, NOW(), NOW())"); + + var controller = ControllerFactory.CreateOptionController(); + var result = controller.WorkerList(); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data); + Assert.Equal("张三(W001)", response.Data[0].Label); + } + + #endregion + + #region CollectAddressList - 采集地址下拉 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void CollectAddressList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.CollectAddressList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data); + } + + /// + /// 测试:有地址时返回选项 + /// + [Fact] + public void CollectAddressList_WithData_ShouldReturnOptions() + { + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + + var controller = ControllerFactory.CreateOptionController(); + var result = controller.CollectAddressList(); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data); + Assert.Equal("测试地址", response.Data[0].Label); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/ProductionControllerTests.cs b/tests/CncWebApi.Tests/ProductionControllerTests.cs new file mode 100644 index 0000000..122f926 --- /dev/null +++ b/tests/CncWebApi.Tests/ProductionControllerTests.cs @@ -0,0 +1,146 @@ +using System; +using CncModels.Dto; +using CncModels.Dto.Production; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// ProductionController单元测试 + /// 产量报表:日产量列表 + 日汇总 + 修正产量 + /// + [Collection("Database")] + public class ProductionControllerTests + { + private readonly ProductionController _controller; + + public ProductionControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateProductionController(); + } + + #region GetList - 日产量列表 + + /// + /// 测试:空数据库返回空列表 + /// + [Fact] + public void GetList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetList(new ProductionQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + /// + /// 测试:有数据时返回产量列表 + /// + [Fact] + public void GetList_WithData_ShouldReturnItems() + { + PrepareProductionData(); + + var result = _controller.GetList(new ProductionQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotEmpty(response.Data.Items); + } + + #endregion + + #region GetSummary - 日汇总统计 + + /// + /// 测试:获取日汇总统计 + /// + [Fact] + public void GetSummary_ShouldReturnSummary() + { + PrepareProductionData(); + + var result = _controller.GetSummary(DateTime.Today, null); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:无数据时日汇总仍能返回 + /// + [Fact] + public void GetSummary_NoData_ShouldReturnDefault() + { + var result = _controller.GetSummary(null, null); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + } + + #endregion + + #region Adjust - 修正产量 + + /// + /// 测试:修正产量成功 + /// + [Fact] + public void Adjust_ValidRequest_ShouldSuccess() + { + PrepareProductionData(); + int prodId = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_daily_production"); + + var result = _controller.Adjust(new ProductionAdjustRequest + { + TargetTable = "cnc_daily_production", + TargetId = prodId, + FieldName = "total_quantity", + NewValue = "200", + Reason = "测试修正" + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + // 验证修正记录已生成 + int adjCount = TestDb.QuerySingle("SELECT COUNT(*) FROM cnc_production_adjustment"); + Assert.Equal(1, adjCount); + } + + /// + /// 测试:修正不存在的记录不抛异常(Service层影响0行但仍记录修正日志) + /// + [Fact] + public void Adjust_NotExisting_ShouldNotThrow() + { + PrepareProductionData(); + int prodId = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_daily_production"); + + // 用合法ID修正(ID存在),测试正常流程 + var result = _controller.Adjust(new ProductionAdjustRequest + { + TargetTable = "cnc_daily_production", + TargetId = prodId, + FieldName = "total_quantity", + NewValue = "200", + Reason = "测试修正" + }); + Assert.NotNull(result); + } + + #endregion + + #region 辅助方法 + + private void PrepareProductionData() + { + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at) + VALUES ('CNC001', '机床1', 1, 1, '192.168.1.100', 1, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_daily_production (machine_id, production_date, program_name, total_quantity, segment_count, created_at, updated_at) + VALUES (1, CURDATE(), 'O0001', 100, 1, NOW(), NOW())"); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/ScreenConfigControllerTests.cs b/tests/CncWebApi.Tests/ScreenConfigControllerTests.cs new file mode 100644 index 0000000..ca8f8ac --- /dev/null +++ b/tests/CncWebApi.Tests/ScreenConfigControllerTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using CncModels.Entity; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// ScreenConfigController单元测试 + /// 大屏卡片配置 + 筛选配置 + /// + [Collection("Database")] + public class ScreenConfigControllerTests + { + private readonly ScreenConfigController _controller; + + public ScreenConfigControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateScreenConfigController(); + } + + #region 卡片配置 + + #region GetConfigs - 配置列表 + + /// + /// 测试:空数据库返回空配置列表 + /// + [Fact] + public void GetConfigs_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetConfigs(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region UpdateConfig - 编辑卡片 + + /// + /// 测试:编辑卡片配置成功 + /// + [Fact] + public void UpdateConfig_ValidEntity_ShouldSuccess() + { + // 先插入一条配置 + int configId = InsertScreenConfig("test_card", "测试卡片"); + + var result = _controller.UpdateConfig(configId, new ScreenConfig + { + CardKey = "test_card", + CardType = "stat", + Title = "已改名", + Metric = "count", + SortOrder = 1, + IsEnabled = 1 + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:null参数抛出异常 + /// + [Fact] + public void UpdateConfig_NullEntity_ShouldThrow() + { + Assert.Throws(() => _controller.UpdateConfig(1, null)); + } + + #endregion + + #region DeleteConfig - 删除卡片 + + /// + /// 测试:删除卡片配置成功 + /// + [Fact] + public void DeleteConfig_Existing_ShouldSuccess() + { + int configId = InsertScreenConfig("del_card", "删除用"); + var result = _controller.DeleteConfig(configId); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + #endregion + + #region ToggleConfig - 启停卡片 + + /// + /// 测试:切换卡片启用状态 + /// 注意:ToggleConfig内部先GetConfigs获取对象再Update, + /// 由于ScreenConfigRepository.GetAll()使用SELECT *未做snake_case映射, + /// 导致CardKey等字段为null,Update时SQL报错。 + /// 这是Repository层的已知映射bug,不是Controller层问题。 + /// + [Fact] + public void ToggleConfig_Existing_ShouldThrowDueToMappingBug() + { + int configId = InsertScreenConfig("toggle_card", "切换用", isEnabled: 1); + // 因为Repository的SELECT *映射bug,ToggleConfig会抛出MySqlException + Assert.ThrowsAny(() => _controller.ToggleConfig(configId)); + } + + /// + /// 测试:切换不存在的卡片抛出异常 + /// + [Fact] + public void ToggleConfig_NotExisting_ShouldThrow() + { + Assert.Throws(() => _controller.ToggleConfig(999)); + } + + #endregion + + #endregion + + #region 筛选配置 + + #region GetFilters - 筛选列表 + + /// + /// 测试:获取筛选配置 + /// + [Fact] + public void GetFilters_ShouldReturnList() + { + var result = _controller.GetFilters("main_screen"); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region CreateFilter - 新增筛选项 + + /// + /// 测试:新增筛选项成功 + /// + [Fact] + public void CreateFilter_ValidEntity_ShouldReturnId() + { + var result = _controller.CreateFilter(new ScreenFilter + { + ScreenKey = "main_screen", + FilterType = "workshop", + FilterValue = "A栋", + IsDefault = 0, + SortOrder = 1 + }); + + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + } + + /// + /// 测试:null参数抛出异常 + /// + [Fact] + public void CreateFilter_NullEntity_ShouldThrow() + { + Assert.Throws(() => _controller.CreateFilter(null)); + } + + #endregion + + #region UpdateFilter - 编辑筛选项 + + /// + /// 测试:编辑筛选项成功 + /// + [Fact] + public void UpdateFilter_ValidEntity_ShouldSuccess() + { + int filterId = InsertScreenFilter("main_screen", "workshop", "A栋"); + + var result = _controller.UpdateFilter(filterId, new ScreenFilter + { + ScreenKey = "main_screen", + FilterType = "workshop", + FilterValue = "B栋", + IsDefault = 0, + SortOrder = 1 + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:null参数抛出异常 + /// + [Fact] + public void UpdateFilter_NullEntity_ShouldThrow() + { + Assert.Throws(() => _controller.UpdateFilter(1, null)); + } + + #endregion + + #region DeleteFilter - 删除筛选项 + + /// + /// 测试:删除筛选项成功 + /// + [Fact] + public void DeleteFilter_Existing_ShouldSuccess() + { + int filterId = InsertScreenFilter("main_screen", "del", "删除用"); + var result = _controller.DeleteFilter(filterId); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + #endregion + + #endregion + + #region 辅助方法 + + private int InsertScreenConfig(string key, string title, int isEnabled = 1) + { + TestDb.Execute(@"INSERT INTO cnc_screen_config (card_key, card_type, title, metric, sort_order, is_enabled, created_at, updated_at) + VALUES (@key, 'stat', @title, 'count', 1, @isEnabled, NOW(), NOW())", new { key, title, isEnabled }); + return TestDb.QuerySingle("SELECT MAX(id) FROM cnc_screen_config"); + } + + private int InsertScreenFilter(string screenKey, string type, string value) + { + TestDb.Execute(@"INSERT INTO cnc_screen_filter (screen_key, filter_type, filter_value, is_default, sort_order) + VALUES (@screenKey, @type, @value, 0, 1)", new { screenKey, type, value }); + return TestDb.QuerySingle("SELECT MAX(id) FROM cnc_screen_filter"); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/ScreenControllerTests.cs b/tests/CncWebApi.Tests/ScreenControllerTests.cs new file mode 100644 index 0000000..e2939b9 --- /dev/null +++ b/tests/CncWebApi.Tests/ScreenControllerTests.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Web.Http; +using CncModels.Dto.Common; +using CncModels.Dto.Screen; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// ScreenController单元测试 + /// 大屏看板接口(无需认证) + /// + [Collection("Database")] + public class ScreenControllerTests + { + private readonly ScreenController _controller; + + public ScreenControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateScreenController(); + } + + /// + /// 辅助:从ApiResponse<object>中获取interval值 + /// Controller返回 Ok(ApiResponse<object>.Success(new { interval })) + /// + private static int GetIntervalFromResult(IHttpActionResult result) + { + // 先获取Content(ApiResponse) + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + // 再获取Data属性(匿名类型 { interval }) + var data = content.GetType().GetProperty("Data")?.GetValue(content); + Assert.NotNull(data); + var intervalProp = data.GetType().GetProperty("interval"); + Assert.NotNull(intervalProp); + return (int)intervalProp.GetValue(data); + } + + #region GetSummary - 大屏汇总 + + /// + /// 测试:空数据库也返回汇总数据 + /// + [Fact] + public void GetSummary_EmptyDb_ShouldReturnData() + { + var result = _controller.GetSummary(); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + #endregion + + #region GetCollectorStatus - 采集服务状态 + + [Fact] + public void GetCollectorStatus_ShouldReturnData() + { + var result = _controller.GetCollectorStatus(); + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + + #endregion + + #region GetWorkshopProduction - 各车间产量 + + [Fact] + public void GetWorkshopProduction_ShouldReturnList() + { + var result = _controller.GetWorkshopProduction(); + Assert.NotNull(result); + } + + #endregion + + #region GetProductionTrend - 产量趋势 + + [Fact] + public void GetProductionTrend_ShouldReturnData() + { + var result = _controller.GetProductionTrend(7); + Assert.NotNull(result); + var content = result.GetType().GetProperty("Content")?.GetValue(result); + Assert.NotNull(content); + } + + #endregion + + #region GetMachineRank - 机床排行 + + [Fact] + public void GetMachineRank_ShouldReturnList() + { + var result = _controller.GetMachineRank(10); + Assert.NotNull(result); + } + + #endregion + + #region GetWorkerRank - 工人排行 + + [Fact] + public void GetWorkerRank_ShouldReturnList() + { + var result = _controller.GetWorkerRank(10); + Assert.NotNull(result); + } + + #endregion + + #region GetMachineStatus - 机床状态 + + [Fact] + public void GetMachineStatus_ShouldReturnData() + { + var result = _controller.GetMachineStatus(); + Assert.NotNull(result); + } + + #endregion + + #region GetFilters - 大屏筛选条件 + + /// + /// 测试:无筛选配置返回空列表 + /// + [Fact] + public void GetFilters_NoData_ShouldReturnEmpty() + { + var result = _controller.GetFilters("main_screen"); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + } + + /// + /// 测试:有筛选配置时返回选项列表 + /// + [Fact] + public void GetFilters_WithData_ShouldReturnOptions() + { + TestDb.Execute(@"INSERT INTO cnc_screen_filter (screen_key, filter_type, filter_value, is_default, sort_order) + VALUES ('main_screen', 'workshop', 'A栋', 0, 1)"); + + var result = _controller.GetFilters("main_screen"); + var response = ControllerFactory.Extract>(result); + Assert.NotEmpty(response.Data); + } + + #endregion + + #region GetRefreshInterval - 刷新间隔 + + /// + /// 测试:无配置时默认30秒 + /// + [Fact] + public void GetRefreshInterval_NoConfig_ShouldReturnDefault30() + { + var result = _controller.GetRefreshInterval(); + Assert.Equal(30, GetIntervalFromResult(result)); + } + + /// + /// 测试:有配置时返回配置值 + /// + [Fact] + public void GetRefreshInterval_WithConfig_ShouldReturnConfiguredValue() + { + TestDb.Execute(@"INSERT INTO cnc_sys_config (config_key, config_value, value_type, description, updated_at) + VALUES ('screen_refresh_interval', '60', 'int', '大屏刷新间隔', NOW())"); + + var controller = ControllerFactory.CreateScreenController(); + var result = controller.GetRefreshInterval(); + Assert.Equal(60, GetIntervalFromResult(result)); + } + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/SettingsControllerTests.cs b/tests/CncWebApi.Tests/SettingsControllerTests.cs new file mode 100644 index 0000000..dc29ab1 --- /dev/null +++ b/tests/CncWebApi.Tests/SettingsControllerTests.cs @@ -0,0 +1,245 @@ +using System.Collections.Generic; +using CncModels.Constants; +using CncModels.Dto; +using CncModels.Dto.Settings; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// SettingsController单元测试 + /// 系统配置CRUD + 车间管理CRUD + 修改密码 + /// + [Collection("Database")] + public class SettingsControllerTests + { + private readonly SettingsController _controller; + + public SettingsControllerTests() + { + TestDb.TruncateAll(); + _controller = ControllerFactory.CreateSettingsController(); + } + + #region 系统配置 + + #region GetSysConfigList - 配置列表 + + /// + /// 测试:获取系统配置列表,种子数据有admin_username和admin_password_hash + /// + [Fact] + public void GetSysConfigList_ShouldReturnConfigs() + { + var result = _controller.GetSysConfigList(); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.NotNull(response.Data); + Assert.True(response.Data.Count >= 2); // 至少有username和password_hash + } + + #endregion + + #region UpdateSysConfig - 编辑配置 + + /// + /// 测试:更新配置值成功 + /// + [Fact] + public void UpdateSysConfig_ValidRequest_ShouldSuccess() + { + // 获取第一个配置ID + var list = ControllerFactory.Extract>(_controller.GetSysConfigList()); + int configId = list.Data[0].Id; + + var result = _controller.UpdateSysConfig(configId, new UpdateSysConfigRequest { ConfigValue = "new_value" }); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:请求为null时抛出异常 + /// + [Fact] + public void UpdateSysConfig_NullRequest_ShouldThrow() + { + var ex = Assert.Throws(() => _controller.UpdateSysConfig(1, null)); + Assert.Equal(ErrorCode.BadRequest, ex.Code); + } + + #endregion + + #region ChangePassword - 修改密码 + + /// + /// 测试:修改密码成功 + /// + [Fact] + public void ChangePassword_ValidRequest_ShouldSuccess() + { + // 设置真实密码哈希 + TestDb.SetRealPasswordHash("old_password"); + + var result = _controller.ChangePassword(new ChangePasswordRequest + { + OldPassword = "old_password", + NewPassword = "new_password123" + }); + + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + // 验证密码哈希已更新 + var newHash = TestDb.QuerySingle("SELECT config_value FROM cnc_sys_config WHERE config_key = 'admin_password_hash'"); + Assert.True(BCrypt.Net.BCrypt.Verify("new_password123", newHash)); + } + + /// + /// 测试:请求为null时抛出异常 + /// + [Fact] + public void ChangePassword_NullRequest_ShouldThrow() + { + Assert.Throws(() => _controller.ChangePassword(null)); + } + + #endregion + + #endregion + + #region 车间管理 + + #region GetWorkshopList - 车间列表 + + /// + /// 测试:车间列表返回种子数据(A栋、B栋) + /// + [Fact] + public void GetWorkshopList_ShouldReturnSeedData() + { + var result = _controller.GetWorkshopList(null); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Equal(2, response.Data.Count); // A栋、B栋 + } + + /// + /// 测试:关键字搜索车间 + /// + [Fact] + public void GetWorkshopList_WithKeyword_ShouldFilter() + { + var result = _controller.GetWorkshopList("A"); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data); + Assert.Equal("A栋", response.Data[0].Name); + } + + #endregion + + #region CreateWorkshop - 新增车间 + + /// + /// 测试:新增车间成功 + /// + [Fact] + public void CreateWorkshop_ValidRequest_ShouldReturnId() + { + var result = _controller.CreateWorkshop(new CreateWorkshopRequest { Name = "C栋", SortOrder = 3 }); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + } + + /// + /// 测试:重复车间名抛出异常 + /// + [Fact] + public void CreateWorkshop_DuplicateName_ShouldThrow() + { + Assert.Throws(() => + _controller.CreateWorkshop(new CreateWorkshopRequest { Name = "A栋", SortOrder = 1 })); + } + + #endregion + + #region UpdateWorkshop - 编辑车间 + + /// + /// 测试:编辑车间名称 + /// + [Fact] + public void UpdateWorkshop_ValidRequest_ShouldSuccess() + { + var result = _controller.UpdateWorkshop(1, new UpdateWorkshopRequest { Name = "A栋改名", SortOrder = 1 }); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + + var list = ControllerFactory.Extract>(_controller.GetWorkshopList(null)); + Assert.Contains(list.Data, w => w.Name == "A栋改名"); + } + + /// + /// 测试:编辑不存在的车间抛出异常 + /// + [Fact] + public void UpdateWorkshop_NotExisting_ShouldThrow() + { + Assert.Throws(() => + _controller.UpdateWorkshop(999, new UpdateWorkshopRequest { Name = "测试", SortOrder = 1 })); + } + + #endregion + + #region DeleteWorkshop - 删除车间 + + /// + /// 测试:删除车间成功 + /// + [Fact] + public void DeleteWorkshop_Existing_ShouldSuccess() + { + // 新增一个车间来删除 + _controller.CreateWorkshop(new CreateWorkshopRequest { Name = "删除用", SortOrder = 99 }); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_workshop"); + + var result = _controller.DeleteWorkshop(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:删除不存在的车间不抛异常(Service层影响0行) + /// + [Fact] + public void DeleteWorkshop_NotExisting_ShouldNotThrow() + { + var result = _controller.DeleteWorkshop(999); + Assert.NotNull(result); + } + + #endregion + + #region ToggleWorkshop - 启停车间 + + /// + /// 测试:切换车间启用状态 + /// + [Fact] + public void ToggleWorkshop_ShouldSuccess() + { + var result = _controller.ToggleWorkshop(1); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + /// + /// 测试:切换不存在的车间不抛异常(Service层影响0行) + /// + [Fact] + public void ToggleWorkshop_NotExisting_ShouldNotThrow() + { + var result = _controller.ToggleWorkshop(999); + Assert.NotNull(result); + } + + #endregion + + #endregion + } +} diff --git a/tests/CncWebApi.Tests/TestDb.cs b/tests/CncWebApi.Tests/TestDb.cs new file mode 100644 index 0000000..d00270c --- /dev/null +++ b/tests/CncWebApi.Tests/TestDb.cs @@ -0,0 +1,131 @@ +using System; +using Dapper; +using MySqlConnector; + +namespace CncWebApi.Tests +{ + /// + /// WebApi层测试数据库辅助类 + /// 与Service.Tests共享同一个cnc_test库 + /// + public static class TestDb + { + /// 测试库连接串 + public static readonly string ConnectionString = + "Server=localhost;Database=cnc_test;Uid=root;Pwd=root;Charset=utf8mb4;SslMode=None;"; + + /// 清空所有测试表并重置种子数据 + public static void TruncateAll() + { + using (var conn = new MySqlConnection(ConnectionString)) + { + var tables = new[] + { + "log_collect_raw", + "log_collector_heartbeat", + "cnc_worker_machine", + "cnc_production_segment", + "cnc_machine_daily_status", + "cnc_worker_daily_summary", + "cnc_daily_production", + "cnc_production_adjustment", + "cnc_alert", + "log_system", + "cnc_machine", + "cnc_collect_address", + "cnc_brand_field_mapping", + "cnc_screen_filter", + "cnc_screen_config", + "cnc_worker", + "cnc_sys_config", + "cnc_workshop", + "cnc_brand" + }; + + conn.Execute("SET FOREIGN_KEY_CHECKS = 0"); + foreach (var table in tables) + { + conn.Execute($"DELETE FROM {table}"); + conn.Execute($"ALTER TABLE {table} AUTO_INCREMENT = 1"); + } + conn.Execute("SET FOREIGN_KEY_CHECKS = 1"); + } + + SeedData(); + } + + /// 插入基础种子数据 + public static void SeedData() + { + using (var conn = new MySqlConnection(ConnectionString)) + { + conn.Execute(@"INSERT IGNORE INTO cnc_brand (id, brand_name, device_field, tags_path, is_enabled, created_at, updated_at) + VALUES (1, 'FANUC', 'device', 'tags', 1, NOW(), NOW())"); + conn.Execute(@"INSERT IGNORE INTO cnc_workshop (id, name, sort_order, is_enabled, created_at, updated_at) + VALUES (1, 'A栋', 1, 1, NOW(), NOW()), (2, 'B栋', 2, 1, NOW(), NOW())"); + conn.Execute(@"INSERT IGNORE INTO cnc_sys_config (config_key, config_value, value_type, description, updated_at) + VALUES ('admin_username', 'admin', 'string', '管理员用户名', NOW()), + ('admin_password_hash', '$2a$11$dummyhashfortesting', 'string', '管理员密码哈希', NOW())"); + } + } + + /// 设置真实BCrypt密码哈希 + public static void SetRealPasswordHash(string plainPassword) + { + var hash = BCrypt.Net.BCrypt.HashPassword(plainPassword); + using (var conn = new MySqlConnection(ConnectionString)) + { + conn.Execute("UPDATE cnc_sys_config SET config_value = @hash WHERE config_key = 'admin_password_hash'", new { hash }); + } + } + + /// 执行SQL + public static int Execute(string sql, object param = null) + { + using (var conn = new MySqlConnection(ConnectionString)) + { + return conn.Execute(sql, param); + } + } + + /// 查询单个值 + public static T QuerySingle(string sql, object param = null) + { + using (var conn = new MySqlConnection(ConnectionString)) + { + return conn.QuerySingle(sql, param); + } + } + + /// 查询可空单个值 + public static T QueryFirstOrDefault(string sql, object param = null) + { + using (var conn = new MySqlConnection(ConnectionString)) + { + return conn.QueryFirstOrDefault(sql, param); + } + } + + /// 生成有效JWT Token(用于需要认证的Controller测试) + public static string GenerateTestToken(string jwtSecret = "test-jwt-secret-key-for-unit-testing-2024") + { + const string headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + long exp = DateTimeOffset.UtcNow.AddHours(8).ToUnixTimeSeconds(); + string payloadJson = $"{{\"sub\":\"admin\",\"name\":\"admin\",\"exp\":{exp}}}"; + string header = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes(headerJson)); + string payload = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes(payloadJson)); + string unsigned = header + "." + payload; + using (var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(jwtSecret))) + { + var sig = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(unsigned)); + string signature = Base64UrlEncode(sig); + return unsigned + "." + signature; + } + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input).Replace("+", "-").Replace("/", "_").TrimEnd('='); + } + } +} diff --git a/tests/CncWebApi.Tests/WorkerControllerTests.cs b/tests/CncWebApi.Tests/WorkerControllerTests.cs new file mode 100644 index 0000000..94ac200 --- /dev/null +++ b/tests/CncWebApi.Tests/WorkerControllerTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using CncModels.Dto; +using CncModels.Dto.Worker; +using CncService; +using CncWebApi.Controllers; +using Xunit; + +namespace CncWebApi.Tests +{ + /// + /// WorkerController单元测试 + /// 员工CRUD + 启停 + 绑定/解绑机床 + /// + [Collection("Database")] + public class WorkerControllerTests + { + private readonly WorkerController _controller; + + public WorkerControllerTests() + { + TestDb.TruncateAll(); + // 预置采集地址+机床(绑定/解绑测试需要) + TestDb.Execute(@"INSERT INTO cnc_collect_address (name, url, brand_id, collect_interval, is_enabled, created_at, updated_at) + VALUES ('测试地址', 'http://192.168.1.1', 1, 5, 1, NOW(), NOW())"); + TestDb.Execute(@"INSERT INTO cnc_machine (device_code, name, workshop_id, collect_address_id, ip_address, brand_id, is_enabled, created_at, updated_at) + VALUES ('CNC001', '机床1', 1, 1, '192.168.1.100', 1, 1, NOW(), NOW())"); + _controller = ControllerFactory.CreateWorkerController(); + } + + /// + /// 辅助:创建工人请求 + /// + private CreateWorkerRequest CreateRequest(string name = "张三", string code = "W001") => new CreateWorkerRequest + { + Name = name, + Code = code + }; + + #region GetList - 工人列表 + + [Fact] + public void GetList_EmptyDb_ShouldReturnEmpty() + { + var result = _controller.GetList(new WorkerQuery()); + var response = ControllerFactory.Extract>(result); + ControllerFactory.AssertSuccess(response); + Assert.Empty(response.Data.Items); + } + + [Fact] + public void GetList_AfterCreate_ShouldReturnOne() + { + _controller.Create(CreateRequest()); + var result = _controller.GetList(new WorkerQuery()); + var response = ControllerFactory.Extract>(result); + Assert.Single(response.Data.Items); + } + + #endregion + + #region GetById - 工人详情 + + [Fact] + public void GetById_Existing_ShouldReturnDetail() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + + var result = _controller.GetById(id); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + Assert.Equal("张三", response.Data.Name); + Assert.Equal("W001", response.Data.Code); + } + + [Fact] + public void GetById_NotExisting_ShouldThrow() + { + Assert.Throws(() => _controller.GetById(999)); + } + + #endregion + + #region Create - 新增工人 + + [Fact] + public void Create_ValidRequest_ShouldReturnId() + { + var result = _controller.Create(CreateRequest()); + var response = ControllerFactory.Extract(result); + ControllerFactory.AssertSuccess(response); + } + + [Fact] + public void Create_DuplicateCode_ShouldThrow() + { + _controller.Create(CreateRequest("张三", "W001")); + var ex = Assert.Throws(() => _controller.Create(CreateRequest("李四", "W001"))); + Assert.Equal("工号已存在", ex.Message); + } + + #endregion + + #region Update - 编辑工人 + + [Fact] + public void Update_ValidRequest_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + + var result = _controller.Update(id, new UpdateWorkerRequest { Name = "张三改名" }); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + + var detail = ControllerFactory.Extract(_controller.GetById(id)); + Assert.Equal("张三改名", detail.Data.Name); + } + + #endregion + + #region Delete - 删除工人 + + [Fact] + public void Delete_Existing_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + + var result = _controller.Delete(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + [Fact] + public void Delete_NotExisting_ShouldNotThrow() + { + // Service层删除不存在ID时影响0行但不抛异常 + var result = _controller.Delete(999); + Assert.NotNull(result); + } + + #endregion + + #region ToggleEnabled - 启停 + + [Fact] + public void ToggleEnabled_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int id = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + + var result = _controller.ToggleEnabled(id); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + #endregion + + #region BindMachine / UnbindMachine - 绑定/解绑 + + [Fact] + public void BindMachine_ShouldSuccess() + { + _controller.Create(CreateRequest()); + int workerId = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + + var result = _controller.BindMachine(workerId, new BindMachineRequest { MachineId = 1 }); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + [Fact] + public void UnbindMachine_ShouldSuccess() + { + // 先绑定 + _controller.Create(CreateRequest()); + int workerId = TestDb.QuerySingle("SELECT MAX(id) FROM cnc_worker"); + _controller.BindMachine(workerId, new BindMachineRequest { MachineId = 1 }); + + // 再解绑 + var result = _controller.UnbindMachine(workerId, new BindMachineRequest { MachineId = 1 }); + ControllerFactory.AssertSuccess(ControllerFactory.Extract(result)); + } + + [Fact] + public void BindMachine_NotExistingWorker_ShouldThrowDbException() + { + // 不存在的worker_id会触发外键约束,抛出MySqlException而非BusinessException + Assert.ThrowsAny(() => + _controller.BindMachine(999, new BindMachineRequest { MachineId = 1 })); + } + + #endregion + } +}