_TESTS/PSLocalBase.Contracts.Tests.ps1

$ErrorActionPreference = 'Stop'
Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PSLocalBase.psd1') -Force
$script:failed = 0
$script:passed = 0
function Assert-Contract($Condition, [string]$Message) {
    if (-not $Condition) { throw $Message }
}
function Assert-Rejected([scriptblock]$Body, [string]$Pattern = '.') {
    $failure = $null
    try { & $Body } catch { $failure = $_ }
    if (-not $failure -or $failure.Exception.Message -notmatch $Pattern) { throw "Expected rejection matching '$Pattern'; got '$failure'" }
}
function Test-Contract([string]$Name, [scriptblock]$Body) {
    $db = Connect-LocalBase ':memory:' -PassThru
    try { & $Body $db; $script:passed++; Write-Host "[PASS] $Name" }
    catch { $script:failed++; Write-Host "[FAIL] ${Name}: $_`n$($_.ScriptStackTrace)" }
    finally { Disconnect-LocalBase; $db.close() }
}

Test-Contract 'Identity: point/query Get, collection/id collisions, Set/Update/Remove streaming' {
    param($db)
    foreach ($lookup in @('point', 'query')) {
        foreach ($verb in @('Set', 'Update', 'Remove')) {
            foreach ($key in @('a', 'b')) {
                $db.collection('x').doc($key).set(@{ id = 'business'; Collection = 'other'; v = 1 })
                $db.collection('other').doc($key).set(@{ v = 99 })
            }
            $docs = if ($lookup -eq 'point') { foreach ($key in @('a','b')) { Get-LocalBaseDocument x -Id $key } } else { Get-LocalBaseDocument x }
            switch ($verb) {
                Set { $docs | ForEach-Object { $_.v = 2; $_ } | Set-LocalBaseDocument }
                Update { $docs | Update-LocalBaseDocument -Data @{ v = 2 } }
                Remove { $docs | Remove-LocalBaseDocument -Confirm:$false }
            }
            foreach ($key in @('a', 'b')) {
                Assert-Contract ($db.collection('other').doc($key).get().v -eq 99) 'Payload Collection redirected operation'
                if ($verb -eq 'Remove') { Assert-Contract (-not $db.collection('x').doc($key).exists()) 'Real key was not removed' }
                else {
                    $raw = $db.collection('x').doc($key).get()
                    Assert-Contract ($raw.v -eq 2 -and $raw.id -eq 'business') 'Real key not updated or business id lost'
                    Assert-Contract (-not $raw.PSObject.Properties['_localbase_key']) 'Synthetic metadata persisted'
                }
            }
            Assert-Contract (-not $db.collection('x').doc('business').exists()) 'Payload id became storage key'
        }
    }
}

Test-Contract 'Identity: explicit overrides, fallback objects, metadata collisions and synthetic id' {
    param($db)
    $db.collection('x').doc('a').set(@{ Collection='other'; id='business'; _localbase_key='payload-key'; _localbase_collection='payload-col'; v=1 })
    $doc = Get-LocalBaseDocument x -Id a
    $doc._localbase_key = 'tampered'
    $doc._localbase_collection = 'tampered'
    $doc | Set-LocalBaseDocument
    $raw = $db.collection('x').doc('a').get()
    Assert-Contract ($raw._localbase_key -eq 'payload-key' -and $raw._localbase_collection -eq 'payload-col') 'Collision data was lost'
    $doc | Set-LocalBaseDocument -Collection dest -Id explicit
    $doc | Update-LocalBaseDocument -Collection dest -Id explicit -Data @{ v=3 }
    Assert-Contract ($db.collection('dest').doc('explicit').get().v -eq 3) 'Explicit update override ignored'
    $doc | Remove-LocalBaseDocument -Collection dest -Id explicit -Confirm:$false
    Assert-Contract ($db.collection('x').doc('a').exists() -and -not $db.collection('dest').doc('explicit').exists()) 'Explicit remove override ignored'
    $doc | Set-LocalBaseDocument -Id new
    $doc | Set-LocalBaseDocument -Collection dest
    Assert-Contract ($db.collection('x').doc('new').exists() -and $db.collection('dest').doc('a').exists()) 'Partial explicit override ignored'
    @([pscustomobject]@{ Collection='fallback'; id='one'; v=1 }, [pscustomobject]@{ Collection='fallback'; id='two'; v=2 }) | Set-LocalBaseDocument
    Get-LocalBaseDocument fallback | Update-LocalBaseDocument -Data @{ v=4 }
    Assert-Contract ($db.collection('fallback').count() -eq 2 -and $db.collection('fallback').sum('v') -eq 8) 'Content fallback or streaming failed'
    $db.collection('plain').doc('real').set(@{ v=1 })
    Get-LocalBaseDocument plain -Id real | Set-LocalBaseDocument
    Get-LocalBaseDocument plain | Add-LocalBaseDocument -Collection copies | Out-Null
    foreach ($raw in @($db.collection('plain').doc('real').get(), $db.collection('copies').first())) {
        Assert-Contract (-not $raw.PSObject.Properties['id'] -and -not $raw.PSObject.Properties['_localbase_collection']) 'Artificial id/metadata persisted'
    }
}

