tests/Client/Export-XrmRecordToWord.Tests.ps1

<#!
    Integration Test: Export-XrmRecordToWord cmdlet
        Validates Word export and creates a reusable account Word template when the environment does not provide one.
#>

. "$PSScriptRoot\..\_TestConfig.ps1";

function New-TestWordTemplateFile {
        [CmdletBinding()]
        param(
                [Parameter(Mandatory = $true)]
                [String]
                $FilePath,

                [Parameter(Mandatory = $true)]
                [String]
                $EntityLogicalName,

                [Parameter(Mandatory = $true)]
                [int]
                $ObjectTypeCode,

                [Parameter(Mandatory = $true)]
                [String]
                $FieldLogicalName
        );

        Add-Type -AssemblyName 'System.IO.Compression';
        Add-Type -AssemblyName 'System.IO.Compression.FileSystem';

        $storeItemId = '{' + ([Guid]::NewGuid().ToString().ToUpperInvariant()) + '}';
        $templateNamespace = "urn:microsoft-crm/document-template/$EntityLogicalName/$ObjectTypeCode/";

        $contentTypesXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
    <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
    <Default Extension="xml" ContentType="application/xml"/>
        <Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/>
        <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
        <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
    <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
    <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>
"@
;
        $packageRelsXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
        <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
        <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
    <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>
"@
;
                $coreXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
        <dc:title>PowerDataOps Test Template</dc:title>
        <dc:creator>PowerDataOps</dc:creator>
        <cp:lastModifiedBy>PowerDataOps</cp:lastModifiedBy>
        <dcterms:created xsi:type="dcterms:W3CDTF">2026-04-24T00:00:00Z</dcterms:created>
        <dcterms:modified xsi:type="dcterms:W3CDTF">2026-04-24T00:00:00Z</dcterms:modified>
</cp:coreProperties>
"@
;
                $appXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
        <Template>Normal.dotm</Template>
        <TotalTime>0</TotalTime>
        <Pages>1</Pages>
        <Words>1</Words>
        <Characters>1</Characters>
        <Application>Microsoft Office Word</Application>
        <DocSecurity>0</DocSecurity>
        <Lines>1</Lines>
        <Paragraphs>1</Paragraphs>
        <ScaleCrop>false</ScaleCrop>
        <Company>PowerDataOps</Company>
        <LinksUpToDate>false</LinksUpToDate>
        <CharactersWithSpaces>1</CharactersWithSpaces>
        <SharedDoc>false</SharedDoc>
        <HyperlinksChanged>false</HyperlinksChanged>
        <AppVersion>16.0000</AppVersion>
</Properties>
"@
;
        $documentXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w15">
    <w:body>
        <w:p>
            <w:r>
                <w:t xml:space="preserve">PowerDataOps Test Template: </w:t>
            </w:r>
            <w:sdt>
                <w:sdtPr>
                    <w:id w:val="-123456789"/>
                    <w15:dataBinding w:prefixMappings="xmlns:ns0='$templateNamespace' " w:xpath="/ns0:DocumentTemplate[1]/$EntityLogicalName[1]/$FieldLogicalName[1]" w:storeItemID="$storeItemId"/>
                    <w:text/>
                </w:sdtPr>
                <w:sdtContent>
                    <w:r>
                        <w:t>$FieldLogicalName</w:t>
                    </w:r>
                </w:sdtContent>
            </w:sdt>
        </w:p>
        <w:sectPr>
            <w:pgSz w:w="12240" w:h="15840"/>
            <w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/>
            <w:cols w:space="708"/>
            <w:docGrid w:linePitch="360"/>
        </w:sectPr>
    </w:body>
</w:document>
"@
;
        $stylesXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
    <w:style w:type="paragraph" w:default="1" w:styleId="Normal">
        <w:name w:val="Normal"/>
    </w:style>
</w:styles>
"@
;
        $documentRelsXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
    <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
    <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/>
</Relationships>
"@
;
        $itemXml = @"
<?xml version="1.0" encoding="utf-8"?>
<DocumentTemplate xmlns="$templateNamespace">
    <$($EntityLogicalName) xmlns="">
        <$($FieldLogicalName)>$FieldLogicalName</$($FieldLogicalName)>
    </$($EntityLogicalName)>
</DocumentTemplate>
"@
;
        $itemPropsXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<ds:datastoreItem ds:itemID="$storeItemId" xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml">
    <ds:schemaRefs>
        <ds:schemaRef ds:uri="$templateNamespace"/>
        <ds:schemaRef ds:uri=""/>
    </ds:schemaRefs>
</ds:datastoreItem>
"@
;
        $itemRelsXml = @"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
    <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" Target="itemProps1.xml"/>
</Relationships>
"@
;

        if (Test-Path $FilePath) {
                Remove-Item -Path $FilePath -Force;
        }

        $fileStream = [System.IO.File]::Open($FilePath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None);
        try {
                $archive = [System.IO.Compression.ZipArchive]::new($fileStream, [System.IO.Compression.ZipArchiveMode]::Create, $false);
                try {
                        $entries = [ordered]@{
                                '[Content_Types].xml' = $contentTypesXml;
                                '_rels/.rels' = $packageRelsXml;
                                'docProps/app.xml' = $appXml;
                                'docProps/core.xml' = $coreXml;
                                'word/document.xml' = $documentXml;
                                'word/styles.xml' = $stylesXml;
                                'word/_rels/document.xml.rels' = $documentRelsXml;
                                'customXml/item1.xml' = $itemXml;
                                'customXml/itemProps1.xml' = $itemPropsXml;
                                'customXml/_rels/item1.xml.rels' = $itemRelsXml;
                        };
                        $utf8Encoding = [System.Text.UTF8Encoding]::new($false);
                        foreach ($entryPath in $entries.Keys) {
                                $entry = $archive.CreateEntry($entryPath);
                                $writer = [System.IO.StreamWriter]::new($entry.Open(), $utf8Encoding);
                                try {
                                        $writer.Write($entries[$entryPath]);
                                }
                                finally {
                                        $writer.Dispose();
                                }
                        }
                }
                finally {
                        $archive.Dispose();
                }
        }
        finally {
                $fileStream.Dispose();
        }

        $FilePath;
};

