ソースを参照

Merge pull request #14 from Timmoth/Manage-Switch-Through-Cli

Added e2e test infra
Tim Jones 2 ヶ月 前
コミット
30dec38005

+ 32 - 71
RackPeek/Program.cs

@@ -14,8 +14,8 @@ using RackPeek.Domain.Resources.Hardware.Server.Cpu;
 using RackPeek.Domain.Resources.Hardware.Server.Drive;
 using RackPeek.Domain.Resources.Hardware.Server.Nic;
 using RackPeek.Domain.Resources.Hardware.Switchs;
+using RackPeek.Spectre;
 using RackPeek.Yaml;
-using Spectre.Console;
 using Spectre.Console.Cli;
 
 namespace RackPeek;
@@ -33,32 +33,46 @@ public static class Program
         // DI
         var services = new ServiceCollection();
 
+        var registrar = new TypeRegistrar(services);
+        var app = new CommandApp(registrar);
+        
+        CliBootstrap.BuildApp(app, services, configuration);
+        
+        services.AddLogging(configure =>
+            configure
+                .AddSimpleConsole(opts => { opts.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; }));
+        
+        return await app.RunAsync(args);
+    }
+}
+
+public static class CliBootstrap
+{
+    public static void BuildApp(CommandApp app, IServiceCollection services, IConfiguration configuration)
+    {
         services.AddSingleton<IConfiguration>(configuration);
 
         // Infrastructure
         services.AddScoped<IHardwareRepository>(_ =>
         {
-            var path = configuration["HardwareFile"] ?? "hardware.yaml";
-
             var collection = new YamlResourceCollection();
-            collection.LoadFiles([
-                "servers.yaml",
-                "aps.yaml",
-                "desktops.yaml",
-                "switches.yaml",
-                "ups.yaml",
-                "firewalls.yaml",
-                "laptops.yaml",
-                "routers.yaml"
-            ]);
+            var basePath = configuration["HardwarePath"] ?? Directory.GetCurrentDirectory();
 
-            return new YamlHardwareRepository(collection);
-        });
+            collection.LoadFiles(new[]
+            {
+                Path.Combine(basePath, "servers.yaml"),
+                Path.Combine(basePath, "aps.yaml"),
+                Path.Combine(basePath, "desktops.yaml"),
+                Path.Combine(basePath, "switches.yaml"),
+                Path.Combine(basePath, "ups.yaml"),
+                Path.Combine(basePath, "firewalls.yaml"),
+                Path.Combine(basePath, "laptops.yaml"),
+                Path.Combine(basePath, "routers.yaml")
+            });
 
-        services.AddLogging(configure =>
-            configure
-                .AddSimpleConsole(opts => { opts.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; }));
 
+            return new YamlHardwareRepository(collection);
+        });
 
         // Application
         services.AddScoped<ServerHardwareReportUseCase>();
@@ -134,9 +148,6 @@ public static class Program
 
 
         // Spectre bootstrap
-        var registrar = new TypeRegistrar(services);
-        var app = new CommandApp(registrar);
-
         app.Configure(config =>
         {
             config.SetApplicationName("rackpeek");
@@ -245,55 +256,5 @@ public static class Program
                 config.ValidateExamples();
             });
         });
-
-        return await app.RunAsync(args);
-    }
-}
-
-public class SpectreConsoleLoggerProvider : ILoggerProvider
-{
-    public ILogger CreateLogger(string categoryName)
-    {
-        return new SpectreConsoleLogger();
-    }
-
-    public void Dispose()
-    {
     }
 }
-
-public class SpectreConsoleLogger : ILogger
-{
-    public IDisposable BeginScope<T>(T state)
-    {
-        return null!;
-    }
-
-    public bool IsEnabled(LogLevel logLevel)
-    {
-        return true;
-    }
-
-    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
-        Func<TState, Exception, string> formatter)
-    {
-        var message = formatter(state, exception);
-        var style = GetStyle(logLevel);
-
-        AnsiConsole.MarkupLine($"[{style}] {message}[/]");
-    }
-
-    private Style GetStyle(LogLevel logLevel)
-    {
-        return logLevel switch
-        {
-            LogLevel.Trace => new Style(Color.Grey),
-            LogLevel.Debug => new Style(Color.Grey),
-            LogLevel.Information => new Style(Color.Green),
-            LogLevel.Warning => new Style(Color.Yellow),
-            LogLevel.Error => new Style(Color.Red),
-            LogLevel.Critical => new Style(Color.Red),
-            _ => new Style(Color.White)
-        };
-    }
-}