Test-Contract 'Key AND filters: both orders, all read terminals, delete and write rejection' {
    param($db)
    $c = $db.collection('c')
    $c.doc('a').set(@{ role='admin'; n=10; tags=@(1,2); ref='f' })
    $c.doc('b').set(@{ role='guest'; n=20; tags=@(1); ref='f' })
    $db.collection('foreign').doc('f').set(@{ label='joined' })
    foreach ($q in @($c.doc('a').where('role','==','guest'), $c.where('role','==','guest').doc('a'))) {
        Assert-Contract ($null -eq $q.get() -and $q.count() -eq 0 -and -not $q.exists()) 'Key and filter did not intersect'
        Assert-Contract ($q.sum('n') -eq 0 -and @($q.distinct('role')).Count -eq 0 -and $null -eq $q.first()) 'Read helper ignored intersection'
        Assert-Contract ($null -eq $q.select(@('role')).get() -and $null -eq $q.populate('ref','foreign').get()) 'Projection/populate ignored filter'
        $q.delete()
        Assert-Contract ($c.count() -eq 2) 'Mismatching filter deleted a document'
        Assert-Rejected { $q.set(@{ n=9 }) } 'filters'
        Assert-Rejected { $q.update(@{ n=9 }) } 'filters'
        Assert-Rejected { $q.upsert(@{ n=9 }) } 'filters'
    }
    foreach ($q in @($c.doc('a').whereExists('tags'), $c.whereExists('tags').doc('a'), $c.doc('a').whereLength('tags',2), $c.doc('a').whereType('role','text'))) {
        Assert-Contract ($q.count() -eq 1 -and $q.get().n -eq 10 -and $q.exists()) 'Filter helper lost key'
    }
    Assert-Contract (-not $c.doc('a').whereMissing('tags').exists()) 'whereMissing ignored key/filter'
    $union = $c.unionAll($db.collection('empty')).doc('a').where('role','==','guest')
    Assert-Contract ($union.count() -eq 0 -and @($union.get()).Count -eq 0 -and -not $union.exists()) 'Union outer key/filter mismatch'
    $match = $c.where('role','==','guest').doc('b')
    Assert-Contract ($match.get(@{keys=$true}).key -eq 'b' -and $match.sum('n') -eq 20) 'Matching key/filter read failed'
    $match.delete()
    Assert-Contract ($c.count() -eq 1 -and $c.doc('a').exists()) 'Matching delete broadened selection'
}

