Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
<PackageVersion Include="EnumerableAsyncProcessor" Version="3.8.4" />
<PackageVersion Include="FluentFTP" Version="54.1.2" />
<PackageVersion Include="FSharp.Core" Version="10.1.300" />
<PackageVersion Include="Initialization.Microsoft.Extensions.DependencyInjection" Version="1.1.44" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
Expand Down Expand Up @@ -83,6 +84,7 @@
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageVersion Include="TUnit" Version="1.44.39" />
<PackageVersion Include="TUnit.Assertions" Version="1.44.39" />
<PackageVersion Include="TUnit.Assertions.FSharp" Version="1.44.39" />
<PackageVersion Include="TUnit.Core" Version="1.44.39" />
<PackageVersion Include="vertical-spectreconsolelogger" Version="0.10.1-dev.20241201.35" />
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
Expand Down
19 changes: 17 additions & 2 deletions ModularPipelines.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.6.33815.320
# Visual Studio Version 18
VisualStudioVersion = 18.5.11723.231 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ModularPipelines", "src\ModularPipelines\ModularPipelines.csproj", "{A25FAFCF-E226-4263-B3D6-732668604BD9}"
EndProject
Expand Down Expand Up @@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Syft", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Grype", "src\ModularPipelines.Grype\ModularPipelines.Grype.csproj", "{60E4E82D-7BBF-4513-80ED-36A2273BB97D}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ModularPipelines.UnitTests.FSharp", "test\ModularPipelines.UnitTests.FSharp\ModularPipelines.UnitTests.FSharp.fsproj", "{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -807,6 +809,18 @@ Global
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x64.Build.0 = Release|Any CPU
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x86.ActiveCfg = Release|Any CPU
{60E4E82D-7BBF-4513-80ED-36A2273BB97D}.Release|x86.Build.0 = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x64.ActiveCfg = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x64.Build.0 = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x86.ActiveCfg = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Debug|x86.Build.0 = Debug|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|Any CPU.Build.0 = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x64.ActiveCfg = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x64.Build.0 = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x86.ActiveCfg = Release|Any CPU
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -868,6 +882,7 @@ Global
{0FB125FE-5AB3-4667-8D1B-85A6284474ED} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2E70AA19-0309-4C6F-83D2-8E3DD2A7EC89} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{60E4E82D-7BBF-4513-80ED-36A2273BB97D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7383D287-8BC9-4214-90A2-9B7CA4C4CBE6} = {F213898F-1E32-48F1-AB8C-83D2BD01A93B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5905A5D-B4E1-4A7A-9279-0283D86A9F7F}
Expand Down
3 changes: 3 additions & 0 deletions src/ModularPipelines/ModularPipelines.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ModularPipelines.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ModularPipelines.UnitTests.FSharp</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ModularPipelines.TestHelpers</_Parameter1>
</AssemblyAttribute>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
namespace ModularPipelines.UnitTests.FSharp.Api

open System
open ModularPipelines.Attributes
open ModularPipelines.Context
open ModularPipelines.DependencyInjection
open ModularPipelines.Modules
open TUnit.Core
open TUnit.Assertions.FSharp.Operations
open TUnit.Assertions.Extensions
open TUnit.Assertions

/// <summary>
/// Verifies that all public types from the flexible dependency API are accessible from their expected namespaces. This
/// ensures the API surface is correctly exported and consumable by library users.
/// </summary>
type FlexibleDependencyApiExportTests() =
[<Test>]
member _.IDependencyContext_IsAccessibleFromContextNamespace() =
async {
let dependencyContextType = typeof<IDependencyContext>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(dependencyContextType.Namespace),
"ModularPipelines.Context"
)
)

do! check(Assert.That(dependencyContextType.IsPublic).IsTrue())
do! check(Assert.That(dependencyContextType.IsInterface).IsTrue())
}
[<Test>]
member _.DependsOnBaseAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify DependsOnBaseAttribute is in ModularPipelines.Attributes namespace
let dependsOnBaseAttributeType = typeof<DependsOnBaseAttribute>
do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(dependsOnBaseAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(dependsOnBaseAttributeType.IsPublic).IsTrue())
do! check(Assert.That(dependsOnBaseAttributeType.IsAbstract).IsTrue())
do! check(Assert.That(dependsOnBaseAttributeType.IsSubclassOf(typeof<Attribute>)).IsTrue())
}

[<Test>]
member _.ModuleTagAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify ModuleTagAttribute is in ModularPipelines.Attributes namespace
let moduleTagAttributeType = typeof<ModuleTagAttribute>
do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(moduleTagAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(moduleTagAttributeType.IsPublic).IsTrue())
do! check(Assert.That(moduleTagAttributeType.IsSealed).IsTrue())
do! check(Assert.That(moduleTagAttributeType.IsSubclassOf(typeof<Attribute>)).IsTrue())
}

