Jelajahi Sumber

Merge pull request #64 from Timmoth/Add-System-Drives-Commands

UseCases, Commands and unit tests for system.drives
Tim Jones 2 bulan lalu
induk
melakukan
97f936ddfd

+ 0 - 1
.idea/.idea.RackPeek/.idea/.name

@@ -1 +0,0 @@
-RackPeek

+ 4 - 0
RackPeek.Domain/Helpers/Normalize.cs

@@ -2,6 +2,10 @@ namespace RackPeek.Domain.Helpers;
 
 public static class Normalize
 {
+    public static string DriveType(string value)
+    {
+        return value.Trim().ToLowerInvariant();
+    }
     public static string NicType(string value)
     {
         return value.Trim().ToLowerInvariant();

+ 59 - 31
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -6,11 +6,9 @@ public static class ThrowIfInvalid
 {
     public static void ResourceName(string name)
     {
-        if (string.IsNullOrWhiteSpace(name))
-            throw new ValidationException("Name is required.");
+        if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("Name is required.");
 
-        if (name.Length > 50)
-            throw new ValidationException("Name is too long.");
+        if (name.Length > 50) throw new ValidationException("Name is too long.");
     }
 
     public static void AccessPointModelName(string name)
@@ -24,14 +22,11 @@ public static class ThrowIfInvalid
 
     public static void RamGb(int? value)
     {
-        if (value is null)
-            throw new ValidationException("RAM value must be specified.");
+        if (value is null) throw new ValidationException("RAM value must be specified.");
 
-        if (value < 0)
-            throw new ValidationException("RAM value must be a non negative number of gigabytes.");
+        if (value < 0) throw new ValidationException("RAM value must be a non negative number of gigabytes.");
     }
 
-
     #region Nics
 
     public static readonly string[] ValidNicTypes =
@@ -55,8 +50,7 @@ public static class ThrowIfInvalid
         "osfp",
 
         // Legacy / niche but still seen
-        "xfp",
-        "cx4",
+        "xfp", "cx4",
 
         // Management / special-purpose
         "mgmt" // Dedicated management NIC (IPMI/BMC)
@@ -64,13 +58,11 @@ public static class ThrowIfInvalid
 
     public static void NicType(string nicType)
     {
-        if (string.IsNullOrWhiteSpace(nicType))
-            throw new ValidationException("NIC type is required.");
+        if (string.IsNullOrWhiteSpace(nicType)) throw new ValidationException("NIC type is required.");
 
         var normalized = nicType.Trim().ToLowerInvariant();
 
-        if (ValidNicTypes.Contains(normalized))
-            return;
+        if (ValidNicTypes.Contains(normalized)) return;
 
         var suggestions = GetNicTypeSuggestions(normalized).ToList();
 
@@ -83,12 +75,7 @@ public static class ThrowIfInvalid
 
     private static IEnumerable<string> GetNicTypeSuggestions(string input)
     {
-        return ValidNicTypes
-            .Select(type => new
-            {
-                Type = type,
-                Score = SimilarityScore(input, type)
-            })
+        return ValidNicTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
             .Where(x => x.Score >= 0.5)
             .OrderByDescending(x => x.Score)
             .Take(3)
@@ -97,11 +84,9 @@ public static class ThrowIfInvalid
 
     private static double SimilarityScore(string a, string b)
     {
-        if (a == b)
-            return 1.0;
+        if (a == b) return 1.0;
 
-        if (b.StartsWith(a) || a.StartsWith(b))
-            return 0.9;
+        if (b.StartsWith(a) || a.StartsWith(b)) return 0.9;
 
         var commonChars = a.Intersect(b).Count();
         return (double)commonChars / Math.Max(a.Length, b.Length);
@@ -109,9 +94,7 @@ public static class ThrowIfInvalid
 
     public static void NicSpeed(int speed)
     {
-        if (speed < 0)
-            throw new ValidationException(
-                "NIC speed must be a non negative number of gigabits per second.");
+        if (speed < 0) throw new ValidationException("NIC speed must be a non negative number of gigabits per second.");
     }
 
     public static void NetworkSpeed(double speed)
@@ -124,9 +107,54 @@ public static class ThrowIfInvalid
 
     public static void NicPorts(int ports)
     {
-        if (ports < 0)
-            throw new ValidationException(
-                "NIC port count must be a non negative integer.");
+        if (ports < 0) throw new ValidationException("NIC port count must be a non negative integer.");
+    }
+
+    #endregion
+
+    #region Drives
+
+    public static readonly string[] ValidDriveTypes =
+    {
+        // Flash storage
+        "nvme", "ssd",
+        // Traditional spinning disks
+        "hdd",
+        // Enterprise interfaces
+        "sas", "sata",
+        // External / removable
+        "usb", "sdcard", "micro-sd"
+    };
+
+    public static void DriveType(string driveType)
+    {
+        if (string.IsNullOrWhiteSpace(driveType)) throw new ValidationException("Drive type is required.");
+
+        var normalized = driveType.Trim().ToLowerInvariant();
+
+        if (ValidDriveTypes.Contains(normalized)) return;
+
+        var suggestions = GetDriveTypeSuggestions(normalized).ToList();
+
+        var message = suggestions.Any()
+            ? $"Drive type '{driveType}' is not valid. Did you mean: {string.Join(", ", suggestions)}?"
+            : $"Drive type '{driveType}' is not valid. Valid Drive types include nvme, ssd, hdd, sata, sas etc.";
+
+        throw new ValidationException(message);
+    }
+
+    private static IEnumerable<string> GetDriveTypeSuggestions(string input)
+    {
+        return ValidDriveTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
+            .Where(x => x.Score >= 0.5)
+            .OrderByDescending(x => x.Score)
+            .Take(3)
+            .Select(x => x.Type);
+    }
+
+    public static void DriveSize(int size)
+    {
+        if (size < 0) throw new ValidationException("Drive size value must be a non negative number of gigabytes.");
     }
 
     #endregion

+ 31 - 0
RackPeek.Domain/Resources/SystemResources/UseCases/AddSystemDriveUseCase.cs

@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.SystemResources.UseCases;
+
+public class AddSystemDriveUseCase(ISystemRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string systemName, string driveType, int size)
+    {
+        ThrowIfInvalid.ResourceName(systemName);
+        
+        var driveTypeNormalized = Normalize.DriveType(driveType);
+        ThrowIfInvalid.DriveType(driveTypeNormalized);
+        ThrowIfInvalid.DriveSize(size); 
+
+        var system = await repository.GetByNameAsync(systemName)
+                     ?? throw new NotFoundException($"System '{systemName}' not found.");
+
+        system.Drives ??= new List<Drive>();
+
+        system.Drives.Add(new Drive
+        {
+            Type = driveTypeNormalized,
+            Size = size
+        });
+
+        await repository.UpdateAsync(system);
+    }
+}

+ 23 - 0
RackPeek.Domain/Resources/SystemResources/UseCases/RemoveSystemDriveUseCase.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.SystemResources.UseCases;
+
+public class RemoveSystemDriveUseCase(ISystemRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string systemName, int index)
+    {
+        ThrowIfInvalid.ResourceName(systemName);
+
+        var system = await repository.GetByNameAsync(systemName)
+                     ?? throw new NotFoundException($"System '{systemName}' not found.");
+
+        if (system.Drives == null || index < 0 || index >= system.Drives.Count)
+            throw new NotFoundException($"Drive index {index} not found on system '{systemName}'.");
+
+        system.Drives.RemoveAt(index);
+
+        await repository.UpdateAsync(system);
+    }
+}

+ 30 - 0
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemDriveUseCase.cs

@@ -0,0 +1,30 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Resources.SystemResources.UseCases;
+
+public class UpdateSystemDriveUseCase(ISystemRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string systemName, int index, string driveType, int size)
+    {
+        ThrowIfInvalid.ResourceName(systemName);
+        var driveTypeNormalized = Normalize.DriveType(driveType);
+        ThrowIfInvalid.DriveType(driveTypeNormalized);
+        ThrowIfInvalid.DriveSize(size);
+
+        var system = await repository.GetByNameAsync(systemName) ??
+                     throw new NotFoundException($"System '{systemName}' not found.");
+
+        if (system.Drives == null || index < 0 || index >= system.Drives.Count)
+            throw new NotFoundException($"Drive index {index} not found on system '{systemName}'.");
+
+        var drive = system.Drives[index];
+
+        drive.Type = driveTypeNormalized;
+        drive.Size = size;
+
+        await repository.UpdateAsync(system);
+    }
+}

+ 33 - 0
RackPeek/Commands/Systems/Drives/SystemDriveAddCommand.cs

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

+ 30 - 0
RackPeek/Commands/Systems/Drives/SystemDriveRemoveCommand.cs

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

+ 36 - 0
RackPeek/Commands/Systems/Drives/SystemDriveUpdateCommand.cs

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

+ 108 - 0
Tests/HardwareResources/Systems/AddSystemDriveUseCaseTests.cs

@@ -0,0 +1,108 @@
+using System.ComponentModel.DataAnnotations;
+using NSubstitute;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+
+namespace Tests.HardwareResources.Systems;
+
+public class AddSystemDriveUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Adds_drive_when_system_exists()
+    {
+        // Arrange
+        var repo = Substitute.For<ISystemRepository>();
+        var system = new SystemResource
+        {
+            Name = "sys1",
+            Drives = new List<Drive>()
+        };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new AddSystemDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("sys1", "ssd", 512);
+
+        // Assert
+        Assert.Single(system.Drives);
+        Assert.Equal("ssd", system.Drives[0].Type);
+        Assert.Equal(512, system.Drives[0].Size);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<SystemResource>(s =>
+            s.Name == "sys1" &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "ssd" &&
+            s.Drives[0].Size == 512
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Initializes_drive_list_when_null()
+    {
+        // Arrange
+        var repo = Substitute.For<ISystemRepository>();
+        var system = new SystemResource
+        {
+            Name = "sys1",
+            Drives = null
+        };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new AddSystemDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("sys1", "sata", 500);
+
+        // Assert
+        Assert.NotNull(system.Drives);
+        Assert.Single(system.Drives);
+
+        await repo.Received(1).UpdateAsync(Arg.Is<SystemResource>(s =>
+            s.Drives != null &&
+            s.Drives.Count == 1 &&
+            s.Drives[0].Type == "sata"
+        ));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_system_not_found()
+    {
+        // Arrange
+        var repo = Substitute.For<ISystemRepository>();
+        repo.GetByNameAsync("sys1").Returns((SystemResource?)null);
+
+        var sut = new AddSystemDriveUseCase(repo);
+
+        // Act + Assert
+        await Assert.ThrowsAsync<NotFoundException>(() =>
+            sut.ExecuteAsync("sys1", "ssd", 512)
+        );
+    }
+    
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_type_invalid()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var sut = new AddSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<ValidationException>(() =>
+            sut.ExecuteAsync("sys1", "", 512)
+        );
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_size_negative()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var sut = new AddSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<ValidationException>(() =>
+            sut.ExecuteAsync("sys1", "ssd", -1)
+        );
+    }
+}

+ 72 - 0
Tests/HardwareResources/Systems/RemoveSystemDriveUseCaseTests.cs

@@ -0,0 +1,72 @@
+using System.ComponentModel.DataAnnotations;
+using NSubstitute;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+
+namespace Tests.HardwareResources.Systems;
+
+public class RemoveSystemDriveUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Removes_drive_when_found()
+    {
+        // Arrange
+        var repo = Substitute.For<ISystemRepository>();
+        var drive = new Drive { Type = "ssd", Size = 256 };
+        var system = new SystemResource { Name = "sys1", Drives = new List<Drive> { drive } };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new RemoveSystemDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("sys1", 0);
+
+        // Assert
+        Assert.Empty(system.Drives);
+
+        await repo.Received(1).UpdateAsync(system);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_system_not_found()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        repo.GetByNameAsync("sys1").Returns((SystemResource?)null);
+
+        var sut = new RemoveSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sut.ExecuteAsync("sys1", 0));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_drive_not_found()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var system = new SystemResource { Name = "sys1", Drives = new List<Drive>() };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new RemoveSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sut.ExecuteAsync("sys1", 0));
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_index_invalid()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+
+        repo.GetByNameAsync("sys1")
+            .Returns(new SystemResource
+            {
+                Name = "sys1", Drives = new List<Drive> { new Drive { Type = "ssd", Size = 256 } }
+            });
+
+        var sut = new RemoveSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sut.ExecuteAsync("sys1", -1));
+    }
+}

