From 88e2ca2a89836d8ae35de8a641e648901d0111fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Mon, 12 Jan 2026 15:08:35 +0100 Subject: [PATCH 1/5] Add file block upload/download support with tests Implement in-memory file block upload and download operations in XrmMockup, including new FileBlockStore and supporting data structures. Add request handlers for file upload and download flows, with robust error handling for invalid tokens and missing files. Integrate with Core and provide comprehensive unit tests covering single/multi-block scenarios, partial downloads, and error cases. --- src/XrmMockup365/Core.cs | 4 + src/XrmMockup365/Database/FileBlockStore.cs | 97 ++++++ .../CommitFileBlocksUploadRequestHandler.cs | 55 +++- .../Requests/DownloadBlockRequestHandler.cs | 39 +++ ...tializeFileBlocksDownloadRequestHandler.cs | 32 ++ ...nitializeFileBlocksUploadRequestHandler.cs | 31 +- .../Requests/UploadBlockRequestHandler.cs | 28 +- tests/XrmMockup365Test/TestFileOperations.cs | 302 ++++++++++++++++++ 8 files changed, 562 insertions(+), 26 deletions(-) create mode 100644 src/XrmMockup365/Database/FileBlockStore.cs create mode 100644 src/XrmMockup365/Requests/DownloadBlockRequestHandler.cs create mode 100644 src/XrmMockup365/Requests/InitializeFileBlocksDownloadRequestHandler.cs create mode 100644 tests/XrmMockup365Test/TestFileOperations.cs diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index c17fe06c..bbd35ac5 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -63,6 +63,7 @@ internal class Core : IXrmMockupExtension private int baseCurrencyPrecision; private FormulaFieldEvaluator FormulaFieldEvaluator { get; set; } private List systemAttributeNames; + internal FileBlockStore FileBlockStore { get; private set; } /// /// Creates a new instance of Core @@ -119,6 +120,7 @@ private void InitializeCore(CoreInitializationData initData) entityTypeMap = initData.EntityTypeMap; db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineProxy); + FileBlockStore = new FileBlockStore(); snapshots = new Dictionary(); security = new Security(this, initData.Metadata, initData.SecurityRoles, db); TracingServiceFactory = initData.Settings.TracingServiceFactory ?? new TracingServiceFactory(); @@ -397,6 +399,8 @@ private void InitializeDB() new InitializeFileBlocksUploadRequestHandler(this, db, metadata, security), new UploadBlockRequestHandler(this, db, metadata, security), new CommitFileBlocksUploadRequestHandler(this, db, metadata, security), + new InitializeFileBlocksDownloadRequestHandler(this, db, metadata, security), + new DownloadBlockRequestHandler(this, db, metadata, security), new InstantiateTemplateRequestHandler(this, db, metadata, security), new CreateMultipleRequestHandler(this, db, metadata, security), new UpdateMultipleRequestHandler(this, db, metadata, security), diff --git a/src/XrmMockup365/Database/FileBlockStore.cs b/src/XrmMockup365/Database/FileBlockStore.cs new file mode 100644 index 00000000..35e8c733 --- /dev/null +++ b/src/XrmMockup365/Database/FileBlockStore.cs @@ -0,0 +1,97 @@ +using Microsoft.Xrm.Sdk; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace DG.Tools.XrmMockup.Database +{ + internal sealed class FileUploadSession + { + public Guid FileAttachmentId { get; set; } + public string FileName { get; set; } + public string MimeType { get; set; } + public EntityReference Target { get; set; } + public string FileAttributeName { get; set; } + public List Blocks { get; set; } = new List(); + public DateTime CreatedOn { get; set; } + } + + internal sealed class FileBlock + { + public string BlockId { get; set; } + public byte[] Data { get; set; } + } + + internal sealed class CommittedFile + { + public Guid FileAttachmentId { get; set; } + public string FileName { get; set; } + public string MimeType { get; set; } + public long FileSize { get; set; } + public byte[] Data { get; set; } + public EntityReference Target { get; set; } + public string FileAttributeName { get; set; } + } + + internal sealed class FileBlockStore + { + private readonly ConcurrentDictionary pendingUploads = new ConcurrentDictionary(); + private readonly ConcurrentDictionary committedFiles = new ConcurrentDictionary(); + private readonly ConcurrentDictionary downloadSessions = new ConcurrentDictionary(); + + public void StartUpload(string token, FileUploadSession session) + { + pendingUploads[token] = session; + } + + public FileUploadSession GetUploadSession(string token) + { + pendingUploads.TryGetValue(token, out var session); + return session; + } + + public void CommitUpload(string token, CommittedFile committedFile) + { + committedFiles[committedFile.FileAttachmentId] = committedFile; + pendingUploads.TryRemove(token, out _); + } + + public CommittedFile GetCommittedFile(Guid fileAttachmentId) + { + committedFiles.TryGetValue(fileAttachmentId, out var file); + return file; + } + + public void StartDownload(string token, CommittedFile committedFile) + { + downloadSessions[token] = committedFile; + } + + public CommittedFile GetDownloadSession(string token) + { + downloadSessions.TryGetValue(token, out var file); + return file; + } + + public CommittedFile FindCommittedFile(EntityReference target, string fileAttributeName) + { + foreach (var file in committedFiles.Values) + { + if (file.Target?.Id == target.Id && + file.Target?.LogicalName == target.LogicalName && + file.FileAttributeName == fileAttributeName) + { + return file; + } + } + return null; + } + + public void Clear() + { + pendingUploads.Clear(); + committedFiles.Clear(); + downloadSessions.Clear(); + } + } +} diff --git a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs index fa1812b6..8fec8465 100644 --- a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs @@ -1,20 +1,61 @@ -using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk; using Microsoft.Crm.Sdk.Messages; using DG.Tools.XrmMockup.Database; using System; +using System.Collections.Generic; using System.Linq; +using System.ServiceModel; namespace DG.Tools.XrmMockup { - internal class CommitFileBlocksUploadRequestHandler : RequestHandler { - internal CommitFileBlocksUploadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) : base(core, db, metadata, security, "CommitFileBlocksUpload") {} + internal sealed class CommitFileBlocksUploadRequestHandler : RequestHandler + { + internal CommitFileBlocksUploadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) + : base(core, db, metadata, security, "CommitFileBlocksUpload") { } - internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) { + internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) + { var request = MakeRequest(orgRequest); - - // Document store not implemented in database yet - var resp = new UploadBlockResponse(); + var session = core.FileBlockStore.GetUploadSession(request.FileContinuationToken); + if (session is null) + throw new FaultException("Invalid or expired file continuation token."); + + var blockDataList = new List(); + foreach (var blockId in request.BlockList) + { + var block = session.Blocks.FirstOrDefault(b => b.BlockId == blockId); + if (block is null) + throw new FaultException($"Block with ID '{blockId}' not found in upload session."); + + blockDataList.Add(block.Data); + } + + var totalSize = blockDataList.Sum(b => b.Length); + var fileData = new byte[totalSize]; + var offset = 0; + foreach (var blockData in blockDataList) + { + Buffer.BlockCopy(blockData, 0, fileData, offset, blockData.Length); + offset += blockData.Length; + } + + var committedFile = new CommittedFile + { + FileAttachmentId = session.FileAttachmentId, + FileName = session.FileName, + MimeType = request.MimeType, + FileSize = fileData.Length, + Data = fileData, + Target = session.Target, + FileAttributeName = session.FileAttributeName + }; + + core.FileBlockStore.CommitUpload(request.FileContinuationToken, committedFile); + + var resp = new CommitFileBlocksUploadResponse(); + resp.Results["FileId"] = session.FileAttachmentId; + resp.Results["FileSizeInBytes"] = (long)fileData.Length; return resp; } } diff --git a/src/XrmMockup365/Requests/DownloadBlockRequestHandler.cs b/src/XrmMockup365/Requests/DownloadBlockRequestHandler.cs new file mode 100644 index 00000000..6fa73482 --- /dev/null +++ b/src/XrmMockup365/Requests/DownloadBlockRequestHandler.cs @@ -0,0 +1,39 @@ +using Microsoft.Xrm.Sdk; +using Microsoft.Crm.Sdk.Messages; +using DG.Tools.XrmMockup.Database; +using System; +using System.ServiceModel; + +namespace DG.Tools.XrmMockup +{ + internal sealed class DownloadBlockRequestHandler : RequestHandler + { + internal DownloadBlockRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) + : base(core, db, metadata, security, "DownloadBlock") { } + + internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) + { + var request = MakeRequest(orgRequest); + + var committedFile = core.FileBlockStore.GetDownloadSession(request.FileContinuationToken); + if (committedFile is null) + throw new FaultException("Invalid or expired file continuation token."); + + var offset = (int)request.Offset; + var blockLength = (int)request.BlockLength; + + if (offset < 0 || offset >= committedFile.Data.Length) + throw new FaultException($"Invalid offset: {offset}. File size is {committedFile.Data.Length} bytes."); + + var availableBytes = committedFile.Data.Length - offset; + var actualLength = Math.Min(blockLength, availableBytes); + + var data = new byte[actualLength]; + Buffer.BlockCopy(committedFile.Data, offset, data, 0, actualLength); + + var resp = new DownloadBlockResponse(); + resp.Results["Data"] = data; + return resp; + } + } +} diff --git a/src/XrmMockup365/Requests/InitializeFileBlocksDownloadRequestHandler.cs b/src/XrmMockup365/Requests/InitializeFileBlocksDownloadRequestHandler.cs new file mode 100644 index 00000000..da7ebc2e --- /dev/null +++ b/src/XrmMockup365/Requests/InitializeFileBlocksDownloadRequestHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.Xrm.Sdk; +using Microsoft.Crm.Sdk.Messages; +using DG.Tools.XrmMockup.Database; +using System; +using System.ServiceModel; + +namespace DG.Tools.XrmMockup +{ + internal sealed class InitializeFileBlocksDownloadRequestHandler : RequestHandler + { + internal InitializeFileBlocksDownloadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) + : base(core, db, metadata, security, "InitializeFileBlocksDownload") { } + + internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) + { + var request = MakeRequest(orgRequest); + + var committedFile = core.FileBlockStore.FindCommittedFile(request.Target, request.FileAttributeName); + if (committedFile is null) + throw new FaultException($"No file attachment found for target entity '{request.Target.LogicalName}' with ID '{request.Target.Id}' and attribute '{request.FileAttributeName}'."); + + var token = Guid.NewGuid().ToString(); + core.FileBlockStore.StartDownload(token, committedFile); + + var resp = new InitializeFileBlocksDownloadResponse(); + resp.Results["FileContinuationToken"] = token; + resp.Results["FileSizeInBytes"] = committedFile.FileSize; + resp.Results["FileName"] = committedFile.FileName; + return resp; + } + } +} diff --git a/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs b/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs index f2128d2c..0f7b8377 100644 --- a/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs @@ -1,26 +1,35 @@ -using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk; using Microsoft.Crm.Sdk.Messages; using DG.Tools.XrmMockup.Database; using System; -using System.Linq; namespace DG.Tools.XrmMockup { - internal class InitializeFileBlocksUploadRequestHandler : RequestHandler { - internal InitializeFileBlocksUploadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) : base(core, db, metadata, security, "InitializeFileBlocksUpload") {} + internal sealed class InitializeFileBlocksUploadRequestHandler : RequestHandler + { + internal InitializeFileBlocksUploadRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) + : base(core, db, metadata, security, "InitializeFileBlocksUpload") { } - internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) { + internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) + { var request = MakeRequest(orgRequest); - var fileAttachment = new Entity("fileattachment"); - fileAttachment["filename"] = request.FileName; - fileAttachment["regardingfieldname"] = request.FileAttributeName; - fileAttachment["objectid"] = request.Target; - db.Add(fileAttachment); + var token = Guid.NewGuid().ToString(); + var fileAttachmentId = Guid.NewGuid(); + var session = new FileUploadSession + { + FileAttachmentId = fileAttachmentId, + FileName = request.FileName, + Target = request.Target, + FileAttributeName = request.FileAttributeName, + CreatedOn = DateTime.UtcNow + }; + + core.FileBlockStore.StartUpload(token, session); var resp = new InitializeFileBlocksUploadResponse(); - resp.Results["FileContinuationToken"] = Guid.NewGuid().ToString(); + resp.Results["FileContinuationToken"] = token; return resp; } } diff --git a/src/XrmMockup365/Requests/UploadBlockRequestHandler.cs b/src/XrmMockup365/Requests/UploadBlockRequestHandler.cs index d75f3227..cd218ea3 100644 --- a/src/XrmMockup365/Requests/UploadBlockRequestHandler.cs +++ b/src/XrmMockup365/Requests/UploadBlockRequestHandler.cs @@ -1,18 +1,30 @@ -using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk; using Microsoft.Crm.Sdk.Messages; using DG.Tools.XrmMockup.Database; -using System; -using System.Linq; +using System.ServiceModel; namespace DG.Tools.XrmMockup { - internal class UploadBlockRequestHandler : RequestHandler { - internal UploadBlockRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) : base(core, db, metadata, security, "UploadBlock") {} + internal sealed class UploadBlockRequestHandler : RequestHandler + { + internal UploadBlockRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) + : base(core, db, metadata, security, "UploadBlock") { } - internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) { + internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef) + { var request = MakeRequest(orgRequest); - - // Document store not implemented in database yet + + var session = core.FileBlockStore.GetUploadSession(request.FileContinuationToken); + if (session is null) + throw new FaultException("Invalid or expired file continuation token."); + + var block = new FileBlock + { + BlockId = request.BlockId, + Data = request.BlockData + }; + + session.Blocks.Add(block); var resp = new UploadBlockResponse(); return resp; diff --git a/tests/XrmMockup365Test/TestFileOperations.cs b/tests/XrmMockup365Test/TestFileOperations.cs new file mode 100644 index 00000000..b3ef9694 --- /dev/null +++ b/tests/XrmMockup365Test/TestFileOperations.cs @@ -0,0 +1,302 @@ +using System; +using System.Linq; +using System.ServiceModel; +using System.Text; +using Microsoft.Crm.Sdk.Messages; +using Microsoft.Xrm.Sdk; +using DG.XrmFramework.BusinessDomain.ServiceContext; +using Xunit; + +namespace DG.XrmMockupTest +{ + public class TestFileOperations : UnitTestBase, IClassFixture + { + public TestFileOperations(XrmMockupFixture fixture) : base(fixture) { } + + [Fact] + public void TestSingleBlockFileUploadAndDownload() + { + // Arrange + var account = new Account { Name = "Test Account" }; + account.Id = orgAdminService.Create(account); + + var fileName = "test.txt"; + var fileAttributeName = "dg_testfile"; + var fileContent = "Hello, World!"; + var fileData = Encoding.UTF8.GetBytes(fileContent); + var mimeType = "text/plain"; + + // Act - Initialize upload + var initUploadRequest = new InitializeFileBlocksUploadRequest + { + Target = account.ToEntityReference(), + FileName = fileName, + FileAttributeName = fileAttributeName + }; + var initUploadResponse = (InitializeFileBlocksUploadResponse)orgAdminService.Execute(initUploadRequest); + + // Assert - FileContinuationToken is not null + var uploadToken = initUploadResponse.FileContinuationToken; + Assert.NotNull(uploadToken); + + // Act - Upload single block + var blockId = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + var uploadBlockRequest = new UploadBlockRequest + { + FileContinuationToken = uploadToken, + BlockId = blockId, + BlockData = fileData + }; + orgAdminService.Execute(uploadBlockRequest); + + // Act - Commit upload + var commitRequest = new CommitFileBlocksUploadRequest + { + FileContinuationToken = uploadToken, + BlockList = new string[] { blockId }, + MimeType = mimeType + }; + var commitResponse = (CommitFileBlocksUploadResponse)orgAdminService.Execute(commitRequest); + + // Assert - FileId and FileSizeInBytes + Assert.NotEqual(Guid.Empty, commitResponse.FileId); + Assert.Equal(fileData.Length, commitResponse.FileSizeInBytes); + + // Act - Initialize download + var initDownloadRequest = new InitializeFileBlocksDownloadRequest + { + Target = account.ToEntityReference(), + FileAttributeName = fileAttributeName + }; + var initDownloadResponse = (InitializeFileBlocksDownloadResponse)orgAdminService.Execute(initDownloadRequest); + + // Assert - FileName and FileSizeInBytes match + Assert.Equal(fileName, initDownloadResponse.FileName); + Assert.Equal(fileData.Length, initDownloadResponse.FileSizeInBytes); + + // Act - Download full file + var downloadToken = initDownloadResponse.FileContinuationToken; + var downloadBlockRequest = new DownloadBlockRequest + { + FileContinuationToken = downloadToken, + Offset = 0, + BlockLength = initDownloadResponse.FileSizeInBytes + }; + var downloadBlockResponse = (DownloadBlockResponse)orgAdminService.Execute(downloadBlockRequest); + + // Assert - Downloaded data matches original + Assert.Equal(fileData, downloadBlockResponse.Data); + var downloadedContent = Encoding.UTF8.GetString(downloadBlockResponse.Data); + Assert.Equal(fileContent, downloadedContent); + } + + [Fact] + public void TestMultiBlockFileUpload() + { + // Arrange + var account = new Account { Name = "Test Account Multi Block" }; + account.Id = orgAdminService.Create(account); + + var fileName = "multiblock.bin"; + var fileAttributeName = "dg_testfile"; + var mimeType = "application/octet-stream"; + + // Three blocks of data + var block1Data = new byte[] { 1, 2, 3 }; + var block2Data = new byte[] { 4, 5, 6 }; + var block3Data = new byte[] { 7, 8, 9 }; + + // Act - Initialize upload + var initUploadRequest = new InitializeFileBlocksUploadRequest + { + Target = account.ToEntityReference(), + FileName = fileName, + FileAttributeName = fileAttributeName + }; + var initUploadResponse = (InitializeFileBlocksUploadResponse)orgAdminService.Execute(initUploadRequest); + var uploadToken = initUploadResponse.FileContinuationToken; + + // Upload three blocks + var blockId1 = Convert.ToBase64String(BitConverter.GetBytes(1)); + var blockId2 = Convert.ToBase64String(BitConverter.GetBytes(2)); + var blockId3 = Convert.ToBase64String(BitConverter.GetBytes(3)); + + orgAdminService.Execute(new UploadBlockRequest + { + FileContinuationToken = uploadToken, + BlockId = blockId1, + BlockData = block1Data + }); + + orgAdminService.Execute(new UploadBlockRequest + { + FileContinuationToken = uploadToken, + BlockId = blockId2, + BlockData = block2Data + }); + + orgAdminService.Execute(new UploadBlockRequest + { + FileContinuationToken = uploadToken, + BlockId = blockId3, + BlockData = block3Data + }); + + // Commit with blocks in correct order + var commitRequest = new CommitFileBlocksUploadRequest + { + FileContinuationToken = uploadToken, + BlockList = new string[] { blockId1, blockId2, blockId3 }, + MimeType = mimeType + }; + var commitResponse = (CommitFileBlocksUploadResponse)orgAdminService.Execute(commitRequest); + + // Assert - FileSizeInBytes equals 9 + Assert.Equal(9, commitResponse.FileSizeInBytes); + + // Download and verify assembled data + var initDownloadRequest = new InitializeFileBlocksDownloadRequest + { + Target = account.ToEntityReference(), + FileAttributeName = fileAttributeName + }; + var initDownloadResponse = (InitializeFileBlocksDownloadResponse)orgAdminService.Execute(initDownloadRequest); + + var downloadBlockRequest = new DownloadBlockRequest + { + FileContinuationToken = initDownloadResponse.FileContinuationToken, + Offset = 0, + BlockLength = initDownloadResponse.FileSizeInBytes + }; + var downloadBlockResponse = (DownloadBlockResponse)orgAdminService.Execute(downloadBlockRequest); + + // Assert - Downloaded data is {1,2,3,4,5,6,7,8,9} + var expectedData = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + Assert.Equal(expectedData, downloadBlockResponse.Data); + } + + [Fact] + public void TestMultiBlockDownloadWithOffset() + { + // Arrange - Upload a file with known data + var account = new Account { Name = "Test Account Offset Download" }; + account.Id = orgAdminService.Create(account); + + var fileName = "offset.bin"; + var fileAttributeName = "dg_testfile"; + var mimeType = "application/octet-stream"; + var fileData = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + + // Upload the file + var initUploadRequest = new InitializeFileBlocksUploadRequest + { + Target = account.ToEntityReference(), + FileName = fileName, + FileAttributeName = fileAttributeName + }; + var initUploadResponse = (InitializeFileBlocksUploadResponse)orgAdminService.Execute(initUploadRequest); + + var blockId = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + orgAdminService.Execute(new UploadBlockRequest + { + FileContinuationToken = initUploadResponse.FileContinuationToken, + BlockId = blockId, + BlockData = fileData + }); + + orgAdminService.Execute(new CommitFileBlocksUploadRequest + { + FileContinuationToken = initUploadResponse.FileContinuationToken, + BlockList = new string[] { blockId }, + MimeType = mimeType + }); + + // Act - Initialize download + var initDownloadRequest = new InitializeFileBlocksDownloadRequest + { + Target = account.ToEntityReference(), + FileAttributeName = fileAttributeName + }; + var initDownloadResponse = (InitializeFileBlocksDownloadResponse)orgAdminService.Execute(initDownloadRequest); + var downloadToken = initDownloadResponse.FileContinuationToken; + + var half = (int)(initDownloadResponse.FileSizeInBytes / 2); + + // Download first half + var downloadFirstHalfRequest = new DownloadBlockRequest + { + FileContinuationToken = downloadToken, + Offset = 0, + BlockLength = half + }; + var firstHalfResponse = (DownloadBlockResponse)orgAdminService.Execute(downloadFirstHalfRequest); + + // Download second half + var downloadSecondHalfRequest = new DownloadBlockRequest + { + FileContinuationToken = downloadToken, + Offset = half, + BlockLength = half + }; + var secondHalfResponse = (DownloadBlockResponse)orgAdminService.Execute(downloadSecondHalfRequest); + + // Assert - Both parts combine to original data + var combinedData = firstHalfResponse.Data.Concat(secondHalfResponse.Data).ToArray(); + Assert.Equal(fileData, combinedData); + } + + [Fact] + public void TestInvalidUploadTokenThrows() + { + // Arrange + var invalidToken = Guid.NewGuid().ToString(); + + // Act & Assert + var uploadBlockRequest = new UploadBlockRequest + { + FileContinuationToken = invalidToken, + BlockId = "block1", + BlockData = new byte[] { 1, 2, 3 } + }; + + var exception = Assert.Throws(() => orgAdminService.Execute(uploadBlockRequest)); + Assert.Contains("Invalid or expired file continuation token", exception.Message); + } + + [Fact] + public void TestInvalidDownloadTokenThrows() + { + // Arrange + var invalidToken = Guid.NewGuid().ToString(); + + // Act & Assert + var downloadBlockRequest = new DownloadBlockRequest + { + FileContinuationToken = invalidToken, + Offset = 0, + BlockLength = 100 + }; + + var exception = Assert.Throws(() => orgAdminService.Execute(downloadBlockRequest)); + Assert.Contains("Invalid or expired file continuation token", exception.Message); + } + + [Fact] + public void TestDownloadNonExistentFileThrows() + { + // Arrange - Create account but don't upload any file + var account = new Account { Name = "Test Account No File" }; + account.Id = orgAdminService.Create(account); + + // Act & Assert + var initDownloadRequest = new InitializeFileBlocksDownloadRequest + { + Target = account.ToEntityReference(), + FileAttributeName = "dg_testfile" + }; + + var exception = Assert.Throws(() => orgAdminService.Execute(initDownloadRequest)); + Assert.Contains("No file attachment found", exception.Message); + } + } +} From 726c422f9a047f5780de603274878db35d64bd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Mon, 12 Jan 2026 15:40:52 +0100 Subject: [PATCH 2/5] Added entity store as well --- .../Requests/CommitFileBlocksUploadRequestHandler.cs | 2 ++ .../Requests/InitializeFileBlocksUploadRequestHandler.cs | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs index 8fec8465..6485d7fa 100644 --- a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs @@ -53,6 +53,8 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E core.FileBlockStore.CommitUpload(request.FileContinuationToken, committedFile); + // file attachment metadata not added, so we don't store in fileattachment entity + var resp = new CommitFileBlocksUploadResponse(); resp.Results["FileId"] = session.FileAttachmentId; resp.Results["FileSizeInBytes"] = (long)fileData.Length; diff --git a/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs b/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs index 0f7b8377..2d8f74cc 100644 --- a/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs @@ -17,6 +17,13 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E var token = Guid.NewGuid().ToString(); var fileAttachmentId = Guid.NewGuid(); + // Create the fileattachment entity in the database + var fileAttachment = new Entity("fileattachment"); + fileAttachment["filename"] = request.FileName; + fileAttachment["regardingfieldname"] = request.FileAttributeName; + fileAttachment["objectid"] = request.Target; + db.Add(fileAttachment); + var session = new FileUploadSession { FileAttachmentId = fileAttachmentId, From 21d30e699310efa9291c198b69c289f4e87d8c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Mon, 12 Jan 2026 15:51:26 +0100 Subject: [PATCH 3/5] Add runtime add of entitymetadata --- src/XrmMockup365/Core.cs | 50 ++++++++++++++++++++++++++++++ src/XrmMockup365/Database/XrmDb.cs | 8 +++++ 2 files changed, 58 insertions(+) diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index bbd35ac5..8fe3d26d 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -120,6 +120,7 @@ private void InitializeCore(CoreInitializationData initData) entityTypeMap = initData.EntityTypeMap; db = new XrmDb(initData.Metadata.EntityMetadata, initData.OnlineProxy); + EnsureFileAttachmentMetadata(); FileBlockStore = new FileBlockStore(); snapshots = new Dictionary(); security = new Security(this, initData.Metadata, initData.SecurityRoles, db); @@ -1356,6 +1357,7 @@ internal void ResetEnvironment() pluginManager.ResetPlugins(); this.db = new XrmDb(metadata.EntityMetadata, GetOnlineProxy()); + EnsureFileAttachmentMetadata(); this.RequestHandlers = GetRequestHandlers(db); InitializeDB(); security.ResetEnvironment(db); @@ -1430,6 +1432,54 @@ internal void AddSecurityRole(SecurityRole role) security.AddSecurityRole(role); } + private void EnsureFileAttachmentMetadata() + { + if (db.IsValidEntity("fileattachment")) + return; + + var entityMetadata = new EntityMetadata(); + SetMetadataProperty(entityMetadata, "LogicalName", "fileattachment"); + SetMetadataProperty(entityMetadata, "PrimaryIdAttribute", "fileattachmentid"); + SetMetadataProperty(entityMetadata, "PrimaryNameAttribute", "filename"); + + var attributes = new AttributeMetadata[] + { + CreateAttributeMetadata("fileattachmentid", AttributeTypeCode.Uniqueidentifier), + CreateAttributeMetadata("filename", AttributeTypeCode.String), + CreateAttributeMetadata("createdon", AttributeTypeCode.DateTime), + CreateAttributeMetadata("filesizeinbytes", AttributeTypeCode.BigInt), + CreateAttributeMetadata("mimetype", AttributeTypeCode.String), + CreateAttributeMetadata("objecttypecode", AttributeTypeCode.String), + CreateAttributeMetadata("regardingfieldname", AttributeTypeCode.String), + CreateAttributeMetadata("objectid", AttributeTypeCode.Lookup) + }; + + SetMetadataProperty(entityMetadata, "Attributes", attributes); + db.RegisterEntityMetadata(entityMetadata); + } + + private static void SetMetadataProperty(object metadata, string propertyName, object value) + { + var property = metadata.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (property != null && property.CanWrite) + { + property.SetValue(metadata, value); + return; + } + + var field = metadata.GetType().GetField($"_{char.ToLower(propertyName[0])}{propertyName.Substring(1)}", + BindingFlags.NonPublic | BindingFlags.Instance); + field?.SetValue(metadata, value); + } + + private static T CreateAttributeMetadata(string logicalName, AttributeTypeCode typeCode) where T : AttributeMetadata, new() + { + var attribute = new T(); + SetMetadataProperty(attribute, "LogicalName", logicalName); + SetMetadataProperty(attribute, "AttributeType", typeCode); + return attribute; + } + public void TriggerExtension(IOrganizationService service, OrganizationRequest request, Entity currentEntity, Entity preEntity, EntityReference userRef) { diff --git a/src/XrmMockup365/Database/XrmDb.cs b/src/XrmMockup365/Database/XrmDb.cs index e99d8afb..30055182 100644 --- a/src/XrmMockup365/Database/XrmDb.cs +++ b/src/XrmMockup365/Database/XrmDb.cs @@ -103,6 +103,14 @@ internal bool IsValidEntity(string entityLogicalName) return EntityMetadata.TryGetValue(entityLogicalName, out EntityMetadata entityMetadata); } + internal void RegisterEntityMetadata(EntityMetadata entityMetadata) + { + if (entityMetadata is null) + throw new ArgumentNullException(nameof(entityMetadata)); + + EntityMetadata[entityMetadata.LogicalName] = entityMetadata; + } + internal void PrefillDBWithOnlineData(QueryExpression queryExpr) { if (OnlineProxy != null) From 01d23d0ccae2951f0f4e962375270988d8afa85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Tue, 13 Jan 2026 17:47:10 +0100 Subject: [PATCH 4/5] Added nullability to metadata fetches --- src/XrmMockup365/Internal/Utility.cs | 9 ++++++--- .../Requests/AddUserToRecordTeamRequestHandler.cs | 2 +- .../Requests/RemoveUserFromRecordTeamRequestHandler.cs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/XrmMockup365/Internal/Utility.cs b/src/XrmMockup365/Internal/Utility.cs index 7009dc03..5ba1638a 100644 --- a/src/XrmMockup365/Internal/Utility.cs +++ b/src/XrmMockup365/Internal/Utility.cs @@ -264,17 +264,20 @@ internal static RelationshipMetadataBase GetRelationshipMetadataDefaultNull(Dict RelationshipMetadataBase relationshipBase; foreach (var meta in entityMetadata) { - relationshipBase = meta.Value.ManyToManyRelationships.FirstOrDefault(rel => rel.MetadataId == metadataId); + var manyToMany = meta.Value.ManyToManyRelationships ?? Array.Empty(); + relationshipBase = manyToMany.FirstOrDefault(rel => rel.MetadataId == metadataId); if (relationshipBase != null) { return relationshipBase; } - relationshipBase = meta.Value.ManyToManyRelationships.FirstOrDefault(rel => rel.SchemaName == name); + relationshipBase = manyToMany.FirstOrDefault(rel => rel.SchemaName == name); if (relationshipBase != null) { return relationshipBase; } - var oneToManyBases = meta.Value.ManyToOneRelationships.Concat(meta.Value.OneToManyRelationships); + var manyToOne = meta.Value.ManyToOneRelationships ?? Array.Empty(); + var oneToMany = meta.Value.OneToManyRelationships ?? Array.Empty(); + var oneToManyBases = manyToOne.Concat(oneToMany); relationshipBase = oneToManyBases.FirstOrDefault(rel => rel.MetadataId == metadataId); if (relationshipBase != null) { diff --git a/src/XrmMockup365/Requests/AddUserToRecordTeamRequestHandler.cs b/src/XrmMockup365/Requests/AddUserToRecordTeamRequestHandler.cs index 9863ac26..3a976424 100644 --- a/src/XrmMockup365/Requests/AddUserToRecordTeamRequestHandler.cs +++ b/src/XrmMockup365/Requests/AddUserToRecordTeamRequestHandler.cs @@ -26,7 +26,7 @@ internal override void CheckSecurity(OrganizationRequest orgRequest, EntityRefer var callingUserPrivs = security.GetPrincipalPrivilege(userRef.Id); - var entityMetadata = metadata.EntityMetadata.Single(x => x.Value.ObjectTypeCode.Value == ttRow.GetAttributeValue("objecttypecode")); + var entityMetadata = metadata.EntityMetadata.Single(x => x.Value.ObjectTypeCode.HasValue && x.Value.ObjectTypeCode.Value == ttRow.GetAttributeValue("objecttypecode")); var callingPrivs = callingUserPrivs[entityMetadata.Value.LogicalName]; diff --git a/src/XrmMockup365/Requests/RemoveUserFromRecordTeamRequestHandler.cs b/src/XrmMockup365/Requests/RemoveUserFromRecordTeamRequestHandler.cs index 3c51f9fe..ae714f30 100644 --- a/src/XrmMockup365/Requests/RemoveUserFromRecordTeamRequestHandler.cs +++ b/src/XrmMockup365/Requests/RemoveUserFromRecordTeamRequestHandler.cs @@ -25,7 +25,7 @@ internal override void CheckSecurity(OrganizationRequest orgRequest, EntityRefer var callingUserPrivs = security.GetPrincipalPrivilege(userRef.Id); - var entityMetadata = metadata.EntityMetadata.Single(x => x.Value.ObjectTypeCode.Value == ttRow.GetAttributeValue("objecttypecode")); + var entityMetadata = metadata.EntityMetadata.Single(x => x.Value.ObjectTypeCode.HasValue && x.Value.ObjectTypeCode.Value == ttRow.GetAttributeValue("objecttypecode")); var callingPrivs = callingUserPrivs[entityMetadata.Value.LogicalName]; From 4cc0401c772cb0157dd1f38ac1928dbcc14c889a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Thu, 15 Jan 2026 15:38:48 +0100 Subject: [PATCH 5/5] Removed old comment --- .../Requests/CommitFileBlocksUploadRequestHandler.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs index 6485d7fa..8fec8465 100644 --- a/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/CommitFileBlocksUploadRequestHandler.cs @@ -53,8 +53,6 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E core.FileBlockStore.CommitUpload(request.FileContinuationToken, committedFile); - // file attachment metadata not added, so we don't store in fileattachment entity - var resp = new CommitFileBlocksUploadResponse(); resp.Results["FileId"] = session.FileAttachmentId; resp.Results["FileSizeInBytes"] = (long)fileData.Length;