[<Test>]
member _.ModuleCategoryAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify ModuleCategoryAttribute is in ModularPipelines.Attributes namespace
let moduleCategoryAttributeType = typeof<ModuleCategoryAttribute>
do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(moduleCategoryAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(moduleCategoryAttributeType.IsPublic).IsTrue())
do! check(Assert.That(moduleCategoryAttributeType.IsSealed).IsTrue())
do! check(Assert.That(moduleCategoryAttributeType.IsSubclassOf(typeof<Attribute>)).IsTrue())
}

[<Test>]
member _.DependsOnModulesWithTagAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify DependsOnModulesWithTagAttribute is in ModularPipelines.Attributes namespace
let dependsOnModulesWithTagAttributeType = typeof<DependsOnModulesWithTagAttribute>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(dependsOnModulesWithTagAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsPublic).IsTrue())
do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsSealed).IsTrue())
do! check(Assert.That(dependsOnModulesWithTagAttributeType.IsSubclassOf(typeof<DependsOnBaseAttribute>)).IsTrue())
}

[<Test>]
member _.DependsOnModulesInCategoryAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify DependsOnModulesInCategoryAttribute is in ModularPipelines.Attributes namespace
let dependsOnModulesInCategoryAttributeType = typeof<DependsOnModulesInCategoryAttribute>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(dependsOnModulesInCategoryAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsPublic).IsTrue())
do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsSealed).IsTrue())
do! check(Assert.That(dependsOnModulesInCategoryAttributeType.IsSubclassOf(typeof<DependsOnBaseAttribute>)).IsTrue())
}

[<Test>]
member _.DependsOnModulesWithAttributeAttribute_IsAccessibleFromAttributesNamespace() = async {
// Verify DependsOnModulesWithAttributeAttribute<T> is in ModularPipelines.Attributes namespace
let dependsOnModulesWithAttributeAttributeType = typedefof<DependsOnModulesWithAttributeAttribute<_>>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(dependsOnModulesWithAttributeAttributeType.Namespace),
"ModularPipelines.Attributes"
)
)
do! check(Assert.That(dependsOnModulesWithAttributeAttributeType.IsPublic).IsTrue())
do! check(Assert.That(dependsOnModulesWithAttributeAttributeType.IsGenericTypeDefinition).IsTrue())

let closedType = typeof<DependsOnModulesWithAttributeAttribute<ObsoleteAttribute>>
do! check(Assert.That(closedType.IsSubclassOf(typeof<DependsOnBaseAttribute>)).IsTrue())
}

[<Test>]
member _.ITaggedModule_IsAccessibleFromModulesNamespace() = async {
// Verify ITaggedModule is in ModularPipelines.Modules namespace
let taggedModuleType = typeof<ITaggedModule>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(taggedModuleType.Namespace),
"ModularPipelines.Modules"
)
)
do! check(Assert.That(taggedModuleType.IsPublic).IsTrue())
do! check(Assert.That(taggedModuleType.IsInterface).IsTrue())
}

[<Test>]
member _.IModuleRegistrationBuilder_IsAccessibleFromDependencyInjectionNamespace() = async {
// Verify IModuleRegistrationBuilder is in ModularPipelines.DependencyInjection namespace
let moduleRegistrationBuilderType = typeof<IModuleRegistrationBuilder>

do! check(
StringEqualsAssertionExtensions.IsEqualTo(
Assert.That(moduleRegistrationBuilderType.Namespace),
"ModularPipelines.DependencyInjection"
)
)
do! check(Assert.That(moduleRegistrationBuilderType.IsPublic).IsTrue())
do! check(Assert.That(moduleRegistrationBuilderType.IsInterface).IsTrue())
}

[<Test>]
member _.AllFlexibleDependencyAttributes_HaveCorrectAttributeUsage() = async {
// Verify all dependency attributes allow multiple usage and inheritance
let dependencyAttributes =
[|
typeof<ModuleTagAttribute>
typeof<DependsOnModulesWithTagAttribute>
typeof<DependsOnModulesInCategoryAttribute>
typeof<DependsOnModulesWithAttributeAttribute<_>>
|]

for attrType in dependencyAttributes do
let usage =
attrType.GetCustomAttributes(typeof<AttributeUsageAttribute>, false)
|> Array.choose (function
| :? AttributeUsageAttribute as attributeUsage -> Some attributeUsage
| _ -> None)
|> Array.tryHead

do! check(Assert.That(usage.IsSome).IsTrue())

match usage with
| Some attributeUsage ->
do! check(Assert.That(attributeUsage.AllowMultiple).IsTrue())
do! check(Assert.That(attributeUsage.Inherited).IsTrue())
| None -> ()
}

