346 lines
19 KiB
C#
346 lines
19 KiB
C#
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);
|
||
}
|
||
}
|