Complete IoT monitoring platform for Acuvim II power meters via ESP32. Firmware (Phases 1-7): - ESP32-WROVER-B (TTGO T-Call v1.4) with RS485 Modbus RTU - WiFi STA+AP concurrent mode with GSM/GPRS failover - Transport abstraction layer with 4 priority modes - MQTT protocol with 20 commands, LWT, QoS, exponential backoff - SD card offline buffering with JSONL rotation and non-blocking drain - OTA firmware updates with dual partition rollback protection - Watchdog timer, crash loop detection, Acuvim health monitoring - Captive portal provisioning with AP mode Console backend (Phase 8): - .NET 10 minimal API with PostgreSQL + EF Core - JWT authentication, SignalR real-time updates - MQTTnet 5.x bridge service with health monitoring - Device, telemetry, firmware, alert, group management - Rate limiting, security headers, Swagger/OpenAPI Frontend (Phase 9): - React 18 + TypeScript + Vite with Ant Design 5 - ECharts telemetry visualization, TanStack Query - SignalR live updates, device management UI - Dashboard, fleet management, firmware deployment Testing & Production (Phase 10): - 28 firmware unit tests (Modbus, JSON, config, version) - 23 xUnit backend tests (device, telemetry, command, alert) - Docker Compose with nginx, TLS MQTT, PostgreSQL - Production deployment, commissioning, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
4.4 KiB
C#
144 lines
4.4 KiB
C#
using System.Text.Json;
|
|
using Tau.Acuvim.Console.Models;
|
|
using Tau.Acuvim.Console.Services;
|
|
|
|
namespace Tau.Acuvim.Console.Tests;
|
|
|
|
public class CommandServiceTests
|
|
{
|
|
private CommandService CreateService(Data.AppDbContext db)
|
|
=> new(db, TestHelpers.CreateLogger<CommandService>());
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_CreatesCommand_WithPendingStatus()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
var cmd = await svc.CreateAsync("DEV-C1", "reboot", null, "admin");
|
|
|
|
Assert.Equal("DEV-C1", cmd.DeviceId);
|
|
Assert.Equal("reboot", cmd.CommandName);
|
|
Assert.Equal("pending", cmd.Status);
|
|
Assert.Equal("admin", cmd.CreatedBy);
|
|
Assert.StartsWith("cmd-", cmd.RequestId);
|
|
Assert.Single(db.Commands);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MarkSentAsync_UpdatesStatus()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
var cmd = await svc.CreateAsync("DEV-C2", "reset", null, "admin");
|
|
|
|
await svc.MarkSentAsync(cmd.RequestId);
|
|
|
|
var updated = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
|
Assert.Equal("sent", updated.Status);
|
|
Assert.NotNull(updated.SentAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessResponseAsync_CompletesCommand()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
var cmd = await svc.CreateAsync("DEV-C3", "ping", null, "admin");
|
|
await svc.MarkSentAsync(cmd.RequestId);
|
|
|
|
var responsePayload = JsonSerializer.Serialize(new
|
|
{
|
|
request_id = cmd.RequestId,
|
|
status = "success",
|
|
data = "pong"
|
|
});
|
|
|
|
await svc.ProcessResponseAsync("DEV-C3", responsePayload);
|
|
|
|
var updated = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
|
Assert.Equal("success", updated.Status);
|
|
Assert.NotNull(updated.Response);
|
|
Assert.NotNull(updated.CompletedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessResponseAsync_IgnoresUnknownRequestId()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
var responsePayload = JsonSerializer.Serialize(new
|
|
{
|
|
request_id = "cmd-unknown",
|
|
status = "success"
|
|
});
|
|
|
|
// Should not throw
|
|
await svc.ProcessResponseAsync("DEV-C4", responsePayload);
|
|
|
|
Assert.Empty(db.Commands);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BuildCommandPayload_IncludesCommandAndRequestId()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
var cmd = await svc.CreateAsync("DEV-C5", "firmware_update", null, "admin");
|
|
|
|
var json = svc.BuildCommandPayload(cmd);
|
|
using var doc = JsonDocument.Parse(json);
|
|
var root = doc.RootElement;
|
|
|
|
Assert.Equal("firmware_update", root.GetProperty("cmd").GetString());
|
|
Assert.Equal(cmd.RequestId, root.GetProperty("request_id").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TimeoutStalCommandsAsync_TimesOutOldCommands()
|
|
{
|
|
using var db = TestHelpers.CreateDbContext();
|
|
var svc = CreateService(db);
|
|
|
|
// Create a command and mark it as sent with an old SentAt time
|
|
var cmd = new Command
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DeviceId = "DEV-C6",
|
|
RequestId = $"cmd-{Guid.NewGuid():N}",
|
|
CommandName = "check",
|
|
Status = "sent",
|
|
SentAt = DateTime.UtcNow.AddSeconds(-120)
|
|
};
|
|
db.Commands.Add(cmd);
|
|
|
|
// Also add a recent sent command that should NOT be timed out
|
|
var recentCmd = new Command
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DeviceId = "DEV-C6",
|
|
RequestId = $"cmd-{Guid.NewGuid():N}",
|
|
CommandName = "check2",
|
|
Status = "sent",
|
|
SentAt = DateTime.UtcNow
|
|
};
|
|
db.Commands.Add(recentCmd);
|
|
await db.SaveChangesAsync();
|
|
|
|
var count = await svc.TimeoutStalCommandsAsync(timeoutSeconds: 60);
|
|
|
|
Assert.Equal(1, count);
|
|
|
|
var timedOut = db.Commands.First(c => c.RequestId == cmd.RequestId);
|
|
Assert.Equal("timeout", timedOut.Status);
|
|
Assert.NotNull(timedOut.CompletedAt);
|
|
|
|
var stillSent = db.Commands.First(c => c.RequestId == recentCmd.RequestId);
|
|
Assert.Equal("sent", stillSent.Status);
|
|
}
|
|
}
|