+ 86 - 0
Tests/HardwareResources/Systems/UpdateSystemDriveUseCaseTests.cs

@@ -0,0 +1,86 @@
+using System.ComponentModel.DataAnnotations;
+using NSubstitute;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+
+namespace Tests.HardwareResources.Systems;
+
+public class UpdateSystemDriveUseCaseTests
+{
+    [Fact]
+    public async Task ExecuteAsync_Updates_drive_when_found()
+    {
+        // Arrange
+        var repo = Substitute.For<ISystemRepository>();
+        var system = new SystemResource
+        {
+            Name = "sys1",
+            Drives = new List<Drive> { new() { Type = "ssd", Size = 256 } }
+        };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new UpdateSystemDriveUseCase(repo);
+
+        // Act
+        await sut.ExecuteAsync("sys1", 0, "nvme", 512);
+
+        // Assert
+        Assert.Equal("nvme", system.Drives[0].Type);
+        Assert.Equal(512, system.Drives[0].Size);
+
+        await repo.Received(1).UpdateAsync(system);
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_system_not_found()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        repo.GetByNameAsync("sys1").Returns((SystemResource?)null);
+
+        var sut = new UpdateSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<NotFoundException>(() =>
+            sut.ExecuteAsync("sys1", 0, "ssd", 512)
+        );
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_drive_not_found()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var system = new SystemResource { Name = "sys1", Drives = new List<Drive>() };
+
+        repo.GetByNameAsync("sys1").Returns(system);
+
+        var sut = new UpdateSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<NotFoundException>(() =>
+            sut.ExecuteAsync("sys1", 0, "ssd", 512)
+        );
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_type_invalid()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var sut = new UpdateSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<ValidationException>(() =>
+            sut.ExecuteAsync("sys1", 0, "", 512)
+        );
+    }
+
+    [Fact]
+    public async Task ExecuteAsync_Throws_when_size_negative()
+    {
+        var repo = Substitute.For<ISystemRepository>();
+        var sut = new UpdateSystemDriveUseCase(repo);
+
+        await Assert.ThrowsAsync<ValidationException>(() =>
+            sut.ExecuteAsync("sys1", 0, "ssd", -1)
+        );
+    }
+}