using Admin.NET.Core; using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Integrations.Ib43; using Evergreen.Health.Application.Health.Services; using Evergreen.Health.Application.Health.Sleep; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SqlSugar; using System.Security.Claims; using Xunit; namespace Admin.NET.Test.Health; public sealed class Ib43PersistenceTests : IDisposable { private const string PreviewAction = "dehumidifier-humidity-preview"; private readonly SqlSugarClient _db; private readonly string _databasePath = Path.Combine(Path.GetTempPath(), "ib43-test-" + Guid.NewGuid().ToString("N") + ".db"); private readonly Ib43DataRepository _repository; private readonly Clock _clock = new(); private readonly Ib43Binding _device = new() { ProductKey = "test", SerialNumber = "sim", Simulation = true }; private readonly HttpContextAccessor _context = new() { HttpContext = new DefaultHttpContext() }; private readonly HealthIb43Service _service; private readonly BedPresenceAutomation _automation; private sealed class Clock : TimeProvider { public DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1000000); public override DateTimeOffset GetUtcNow() => Now; } public Ib43PersistenceTests() { _db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath, DbType = DbType.Sqlite, IsAutoCloseConnection = false, InitKeyType = InitKeyType.Attribute, ConfigureExternalServices = new ConfigureExternalServices { EntityService = (property, column) => { // SQLite 的 rowid 必须声明 INTEGER;MySQL 生产表保持 bigint identity。 if (property.DeclaringType == typeof(HealthIb43Observation) && property.Name == "Id") column.DataType = "INTEGER"; if (Nullable.GetUnderlyingType(property.PropertyType) != null) column.IsNullable = true; } } }); _db.CodeFirst.InitTables(); _db.CodeFirst.InitTables(); _db.Ado.ExecuteCommand("CREATE TABLE SysUser (Id INTEGER PRIMARY KEY, AccountType INTEGER, Status INTEGER)"); _db.Ado.ExecuteCommand("INSERT INTO SysUser VALUES (99,999,1)"); foreach (var id in new[] { 1L, 2L, 3L }) _db.Insertable(new HealthUser { Id = id, Phone = id.ToString(), DisplayName = "test", PasswordHash = "unused", CreateTime = DateTime.UtcNow, CreateUserName = "test", UpdateUserName = "test" }).ExecuteCommand(); _repository = new(_db); _automation = CreateAutomation(_repository, PreviewAction); _service = CreateService(_repository, _automation); } private BedPresenceAutomation CreateAutomation(Ib43DataRepository repository, string secondaryAction) => new(Options.Create(new BedPresenceAutomationOptions { Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, SerialNumber = _device.SerialNumber, ActionKey = "curtain-close", TargetEntityId = "cover.test", TargetLabel = "测试窗帘", AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ProductKey = _device.ProductKey, SerialNumber = _device.SerialNumber, ActionKey = secondaryAction, TargetEntityId = "humidifier.test" }] }), [new TestAction("curtain-close", "cover.test"), new TestAction(secondaryAction, "humidifier.test")], _clock, NullLogger.Instance, new TestExecutionLog(repository)); private HealthIb43Service CreateService(Ib43DataRepository repository, BedPresenceAutomation automation) { var options = Options.Create(new Ib43Options { Enabled = true, Devices = [_device] }); return new(repository, _context, options, new(options, new(), _clock), _clock, automation); } private void User(long id) => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim("health_user_id", id.ToString())], "test")); private void Admin() => _context.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimConst.UserId, "99"), new Claim(ClaimConst.AccountType, "999")], "test")); private async Task Assign(string side, long user) { Admin(); return await _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = side, UserId = user, BedName = "测试床" }); } private Ib43Report Report(string fields) => new Ib43Parser().Parse(_device.PostTopic, "{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"sim\"," + fields + "}}", _device, _clock.Now); [Fact] public async Task Stored_sides_are_isolated_and_survive_new_store_instance() { var left = await Assign("left", 1); var right = await Assign("right", 2); await _repository.Save("a", _device, Report("\"HR\":[72,16,0,80,18,1]")); User(1); var current = await _service.Current(left.Id); Assert.Equal(72d, current.Readings["heartRate"].Value); Assert.Equal("unknown", current.Readings["presence"].Freshness); Assert.Single(await _service.Bindings()); await ExpectRejection(() => _service.Current(right.Id)); await ExpectRejection(() => _service.History(right.Id, 0, 2000000)); _db.Ado.Close(); using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath, DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }); var freshRepository = new Ib43DataRepository(reopened); Assert.Equal(80d, (await freshRepository.Latest(2, right.Id))["heartRate"].Value); var rows = await _db.Queryable().Where(o => o.UserId == 1).ToListAsync(); Assert.DoesNotContain("right", rows[0].ReadingsJson); Assert.DoesNotContain("80", rows[0].ReadingsJson); } [Fact] public async Task Partial_updates_and_zero_are_distinct_and_each_field_has_own_age() { var bed = await Assign("left", 1); await _repository.Save("a", _device, Report("\"SleepReportNew\":{\"ibNew\":[1,0,0,16,74,0,0,0,0,0,0,0]}")); _clock.Now += TimeSpan.FromSeconds(151); await _repository.Save("b", _device, Report("\"HR\":[0,0,0,80,18,0]")); User(1); var current = await _service.Current(bed.Id); Assert.Null(current.Readings["heartRate"].Value); Assert.Equal("recent", current.Readings["heartRate"].Freshness); Assert.Equal(1d, current.Readings["presence"].Value); Assert.Equal("stale", current.Readings["presence"].Freshness); Assert.Equal(2, (await _service.History(bed.Id, 0, 2000000)).Items.Count); } [Fact] public async Task Reassignment_does_not_transfer_previous_users_data_or_delayed_reports() { var oldBed = await Assign("left", 1); var pending = Report("\"HR\":[72,16,0,80,18,0]"); _clock.Now += TimeSpan.FromSeconds(1); Admin(); await _service.Release(oldBed.Id); var newBed = await Assign("left", 3); await _repository.Save("delayed", _device, pending); User(3); Assert.Equal("unknown", (await _service.Current(newBed.Id)).Readings["heartRate"].Freshness); Assert.Empty((await _service.History(newBed.Id, 0, 2000000)).Items); await ExpectRejection(() => _service.Current(oldBed.Id)); await _repository.Save("new", _device, Report("\"HR\":[90,20,0,0,0,0]")); Assert.Equal(90d, (await _service.Current(newBed.Id)).Readings["heartRate"].Value); User(1); Assert.Equal(72d, (await _service.Current(oldBed.Id)).Readings["heartRate"].Value); Assert.False((await _service.Current(oldBed.Id)).Active); } [Fact] public async Task Retries_are_idempotent_and_history_cursor_does_not_skip_same_timestamp() { var bed = await Assign("left", 1); var report = Report("\"HR\":[72,16,0,80,18,0]"); await _repository.Save("a", _device, report); await _repository.Save("a", _device, report); await _repository.Save("b", _device, report); User(1); var page = await _service.History(bed.Id, 0, 2000000, take: 1); Assert.NotNull(page.NextBeforeId); var next = await _service.History(bed.Id, 0, 2000000, page.NextBeforeId, 1); Assert.Single(next.Items); Assert.Null(next.NextBeforeId); Assert.NotEqual(page.Items[0].Id, next.Items[0].Id); await ExpectRejection(() => _service.History(bed.Id, 0, 8L * 86400000)); await ExpectRejection(() => _service.History(bed.Id, 0, 2000000, take: 201)); } [Fact] public async Task Anonymous_disabled_and_health_users_cannot_provision_or_read_others() { await ExpectRejection(() => _service.Bindings()); var bed = await Assign("left", 1); User(1); await ExpectRejection(() => _service.Assign(new() { ProductKey = "test", SerialNumber = "sim", Side = "right", UserId = 1, BedName = "test" })); await ExpectRejection(() => _service.Release(bed.Id)); await _db.Updateable().SetColumns(u => u.Status == HealthUserStatus.Disabled).Where(u => u.Id == 1).ExecuteCommandAsync(); await ExpectRejection(() => _service.Bindings()); await ExpectRejection(() => _service.Current(bed.Id)); } [Fact] public async Task Duplicate_active_slot_is_rejected_and_unbound_invalid_reports_are_not_saved() { await _repository.Save("unbound", _device, Report("\"HR\":[72,16,0,80,18,0]")); Assert.Equal(0, await _db.Queryable().CountAsync()); await Assign("left", 1); await ExpectRejection(() => Assign("left", 2)); await _repository.Save("bad", _device, Report("\"HR\":[72]")); Assert.Equal(0, await _db.Queryable().CountAsync()); await Assign("right", 2); } private static async Task ExpectRejection(Func action) { var error = await Assert.ThrowsAnyAsync(action); Assert.Equal("Furion.FriendlyException.AppFriendlyException", error.GetType().FullName); } [Fact] public async Task Owner_controls_only_the_configured_bed_and_can_read_the_durable_execution_result() { var left = await Assign("left", 1); var right = await Assign("right", 2); await _automation.StartAsync(default); try { User(1); var state = await _service.AutomationStatus(left.Id); Assert.True(state.Available); Assert.False(state.Enabled); Assert.Equal(new[] { "curtain-close", PreviewAction }, state.Rules.Select(rule => rule.ActionKey)); await _service.StartAutomation(left.Id); Assert.True((await _service.AutomationStatus(left.Id)).Enabled); await _service.StartAutomation(left.Id, PreviewAction); Assert.All((await _service.AutomationStatus(left.Id)).Rules, rule => Assert.True(rule.Enabled)); await ExpectRejection(() => _service.StartAutomation(left.Id, "unknown")); await ExpectRejection(() => _service.StopAutomation(left.Id, "unknown")); var id = await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default); Assert.NotNull(id); await _repository.CompleteAutomation(id!, "readback-confirmed", _clock.Now.ToUnixTimeMilliseconds()); Assert.Equal("readback-confirmed", Assert.Single((await _service.AutomationStatus(left.Id)).Records).Result); User(2); await ExpectRejection(() => _service.AutomationStatus(left.Id)); await ExpectRejection(() => _service.StartAutomation(left.Id)); await ExpectRejection(() => _service.StopAutomation(left.Id)); await ExpectRejection(() => _service.StartAutomation(left.Id, PreviewAction)); await ExpectRejection(() => _service.StopAutomation(left.Id, PreviewAction)); Assert.False((await _service.AutomationStatus(right.Id)).Available); await ExpectRejection(() => _service.StartAutomation(right.Id)); Assert.True(_automation.IsEnabled(left.Id, 1)); User(1); await _service.StopAutomation(left.Id); Assert.False((await _service.AutomationStatus(left.Id)).Enabled); Assert.True(_automation.IsEnabled(left.Id, 1, PreviewAction)); await _service.StartAutomation(left.Id); Admin(); await _service.Release(left.Id); Assert.False(_automation.IsEnabled(left.Id, 1)); Assert.False(_automation.IsEnabled(left.Id, 1, PreviewAction)); Assert.Null(await _repository.BeginAutomation(left.Id, 1, "curtain-close", "测试窗帘", _clock.Now.ToUnixTimeMilliseconds(), default)); User(1); state = await _service.AutomationStatus(left.Id); Assert.False(state.Available); Assert.Single(state.Records); } finally { await _automation.StopAsync(default); } } [Fact] public async Task Restarted_backend_services_leave_rules_stopped_and_return_the_same_saved_execution_history() { const string dehumidifier = "dehumidifier-humidity-start"; var left = await Assign("left", 1); User(1); Ib43AutomationOutput before; using (var automation = CreateAutomation(_repository, dehumidifier)) { await automation.StartAsync(default); try { var service = CreateService(_repository, automation); await service.StartAutomation(left.Id); await service.StartAutomation(left.Id, dehumidifier); foreach (var (action, target) in new[] { ("curtain-close", "测试窗帘"), (dehumidifier, "测试除湿机") }) { var now = _clock.GetUtcNow().ToUnixTimeMilliseconds(); var id = await _repository.BeginAutomation(left.Id, 1, action, target, now, default); Assert.NotNull(id); await _repository.CompleteAutomation(id!, "readback-confirmed", now); _clock.Now = _clock.Now.AddSeconds(1); } before = await service.AutomationStatus(left.Id); Assert.Equal(2, before.Rules.Count); Assert.All(before.Rules, rule => Assert.True(rule.Enabled)); Assert.Equal(2, before.Records.Count); } finally { await automation.StopAsync(default); } } _db.Ado.Close(); using var reopened = new SqlSugarClient(new ConnectionConfig { ConnectionString = "DataSource=" + _databasePath, DbType = DbType.Sqlite, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }); var repository = new Ib43DataRepository(reopened); using var restarted = CreateAutomation(repository, dehumidifier); await restarted.StartAsync(default); try { var after = await CreateService(repository, restarted).AutomationStatus(left.Id); Assert.False(after.Enabled); Assert.Equal(before.Rules.Select(rule => rule with { Enabled = false }), after.Rules); Assert.Equal(before.Records.ToArray(), after.Records.ToArray()); } finally { await restarted.StopAsync(default); } } private sealed class TestAction(string key, string target) : IBedPresenceAction { public string Key => key; public string Description => "测试"; public bool SupportsTarget(string entityId) => entityId == target; public Task ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test"); } private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog { public Task BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token) => repository.BeginAutomation(bindingId, userId, action, target, triggeredAt.ToUnixTimeMilliseconds(), token); public Task CompleteAsync(string id, string result, DateTimeOffset completedAt) => repository.CompleteAutomation(id, result, completedAt.ToUnixTimeMilliseconds()); } [Fact] public async Task Local_maintenance_previews_then_binds_with_audit_and_is_idempotent() { _db.CodeFirst.InitTables(); var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] }; var tool = new Ib43LocalBinding(_db, _clock); await tool.Run("1", options, "test-machine/test-operator", false); Assert.Equal(0, await _db.Queryable().CountAsync()); var first = await tool.Run("1", options, "test-machine/test-operator", true); var repeat = await tool.Run("1", options, "test-machine/test-operator", true); Assert.Equal(first.Id, repeat.Id); Assert.Equal(0, first.AssignedBy); Assert.Equal("left", first.Side); var audit = await _db.Queryable().SingleAsync(); Assert.Equal(first.Id, audit.BindingId); Assert.Equal("test-machine/test-operator", audit.Actor); await Assert.ThrowsAsync(() => tool.Run("2", options, "test", true)); Assert.Single(await _db.Queryable().ToListAsync()); options.Devices[0].Simulation = false; await Assert.ThrowsAsync(() => tool.Run("1", options, "test", true)); } [Fact] public async Task Local_maintenance_refuses_unknown_user_and_rolls_back_when_audit_fails() { var options = new Ib43Options { Devices = [new() { ProductKey = "evergreen-test", SerialNumber = "IB43SIM001", Simulation = true }] }; var tool = new Ib43LocalBinding(_db, _clock); await Assert.ThrowsAsync(() => tool.Run("missing", options, "test", true)); // 故意不建审计表,验证绑定不会单独提交。 await Assert.ThrowsAsync(() => tool.Run("1", options, "test", true)); Assert.Equal(0, await _db.Queryable().CountAsync()); } public void Dispose() { _automation.Dispose(); _db.Dispose(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); File.Delete(_databasePath); } }