function New-TestWordTemplateRecord {
        [CmdletBinding()]
        param(
                [Parameter(Mandatory = $true)]
                [String]
                $TemplateName,

                [Parameter(Mandatory = $true)]
                [String]
                $EntityLogicalName,

                [Parameter(Mandatory = $true)]
                [String]
                $FieldLogicalName
        );

        $entityMetadata = Get-XrmEntityMetadata -LogicalName $EntityLogicalName;
        if ($null -eq $entityMetadata -or $null -eq $entityMetadata.ObjectTypeCode) {
                throw "Unable to resolve the object type code for entity '$EntityLogicalName'.";
        }

        $templateFilePath = Join-Path $env:TEMP ($TemplateName + '.docx');
        New-TestWordTemplateFile -FilePath $templateFilePath -EntityLogicalName $EntityLogicalName -ObjectTypeCode ([int]$entityMetadata.ObjectTypeCode) -FieldLogicalName $FieldLogicalName | Out-Null;

        $templateRecord = New-XrmEntity -LogicalName 'documenttemplate' -Attributes @{
                'name' = $TemplateName;
                'associatedentitytypecode' = $EntityLogicalName;
                'documenttype' = (New-XrmOptionSetValue -Value 2);
                'languagecode' = 1033;
                'content' = (Get-XrmBase64 -FilePath $templateFilePath);
                'status' = $false;
        };
        $templateId = $Global:XrmClient | Add-XrmRecord -Record $templateRecord;
        if ($null -eq $templateId -or $templateId -eq [Guid]::Empty) {
                throw "Failed to create reusable Word template '$TemplateName'.";
        }

        $templateRecord.Id = $templateId;
        $template = $Global:XrmClient | Get-XrmRecord -LogicalName 'documenttemplate' -Id $templateRecord.Id -Columns 'name', 'associatedentitytypecode';
        if ($null -eq $template) {
                throw "Reusable Word template '$TemplateName' was created but could not be retrieved.";
        }

        [PSCustomObject]@{
                Template = $template;
                TemplateFilePath = $templateFilePath;
        };
};

Write-Section "Resolve Template And Record";

