Tests/Varibill.SourceCollector.Core.Tests.ps1

# Requires Pester 5

Import-Module "$PSScriptRoot/../Varibill.SourceCollector.Core.psm1" -Force

Describe "Varibill SourceData Module" {

    BeforeAll {
        $script:BaseTemp = Join-Path ([System.IO.Path]::GetTempPath()) "VaribillTests"

        function New-TestUnrated {
            param([string]$Path, [int]$MaxRecords = 1000)
            New-UnratedSourceData -SourceCollectorAPI "https://test.api" `
                                  -TenantKey ([guid]::NewGuid()) `
                                  -SourceCollectorKey ([guid]::NewGuid()) `
                                  -SourceCollectorSecret "secret" `
                                  -SourceCollectorPath $Path `
                                  -MaxRecordsPerFile $MaxRecords
        }

        function New-TestRated {
            param([string]$Path, [int]$MaxRecords = 1000)
            New-RatedSourceData -SourceCollectorAPI "https://test.api" `
                                -TenantKey ([guid]::NewGuid()) `
                                -SourceCollectorKey ([guid]::NewGuid()) `
                                -SourceCollectorSecret "secret" `
                                -SourceCollectorPath $Path `
                                -MaxRecordsPerFile $MaxRecords
        }
    }

    AfterAll {
        if (Test-Path $script:BaseTemp) { Remove-Item $script:BaseTemp -Recurse -Force }
    }

    # Each test gets an isolated temp directory so leftover files never affect counts.
    BeforeEach {
        $script:TempPath = Join-Path $script:BaseTemp ([guid]::NewGuid().ToString())
        New-Item -Path $script:TempPath -ItemType Directory | Out-Null
    }

    AfterEach {
        if (Test-Path $script:TempPath) { Remove-Item $script:TempPath -Recurse -Force }
    }

    Context "UnratedSourceData" {

        It "can create an instance via constructor" {
            $u = New-TestUnrated -Path $script:TempPath -MaxRecords 10
            $u.GetType().Name | Should -Be 'UnratedSourceData'
        }

        It "can create an instance via New-UnratedSourceData function" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.GetType().Name | Should -Be 'UnratedSourceData'
        }

        It "can add records" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("Client1","Prod1","Rec1","UID1",(Get-Date),5,"Tag1")
            $u.Records.Count | Should -Be 1
        }

        It "creates one .rdy file when record count equals MaxRecordsPerFile" {
            $u = New-TestUnrated -Path $script:TempPath -MaxRecords 3
            1..3 | ForEach-Object { $u.AddUnratedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,"") }
            $u.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "splits into multiple .rdy files when records exceed MaxRecordsPerFile" {
            $u = New-TestUnrated -Path $script:TempPath -MaxRecords 2
            1..5 | ForEach-Object { $u.AddUnratedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,"") }
            $u.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $files.Count | Should -Be 3
        }

        It "saves a single record as a JSON array, not a bare object" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("Client1","Prod1","Rec1","UID1",(Get-Date),1,"Tag1")
            $u.SaveData()
            $file = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy" |
                        Select-Object -First 1
            $content = Get-Content -Path $file.FullName -Raw
            $parsed = $content | ConvertFrom-Json -NoEnumerate
            # Must be an array so the API accepts it; a bare object causes 400 Bad Request
            ($parsed -is [System.Array]) | Should -BeTrue
        }

        It "clears records after SaveData so a second call writes nothing" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),1,"")
            $u.SaveData()
            $u.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "uploads all .rdy files and renames them to .json on success" {
            $u = New-TestUnrated -Path $script:TempPath -MaxRecords 1
            1..3 | ForEach-Object { $u.AddUnratedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,"") }

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{"result":"ok"}' }
            }

            $u.PostSourceData()
            $rdyFiles  = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $jsonFiles = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.json"
            $rdyFiles.Count  | Should -Be 0
            $jsonFiles.Count | Should -Be 3
        }

        It "keeps .rdy file when server returns non-2xx status" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),5,"")

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 500; Content = '{"error":"internal"}' }
            }

            $u.PostSourceData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "throws with the filename when the .rdy file has an empty payload" {
            $u = New-TestUnrated -Path $script:TempPath
            $dir = Join-Path $script:TempPath "unrated"
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
            $emptyFile = Join-Path $dir "empty_payload.rdy"
            Set-Content -Path $emptyFile -Value "" -Encoding utf8

            { $u.PostSourceData() } | Should -Throw -ExpectedMessage "*empty_payload.rdy*"
        }

        It "throws with the filename when the .rdy file contains only whitespace" {
            $u = New-TestUnrated -Path $script:TempPath
            $dir = Join-Path $script:TempPath "unrated"
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
            $wsFile = Join-Path $dir "whitespace_only.rdy"
            Set-Content -Path $wsFile -Value " `n " -Encoding utf8

            { $u.PostSourceData() } | Should -Throw -ExpectedMessage "*whitespace_only.rdy*"
        }

        It "throws with the filename when Invoke-WebRequest fails" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),5,"")

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                throw [System.Net.Http.HttpRequestException]::new("Connection refused")
            }

            { $u.PostSourceData() } | Should -Throw -ExpectedMessage "*Upload failed for file*"
        }

        It "SaveData with no records creates no files" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy"
            $files.Count | Should -Be 0
        }

        It "PostSourceData with no records does not call Invoke-WebRequest" {
            $u = New-TestUnrated -Path $script:TempPath
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith { }
            $u.PostSourceData()
            Should -Invoke Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -Times 0 -Exactly
        }

        It "saved .rdy file contains correct field names and ISO 8601 date" {
            $u = New-TestUnrated -Path $script:TempPath
            $date = [datetime]"2025-06-15T09:30:00"
            $u.AddUnratedSourceData("ClientA","ProdB","RecC","UID-X",$date,42.5,"MyTag")
            $u.SaveData()
            $rdyFile = Get-ChildItem -Path (Join-Path $script:TempPath "unrated") -Filter "*.rdy" | Select-Object -First 1
            $record = Get-Content $rdyFile.FullName -Raw | ConvertFrom-Json
            $rawJson = Get-Content $rdyFile.FullName -Raw
            $record.ClientIdentifier  | Should -Be "ClientA"
            $record.ProductIdentifier | Should -Be "ProdB"
            $record.RecordIdentifier  | Should -Be "RecC"
            $record.UniqueIdentifier  | Should -Be "UID-X"
            $record.Quantity          | Should -Be 42.5
            $record.Tag               | Should -Be "MyTag"
            # Verify ISO 8601 format is preserved in the raw JSON that will be sent to the API
            $rawJson | Should -Match '"LastSeenDate"\s*:\s*"\d{4}-\d{2}-\d{2}T'
        }

        It "writes a response log file to the log directory after a successful upload" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),5,"")
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{"result":"ok"}' }
            }
            $u.PostSourceData()
            $logFiles = Get-ChildItem -Path (Join-Path $script:TempPath "log") -Filter "*.json"
            $logFiles.Count | Should -Be 1
        }

        It "posts to the Unrated API endpoint" {
            $u = New-TestUnrated -Path $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),5,"")
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{}' }
            }
            $u.PostSourceData()
            Should -Invoke Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -Times 1 -Exactly `
                -ParameterFilter { $Uri -like "*api/v1/SourceCollectors/Unrated*" }
        }

        It "creates a temp directory structure when no SourceCollectorPath is provided" {
            $tenantKey    = [guid]::NewGuid()
            $collectorKey = [guid]::NewGuid()
            $u = New-UnratedSourceData -SourceCollectorAPI "https://test.api" `
                                       -TenantKey $tenantKey `
                                       -SourceCollectorKey $collectorKey `
                                       -SourceCollectorSecret "secret"
            $expectedPath = Join-Path ([System.IO.Path]::GetTempPath()) (Join-Path "Varibill" (Join-Path $tenantKey $collectorKey))
            try {
                Test-Path $expectedPath | Should -BeTrue
            } finally {
                Remove-Item (Join-Path ([System.IO.Path]::GetTempPath()) (Join-Path "Varibill" $tenantKey)) -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It "sets Host header to localhost when posting to host.docker.internal" {
            $u = New-UnratedSourceData -SourceCollectorAPI "http://host.docker.internal/" `
                                       -TenantKey ([guid]::NewGuid()) `
                                       -SourceCollectorKey ([guid]::NewGuid()) `
                                       -SourceCollectorSecret "secret" `
                                       -SourceCollectorPath $script:TempPath
            $u.AddUnratedSourceData("C1","P1","R1","U1",(Get-Date),5,"")
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{}' }
            }
            $u.PostSourceData()
            Should -Invoke Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -Times 1 -Exactly `
                -ParameterFilter { $Headers['Host'] -eq 'localhost' }
        }

        It "removes an existing .json file before renaming .rdy on successful upload" {
            $u = New-TestUnrated -Path $script:TempPath
            $dir = Join-Path $script:TempPath "unrated"
            $rdyFile  = Join-Path $dir "collision_test.rdy"
            $jsonFile = Join-Path $dir "collision_test.json"
            Set-Content -Path $rdyFile  -Value '{"test":"data"}' -Encoding utf8
            Set-Content -Path $jsonFile -Value '{"old":"data"}'  -Encoding utf8
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{}' }
            }
            $u.PostSourceData()
            Test-Path $rdyFile  | Should -BeFalse
            Test-Path $jsonFile | Should -BeTrue
        }
    }

    Context "RatedSourceData" {

        It "can create an instance via constructor" {
            $r = New-TestRated -Path $script:TempPath -MaxRecords 10
            $r.GetType().Name | Should -Be 'RatedSourceData'
        }

        It "can create an instance via New-RatedSourceData function" {
            $r = New-TestRated -Path $script:TempPath
            $r.GetType().Name | Should -Be 'RatedSourceData'
        }

        It "can add records" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"Tag1")
            $r.Records.Count | Should -Be 1
        }

        It "creates one .rdy file when record count equals MaxRecordsPerFile" {
            $r = New-TestRated -Path $script:TempPath -MaxRecords 3
            1..3 | ForEach-Object { $r.AddRatedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,$_ * 2,$_ * 1.5,"") }
            $r.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "splits into multiple .rdy files when records exceed MaxRecordsPerFile" {
            $r = New-TestRated -Path $script:TempPath -MaxRecords 2
            1..5 | ForEach-Object { $r.AddRatedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,$_ * 2,$_ * 1.5,"Tag$_") }
            $r.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $files.Count | Should -Be 3
        }

        It "saves a single record as a JSON array, not a bare object" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("Client1","Prod1","Rec1","UID1",(Get-Date),10,20,5,"Tag1")
            $r.SaveData()
            $file = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy" |
                        Select-Object -First 1
            $content = Get-Content -Path $file.FullName -Raw
            $parsed = $content | ConvertFrom-Json -NoEnumerate
            # Must be an array so the API accepts it; a bare object causes 400 Bad Request
            ($parsed -is [System.Array]) | Should -BeTrue
        }

        It "clears records after SaveData so a second call writes nothing" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"")
            $r.SaveData()
            $r.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "uploads all .rdy files and renames them to .json on success" {
            $r = New-TestRated -Path $script:TempPath -MaxRecords 1
            1..3 | ForEach-Object { $r.AddRatedSourceData("C$_","P$_","R$_","U$_",(Get-Date),$_,$_ * 2,$_ * 1.5,"") }

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{"result":"ok"}' }
            }

            $r.PostSourceData()
            $rdyFiles  = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $jsonFiles = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.json"
            $rdyFiles.Count  | Should -Be 0
            $jsonFiles.Count | Should -Be 3
        }

        It "keeps .rdy file when server returns non-2xx status" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"")

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 400; Content = '{"error":"bad request"}' }
            }

            $r.PostSourceData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $files.Count | Should -Be 1
        }

        It "throws with the filename when the .rdy file has an empty payload" {
            $r = New-TestRated -Path $script:TempPath
            $dir = Join-Path $script:TempPath "rated"
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
            $emptyFile = Join-Path $dir "empty_payload.rdy"
            Set-Content -Path $emptyFile -Value "" -Encoding utf8

            { $r.PostSourceData() } | Should -Throw -ExpectedMessage "*empty_payload.rdy*"
        }

        It "throws with the filename when the .rdy file contains only whitespace" {
            $r = New-TestRated -Path $script:TempPath
            $dir = Join-Path $script:TempPath "rated"
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
            $wsFile = Join-Path $dir "whitespace_only.rdy"
            Set-Content -Path $wsFile -Value " `n " -Encoding utf8

            { $r.PostSourceData() } | Should -Throw -ExpectedMessage "*whitespace_only.rdy*"
        }

        It "throws with the filename when Invoke-WebRequest fails" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"")

            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                throw [System.Net.Http.HttpRequestException]::new("Connection refused")
            }

            { $r.PostSourceData() } | Should -Throw -ExpectedMessage "*Upload failed for file*"
        }

        It "SaveData with no records creates no files" {
            $r = New-TestRated -Path $script:TempPath
            $r.SaveData()
            $files = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy"
            $files.Count | Should -Be 0
        }

        It "PostSourceData with no records does not call Invoke-WebRequest" {
            $r = New-TestRated -Path $script:TempPath
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith { }
            $r.PostSourceData()
            Should -Invoke Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -Times 0 -Exactly
        }

        It "saved .rdy file contains correct field names, ISO 8601 date, and Rated-specific fields" {
            $r = New-TestRated -Path $script:TempPath
            $date = [datetime]"2025-06-15T09:30:00"
            $r.AddRatedSourceData("ClientA","ProdB","RecC","UID-X",$date,10,20.5,5.25,"MyTag")
            $r.SaveData()
            $rdyFile = Get-ChildItem -Path (Join-Path $script:TempPath "rated") -Filter "*.rdy" | Select-Object -First 1
            $record = Get-Content $rdyFile.FullName -Raw | ConvertFrom-Json
            $rawJson = Get-Content $rdyFile.FullName -Raw
            $record.ClientIdentifier  | Should -Be "ClientA"
            $record.ProductIdentifier | Should -Be "ProdB"
            $record.RecordIdentifier  | Should -Be "RecC"
            $record.UniqueIdentifier  | Should -Be "UID-X"
            $record.Quantity          | Should -Be 10
            $record.Total             | Should -Be 20.5
            $record.Cost              | Should -Be 5.25
            $record.Tag               | Should -Be "MyTag"
            # Verify ISO 8601 format is preserved in the raw JSON that will be sent to the API
            $rawJson | Should -Match '"LastSeenDate"\s*:\s*"\d{4}-\d{2}-\d{2}T'
        }

        It "writes a response log file to the log directory after a successful upload" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"")
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{"result":"ok"}' }
            }
            $r.PostSourceData()
            $logFiles = Get-ChildItem -Path (Join-Path $script:TempPath "log") -Filter "*.json"
            $logFiles.Count | Should -Be 1
        }

        It "posts to the Rated API endpoint" {
            $r = New-TestRated -Path $script:TempPath
            $r.AddRatedSourceData("C1","P1","R1","U1",(Get-Date),10,20,5,"")
            Mock -CommandName Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -MockWith {
                [PSCustomObject]@{ StatusCode = 200; Content = '{}' }
            }
            $r.PostSourceData()
            Should -Invoke Invoke-WebRequest -ModuleName 'Varibill.SourceCollector.Core' -Times 1 -Exactly `
                -ParameterFilter { $Uri -like "*api/v1/SourceCollectors/Rated*" }
        }
    }
}