namespace Zcbot.WindowsNode.Tests; public sealed class NodeApplicationControllerTests : IDisposable { private readonly string root = Path.Combine( Path.GetTempPath(), "zcbot-windows-node-tests", Guid.NewGuid().ToString("N")); [Fact] public void StartWithoutIdentityRemainsNotRegistered() { var store = new FakeConfigStore(); var connections = new List(); using var controller = CreateController(store, connections); controller.Start(); Assert.Equal(NodeState.NotRegistered, controller.CurrentStatus.State); Assert.Null(controller.CurrentConfig); Assert.Empty(connections); } [Fact] public async Task ReconnectWaitsForOldSessionAndStartsOneReplacement() { var store = new FakeConfigStore { Config = CreateConfig() }; var connections = new List(); using var controller = CreateController(store, connections); controller.Start(); Assert.Single(connections); var reconnected = await controller.ReconnectAsync(); Assert.True(reconnected); Assert.Equal(2, connections.Count); Assert.True(connections[0].Completed); Assert.False(connections[1].Completed); await controller.StopAsync(NodeShutdownMode.WaitForJobs); } [Fact] public async Task CancelStopIsIdempotentAndCancelsJobsOnce() { var store = new FakeConfigStore { Config = CreateConfig() }; var connections = new List(); using var controller = CreateController(store, connections); controller.Start(); var first = controller.StopAsync(NodeShutdownMode.CancelJobs); var second = controller.StopAsync(NodeShutdownMode.WaitForJobs); await Task.WhenAll(first, second); Assert.Same(first, second); Assert.Equal(1, connections[0].CancelJobsCalls); Assert.True(connections[0].Completed); Assert.Equal(NodeState.Stopped, controller.CurrentStatus.State); } [Fact] public async Task FailedMigrationRestartsTheExistingConfiguration() { var store = new FakeConfigStore { Config = CreateConfig() }; var connections = new List(); using var controller = CreateController( store, connections, migrate: (_, _, _) => throw new IOException("copy failed")); controller.Start(); await Assert.ThrowsAsync( () => controller.MigrateDataRootAsync(Path.Combine(root, "new-root"))); Assert.Equal(2, connections.Count); Assert.True(connections[0].Completed); Assert.False(connections[1].Completed); await controller.StopAsync(NodeShutdownMode.WaitForJobs); } [Fact] public async Task ResetIdentityWaitsForSessionBeforeDeletingIdentity() { var store = new FakeConfigStore { Config = CreateConfig() }; var connections = new List(); using var controller = CreateController(store, connections); controller.Start(); await controller.ResetIdentityAsync(); Assert.True(connections[0].Completed); Assert.True(store.IdentityDeleted); Assert.Null(controller.CurrentConfig); Assert.Equal(NodeState.NotRegistered, controller.CurrentStatus.State); } public void Dispose() { if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } } private NodeApplicationController CreateController( FakeConfigStore store, List connections, Func>? migrate = null) { var paths = new NodePaths( root, Path.Combine(root, "node.json"), Path.Combine(root, "jobs"), Path.Combine(root, "workspaces")); return new NodeApplicationController( store, paths, (_, _) => { var connection = new FakeConnection(); connections.Add(connection); return connection; }, (_, _, _) => Task.CompletedTask, migrate ?? ((source, target, _) => Task.FromResult( new NodeDataMigrationResult(source, target, 0, 0))), _ => { }); } private static NodeConfig CreateConfig() => new( new Uri("https://zcbot.example/"), Guid.NewGuid(), Guid.NewGuid(), "test-node", "secret", 30, []); private sealed class FakeConfigStore : INodeConfigStore { public NodeConfig? Config { get; set; } public bool IdentityDeleted { get; private set; } public bool Exists => Config is not null; public string ConfigPath => "node.json"; public void Save(NodeConfig config) => Config = config; public NodeConfig Load() => Config ?? throw new NodeConfigurationException("missing identity"); public void DeleteLocalIdentity() { IdentityDeleted = true; Config = null; } } private sealed class FakeConnection : INodeConnection { public int CancelJobsCalls { get; private set; } public bool Completed { get; private set; } public async Task RunAsync(CancellationToken cancellationToken) { try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } finally { Completed = true; } } public void CancelActiveJobsForExit() => CancelJobsCalls++; } }