Files
2026-11-12 15:45:52 +08:00

346 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HealthIb43Binding, HealthIb43Observation, HealthUser>();
_db.CodeFirst.InitTables<HealthIb43AutomationExecution>();
_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<BedPresenceAutomation>.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<Ib43BedOutput> 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<HealthIb43Observation>().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<HealthUser>().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<HealthIb43Observation>().CountAsync());
await Assign("left", 1);
await ExpectRejection(() => Assign("left", 2));
await _repository.Save("bad", _device, Report("\"HR\":[72]"));
Assert.Equal(0, await _db.Queryable<HealthIb43Observation>().CountAsync());
await Assign("right", 2);
}
private static async Task ExpectRejection(Func<Task> action)
{
var error = await Assert.ThrowsAnyAsync<Exception>(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<string> ExecuteAsync(string entityId, CancellationToken token) => throw new InvalidOperationException("No real action in this test");
}
private sealed class TestExecutionLog(Ib43DataRepository repository) : IBedPresenceExecutionLog
{
public Task<string?> 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<HealthIb43MaintenanceAudit>();
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<HealthIb43Binding>().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<HealthIb43MaintenanceAudit>().SingleAsync();
Assert.Equal(first.Id, audit.BindingId);
Assert.Equal("test-machine/test-operator", audit.Actor);
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("2", options, "test", true));
Assert.Single(await _db.Queryable<HealthIb43Binding>().ToListAsync());
options.Devices[0].Simulation = false;
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => tool.Run("missing", options, "test", true));
// 故意不建审计表,验证绑定不会单独提交。
await Assert.ThrowsAsync<InvalidOperationException>(() => tool.Run("1", options, "test", true));
Assert.Equal(0, await _db.Queryable<HealthIb43Binding>().CountAsync());
}
public void Dispose()
{
_automation.Dispose();
_db.Dispose();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
File.Delete(_databasePath);
}
}