$testTemplateName = 'PowerDataOps Test Template - Account';
$templateFilePath = $null;
$temporaryTemplateCreated = $false;
$temporaryAccount = $null;
$record = $null;

$templateQuery = New-XrmQueryExpression -LogicalName 'documenttemplate' -TopCount 1 -Columns 'name', 'associatedentitytypecode', 'createdon';
$templateQuery | Add-XrmQueryCondition -Field 'name' -Condition Equal -Values $testTemplateName | Out-Null;
$templateQuery | Add-XrmQueryOrder -Field 'createdon' -OrderType Descending | Out-Null;
$template = $Global:XrmClient | Get-XrmMultipleRecords -Query $templateQuery | Select-Object -First 1;

if ($null -eq $template) {
        Write-Host " No reusable account Word template was found. Creating one for the test..." -ForegroundColor Yellow;
}

if ($null -eq $template) {
        $createdTemplate = New-TestWordTemplateRecord -TemplateName $testTemplateName -EntityLogicalName 'account' -FieldLogicalName 'name';
        $template = $createdTemplate.Template;
        $templateFilePath = $createdTemplate.TemplateFilePath;
        $temporaryTemplateCreated = $true;
        Assert-Test "Reusable account Word template created" {
                $null -ne $template -and $template.Id -ne [Guid]::Empty;
        };
}

Write-Host " Creating a temporary account for Word export..." -ForegroundColor Yellow;

$temporaryAccount = New-XrmEntity -LogicalName 'account' -Attributes @{
        'name' = (Get-TestName -Prefix 'WordTemplateAccount');
        'accountnumber' = 'WTT-001';
};
$temporaryAccount.Id = $Global:XrmClient | Add-XrmRecord -Record $temporaryAccount;
Assert-Test "Temporary account created for Word export" {
        $temporaryAccount.Id -ne [Guid]::Empty;
};

$record = $Global:XrmClient | Get-XrmRecord -LogicalName 'account' -Id $temporaryAccount.Id -Columns '*';

$annotationQuery = New-XrmQueryExpression -LogicalName 'annotation' -Columns 'annotationid';
$annotationQuery | Add-XrmQueryCondition -Field 'objectid' -Condition Equal -Values $record.Id | Out-Null;
$existingAnnotationIds = @($Global:XrmClient | Get-XrmMultipleRecords -Query $annotationQuery | ForEach-Object { $_.Id });

$wordFilePath = Join-Path $env:TEMP "$(Get-TestName -Prefix 'RecordWord').docx";
$outputPath = Export-XrmRecordToWord -XrmClient $Global:XrmClient -RecordReference $record.Reference -TemplateReference $template.Reference -OutputPath $wordFilePath;

Assert-Test "Export-XrmRecordToWord returns the output path" {
    $outputPath -eq $wordFilePath;
};
Assert-Test "Export-XrmRecordToWord creates a non-empty document" {
    (Test-Path $wordFilePath) -and ([System.IO.FileInfo]::new($wordFilePath).Length -gt 0);
};

Write-Section "Cleanup";
if (Test-Path $wordFilePath) {
    Remove-Item -Path $wordFilePath -Force;
}

if ($null -ne $templateFilePath -and (Test-Path $templateFilePath)) {
    Remove-Item -Path $templateFilePath -Force;
}

$newAnnotations = $Global:XrmClient | Get-XrmMultipleRecords -Query $annotationQuery;
$generatedAnnotation = $newAnnotations | Where-Object { $existingAnnotationIds -notcontains $_.Id } | Select-Object -First 1;
if ($null -ne $generatedAnnotation) {
    $Global:XrmClient | Remove-XrmRecord -LogicalName 'annotation' -Id $generatedAnnotation.Id;
}

if ($null -ne $temporaryAccount -and $temporaryAccount.Id -ne [Guid]::Empty) {
    $Global:XrmClient | Remove-XrmRecord -LogicalName 'account' -Id $temporaryAccount.Id;
}

Assert-Test "Temporary Word file deleted" {
    -not (Test-Path $wordFilePath);
};
Assert-Test "Temporary template file deleted" {
    $null -eq $templateFilePath -or -not (Test-Path $templateFilePath);
};
Assert-Test "Temporary account deleted" {
    $null -eq $temporaryAccount -or $true;
};

Write-TestSummary;