Test-Contract 'Transactions: scoped database, success/error restoration, both errors and nesting' {
    param($db)
    $b = New-LocalBase
    try {
        $ErrorActionPreference = 'Continue'
        foreach ($kind in @('terminating', 'nonterminating')) {
            Assert-Rejected {
                Invoke-LocalBaseTransaction -Database $b {
                    Add-LocalBaseDocument tx -Id item -Data @{ v=1 }
                    if ($kind -eq 'terminating') { throw 'tx failure' } else { Write-Error 'tx failure' }
                }
            } 'tx failure'
            Assert-Contract ($b.collection('tx').count() -eq 0 -and $db.collection('tx').count() -eq 0) 'Transaction leaked writes'
            Assert-Contract ((Get-LocalBaseConnection) -eq $db -and $ErrorActionPreference -eq 'Continue') 'Ambient state not restored after failure'
        }
        $output = Invoke-LocalBaseTransaction -Database $b { Add-LocalBaseDocument tx -Id ok -Data @{ v=2 } }
        Assert-Contract ($output -eq 'ok' -and $b.collection('tx').doc('ok').exists() -and $db.collection('tx').count() -eq 0) 'Success routed incorrectly'
        Assert-Contract ((Get-LocalBaseConnection) -eq $db -and $ErrorActionPreference -eq 'Continue') 'State not restored on success'
        Invoke-LocalBaseTransaction {
            Add-LocalBaseDocument tx -Id outer -Data @{ v=3 } | Out-Null
            Assert-Rejected { Invoke-LocalBaseTransaction { throw 'must not run' } } 'transaction'
            Assert-Contract $db.InTransaction 'Nested rejection rolled back outer transaction'
            Invoke-LocalBaseTransaction -Database $b { Add-LocalBaseDocument tx -Id inner -Data @{ v=4 } | Out-Null }
            Assert-Contract ((Get-LocalBaseConnection) -eq $db) 'Cross-database nested scope not restored'
        }
        Assert-Contract ($db.collection('tx').doc('outer').exists() -and $b.collection('tx').doc('inner').exists()) 'Nested behavior damaged connections'
    } finally { $b.close() }
}

Test-Contract 'Add explicit key rejects duplicate without overwrite and streams generated keys' {
    param($db)
    Assert-Contract ((Add-LocalBaseDocument c -Id a -Data @{ v=1 }) -eq 'a') 'Custom key output changed'
    Assert-Rejected { [pscustomobject]@{v=2} | Add-LocalBaseDocument c -Id a } 'UNIQUE|constraint'
    Assert-Contract ($db.collection('c').doc('a').get().v -eq 1 -and $db.collection('c').count() -eq 1) 'Duplicate overwrote original'
    $keys = @(@{v=3}, @{v=4}) | Add-LocalBaseDocument c
    Assert-Contract ($keys.Count -eq 2 -and $keys[0] -ne $keys[1] -and $db.collection('c').count() -eq 3) 'Add pipeline contract changed'
    Add-LocalBaseDocument arrays -Id empty -Data @() | Out-Null
    Set-LocalBaseDocument arrays -Id single -Data @(1)
    $raw = $db.query('SELECT key, doc FROM arrays ORDER BY key;')
    Assert-Contract ($raw[0].doc -eq '[]' -and $raw[1].doc -eq '[1]') 'Payload cleanup enumerated array documents'
}

Test-Contract 'Write limits: UTF16 boundary, depth, set/add/update/upsert and bulk rollback' {
    param($db)
    foreach ($kind in @('bytes', 'depth')) {
        $options = if ($kind -eq 'bytes') { @{ maxDocumentBytes=64 } } else { @{ maxReadDepth=2 } }
        $limited = New-LocalBase -Options $options
        try {
            $c = $limited.collection('c')
            $c.doc('old').set(@{v=1})
            $bad = if ($kind -eq 'bytes') { @{v=('x'*100)} } else { @{v=@{child=@{leaf=1}}} }
            foreach ($write in @({ $c.doc('old').set($bad) }, { $c.add($bad,'new') }, { $c.doc('old').update($bad) }, { $c.upsert($bad,'old') })) {
                Assert-Rejected $write 'maxDocumentBytes|maxReadDepth'
                Assert-Contract ($c.count() -eq 1 -and $c.doc('old').get().v -eq 1) 'Failed write mutated documents'
            }
            foreach ($outer in @($false,$true)) {
                if ($outer) { $limited.beginTransaction(); $c.doc('outer').set(@{v=2}) }
                Assert-Rejected { $c.replaceAll(@(@{v=2}, $bad)) } 'maxDocumentBytes|maxReadDepth'
                Assert-Contract ($c.doc('old').get().v -eq 1) 'Bulk failure lost previous data'
                if ($outer) {
                    Assert-Contract ($limited.InTransaction -and $c.doc('outer').exists()) 'Bulk savepoint damaged outer transaction'
                    $limited.rollback()
                }
                Assert-Contract ($c.count() -eq 1) 'Bulk failure retained partial writes'
            }
            if ($kind -eq 'bytes') {
                # {"v":"..."} uses eight UTF-16 code units plus the value.
                $c.doc('edge').set(@{v=([string][char]0x00e9 * 24)})
                Assert-Contract ($c.doc('edge').get().v.Length -eq 24) 'Exact UTF16 byte limit rejected'
                Assert-Rejected { $c.doc('edge').set(@{v=('x'*25)}) } 'maxDocumentBytes'
            } else {
                $c.doc('edge').set(@{v=@{leaf=1}})
                Assert-Contract ($c.doc('edge').get().v.leaf -eq 1) 'Exact nesting limit rejected'
            }
        } finally { $limited.close() }
    }
    $db.collection('default').doc('ok').set(@{v=('x'*1000); nested=@{leaf=1}})
    Assert-Contract ($db.collection('default').doc('ok').get().v.Length -eq 1000) 'Default write/read contract changed'
    Assert-Rejected { $db.collection('default').doc('ok').set(@{v=('x'*8MB)}) } 'maxDocumentBytes'
    Assert-Contract ($db.collection('default').doc('ok').get().v.Length -eq 1000) 'Default limit failure overwrote prior data'
}