+ 40 - 0
RackPeek/Spectre/SpectreConsoleLogger.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace RackPeek.Spectre;
+
+public class SpectreConsoleLogger : ILogger
+{
+    public IDisposable BeginScope<T>(T state)
+    {
+        return null!;
+    }
+
+    public bool IsEnabled(LogLevel logLevel)
+    {
+        return true;
+    }
+
+    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+        Func<TState, Exception, string> formatter)
+    {
+        var message = formatter(state, exception);
+        var style = GetStyle(logLevel);
+
+        AnsiConsole.MarkupLine($"[{style}] {message}[/]");
+    }
+
+    private Style GetStyle(LogLevel logLevel)
+    {
+        return logLevel switch
+        {
+            LogLevel.Trace => new Style(Color.Grey),
+            LogLevel.Debug => new Style(Color.Grey),
+            LogLevel.Information => new Style(Color.Green),
+            LogLevel.Warning => new Style(Color.Yellow),
+            LogLevel.Error => new Style(Color.Red),
+            LogLevel.Critical => new Style(Color.Red),
+            _ => new Style(Color.White)
+        };
+    }
+}

+ 15 - 0
RackPeek/Spectre/SpectreConsoleLoggerProvider.cs

@@ -0,0 +1,15 @@
+using Microsoft.Extensions.Logging;
+
+namespace RackPeek.Spectre;
+
+public class SpectreConsoleLoggerProvider : ILoggerProvider
+{
+    public ILogger CreateLogger(string categoryName)
+    {
+        return new SpectreConsoleLogger();
+    }
+
+    public void Dispose()
+    {
+    }
+}

+ 1 - 1
RackPeek/TypeRegistrar.cs → RackPeek/Spectre/TypeRegistrar.cs

@@ -1,7 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Spectre.Console.Cli;
 
-namespace RackPeek;
+namespace RackPeek.Spectre;
 
 public sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar
 {

+ 32 - 7
RackPeek/Yaml/YamlResourceCollection.cs

@@ -10,7 +10,9 @@ namespace RackPeek.Yaml;
 public sealed class YamlResourceCollection
 {
     private readonly List<ResourceEntry> _entries = [];
-    public IReadOnlyList<string> SourceFiles => _entries.Select(e => e.SourceFile).Distinct().ToList();
+    private readonly List<string> _knownFiles = [];
+
+    public IReadOnlyList<string> SourceFiles => _knownFiles.ToList();
 
     public IReadOnlyList<Hardware> HardwareResources =>
         _entries.Select(e => e.Resource).OfType<Hardware>().ToList();
@@ -22,19 +24,39 @@ public sealed class YamlResourceCollection
     {
         foreach (var file in filePaths)
         {
+            // Track the file even if it is empty
+            if (!_knownFiles.Contains(file))
+                _knownFiles.Add(file);
+
             var yaml = File.ReadAllText(file);
-            foreach (var resource in Deserialize(yaml)) _entries.Add(new ResourceEntry(resource, file));
+            var resources = Deserialize(yaml);
+
+            foreach (var resource in resources)
+            {
+                _entries.Add(new ResourceEntry(resource, file));
+            }
         }
     }
 
     public void Load(string yaml, string file)
     {
-        foreach (var resource in Deserialize(yaml)) _entries.Add(new ResourceEntry(resource, file));
+        if (!_knownFiles.Contains(file))
+            _knownFiles.Add(file);
+
+        foreach (var resource in Deserialize(yaml))
+            _entries.Add(new ResourceEntry(resource, file));
     }
 
     public void SaveAll()
     {
-        foreach (var group in _entries.GroupBy(e => e.SourceFile)) SaveToFile(group.Key, group.Select(e => e.Resource));
+        foreach (var file in _knownFiles)
+        {
+            var resources = _entries
+                .Where(e => e.SourceFile == file)
+                .Select(e => e.Resource);
+
+            SaveToFile(file, resources);
+        }
     }
 
     // ----------------------------
@@ -137,6 +159,9 @@ public sealed class YamlResourceCollection
 
     private static List<Resource> Deserialize(string yaml)
     {
+        if (string.IsNullOrWhiteSpace(yaml))
+            return new List<Resource>();
+
         var deserializer = new DeserializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithCaseInsensitivePropertyMatching()
@@ -146,8 +171,8 @@ public sealed class YamlResourceCollection
         var raw = deserializer.Deserialize<
             Dictionary<string, List<Dictionary<string, object>>>>(yaml);
 
-        if (!raw.TryGetValue("resources", out var items))
-            return [];
+        if (raw == null || !raw.TryGetValue("resources", out var items))
+            return new List<Resource>();
 
         var resources = new List<Resource>();
 
@@ -180,4 +205,4 @@ public sealed class YamlResourceCollection
     }
 
     private sealed record ResourceEntry(Resource Resource, string SourceFile);
-}
+}

