modules/Devolutions.CIEM.Graph/Tests/Unit/CIEMGraphEdge.Tests.ps1

BeforeAll {
    Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue
    Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' 'Devolutions.CIEM.psd1')
    Mock -ModuleName Devolutions.CIEM Write-CIEMLog {}

    # Create isolated test DB with base + azure + discovery + graph schemas
    New-CIEMDatabase -Path "$TestDrive/ciem.db"

    $azureSchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Infrastructure' 'Data' 'azure_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $azureSchema -Raw)

    $discoverySchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Discovery' 'Data' 'discovery_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $discoverySchema -Raw)

    $graphSchema = Join-Path $PSScriptRoot '..' '..' 'Data' 'graph_schema.sql'
    Invoke-CIEMQuery -Query (Get-Content $graphSchema -Raw)

    InModuleScope Devolutions.CIEM {
        $script:DatabasePath = "$TestDrive/ciem.db"
    }
}

Describe 'Graph Edge CRUD' {

    Context 'Schema' {
        It 'graph_edges table exists after applying graph_schema.sql' {
            $result = Invoke-CIEMQuery -Query "SELECT name FROM sqlite_master WHERE type='table' AND name='graph_edges'"
            $result | Should -Not -BeNullOrEmpty
        }

        It 'All 6 indexes exist' {
            $indexes = @(Invoke-CIEMQuery -Query "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='graph_edges'")
            $indexNames = $indexes.name
            $indexNames | Should -Contain 'idx_graph_edges_source'
            $indexNames | Should -Contain 'idx_graph_edges_target'
            $indexNames | Should -Contain 'idx_graph_edges_kind'
            $indexNames | Should -Contain 'idx_graph_edges_source_kind'
            $indexNames | Should -Contain 'idx_graph_edges_target_kind'
            $indexNames | Should -Contain 'idx_graph_edges_computed'
        }
    }

    Context 'Command structure' {
        It 'Get-CIEMGraphEdge is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Get-CIEMGraphEdge -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'Save-CIEMGraphEdge is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Save-CIEMGraphEdge -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'Remove-CIEMGraphEdge is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Remove-CIEMGraphEdge -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }
    }

    Context 'Get-CIEMGraphEdge' {
        BeforeAll {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"
            # Seed prerequisite nodes
            Save-CIEMGraphNode -Id 'node-a' -Kind 'EntraUser' -DisplayName 'User A' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-b' -Kind 'EntraGroup' -DisplayName 'Group B' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-c' -Kind 'AzureVM' -DisplayName 'VM C' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-d' -Kind 'ManagedIdentity' -DisplayName 'MI D' -Provider 'azure'
            $ts = (Get-Date).ToString('o')
            # Seed test edges
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-c' -Kind 'HasRoleAssignment' -Properties '{"role_name": "Owner", "privileged": true}' -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-c' -TargetId 'node-d' -Kind 'HasManagedIdentity' -Computed 1 -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-b' -TargetId 'node-c' -Kind 'HasRoleAssignment' -CollectedAt $ts
        }

        It 'Returns all when no filter' {
            $results = Get-CIEMGraphEdge
            $results | Should -HaveCount 4
        }

        It 'Returns CIEMGraphEdge typed objects' {
            $results = Get-CIEMGraphEdge
            $results | ForEach-Object { $_.GetType().Name | Should -Be 'CIEMGraphEdge' }
        }

        It 'Filters by -Id' {
            $all = Get-CIEMGraphEdge
            $first = $all | Select-Object -First 1
            $result = Get-CIEMGraphEdge -Id $first.Id
            $result | Should -HaveCount 1
            $result[0].Id | Should -Be $first.Id
        }

        It 'Filters by -SourceId' {
            $results = Get-CIEMGraphEdge -SourceId 'node-a'
            $results | Should -HaveCount 2
        }

        It 'Filters by -TargetId' {
            $results = Get-CIEMGraphEdge -TargetId 'node-c'
            $results | Should -HaveCount 2
        }

        It 'Filters by -Kind' {
            $results = Get-CIEMGraphEdge -Kind 'HasRoleAssignment'
            $results | Should -HaveCount 2
        }

        It 'Filters by -Computed' {
            $results = Get-CIEMGraphEdge -Computed 1
            $results | Should -HaveCount 1
            $results[0].Kind | Should -Be 'HasManagedIdentity'
        }

        It 'Returns empty array when no match' {
            $results = Get-CIEMGraphEdge -SourceId 'nonexistent'
            $results | Should -BeNullOrEmpty
        }

        It 'Combines multiple filters' {
            $results = Get-CIEMGraphEdge -SourceId 'node-a' -Kind 'MemberOf'
            $results | Should -HaveCount 1
            $results[0].TargetId | Should -Be 'node-b'
        }
    }

    Context 'Save-CIEMGraphEdge' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"
            Save-CIEMGraphNode -Id 'node-a' -Kind 'EntraUser' -DisplayName 'User A' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-b' -Kind 'EntraGroup' -DisplayName 'Group B' -Provider 'azure'
        }

        It 'Inserts a new edge' {
            $ts = (Get-Date).ToString('o')
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' -CollectedAt $ts
            $results = Get-CIEMGraphEdge
            $results | Should -HaveCount 1
            $results[0].SourceId | Should -Be 'node-a'
        }

        It 'INSERT OR REPLACE honors UNIQUE constraint (updates on conflict)' {
            $ts = (Get-Date).ToString('o')
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' -Properties '{"v1": true}' -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' -Properties '{"v2": true}' -CollectedAt $ts
            # UNIQUE(source_id, target_id, kind) - should be 1 row, not 2
            $results = Get-CIEMGraphEdge
            $results | Should -HaveCount 1
            $results[0].Properties | Should -Be '{"v2": true}'
        }

        It 'Accepts -InputObject via pipeline' {
            $obj = InModuleScope Devolutions.CIEM {
                $o = [CIEMGraphEdge]::new()
                $o.SourceId = 'node-a'
                $o.TargetId = 'node-b'
                $o.Kind = 'HasRole'
                $o.Properties = '{"piped": true}'
                $o.Computed = 0
                $o.CollectedAt = (Get-Date).ToString('o')
                $o
            }
            $obj | Save-CIEMGraphEdge
            $result = Get-CIEMGraphEdge -Kind 'HasRole'
            $result | Should -Not -BeNullOrEmpty
            $result[0].Properties | Should -Be '{"piped": true}'
        }
    }

    Context 'Remove-CIEMGraphEdge' {
        BeforeEach {
            Invoke-CIEMQuery -Query "DELETE FROM graph_edges"
            Invoke-CIEMQuery -Query "DELETE FROM graph_nodes"
            Save-CIEMGraphNode -Id 'node-a' -Kind 'EntraUser' -DisplayName 'User A' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-b' -Kind 'EntraGroup' -DisplayName 'Group B' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-c' -Kind 'AzureVM' -DisplayName 'VM C' -Provider 'azure'
            Save-CIEMGraphNode -Id 'node-d' -Kind 'ManagedIdentity' -DisplayName 'MI D' -Provider 'azure'
            $ts = (Get-Date).ToString('o')
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-c' -Kind 'MemberOf' -CollectedAt $ts
            Save-CIEMGraphEdge -SourceId 'node-c' -TargetId 'node-d' -Kind 'HasManagedIdentity' -Computed 1 -CollectedAt $ts
            $script:rmEdge1 = Get-CIEMGraphEdge -SourceId 'node-a' -TargetId 'node-b' -Kind 'MemberOf' | Select-Object -First 1
        }

        It 'Removes by -Id (integer)' {
            Remove-CIEMGraphEdge -Id $script:rmEdge1.Id -Confirm:$false
            $result = Get-CIEMGraphEdge -Id $script:rmEdge1.Id
            $result | Should -BeNullOrEmpty
            Get-CIEMGraphEdge | Should -HaveCount 2
        }

        It 'Removes by -Kind' {
            Remove-CIEMGraphEdge -Kind 'MemberOf' -Confirm:$false
            $results = Get-CIEMGraphEdge
            $results | Should -HaveCount 1
            $results[0].Kind | Should -Be 'HasManagedIdentity'
        }

        It 'Removes by -Computed flag' {
            Remove-CIEMGraphEdge -Computed 1 -Confirm:$false
            $results = Get-CIEMGraphEdge
            $results | Should -HaveCount 2
            $results | ForEach-Object { $_.Computed | Should -Be 0 }
        }

        It 'Removes all records with -All switch' {
            Remove-CIEMGraphEdge -All -Confirm:$false
            $results = Get-CIEMGraphEdge
            $results | Should -BeNullOrEmpty
        }

        It 'Removes via -InputObject' {
            $obj = Get-CIEMGraphEdge -Kind 'HasManagedIdentity'
            Remove-CIEMGraphEdge -InputObject $obj -Confirm:$false
            $result = Get-CIEMGraphEdge -Kind 'HasManagedIdentity'
            $result | Should -BeNullOrEmpty
            Get-CIEMGraphEdge | Should -HaveCount 2
        }

        It 'No-ops when Id does not exist' {
            { Remove-CIEMGraphEdge -Id 99999 -Confirm:$false } | Should -Not -Throw
        }
    }
}