diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index c17fe06c..8fe3d26d 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,8 @@ 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); TracingServiceFactory = initData.Settings.TracingServiceFactory ?? new TracingServiceFactory(); @@ -397,6 +400,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), @@ -1352,6 +1357,7 @@ internal void ResetEnvironment() pluginManager.ResetPlugins(); this.db = new XrmDb(metadata.EntityMetadata, GetOnlineProxy()); + EnsureFileAttachmentMetadata(); this.RequestHandlers = GetRequestHandlers(db); InitializeDB(); security.ResetEnvironment(db); @@ -1426,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/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/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) 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/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..2d8f74cc 100644 --- a/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs +++ b/src/XrmMockup365/Requests/InitializeFileBlocksUploadRequestHandler.cs @@ -1,26 +1,42 @@ -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 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, + 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/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]; 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); + } + } +}