+ 23 - 0
Tests/EndToEnd/SwitchYamlE2ETests.cs

@@ -0,0 +1,23 @@
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) : IClassFixture<TempYamlCliFixture>
+{
+    [Fact]
+    public async Task switches_add_writes_to_yaml_file()
+    {
+        // Act
+        var output = await YamlCliTestHost.RunAsync(
+            new[] { "switches", "add", "sw01" },
+            fs.Root,
+            outputHelper
+        );
+
+        outputHelper.WriteLine(output);
+        
+        // Assert
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "servers.yaml"));
+        Assert.Contains("name: sw01", yaml);
+    }
+}

+ 39 - 0
Tests/EndToEnd/TempYamlCliFixture.cs

@@ -0,0 +1,39 @@
+namespace Tests.EndToEnd;
+
+public sealed class TempYamlCliFixture : IAsyncLifetime
+{
+    public string Root { get; } = Path.Combine(
+        Path.GetTempPath(),
+        "rackpeek-tests",
+        Guid.NewGuid().ToString()
+    );
+
+    public Task InitializeAsync()
+    {
+        Directory.CreateDirectory(Root);
+
+        // Create empty YAML files so repo loads cleanly
+        foreach (var file in new[]
+                 {
+                     "servers.yaml",
+                     "aps.yaml",
+                     "desktops.yaml",
+                     "switches.yaml",
+                     "ups.yaml",
+                     "firewalls.yaml",
+                     "laptops.yaml",
+                     "routers.yaml"
+                 }) 
+        {
+            File.WriteAllText(Path.Combine(Root, file), "");
+        }
+
+        return Task.CompletedTask;
+    }
+
+    public Task DisposeAsync()
+    {
+        Directory.Delete(Root, recursive: true);
+        return Task.CompletedTask;
+    }
+}

+ 48 - 0
Tests/EndToEnd/YamlCliTestHost.cs

@@ -0,0 +1,48 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RackPeek;
+using RackPeek.Spectre;
+using Spectre.Console.Cli;
+using Spectre.Console.Testing;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+public static class YamlCliTestHost
+{
+    public static async Task<string> RunAsync(
+        string[] args,
+        string hardwarePath, 
+        ITestOutputHelper output
+    )
+    {
+        var services = new ServiceCollection();
+
+        var config = new ConfigurationBuilder()
+            .AddInMemoryCollection(new Dictionary<string, string?>
+            {
+                ["HardwarePath"] = hardwarePath
+            })
+            .Build();
+
+        var console = new TestConsole();
+
+        var registrar = new TypeRegistrar(services);
+        var app = new CommandApp(registrar);
+        app.Configure(c => c.Settings.Console = console);
+        
+        CliBootstrap.BuildApp(app, services, config);
+        
+        services.AddLogging(builder =>
+        {
+            builder.ClearProviders();
+            builder.AddProvider(new XUnitLoggerProvider(output));
+        });
+
+        
+        await app.RunAsync(args);
+
+        return console.Output;
+    }
+}

+ 1 - 0
Tests/Tests.csproj

@@ -13,6 +13,7 @@
         <PackageReference Include="NSubstitute" Version="5.3.0"/>
         <PackageReference Include="xunit" Version="2.9.3"/>
         <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.54.0" />
     </ItemGroup>
 
     <ItemGroup>

+ 41 - 0
Tests/XUnitLoggerProvider.cs

@@ -0,0 +1,41 @@
+using Microsoft.Extensions.Logging;
+using Xunit.Abstractions;
+
+namespace Tests;
+
+public sealed class XUnitLoggerProvider : ILoggerProvider
+{
+    private readonly ITestOutputHelper _output;
+
+    public XUnitLoggerProvider(ITestOutputHelper output)
+    {
+        _output = output;
+    }
+
+    public ILogger CreateLogger(string categoryName)
+        => new XUnitLogger(_output, categoryName);
+
+    public void Dispose() { }
+
+    private sealed class XUnitLogger : ILogger
+    {
+        private readonly ITestOutputHelper _output;
+        private readonly string _category;
+
+        public XUnitLogger(ITestOutputHelper output, string category)
+        {
+            _output = output;
+            _category = category;
+        }
+
+        public IDisposable BeginScope<TState>(TState state) => null!;
+
+        public bool IsEnabled(LogLevel logLevel) => true;
+
+        public void Log<TState>(LogLevel logLevel, EventId eventId,
+            TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+        {
+            _output.WriteLine($"[{logLevel}] {_category}: {formatter(state, exception)}");
+        }
+    }
+}