Browse Source

Merge pull request #10 from Timmoth/Manage-Server-Drives-through-CLI

UseCases added
Tim Jones 2 months ago
parent
commit
ede7af02c8

+ 27 - 0
RackPeek.Domain/Resources/Hardware/Server/Drive/AddDriveUseCase.cs

@@ -0,0 +1,27 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Drive;
+
+public class AddDrivesUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(
+        string serverName,
+        string type,
+        int size)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+
+        if (hardware is not Models.Server server)
+        {
+            return;
+        }
+
+        server.Drives ??= [];
+
+        server.Drives.Add(new Models.Drive
+        {
+            Type = type,
+            Size = size
+        });
+        
+        await repository.UpdateAsync(server);
+    }
+}

+ 15 - 0
RackPeek.Domain/Resources/Hardware/Server/Drive/RemoveDriveUseCase.cs

@@ -0,0 +1,15 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Drive;
+
+public class RemoveDriveUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(string serverName, int index)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+        if (hardware is not Models.Server server) return;
+        server.Drives ??= [];
+        if (index < 0 || index >= server.Drives.Count)
+            throw new ArgumentOutOfRangeException(nameof(index), "Drive index out of range.");
+        server.Drives.RemoveAt(index);
+        await repository.UpdateAsync(server);
+    }
+}

+ 21 - 0
RackPeek.Domain/Resources/Hardware/Server/Drive/UpdateDriveUseCase.cs

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Resources.Hardware.Server.Drive;
+
+public class UpdateDriveUseCase(IHardwareRepository repository)
+{
+    public async Task ExecuteAsync(string serverName, int index, string type, int size)
+    {
+        var hardware = await repository.GetByNameAsync(serverName);
+        if (hardware is not Models.Server server)
+        {
+            return;
+        }
+
+        server.Drives ??= [];
+        if (index < 0 || index >= server.Drives.Count)
+            throw new ArgumentOutOfRangeException(nameof(index), "Drive index out of range.");
+        var drive = server.Drives[index];
+        drive.Type = type;
+        drive.Size = size;
+        await repository.UpdateAsync(server);
+    }
+}

+ 36 - 0
RackPeek/Commands/Server/Drives/ServerDriveAddCommand.cs

@@ -0,0 +1,36 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Drives;
+
+public class ServerDriveAddSettings : ServerNameSettings
+{
+    [CommandOption("--type <TYPE>")]
+    public string Type { get; set; }
+
+    [CommandOption("--size <SIZE>")]
+    public int Size { get; set; }
+}
+
+public class ServerDriveAddCommand(IServiceProvider serviceProvider)
+    : AsyncCommand<ServerDriveAddSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerDriveAddSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<AddDrivesUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Type,
+            settings.Size);
+
+        AnsiConsole.MarkupLine($"[green]Drive added to '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 32 - 0
RackPeek/Commands/Server/Drives/ServerDriveRemoveCommand.cs

@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Drives;
+
+public class ServerDriveRemoveSettings : ServerNameSettings
+{
+    [CommandOption("--index <INDEX>")]
+    public int Index { get; set; }
+}
+
+public class ServerDriveRemoveCommand(IServiceProvider serviceProvider)
+    : AsyncCommand<ServerDriveRemoveSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerDriveRemoveSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<RemoveDriveUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Index);
+
+        AnsiConsole.MarkupLine($"[green]Drive {settings.Index} removed from '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 40 - 0
RackPeek/Commands/Server/Drives/ServerDriveUpdateCommand.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Server.Drives;
+
+public class ServerDriveUpdateSettings : ServerNameSettings
+{
+    [CommandOption("--index <INDEX>")]
+    public int Index { get; set; }
+
+    [CommandOption("--type <TYPE>")]
+    public string Type { get; set; }
+
+    [CommandOption("--size <SIZE>")]
+    public int Size { get; set; }
+}
+
+public class ServerDriveUpdateCommand(IServiceProvider serviceProvider)
+    : AsyncCommand<ServerDriveUpdateSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerDriveUpdateSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<UpdateDriveUseCase>();
+
+        await useCase.ExecuteAsync(
+            settings.Name,
+            settings.Index,
+            settings.Type,
+            settings.Size);
+
+        AnsiConsole.MarkupLine($"[green]Drive {settings.Index} updated on '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 29 - 0
RackPeek/Program.cs

@@ -7,9 +7,11 @@ using Microsoft.Extensions.Logging;
 using RackPeek.Commands;
 using RackPeek.Commands.Server;
 using RackPeek.Commands.Server.Cpus;
+using RackPeek.Commands.Server.Drives;
 using RackPeek.Domain.Resources.Hardware.Reports;
 using RackPeek.Domain.Resources.Hardware.Server;
 using RackPeek.Domain.Resources.Hardware.Server.Cpu;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
 using RackPeek.Yaml;
 
 namespace RackPeek;
@@ -85,12 +87,24 @@ public static class Program
         services.AddScoped<AddCpuUseCase>();
         services.AddScoped<UpdateCpuUseCase>();
         services.AddScoped<RemoveCpuUseCase>();
+        
+        // Drive use cases
+        services.AddScoped<AddDrivesUseCase>();
+        services.AddScoped<UpdateDriveUseCase>();
+        services.AddScoped<RemoveDriveUseCase>();
+
 
         // CPU commands
         services.AddScoped<ServerCpuAddCommand>();
         services.AddScoped<ServerCpuSetCommand>();
         services.AddScoped<ServerCpuRemoveCommand>();
         
+        // Drive commands
+        services.AddScoped<ServerDriveAddCommand>();
+        services.AddScoped<ServerDriveUpdateCommand>();
+        services.AddScoped<ServerDriveRemoveCommand>();
+
+        
         // Spectre bootstrap
         var registrar = new TypeRegistrar(services);
         var app = new CommandApp(registrar);
@@ -137,6 +151,21 @@ public static class Program
                     cpu.AddCommand<ServerCpuRemoveCommand>("del")
                         .WithDescription("Remove a CPU from a server");
                 });