[<Test>]
member _.ModuleCategoryAttribute_DoesNotAllowMultiple() = async {
// Verify ModuleCategoryAttribute does NOT allow multiple (only one category per module)
let usage =
typeof<ModuleCategoryAttribute>.GetCustomAttributes(typeof<AttributeUsageAttribute>, false)
|> Array.choose (function
| :? AttributeUsageAttribute as attributeUsage -> Some attributeUsage
| _ -> None)
|> Array.tryHead

do! check(Assert.That(usage.IsSome).IsTrue())

match usage with
| Some attributeUsage ->
do! check(Assert.That(attributeUsage.AllowMultiple).IsFalse())
do! check(Assert.That(attributeUsage.Inherited).IsTrue())
| None -> ()
}

[<Test>]
member _.IDependencyContext_HasExpectedMethods() = async {
// Verify IDependencyContext has all required methods for dependency resolution
let dependencyContextType = typeof<IDependencyContext>
let methods = dependencyContextType.GetMethods()

do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetTags")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetCategory")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "HasAttribute")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetAttribute")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "GetAttributes")).IsTrue())
}

[<Test>]
member _.ITaggedModule_HasExpectedProperties() = async {
// Verify ITaggedModule has all required properties
let taggedModuleType = typeof<ITaggedModule>
let properties = taggedModuleType.GetProperties()

do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Tags")).IsTrue())
do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Category")).IsTrue())
}

[<Test>]
member _.IModuleRegistrationBuilder_HasExpectedMembers() = async {
// Verify IModuleRegistrationBuilder has all required members
let moduleRegistrationBuilderType = typeof<IModuleRegistrationBuilder>
let properties = moduleRegistrationBuilderType.GetProperties()
let methods = moduleRegistrationBuilderType.GetMethods()

do! check(Assert.That(properties |> Array.exists (fun p -> p.Name = "Services")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "WithTags")).IsTrue())
do! check(Assert.That(methods |> Array.exists (fun m -> m.Name = "WithCategory")).IsTrue())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace ModularPipelines.UnitTests.FSharp.Attributes

open Microsoft.Extensions.Logging
open ModularPipelines.Attributes.Events
open ModularPipelines.Context
open ModularPipelines.Engine.Attributes
open Moq
open System
open TUnit.Assertions
open TUnit.Assertions.Extensions
open TUnit.Assertions.FSharp.Operations
open TUnit.Core

module AttributeEventInvokerTests =
type private SuccessfulHandler() =
member val WasCalled = false with get, private set
interface IModuleStartHandler with
member _.ContinueOnError = false
member this.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
task {
this.WasCalled <- true
}

type private FailingHandler() =
interface IModuleStartHandler with
member _.ContinueOnError = false
member _.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
raise (InvalidOperationException("Test exception"))

type private FailingHandlerWithContinue() =
interface IModuleStartHandler with
member _.ContinueOnError = true
member _.OnModuleStartAsync(_: ModularPipelines.Context.IModuleHookContext): System.Threading.Tasks.Task =
raise (InvalidOperationException("Test exception"))

type AttributeEventInvokerTests() =
[<Test>]
member _.InvokeAsync_CallsAllHandlers() = async {
let handler1 = SuccessfulHandler()
let handler2 = SuccessfulHandler()
let handlers = [ handler1 :> IModuleStartHandler; handler2 :> IModuleStartHandler ]
let invoker = AttributeEventInvoker(Mock.Of<ILogger<AttributeEventInvoker>>())
let context = Mock.Of<IModuleHookContext>()

do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask

do! check(Assert.That(handler1.WasCalled).IsTrue())
do! check(Assert.That(handler2.WasCalled).IsTrue())
}

[<Test>]
member _.InvokeAsync_HandlerThrows_ContinueOnErrorFalse_Propagates() = async {
let handler = FailingHandler()
let handlers = [ handler :> IModuleStartHandler ]
let invoker = AttributeEventInvoker(Mock.Of<ILogger<AttributeEventInvoker>>())
let context = Mock.Of<IModuleHookContext>()

let mutable thrownException = None

try
do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask
with ex ->
thrownException <- Some ex

do! check(Assert.That(thrownException.IsSome).IsTrue())

match thrownException with
| Some ex ->
do! check(StringEqualsAssertionExtensions.IsEqualTo(Assert.That(ex.GetBaseException().Message), "Test exception"))
| None -> ()
}

[<Test>]
member _.InvokeAsync_HandlerThrows_ContinueOnErrorTrue_Continues() = async {
let failingHandler = FailingHandlerWithContinue()
let successHandler = SuccessfulHandler()
let handlers = [ failingHandler :> IModuleStartHandler; successHandler :> IModuleStartHandler ]
let invoker = AttributeEventInvoker(Mock.Of<ILogger<AttributeEventInvoker>>())
let context = Mock.Of<IModuleHookContext>()

do! invoker.InvokeStartHandlersAsync(handlers, context) |> Async.AwaitTask

do! check(Assert.That(successHandler.WasCalled).IsTrue())
}
Loading