Test-Contract 'Journal readback and removal: active handles, WhatIf, Force, errors and sidecars' {
    param($db)
    $path = Join-Path ([IO.Path]::GetTempPath()) ('pslb-contract-' + [guid]::NewGuid().ToString('N') + '.db')
    $explicit = $null
    try {
        foreach ($mode in @('WAL','DELETE')) {
            $connection = Connect-LocalBase $path -JournalMode $mode -PassThru
            Assert-Contract ($connection.query('PRAGMA journal_mode;')[0].journal_mode -eq $mode) 'Actual journal mode differs'
            foreach ($force in @($false,$true)) { Assert-Rejected { Remove-LocalBaseDatabase $path -Force:$force -Confirm:$false } 'Close all connections' }
            Remove-LocalBaseDatabase $path -WhatIf
            Assert-Contract ($connection.collection('c').count() -eq 0 -and (Test-Path -LiteralPath $path)) 'Removal closed active connection or ignored WhatIf'
            foreach ($unsupported in @('TRUNCATE','PERSIST','MEMORY','OFF')) {
                Assert-Rejected { Connect-LocalBase $path -JournalMode $unsupported } 'ValidateSet|does not belong|not part'
            }
            Assert-Contract ((Get-LocalBaseConnection) -eq $connection) 'Rejected mode disconnected ambient database'
            Disconnect-LocalBase
        }
        $explicit = New-LocalBase $path
        Assert-Rejected { Remove-LocalBaseDatabase $path -Force -Confirm:$false } 'Close all connections'
        $explicit.close()
        $stream = [IO.File]::Open($path, 'Open', 'Read', 'None')
        try { Assert-Rejected { Remove-LocalBaseDatabase $path -Confirm:$false } 'Close all connections' } finally { $stream.Dispose() }
        [IO.File]::SetAttributes($path, [IO.FileAttributes]::ReadOnly)
        Assert-Rejected { Remove-LocalBaseDatabase $path -Confirm:$false } 'read-only'
        Remove-LocalBaseDatabase $path -Force -WhatIf
        Assert-Contract (Test-Path -LiteralPath $path) 'Force bypassed WhatIf'
        foreach ($suffix in @('-wal','-shm','-journal')) { [IO.File]::WriteAllText($path+$suffix, 'test sidecar') }
        Remove-LocalBaseDatabase $path -Force -Confirm:$false
        foreach ($target in @($path, "$path-wal", "$path-shm", "$path-journal")) { Assert-Contract (-not (Test-Path -LiteralPath $target)) "File was not removed: $target" }
        Remove-LocalBaseDatabase $path -Confirm:$false
        [void][IO.Directory]::CreateDirectory($path)
        Assert-Rejected { Remove-LocalBaseDatabase $path -Force -Confirm:$false } 'directory'
        [IO.Directory]::Delete($path)
    } finally {
        if ($explicit) { $explicit.close() }
        Disconnect-LocalBase
        foreach ($target in @($path, "$path-wal", "$path-shm", "$path-journal")) {
            if ([IO.File]::Exists($target)) { [IO.File]::SetAttributes($target, [IO.FileAttributes]::Normal); [IO.File]::Delete($target) }
        }
        if ([IO.Directory]::Exists($path)) { [IO.Directory]::Delete($path) }
    }
}

Write-Host "Contracts: $script:passed passed, $script:failed failed"
if ($script:failed) { exit 1 }
exit 0