+                
+                server.AddBranch("drive", drive =>
+                {
+                    drive.SetDescription("Manage server drives");
+
+                    drive.AddCommand<ServerDriveAddCommand>("add")
+                        .WithDescription("Add a drive to a server");
+
+                    drive.AddCommand<ServerDriveUpdateCommand>("set")
+                        .WithDescription("Update a drive on a server");
+
+                    drive.AddCommand<ServerDriveRemoveCommand>("del")
+                        .WithDescription("Remove a drive from a server");
+                });
+
             });
 
             // ----------------------------

+ 96 - 0
Tests/Hardware/AddDriveUseCaseTests.cs

@@ -0,0 +1,96 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+
+namespace Tests.Hardware;
+
+public class AddDrivesUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Adds_drive_when_server_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Drives = new List<RackPeek.Domain.Resources.Hardware.Models.Drive>()
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new AddDrivesUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            type: "NVMe",
+            size: 2000
+        );
+
+        // Assert
+        Assert.Single(server.Drives);
+        Assert.Equal("NVMe", server.Drives[0].Type);
+        Assert.Equal(2000, server.Drives[0].Size);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Name == "node01" &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "NVMe" &&
+            s.Drives[0].Size == 2000
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Initializes_drive_list_when_null()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new RackPeek.Domain.Resources.Hardware.Models.Server
+        {
+            Name = "node01",
+            Drives = null
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new AddDrivesUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            type: "SATA",
+            size: 500
+        );
+
+        // Assert
+        Assert.NotNull(server.Drives);
+        Assert.Single(server.Drives);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<RackPeek.Domain.Resources.Hardware.Models.Server>(s =>
+            s.Drives != null &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "SATA"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        repo.GetByNameAsync("node01").Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new AddDrivesUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            type: "NVMe",
+            size: 2000
+        );
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<RackPeek.Domain.Resources.Hardware.Models.Server>());
+    }
+}

+ 89 - 0
Tests/Hardware/RemoveDriveUseCaseTests.cs

@@ -0,0 +1,89 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+using Xunit;
+
+namespace Tests.Hardware;
+
+public class RemoveDriveUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Removes_drive_when_index_is_valid()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Drives = new List<Drive>
+            {
+                new Drive { Type = "NVMe", Size = 2000 },
+                new Drive { Type = "SATA", Size = 500 }
+            }
+        };
+
+        // ❗ FIXED: return the server, not null
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new RemoveDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("node01", index: 0);
+
+        // Assert
+        Assert.Single(server.Drives);
+        Assert.Equal("SATA", server.Drives[0].Type);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<Server>(s =>
+            s.Name == "node01" &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "SATA"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_index_out_of_range()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Drives = new List<Drive>
+            {
+                new Drive { Type = "NVMe", Size = 2000 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new RemoveDriveUseCase(repo);
+
+        // Act & Assert
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
+            await sut.ExecuteAsync("node01", index: 1)
+        );
+
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+
+        
+        repo.GetByNameAsync("node01")
+            .Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new RemoveDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("node01", index: 0);
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+}

+ 102 - 0
Tests/Hardware/UpdateDriveUseCaseTests.cs

@@ -0,0 +1,102 @@
+using NSubstitute;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Hardware.Server.Drive;
+using Xunit;
+
+namespace Tests.Hardware;
+
+public class UpdateDriveUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Updates_drive_when_index_is_valid()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Drives = new List<Drive>
+            {
+                new Drive { Type = "NVMe", Size = 2000 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new UpdateDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            index: 0,
+            type: "SATA",
+            size: 500
+        );
+
+        // Assert
+        Assert.Equal("SATA", server.Drives[0].Type);
+        Assert.Equal(500, server.Drives[0].Size);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<Server>(s =>
+            s.Name == "node01" &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "SATA" &&
+            s.Drives[0].Size == 500
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_if_index_out_of_range()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+        var server = new Server
+        {
+            Name = "node01",
+            Drives = new List<Drive>
+            {
+                new Drive { Type = "NVMe", Size = 2000 }
+            }
+        };
+
+        repo.GetByNameAsync("node01").Returns(server);
+
+        var sut = new UpdateDriveUseCase(repo);
+
+        // Act & Assert
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
+            await sut.ExecuteAsync(
+                serverName: "node01",
+                index: 1,
+                type: "SATA",
+                size: 500
+            )
+        );
+
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Does_nothing_when_server_does_not_exist()
+    {
+        // Arrange
+        var repo = Substitute.For<IHardwareRepository>();
+
+        repo.GetByNameAsync("node01")
+            .Returns((RackPeek.Domain.Resources.Hardware.Models.Hardware?)null);
+
+        var sut = new UpdateDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync(
+            serverName: "node01",
+            index: 0,
+            type: "SATA",
+            size: 500
+        );
+
+        // Assert
+        await repo.DidNotReceive().UpdateAsync(Arg.Any<Server>());
+    }
+}