using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Junction.Core.Plugins; using Junction.Domain; using Junction.Domain.Models; using Junction.Domain.Protocols; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Junction.Tests.Unit { /// /// Tests for . A real loadable plugin is synthesized at test /// time from THIS test assembly: a temp plugin dir gets a copy of the test dll plus a /// plugin.manifest.json whose EntryTypeName points at /// below. Because the copy shares the already-loaded test assembly's identity, /// Assembly.LoadFrom resolves the running assembly and the type is found. /// public sealed class PluginLoaderTests : IDisposable { // ---- Fake plugin types defined IN the test project ---- /// Valid, loadable factory used for the happy path. public sealed class FakeFactory : IProtocolDriverFactory { public string ProtocolId => "fake"; public Result Create(Machine machine) => Result.Ok(new FakeDriver()); } public sealed class FakeDriver : IProtocolDriver { public string ProtocolId => "fake"; public Task> ReadCurrentAsync(CancellationToken cancellationToken) => Task.FromResult(Result.Ok( new MachineSnapshot( Guid.NewGuid(), DateTimeOffset.UtcNow, ConnectionState.Connected, Array.Empty()))); } /// Type that does NOT implement the factory contract. public sealed class NotAFactory { } /// Factory whose ctor throws (activation failure path). public sealed class ThrowingFactory : IProtocolDriverFactory { public ThrowingFactory() => throw new InvalidOperationException("boom"); public string ProtocolId => "throwing"; public Result Create(Machine machine) => throw new NotImplementedException(); } private static readonly string TestAssemblyPath = typeof(PluginLoaderTests).Assembly.Location; private static readonly string TestAssemblyFileName = Path.GetFileName(TestAssemblyPath); private readonly string _root; public PluginLoaderTests() { _root = Path.Combine(Path.GetTempPath(), "junction-plugintests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } public void Dispose() { try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } catch { // best-effort cleanup } } // ---- helpers ---- private static PluginLoader NewLoader(ILogger? logger = null) => new PluginLoader(logger ?? NullLogger.Instance); /// /// Create a plugin subdir with a copy of the test dll (so AssemblyFile resolves) /// and a manifest pointing at . /// private string CreatePluginDir(string dirName, string entryTypeName, string protocolId = "fake", bool copyDll = true) { var dir = Path.Combine(_root, dirName); Directory.CreateDirectory(dir); if (copyDll) File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true); var manifest = "{\n" + $" \"protocolId\": \"{protocolId}\",\n" + " \"displayName\": \"Fake Plugin\",\n" + $" \"assemblyFile\": \"{TestAssemblyFileName}\",\n" + $" \"entryTypeName\": \"{entryTypeName}\",\n" + " \"apiVersion\": \"1.0\"\n" + "}"; File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), manifest); return dir; } private void CreateBadJsonPluginDir(string dirName) { var dir = Path.Combine(_root, dirName); Directory.CreateDirectory(dir); File.Copy(TestAssemblyPath, Path.Combine(dir, TestAssemblyFileName), overwrite: true); File.WriteAllText(Path.Combine(dir, PluginLoader.ManifestFileName), "{ this is : not valid json ]"); } // ---- tests ---- [Fact] public void LoadFrom_ValidPlugin_ReturnsLoadedPluginWithWorkingFactory() { CreatePluginDir("good", typeof(FakeFactory).FullName!); var result = NewLoader().LoadFrom(_root); Assert.True(result.IsSuccess); var plugin = Assert.Single(result.Value); Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId); Assert.Equal("fake", plugin.Factory.ProtocolId); Assert.True(File.Exists(plugin.Descriptor.AssemblyPath)); var machine = new Machine(Guid.NewGuid(), "M1", "fake", null, TimeSpan.FromSeconds(1)); var created = plugin.Factory.Create(machine); Assert.True(created.IsSuccess); Assert.Equal("fake", created.Value.ProtocolId); } [Fact] public void LoadFrom_RootManifest_IsDiscovered() { // manifest + dll directly in root (no subdir) File.Copy(TestAssemblyPath, Path.Combine(_root, TestAssemblyFileName), overwrite: true); File.WriteAllText( Path.Combine(_root, PluginLoader.ManifestFileName), "{ \"protocolId\": \"fake\", \"displayName\": \"d\", " + $"\"assemblyFile\": \"{TestAssemblyFileName}\", " + $"\"entryTypeName\": \"{typeof(FakeFactory).FullName}\", \"apiVersion\": \"1.0\" }}"); var result = NewLoader().LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Single(result.Value); } [Fact] public void LoadFrom_BadJson_SkipsThatPlugin_LoadsOthers() { CreateBadJsonPluginDir("bad"); CreatePluginDir("good", typeof(FakeFactory).FullName!); var logger = new CapturingLogger(); var result = NewLoader(logger).LoadFrom(_root); Assert.True(result.IsSuccess); var plugin = Assert.Single(result.Value); // only the good one Assert.Equal("fake", plugin.Descriptor.Manifest.ProtocolId); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error); } [Fact] public void LoadFrom_MissingDll_SkipsPlugin_NoThrow() { // manifest present, but do not copy the dll CreatePluginDir("nodll", typeof(FakeFactory).FullName!, copyDll: false); var logger = new CapturingLogger(); var result = NewLoader(logger).LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Empty(result.Value); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error); } [Fact] public void LoadFrom_UnknownEntryType_SkipsPlugin_NoThrow() { CreatePluginDir("badtype", "Junction.Tests.Unit.NoSuchType"); var logger = new CapturingLogger(); var result = NewLoader(logger).LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Empty(result.Value); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Error); } [Fact] public void LoadFrom_EntryTypeNotAFactory_SkipsPlugin() { CreatePluginDir("wrongcontract", typeof(NotAFactory).FullName!); var result = NewLoader().LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Empty(result.Value); } [Fact] public void LoadFrom_FactoryCtorThrows_SkipsPlugin() { CreatePluginDir("throwctor", typeof(ThrowingFactory).FullName!); var result = NewLoader().LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Empty(result.Value); } [Fact] public void LoadFrom_MissingDirectory_ReturnsFail() { var missing = Path.Combine(_root, "does-not-exist"); var result = NewLoader().LoadFrom(missing); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING"); } [Fact] public void LoadFrom_EmptyPath_ReturnsFail() { var result = NewLoader().LoadFrom(""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Code == "PLUGIN_DIR_MISSING"); } [Fact] public void LoadFrom_MultiplePlugins_LoadsAllValid() { CreatePluginDir("p1", typeof(FakeFactory).FullName!); CreatePluginDir("p2", typeof(FakeFactory).FullName!); CreateBadJsonPluginDir("bad"); var result = NewLoader().LoadFrom(_root); Assert.True(result.IsSuccess); Assert.Equal(2, result.Value.Count); } // ---- capturing logger ---- private sealed class CapturingLogger : ILogger { public readonly List<(LogLevel Level, string Message)> Entries = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } } }