From cfa99e6a06240bb3da34c2b2bc86d45659cb1c65 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 25 Feb 2026 10:57:06 +0200 Subject: [PATCH 01/10] feat//SSAD-0099: add azure blob storage. Add logic to handle attempts to fetch non-existent blobs --- .../Interfaces/BlobStorage/IBlobService.cs | 11 +- .../GetArtsByStreetcodeIdHandler.cs | 17 ++- .../Media/Audio/Create/CreateAudioHandler.cs | 2 +- .../Media/Audio/GetAll/GetAllAudiosHandler.cs | 13 +- .../Audio/GetBaseAudio/GetBaseAudioHandler.cs | 13 +- .../Audio/GetById/GetAudioByIdHandler.cs | 15 +- .../GetAudioByStreetcodeIdQueryHandler.cs | 14 +- .../Media/Image/Create/CreateImageHandler.cs | 23 ++- .../Media/Image/GetAll/GetAllImagesHandler.cs | 13 +- .../Image/GetBaseImage/GetBaseImageHandler.cs | 13 +- .../Image/GetById/GetImageByIdHandler.cs | 18 ++- .../GetImageByStreetcodeIdHandler.cs | 13 +- .../GetStreetcodeArtByStreetcodeIdHandler.cs | 13 +- .../MediatR/News/GetAll/GetAllNewsHandler.cs | 17 ++- .../News/GetById/GetNewsByIdHandler.cs | 18 ++- .../News/GetByUrl/GetNewsByUrlHandler.cs | 13 +- .../GetNewsAndLinksByUrlHandler.cs | 13 +- .../SortedByDateTimeHandler.cs | 17 ++- .../MediatR/News/Update/UpdateNewsHandler.cs | 29 ++-- .../GetAll/GetAllCategoriesHandler.cs | 13 +- .../GetCategoriesByStreetcodeIdHandler.cs | 13 +- .../GetCategoryById/GetCategoryByIdHandler.cs | 19 ++- .../AzureBlobEnvironmentVariables.cs | 7 + .../BlobStorageService/AzureBlobService.cs | 135 ++++++++++++++++++ .../BlobStorageService/BlobService.cs | 124 ++++++---------- .../Streetcode.BLL/Streetcode.BLL.csproj | 1 + .../Streetcode.Resources/Messages.Designer.cs | 9 ++ Streetcode/Streetcode.Resources/Messages.resx | 3 + .../Streetcode.Shared/Services/FileService.cs | 74 ++++++++++ .../ConfigureHostBuilderExtensions.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 21 ++- Streetcode/Streetcode.WebApi/Program.cs | 4 +- .../Streetcode.WebApi.csproj | 1 + Streetcode/Streetcode.WebApi/appsettings.json | 5 + .../Art/GetArtsByStreetcodeIdHandlerTests.cs | 26 +++- 35 files changed, 598 insertions(+), 143 deletions(-) create mode 100644 Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobEnvironmentVariables.cs create mode 100644 Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs create mode 100644 Streetcode/Streetcode.Shared/Services/FileService.cs diff --git a/Streetcode/Streetcode.BLL/Interfaces/BlobStorage/IBlobService.cs b/Streetcode/Streetcode.BLL/Interfaces/BlobStorage/IBlobService.cs index eace1bc..bcbbec1 100644 --- a/Streetcode/Streetcode.BLL/Interfaces/BlobStorage/IBlobService.cs +++ b/Streetcode/Streetcode.BLL/Interfaces/BlobStorage/IBlobService.cs @@ -2,13 +2,14 @@ public interface IBlobService { - public string SaveFileInStorage(string base64, string name, string mimeType); - public MemoryStream FindFileInStorageAsMemoryStream(string name); - public string UpdateFileInStorage( + public Task SaveFileInStorage(string base64, string name, string mimeType); + public Task UpdateFileInStorage( string previousBlobName, string base64Format, string newBlobName, string extension); - public string FindFileInStorageAsBase64(string name); - public void DeleteFileInStorage(string name); + public Task FindFileInStorageAsMemoryStream(string name); + public Task FindFileInStorageAsBase64(string name); + public Task DeleteFileInStorage(string name); + public Task CleanBlobStorage(); } diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs index b460ea1..57b83fe 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs @@ -52,10 +52,23 @@ public async Task>> Handle(GetArtsByStreetcodeIdQuery foreach (var artDto in artsDto) { - if (artDto.Image != null && artDto.Image.BlobName != null) + if (artDto.Image?.BlobName == null) { - artDto.Image.Base64 = _blobService.FindFileInStorageAsBase64(artDto.Image.BlobName); + continue; } + + var imageBase64 = await _blobService.FindFileInStorageAsBase64(artDto.Image.BlobName); + if (imageBase64 is not null) + { + artDto.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + artDto.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(artsDto); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/Create/CreateAudioHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/Create/CreateAudioHandler.cs index f1990a5..ea66207 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/Create/CreateAudioHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/Create/CreateAudioHandler.cs @@ -31,7 +31,7 @@ public CreateAudioHandler( public async Task> Handle(CreateAudioCommand request, CancellationToken cancellationToken) { - var hashBlobStorageName = _blobService.SaveFileInStorage( + var hashBlobStorageName = await _blobService.SaveFileInStorage( request.Audio.BaseFormat, request.Audio.Title, request.Audio.Extension); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs index b2b25eb..93bd80a 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs @@ -39,7 +39,18 @@ public async Task>> Handle(GetAllAudiosQuery reques var audioDtos = _mapper.Map>(audios); foreach (var audio in audioDtos) { - audio.Base64 = _blobService.FindFileInStorageAsBase64(audio.BlobName); + var audioBase64 = await _blobService.FindFileInStorageAsBase64(audio.BlobName); + if (audioBase64 is not null) + { + audio.Base64 = audioBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Audio), + audio.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(audioDtos); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetBaseAudio/GetBaseAudioHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetBaseAudio/GetBaseAudioHandler.cs index 6b4824b..65e07a5 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetBaseAudio/GetBaseAudioHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetBaseAudio/GetBaseAudioHandler.cs @@ -27,7 +27,18 @@ public async Task> Handle(GetBaseAudioQuery request, Cancel if (audio is not null) { - return _blobStorage.FindFileInStorageAsMemoryStream(audio.BlobName); + var audioMemoryStream = await _blobStorage.FindFileInStorageAsMemoryStream(audio.BlobName); + if (audioMemoryStream is not null) + { + return Result.Ok(audioMemoryStream); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Audio), + audio.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } var errorMsg = Messages.Error_EntityWithIdNotFound.Format(nameof(DAL.Entities.Media.Audio), request.Id); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetById/GetAudioByIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetById/GetAudioByIdHandler.cs index c2c850c..7dc37f4 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetById/GetAudioByIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetById/GetAudioByIdHandler.cs @@ -38,8 +38,19 @@ public async Task> Handle(GetAudioByIdQuery request, Cancellati var audioDto = _mapper.Map(audio); - audioDto.Base64 = _blobService.FindFileInStorageAsBase64(audioDto.BlobName); + var audioBase64 = await _blobService.FindFileInStorageAsBase64(audioDto.BlobName); - return Result.Ok(audioDto); + if (audioBase64 is not null) + { + audioDto.Base64 = audioBase64; + return Result.Ok(audioDto); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Audio), + audioDto.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetByStreetcodeId/GetAudioByStreetcodeIdQueryHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetByStreetcodeId/GetAudioByStreetcodeIdQueryHandler.cs index f58ddcf..a649fd2 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetByStreetcodeId/GetAudioByStreetcodeIdQueryHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetByStreetcodeId/GetAudioByStreetcodeIdQueryHandler.cs @@ -42,8 +42,18 @@ public async Task> Handle(GetAudioByStreetcodeIdQuery request, var audioDto = _mapper.Map(audio); - audioDto.Base64 = _blobService.FindFileInStorageAsBase64(audioDto.BlobName); + var audioBase64 = await _blobService.FindFileInStorageAsBase64(audioDto.BlobName); + if (audioBase64 is not null) + { + audioDto.Base64 = audioBase64; + return Result.Ok(audioDto); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Audio), + audioDto.BlobName); - return Result.Ok(audioDto); + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/Create/CreateImageHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/Create/CreateImageHandler.cs index b566925..a134673 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/Create/CreateImageHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/Create/CreateImageHandler.cs @@ -31,7 +31,7 @@ public CreateImageHandler( public async Task> Handle(CreateImageCommand request, CancellationToken cancellationToken) { - string hashBlobStorageName = _blobService.SaveFileInStorage( + string hashBlobStorageName = await _blobService.SaveFileInStorage( request.Image.BaseFormat, request.Image.Title, request.Image.Extension); @@ -42,18 +42,27 @@ public async Task> Handle(CreateImageCommand request, Cancellat await _repositoryWrapper.ImageRepository.CreateAsync(image); var resultIsSuccess = await _repositoryWrapper.SaveChangesAsync() > 0; + if (!resultIsSuccess) + { + var errorMsg = Messages.Error_FailedToCreateEntity.Format(nameof(DAL.Entities.Media.Images.Image)); + _logger.LogError(request, errorMsg); + return Result.Fail(new Error(errorMsg)); + } var createdImage = _mapper.Map(image); - createdImage.Base64 = _blobService.FindFileInStorageAsBase64(createdImage.BlobName); - - if (resultIsSuccess) + var imageBase64 = await _blobService.FindFileInStorageAsBase64(createdImage.BlobName); + if (imageBase64 is not null) { + createdImage.Base64 = imageBase64; return Result.Ok(createdImage); } - var errorMsg = Messages.Error_FailedToCreateEntity.Format(nameof(DAL.Entities.Media.Images.Image)); - _logger.LogError(request, errorMsg); - return Result.Fail(new Error(errorMsg)); + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + createdImage.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs index 023afff..5eca0bd 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs @@ -40,7 +40,18 @@ public async Task>> Handle(GetAllImagesQuery reques foreach (var image in imageDtos) { - image.Base64 = _blobService.FindFileInStorageAsBase64(image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(image.BlobName); + if (imageBase64 is not null) + { + image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(imageDtos); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetBaseImage/GetBaseImageHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetBaseImage/GetBaseImageHandler.cs index 9dbf5f7..fd4a05b 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetBaseImage/GetBaseImageHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetBaseImage/GetBaseImageHandler.cs @@ -27,7 +27,18 @@ public async Task> Handle(GetBaseImageQuery request, Cancel if (image is not null) { - return _blobStorage.FindFileInStorageAsMemoryStream(image.BlobName); + var imageMemoryStream = await _blobStorage.FindFileInStorageAsMemoryStream(image.BlobName); + if (imageMemoryStream is not null) + { + return Result.Ok(imageMemoryStream); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } var errorMsg = Messages.Error_EntityWithIdNotFound.Format(nameof(DAL.Entities.Media.Images.Image), request.Id); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetById/GetImageByIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetById/GetImageByIdHandler.cs index 6b3092b..615933c 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetById/GetImageByIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetById/GetImageByIdHandler.cs @@ -40,11 +40,23 @@ public async Task> Handle(GetImageByIdQuery request, Cancellati } var imageDto = _mapper.Map(image); - if (imageDto.BlobName != null) + if (imageDto.BlobName == null) { - imageDto.Base64 = _blobService.FindFileInStorageAsBase64(image.BlobName); + return Result.Ok(imageDto); } - return Result.Ok(imageDto); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(imageDto.BlobName); + if (imageBase64 is not null) + { + imageDto.Base64 = imageBase64; + return Result.Ok(imageDto); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + imageDto.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs index 088e121..ca992ca 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs @@ -52,7 +52,18 @@ public async Task>> Handle(GetImageByStreetcodeIdQu foreach (var image in imageDtos) { - image.Base64 = _blobService.FindFileInStorageAsBase64(image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(image.BlobName); + if (imageBase64 is not null) + { + image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(imageDtos); diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs index a400901..f930ab0 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs @@ -53,7 +53,18 @@ public async Task>> Handle(GetStreetcodeArt foreach (var artDto in artsDto) { - artDto.Art.Image.Base64 = _blobService.FindFileInStorageAsBase64(artDto.Art.Image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(artDto.Art.Image.BlobName); + if (imageBase64 is not null) + { + artDto.Art.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + artDto.Art.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(artsDto); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs index 42147cc..9ca48de 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs @@ -42,10 +42,23 @@ public async Task>> Handle(GetAllNewsQuery request, foreach (var dto in newsDTOs) { - if (dto.Image is not null) + if (dto.Image is null) { - dto.Image.Base64 = _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + continue; } + + var imageBase64 = await _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + if (imageBase64 is not null) + { + dto.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + dto.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(newsDTOs); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetById/GetNewsByIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetById/GetNewsByIdHandler.cs index 8542bd5..c3f2b38 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetById/GetNewsByIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetById/GetNewsByIdHandler.cs @@ -39,12 +39,24 @@ await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( return Result.Fail(errorMsg); } - if (newsDTO.Image is not null) + if (newsDTO.Image is null) { - newsDTO.Image.Base64 = _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + return Result.Ok(newsDTO); } - return Result.Ok(newsDTO); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + if (imageBase64 is not null) + { + newsDTO.Image.Base64 = imageBase64; + return Result.Ok(newsDTO); + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + newsDTO.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs index 40630ff..62847f4 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs @@ -41,7 +41,18 @@ await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( if (newsDTO.Image is not null) { - newsDTO.Image.Base64 = _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + if (imageBase64 is not null) + { + newsDTO.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + newsDTO.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(newsDTO); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs index c66430d..9707688 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs @@ -41,7 +41,18 @@ await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( if (newsDTO.Image is not null) { - newsDTO.Image.Base64 = _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + if (imageBase64 is not null) + { + newsDTO.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + newsDTO.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } var news = (await _repositoryWrapper.NewsRepository.GetAllAsync()).ToList(); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs index 44b0b15..649637c 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs @@ -41,10 +41,23 @@ public async Task>> Handle(SortedByDateTimeQuery request, C foreach (var dto in newsDTOs) { - if (dto.Image is not null) + if (dto.Image is null) { - dto.Image.Base64 = _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + continue; } + + var imageBase64 = await _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + if (imageBase64 is not null) + { + dto.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + dto.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(newsDTOs); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs index 2dde74b..ecac0c6 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs @@ -14,13 +14,13 @@ public class UpdateNewsHandler : IRequestHandler> Handle(UpdateNewsCommand request, Cancellatio if (news.Image is not null) { - response.Image.Base64 = _blobSevice.FindFileInStorageAsBase64(response.Image.BlobName); - } - else - { - var img = await _repositoryWrapper.ImageRepository - .GetFirstOrDefaultAsync(x => x.Id == response.ImageId); - if (img != null) + var imageBase64 = await _blobService.FindFileInStorageAsBase64(response.Image.BlobName); + if (imageBase64 is not null) { - _repositoryWrapper.ImageRepository.Delete(img); + response.Image.Base64 = imageBase64; } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + response.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); + } + + var img = await _repositoryWrapper.ImageRepository + .GetFirstOrDefaultAsync(x => x.Id == response.ImageId); + if (img != null) + { + _repositoryWrapper.ImageRepository.Delete(img); } _repositoryWrapper.NewsRepository.Update(news); diff --git a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs index 6a0bae0..4b9a88b 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs @@ -44,7 +44,18 @@ public async Task>> Handle(GetAllCateg foreach (var dto in dtos) { - dto.Image.Base64 = _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(dto.Image.BlobName); + if (imageBase64 is not null) + { + dto.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + dto.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(dtos); diff --git a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs index 15d4dcc..3f200ba 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs @@ -54,7 +54,18 @@ public async Task>> Handle( foreach (var srcCategory in mappedSrcCategories) { - srcCategory.Image.Base64 = _blobService.FindFileInStorageAsBase64(srcCategory.Image.BlobName); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(srcCategory.Image.BlobName); + if (imageBase64 is not null) + { + srcCategory.Image.Base64 = imageBase64; + } + + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + srcCategory.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } return Result.Ok(mappedSrcCategories); diff --git a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoryById/GetCategoryByIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoryById/GetCategoryByIdHandler.cs index 2e6a139..84b2f12 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoryById/GetCategoryByIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoryById/GetCategoryByIdHandler.cs @@ -32,14 +32,14 @@ public GetCategoryByIdHandler( public async Task> Handle(GetCategoryByIdQuery request, CancellationToken cancellationToken) { - var srcCategories = await _repositoryWrapper + var srcCategory = await _repositoryWrapper .SourceCategoryRepository.GetFirstOrDefaultAsync( predicate: sc => sc.Id == request.Id, include: scl => scl .Include(sc => sc.StreetcodeCategoryContents) .Include(sc => sc.Image) !); - if (srcCategories is null) + if (srcCategory is null) { var errorMsg = Messages.Error_EntityWithIdNotFound.Format( nameof(DAL.Entities.Sources.SourceLinkCategory), @@ -49,10 +49,19 @@ public async Task> Handle(GetCategoryByIdQuery req return Result.Fail(new Error(errorMsg)); } - var mappedSrcCategories = _mapper.Map(srcCategories); + var mappedSrcCategory = _mapper.Map(srcCategory); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(mappedSrcCategory.Image.BlobName); + if (imageBase64 is not null) + { + mappedSrcCategory.Image.Base64 = imageBase64; + return Result.Ok(mappedSrcCategory); + } - mappedSrcCategories.Image.Base64 = _blobService.FindFileInStorageAsBase64(mappedSrcCategories.Image.BlobName); + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + mappedSrcCategory.Image.BlobName); - return Result.Ok(mappedSrcCategories); + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobEnvironmentVariables.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobEnvironmentVariables.cs new file mode 100644 index 0000000..13dead3 --- /dev/null +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobEnvironmentVariables.cs @@ -0,0 +1,7 @@ +namespace Streetcode.BLL.Services.BlobStorageService; + +public class AzureBlobEnvironmentVariables +{ + public string ContainerName { get; set; } + public string EncryptionKey { get; set; } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs new file mode 100644 index 0000000..506dfe4 --- /dev/null +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs @@ -0,0 +1,135 @@ +using Azure; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Options; +using Streetcode.BLL.Interfaces.BlobStorage; +using Streetcode.DAL.Repositories.Interfaces.Base; +using Streetcode.Shared.Services; + +namespace Streetcode.BLL.Services.BlobStorageService; + +public class AzureBlobService : IBlobService +{ + private readonly AzureBlobEnvironmentVariables _options; + private readonly IRepositoryWrapper _repositoryWrapper; + private readonly BlobServiceClient _blobServiceClient; + + public AzureBlobService( + IOptions options, + IRepositoryWrapper repositoryWrapper, + BlobServiceClient blobServiceClient) + { + _options = options.Value; + _repositoryWrapper = repositoryWrapper; + _blobServiceClient = blobServiceClient; + } + + public async Task SaveFileInStorage(string base64, string name, string extension) + { + byte[] imageBytes = Convert.FromBase64String(base64); + string createdFileName = FileService.PrepareFileStorageName(name); + + string hashBlobName = FileService.HashFunction(createdFileName); + string fullBlobName = $"{hashBlobName}.{extension}"; + + byte[] encryptedData = FileService.EncryptFile(imageBytes, _options.EncryptionKey); + + var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); + await containerClient.CreateIfNotExistsAsync(); + + var blobClient = containerClient.GetBlobClient(fullBlobName); + + using (var ms = new MemoryStream(encryptedData)) + { + await blobClient.UploadAsync(ms, overwrite: true); + } + + return hashBlobName; + } + + public async Task FindFileInStorageAsMemoryStream(string name) + { + var bytes = await FindFileInStorageAsBytes(name); + return bytes is null ? null : new MemoryStream(bytes); + } + + public async Task FindFileInStorageAsBase64(string name) + { + var bytes = await FindFileInStorageAsBytes(name); + return bytes is null ? null : Convert.ToBase64String(bytes); + } + + public async Task UpdateFileInStorage( + string previousBlobName, + string base64Format, + string newBlobName, + string extension) + { + await DeleteFileInStorage(previousBlobName); + + string hashBlobStorageName = await SaveFileInStorage( + base64Format, + newBlobName, + extension); + + return hashBlobStorageName; + } + + public async Task DeleteFileInStorage(string name) + { + BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); + BlobClient blobClient = containerClient.GetBlobClient(name); + await blobClient.DeleteIfExistsAsync(); + } + + public async Task CleanBlobStorage() + { + var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); + + List blobsInAzure = new (); + await foreach (var blobItem in containerClient.GetBlobsAsync()) + { + blobsInAzure.Add(blobItem.Name); + } + + var existingImages = await _repositoryWrapper.ImageRepository.GetAllAsync(); + var existingAudios = await _repositoryWrapper.AudioRepository.GetAllAsync(); + + var existingMedia = existingImages.Select(img => img.BlobName) + .Concat(existingAudios.Select(aud => aud.BlobName)) + .ToHashSet(); // HashSet makes the lookup/Except logic much faster + + var filesToRemove = blobsInAzure.Except(existingMedia).ToList(); + + foreach (var fileName in filesToRemove) + { + Console.WriteLine($"Deleting {fileName} from Azure..."); + var blobClient = containerClient.GetBlobClient(fileName); + await blobClient.DeleteIfExistsAsync(); + } + } + + private async Task FindFileInStorageAsBytes(string name) + { + var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); + + var blobClient = containerClient.GetBlobClient(name); + + try + { + using var ms = new MemoryStream(); + await blobClient.DownloadToAsync(ms); + byte[] encryptedBytes = ms.ToArray(); + + return FileService.DecryptFile(encryptedBytes, _options.EncryptionKey); + } + catch (RequestFailedException ex) + { + if (ex.Status == 404) + { + return null!; + } + + throw; + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs index 20cb2a9..5a28c93 100644 --- a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs @@ -1,88 +1,92 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.DAL.Repositories.Interfaces.Base; +using Streetcode.Shared.Services; namespace Streetcode.BLL.Services.BlobStorageService; public class BlobService : IBlobService { - private readonly BlobEnvironmentVariables _envirovment; private readonly string _keyCrypt; private readonly string _blobPath; private readonly IRepositoryWrapper _repositoryWrapper; public BlobService(IOptions environment, IRepositoryWrapper? repositoryWrapper = null) { - _envirovment = environment.Value; - _keyCrypt = _envirovment.BlobStoreKey; - _blobPath = _envirovment.BlobStorePath; + _keyCrypt = environment.Value.BlobStoreKey; + _blobPath = environment.Value.BlobStorePath; _repositoryWrapper = repositoryWrapper; } - public MemoryStream FindFileInStorageAsMemoryStream(string name) + public Task FindFileInStorageAsMemoryStream(string name) { - string[] splitedName = name.Split('.'); + var decodedBytes = GetDecryptedFile(name); - byte[] decodedBytes = DecryptFile(splitedName[0], splitedName[1]); + if (decodedBytes == null) + { + return Task.FromResult(null!); + } var image = new MemoryStream(decodedBytes); - return image; + return Task.FromResult(image); } - public string FindFileInStorageAsBase64(string name) + public Task FindFileInStorageAsBase64(string name) { - string[] splitedName = name.Split('.'); + var decodedBytes = GetDecryptedFile(name); - byte[] decodedBytes = DecryptFile(splitedName[0], splitedName[1]); + if (decodedBytes == null) + { + return Task.FromResult(null!); + } string base64 = Convert.ToBase64String(decodedBytes); - return base64; + return Task.FromResult(base64); } - public string SaveFileInStorage(string base64, string name, string extension) + public Task SaveFileInStorage(string base64, string name, string extension) { byte[] imageBytes = Convert.FromBase64String(base64); - string createdFileName = $"{DateTime.Now}{name}" - .Replace(" ", "_") - .Replace(".", "_") - .Replace(":", "_"); + string createdFileName = FileService.PrepareFileStorageName(name); - string hashBlobStorageName = HashFunction(createdFileName); + string hashBlobStorageName = FileService.HashFunction(createdFileName); Directory.CreateDirectory(_blobPath); - EncryptFile(imageBytes, extension, hashBlobStorageName); + byte[] encryptedData = FileService.EncryptFile(imageBytes, _keyCrypt); + File.WriteAllBytes($"{_blobPath}{hashBlobStorageName}.{extension}", encryptedData); - return hashBlobStorageName; + return Task.FromResult(hashBlobStorageName); } - public void SaveFileInStorageBase64(string base64, string name, string extension) + public Task SaveFileInStorageBase64(string base64, string name, string extension) { byte[] imageBytes = Convert.FromBase64String(base64); Directory.CreateDirectory(_blobPath); - EncryptFile(imageBytes, extension, name); + FileService.EncryptFile(imageBytes, _keyCrypt); + File.WriteAllBytes($"{_blobPath}{name}.{extension}", imageBytes); + return Task.CompletedTask; } - public void DeleteFileInStorage(string name) + public Task DeleteFileInStorage(string name) { File.Delete($"{_blobPath}{name}"); + return Task.CompletedTask; } - public string UpdateFileInStorage( + public async Task UpdateFileInStorage( string previousBlobName, string base64Format, string newBlobName, string extension) { - DeleteFileInStorage(previousBlobName); + await DeleteFileInStorage(previousBlobName); - string hashBlobStorageName = SaveFileInStorage( - base64Format, - newBlobName, - extension); + string hashBlobStorageName = await SaveFileInStorage( + base64Format, + newBlobName, + extension); return hashBlobStorageName; } @@ -114,60 +118,14 @@ private IEnumerable GetAllBlobNames() return paths.Select(p => Path.GetFileName(p)); } - private string HashFunction(string createdFileName) - { - using (var hash = SHA256.Create()) - { - Encoding enc = Encoding.UTF8; - byte[] result = hash.ComputeHash(enc.GetBytes(createdFileName)); - return Convert.ToBase64String(result).Replace('/', '_'); - } - } - - private void EncryptFile(byte[] imageBytes, string type, string name) - { - byte[] keyBytes = Encoding.UTF8.GetBytes(_keyCrypt); - - byte[] iv = new byte[16]; - using (var rng = new RNGCryptoServiceProvider()) - { - rng.GetBytes(iv); - } - - byte[] encryptedBytes; - using (Aes aes = Aes.Create()) - { - aes.KeySize = 256; - aes.Key = keyBytes; - aes.IV = iv; - ICryptoTransform encryptor = aes.CreateEncryptor(); - encryptedBytes = encryptor.TransformFinalBlock(imageBytes, 0, imageBytes.Length); - } - - byte[] encryptedData = new byte[encryptedBytes.Length + iv.Length]; - Buffer.BlockCopy(iv, 0, encryptedData, 0, iv.Length); - Buffer.BlockCopy(encryptedBytes, 0, encryptedData, iv.Length, encryptedBytes.Length); - File.WriteAllBytes($"{_blobPath}{name}.{type}", encryptedData); - } - - private byte[] DecryptFile(string fileName, string type) + private byte[] GetDecryptedFile(string name) { - byte[] encryptedData = File.ReadAllBytes($"{_blobPath}{fileName}.{type}"); - byte[] keyBytes = Encoding.UTF8.GetBytes(_keyCrypt); - - byte[] iv = new byte[16]; - Buffer.BlockCopy(encryptedData, 0, iv, 0, iv.Length); - - byte[] decryptedBytes; - using (Aes aes = Aes.Create()) + if (!File.Exists($"{_blobPath}{name}")) { - aes.KeySize = 256; - aes.Key = keyBytes; - aes.IV = iv; - ICryptoTransform decryptor = aes.CreateDecryptor(); - decryptedBytes = decryptor.TransformFinalBlock(encryptedData, iv.Length, encryptedData.Length - iv.Length); + return null!; } - return decryptedBytes; + byte[] encryptedData = File.ReadAllBytes($"{_blobPath}{name}"); + return FileService.DecryptFile(encryptedData, _keyCrypt); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Streetcode.BLL.csproj b/Streetcode/Streetcode.BLL/Streetcode.BLL.csproj index 5277ebf..f33834f 100644 --- a/Streetcode/Streetcode.BLL/Streetcode.BLL.csproj +++ b/Streetcode/Streetcode.BLL/Streetcode.BLL.csproj @@ -18,6 +18,7 @@ + diff --git a/Streetcode/Streetcode.Resources/Messages.Designer.cs b/Streetcode/Streetcode.Resources/Messages.Designer.cs index 78f7adc..44483d3 100644 --- a/Streetcode/Streetcode.Resources/Messages.Designer.cs +++ b/Streetcode/Streetcode.Resources/Messages.Designer.cs @@ -203,6 +203,15 @@ public static string Error_InvalidMonobankTokenException { } } + /// + /// Looks up a localized string similar to {0} blob with BlobName: "{1}" not found. + /// + public static string Error_MediaBlobNotFound { + get { + return ResourceManager.GetString("Error_MediaBlobNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to News with Url: {0} not found. /// diff --git a/Streetcode/Streetcode.Resources/Messages.resx b/Streetcode/Streetcode.Resources/Messages.resx index 2c944d4..d6a4d49 100644 --- a/Streetcode/Streetcode.Resources/Messages.resx +++ b/Streetcode/Streetcode.Resources/Messages.resx @@ -204,4 +204,7 @@ {} data required + + {0} blob with BlobName: "{1}" not found + \ No newline at end of file diff --git a/Streetcode/Streetcode.Shared/Services/FileService.cs b/Streetcode/Streetcode.Shared/Services/FileService.cs new file mode 100644 index 0000000..4f0d3eb --- /dev/null +++ b/Streetcode/Streetcode.Shared/Services/FileService.cs @@ -0,0 +1,74 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Streetcode.Shared.Services; + +public static class FileService +{ + public static string HashFunction(string createdFileName) + { + using (var hash = SHA256.Create()) + { + Encoding enc = Encoding.UTF8; + byte[] result = hash.ComputeHash(enc.GetBytes(createdFileName)); + return Convert.ToBase64String(result).Replace('/', '_'); + } + } + + public static byte[] EncryptFile(byte[] imageBytes, string keyCrypt) + { + byte[] keyBytes = Encoding.UTF8.GetBytes(keyCrypt); + byte[] iv = new byte[16]; + + using (var rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(iv); + } + + using (Aes aes = Aes.Create()) + { + aes.KeySize = 256; + aes.Key = keyBytes; + aes.IV = iv; + + using (ICryptoTransform encryptor = aes.CreateEncryptor()) + { + byte[] encryptedBytes = encryptor.TransformFinalBlock(imageBytes, 0, imageBytes.Length); + + byte[] encryptedData = new byte[iv.Length + encryptedBytes.Length]; + Buffer.BlockCopy(iv, 0, encryptedData, 0, iv.Length); + Buffer.BlockCopy(encryptedBytes, 0, encryptedData, iv.Length, encryptedBytes.Length); + + return encryptedData; + } + } + } + + public static byte[] DecryptFile(byte[] encryptedData, string keyCrypt) + { + byte[] keyBytes = Encoding.UTF8.GetBytes(keyCrypt); + + byte[] iv = new byte[16]; + Buffer.BlockCopy(encryptedData, 0, iv, 0, iv.Length); + + byte[] decryptedBytes; + using (Aes aes = Aes.Create()) + { + aes.KeySize = 256; + aes.Key = keyBytes; + aes.IV = iv; + ICryptoTransform decryptor = aes.CreateDecryptor(); + decryptedBytes = decryptor.TransformFinalBlock(encryptedData, iv.Length, encryptedData.Length - iv.Length); + } + + return decryptedBytes; + } + + public static string PrepareFileStorageName(string name) + { + return $"{DateTime.Now}{name}" + .Replace(" ", "_") + .Replace(".", "_") + .Replace(":", "_"); + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.WebApi/Extensions/ConfigureHostBuilderExtensions.cs b/Streetcode/Streetcode.WebApi/Extensions/ConfigureHostBuilderExtensions.cs index 0624b45..cc50004 100644 --- a/Streetcode/Streetcode.WebApi/Extensions/ConfigureHostBuilderExtensions.cs +++ b/Streetcode/Streetcode.WebApi/Extensions/ConfigureHostBuilderExtensions.cs @@ -20,6 +20,7 @@ public static void ConfigureApplication(this ConfigureHostBuilder host) public static void ConfigureBlob(this IServiceCollection services, WebApplicationBuilder builder) { services.Configure(builder.Configuration.GetSection("Blob")); + services.Configure(builder.Configuration.GetSection("AzureBlobStorage")); } public static void ConfigurePayment(this IServiceCollection services, WebApplicationBuilder builder) diff --git a/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs b/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs index d48a9ae..997d359 100644 --- a/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using Streetcode.DAL.Persistence; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.DAL.Repositories.Realizations.Base; +using Microsoft.Extensions.Azure; namespace Streetcode.WebApi.Extensions; @@ -37,7 +38,10 @@ public static void AddRepositoryServices(this IServiceCollection services) services.AddScoped(); } - public static void AddCustomServices(this IServiceCollection services, IConfiguration configuration) + public static void AddCustomServices( + this IServiceCollection services, + IConfiguration configuration, + IWebHostEnvironment environment) { services.AddRepositoryServices(); services.AddFeatureManagement(); @@ -49,7 +53,20 @@ public static void AddCustomServices(this IServiceCollection services, IConfigur services.AddValidatorsFromAssemblies(currentAssemblies); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorBehavior<,>)); - services.AddScoped(); + if (environment.IsEnvironment("Local")) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + services.AddAzureClients(clientBuilder => + { + var connectionString = configuration.GetSection("AzureBlobStorage")["ConnectionString"]; + clientBuilder.AddBlobServiceClient(connectionString); + }); + } + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Streetcode/Streetcode.WebApi/Program.cs b/Streetcode/Streetcode.WebApi/Program.cs index 324e9dd..5cbc816 100644 --- a/Streetcode/Streetcode.WebApi/Program.cs +++ b/Streetcode/Streetcode.WebApi/Program.cs @@ -11,7 +11,7 @@ builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddSwaggerServices(); builder.Services.AddRedisCacheServices(builder.Configuration); -builder.Services.AddCustomServices(builder.Configuration); +builder.Services.AddCustomServices(builder.Configuration, builder.Environment); builder.Services.ConfigureBlob(builder); builder.Services.ConfigurePayment(builder); builder.Services.ConfigureInstagram(builder); @@ -48,7 +48,7 @@ wp => wp.ParseZipFileFromWebAsync(bypassSslValidation), TimeSpan.FromMinutes(1)); RecurringJob.AddOrUpdate( wp => wp.ParseZipFileFromWebAsync(bypassSslValidation), Cron.Monthly); - RecurringJob.AddOrUpdate( + RecurringJob.AddOrUpdate( b => b.CleanBlobStorage(), Cron.Monthly); } diff --git a/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj b/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj index 9dbc46f..1277290 100644 --- a/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj +++ b/Streetcode/Streetcode.WebApi/Streetcode.WebApi.csproj @@ -41,6 +41,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Streetcode/Streetcode.WebApi/appsettings.json b/Streetcode/Streetcode.WebApi/appsettings.json index 8c3822d..a28dc5d 100644 --- a/Streetcode/Streetcode.WebApi/appsettings.json +++ b/Streetcode/Streetcode.WebApi/appsettings.json @@ -41,6 +41,11 @@ "BlobStoreKey": "SlavaKasterovSuperGoodInshalaKey", "BlobStorePath": "../../BlobStorageFolder/" }, + "AzureBlobStorage": { + "ConnectionString": "azureblobconnectionstring", + "ContainerName": "default", + "EncryptionKey": "super__encryptionkey_32byteslong" + }, "Payment": { "Token": "BombasticTokenForMonobank" }, diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs index 3c97b3e..ccc8cf2 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs @@ -143,6 +143,27 @@ public async Task Handle_ReturnsFail_WhenArtsAreEmpty(int streetcodeId) result.Errors[0].Message); } + [Fact] + public async Task Handle_ReturnsFail_WhenBlobNotExists() + { + // Arrange + var streetcodeId = 1; + List arts = GetArtsList(); + + this.SetupArts(arts); + this.SetupBlobService(null); + + // Act + var result = await this.handler + .Handle(new GetArtsByStreetcodeIdQuery(streetcodeId), CancellationToken.None); + + // Assert + Assert.True(result.IsFailed); + Assert.Equal( + Messages.Error_MediaBlobNotFound.Format(nameof(Image), arts[0].Image.BlobName), + result.Errors[0].Message); + } + private void SetupArts(List? arts) { var artRepository = new Mock(); @@ -158,8 +179,9 @@ private void SetupArts(List? arts) private void SetupBlobService(string base64String) { - this.blobServiceMock.Setup(b => b.FindFileInStorageAsBase64(It.IsAny())) - .Returns(base64String); + this.blobServiceMock + .Setup(b => b.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync(base64String); } private static List GetArtsList() From 4af944d7d63f14466995c9fabb22ad0ec54e4277 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 26 Feb 2026 12:16:53 +0200 Subject: [PATCH 02/10] fix/SSAD-0099: fix news tests after adding aditional blob validations --- .../MediatR/News/GetAll/GetAllNewsHandler.cs | 1 + .../News/GetByUrl/GetNewsByUrlHandler.cs | 27 +- .../GetNewsAndLinksByUrlHandler.cs | 16 +- .../SortedByDateTimeHandler.cs | 1 + .../MediatR/News/Update/UpdateNewsHandler.cs | 66 ++-- .../MediatR/News/GetAllNewsHandlerTests.cs | 72 +++- .../News/GetNewsAndLinksByUrlHandlerTests.cs | 39 ++- .../MediatR/News/GetNewsByIdHandlerTests.cs | 44 ++- .../MediatR/News/GetNewsByUrlHandlerTests.cs | 58 +++- .../News/SortedByDateTimeHandlerTests.cs | 39 ++- .../MediatR/News/UpdateNewsHandlerTests.cs | 315 ++++++++++++++++-- 11 files changed, 573 insertions(+), 105 deletions(-) diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs index 9ca48de..b8376a2 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetAll/GetAllNewsHandler.cs @@ -51,6 +51,7 @@ public async Task>> Handle(GetAllNewsQuery request, if (imageBase64 is not null) { dto.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs index 62847f4..28905da 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetByUrl/GetNewsByUrlHandler.cs @@ -39,23 +39,24 @@ await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( return Result.Fail(errorMsg); } - if (newsDTO.Image is not null) + if (newsDTO.Image is null) { - var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); - if (imageBase64 is not null) - { - newsDTO.Image.Base64 = imageBase64; - } - - var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( - nameof(DAL.Entities.Media.Images.Image), - newsDTO.Image.BlobName); + return Result.Ok(newsDTO); + } - _logger.LogError(request, errorNotFoundMsg); - return Result.Fail(new Error(errorNotFoundMsg)); + var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); + if (imageBase64 is not null) + { + newsDTO.Image.Base64 = imageBase64; + return Result.Ok(newsDTO); } - return Result.Ok(newsDTO); + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + newsDTO.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); } } } \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs index 9707688..10a732b 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/GetNewsAndLinksByUrl/GetNewsAndLinksByUrlHandler.cs @@ -42,17 +42,17 @@ await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( if (newsDTO.Image is not null) { var imageBase64 = await _blobService.FindFileInStorageAsBase64(newsDTO.Image.BlobName); - if (imageBase64 is not null) + if (imageBase64 is null) { - newsDTO.Image.Base64 = imageBase64; - } + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + newsDTO.Image.BlobName); - var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( - nameof(DAL.Entities.Media.Images.Image), - newsDTO.Image.BlobName); + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); + } - _logger.LogError(request, errorNotFoundMsg); - return Result.Fail(new Error(errorNotFoundMsg)); + newsDTO.Image.Base64 = imageBase64; } var news = (await _repositoryWrapper.NewsRepository.GetAllAsync()).ToList(); diff --git a/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs index 649637c..c452efb 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/SortedByDateTime/SortedByDateTimeHandler.cs @@ -50,6 +50,7 @@ public async Task>> Handle(SortedByDateTimeQuery request, C if (imageBase64 is not null) { dto.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs index ecac0c6..7caf0ab 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs @@ -1,6 +1,7 @@ using AutoMapper; using FluentResults; using MediatR; +using Microsoft.EntityFrameworkCore; using Streetcode.BLL.DTO.News; using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.BLL.Interfaces.Logging; @@ -26,45 +27,72 @@ public UpdateNewsHandler(IRepositoryWrapper repositoryWrapper, IMapper mapper, I public async Task> Handle(UpdateNewsCommand request, CancellationToken cancellationToken) { - var news = _mapper.Map(request.News); - if (news is null) + if (request.News is null) { var errorConvertMsg = Messages.Error_ConvertNullToEntity.Format(nameof(DAL.Entities.News.News)); _logger.LogError(request, errorConvertMsg); return Result.Fail(new Error(errorConvertMsg)); } - var response = _mapper.Map(news); + var newsEntity = await _repositoryWrapper.NewsRepository.GetFirstOrDefaultAsync( + n => n.Id == request.News.Id, + x => x.Include(n => n.Image), + true); - if (news.Image is not null) + if (newsEntity is null) { - var imageBase64 = await _blobService.FindFileInStorageAsBase64(response.Image.BlobName); - if (imageBase64 is not null) - { - response.Image.Base64 = imageBase64; - } - - var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( - nameof(DAL.Entities.Media.Images.Image), - response.Image.BlobName); + var errorNotFoundMsg = Messages.Error_EntityWithIdNotFound.Format( + nameof(DAL.Entities.News.News), + request.News.Id); _logger.LogError(request, errorNotFoundMsg); return Result.Fail(new Error(errorNotFoundMsg)); } - var img = await _repositoryWrapper.ImageRepository - .GetFirstOrDefaultAsync(x => x.Id == response.ImageId); - if (img != null) + if (request.News.Image is not null && newsEntity.ImageId != request.News.ImageId) { - _repositoryWrapper.ImageRepository.Delete(img); + var img = await _repositoryWrapper.ImageRepository + .GetFirstOrDefaultAsync( + x => x.Id == request.News.Image.Id, + trackEntities: true); + + if (img is null) + { + var errorNotFoundMsg = Messages.Error_EntityWithIdNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + request.News.Image.Id); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(errorNotFoundMsg); + } + + var imageBase64 = await _blobService.FindFileInStorageAsBase64(request.News.Image.BlobName); + if (imageBase64 is null) + { + var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( + nameof(DAL.Entities.Media.Images.Image), + request.News.Image.BlobName); + + _logger.LogError(request, errorNotFoundMsg); + return Result.Fail(new Error(errorNotFoundMsg)); + } + + request.News.Image.Base64 = imageBase64; + _repositoryWrapper.ImageRepository.Delete(newsEntity.Image); } + else if (request.News.Image is null) + { + _repositoryWrapper.ImageRepository.Delete(newsEntity.Image); + } + + _mapper.Map(request.News, newsEntity); - _repositoryWrapper.NewsRepository.Update(news); + _repositoryWrapper.NewsRepository.Update(newsEntity); var resultIsSuccess = await _repositoryWrapper.SaveChangesAsync() > 0; if (resultIsSuccess) { - return Result.Ok(response); + return Result.Ok(_mapper.Map(newsEntity)); } var errorMsg = Messages.Error_FailedToUpdateEntity.Format(nameof(DAL.Entities.News.News)); diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetAllNewsHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetAllNewsHandlerTests.cs index 021214a..eb9d8ef 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetAllNewsHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetAllNewsHandlerTests.cs @@ -51,11 +51,11 @@ public async Task Handle_ShouldReturnFail_WhenNoNewsInDb() { // Arrange _repositoryWrapperMock.Setup(r => r.NewsRepository.GetAllAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false - )) - .ReturnsAsync([]); + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync([]); var request = new GetAllNewsQuery(); @@ -86,11 +86,11 @@ public async Task Handle_ShouldReturnNewsDto_WhenNewsFound() }; _repositoryWrapperMock.Setup(r => r.NewsRepository.GetAllAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false - )) - .ReturnsAsync(news); + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync(news); var request = new GetAllNewsQuery(); @@ -117,19 +117,18 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsFoundWithImage() URL = "https://github.com/", CreationDate = now, Image = new Image(), - }, }; _repositoryWrapperMock.Setup(r => r.NewsRepository.GetAllAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false - )) - .ReturnsAsync(news); + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync(news); _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(fakeBase); + .ReturnsAsync(fakeBase); var request = new GetAllNewsQuery(); @@ -140,5 +139,44 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsFoundWithImage() result.IsSuccess.Should().BeTrue(); result.Value.FirstOrDefault().Image.Base64.Should().Be(fakeBase); } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewsFoundWithImageButBlobNotExists() + { + // Arrange + var now = DateTime.Now; + + var news = new NewsEntity + { + Title = "Test Title", + Text = "Sample text", + URL = "https://github.com/", + CreationDate = now, + Image = new Image + { + BlobName = "BlobName", + }, + }; + + _repositoryWrapperMock.Setup(r => r.NewsRepository.GetAllAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync(new List { news }); + + _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string?)null); + + var request = new GetAllNewsQuery(); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle( + Messages.Error_MediaBlobNotFound.Format(nameof(Image), news.Image.BlobName)); + } } } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsAndLinksByUrlHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsAndLinksByUrlHandlerTests.cs index 058e654..b604915 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsAndLinksByUrlHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsAndLinksByUrlHandlerTests.cs @@ -97,7 +97,7 @@ public async Task Handle_ShouldReturnNewsDTOWithURLsAndImage_WhenNewsExists() .ReturnsAsync(allNews); _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(expectedBase64); + .ReturnsAsync(expectedBase64); var request = new GetNewsAndLinksByUrlQuery(url); @@ -274,5 +274,42 @@ public async Task Handle_ShouldReturnRandomNewsMinusTwo_WhenNewsIsLastAndCountGr res.Value.RandomNews.RandomNewsUrl.Should().Be(allNews[1].URL); res.Value.RandomNews.Title.Should().Be(allNews[1].Title); } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewsFoundWithImageButBlobNotExists() + { + // Arrange + var now = DateTime.Now; + + var news = new NewsEntity + { + Title = "Test Title", + Text = "Sample text", + URL = "https://github.com/", + CreationDate = now, + Image = new Image + { + BlobName = "BlobName", + }, + }; + + var url = "https://github.com/"; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false)) + .ReturnsAsync(news); + + _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string?)null); + + // Act + var res = await _handler.Handle(new GetNewsAndLinksByUrlQuery(url), CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle(Messages.Error_MediaBlobNotFound.Format(nameof(Image), news.Image.BlobName)); + } } } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByIdHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByIdHandlerTests.cs index ec1b3ef..0f0266c 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByIdHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByIdHandlerTests.cs @@ -129,7 +129,7 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsExistByIdWithImage .ReturnsAsync(news); _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(fakeBase); + .ReturnsAsync(fakeBase); var request = new GetNewsByIdQuery(id); @@ -140,5 +140,47 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsExistByIdWithImage result.IsSuccess.Should().BeTrue(); result.Value.Image.Base64.Should().Be(fakeBase); } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewsExistsByIdWithImageButBlobNotExists() + { + // Arrange + int id = 1; + + var now = DateTime.Now; + + var news = new NewsEntity + { + Id = id, + Title = "Test Title", + Text = "Sample text", + URL = "https://github.com/", + CreationDate = now, + Image = new Image + { + BlobName = "BlobName", + }, + }; + + _repositoryWrapperMock.Setup(r => r.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync(news); + + _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string?)null); + + var request = new GetNewsByIdQuery(id); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should() + .ContainSingle(Messages.Error_MediaBlobNotFound.Format(nameof(Image), news.Image.BlobName)); + } } } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByUrlHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByUrlHandlerTests.cs index c66e59b..f71697b 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByUrlHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/GetNewsByUrlHandlerTests.cs @@ -54,9 +54,9 @@ public async Task Handle_ShouldReturnFail_WhenNewsNotExists() var request = new GetNewsByUrlQuery(url); _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false)) + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false)) .ReturnsAsync((NewsEntity)null); // Act @@ -87,13 +87,13 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsWithImageExists() var request = new GetNewsByUrlQuery(url); _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false)) + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false)) .ReturnsAsync(news); _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(expectedBase64); + .ReturnsAsync(expectedBase64); // Act var res = await _handler.Handle(request, CancellationToken.None); @@ -120,13 +120,13 @@ public async Task Handle_ShouldReturnNewsDtoWithOutImage_WhenNewsExistsButImageN CreationDate = now, }; - var url = "test"; + var url = "https://github.com/"; var request = new GetNewsByUrlQuery(url); _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( - It.IsAny>>(), - It.IsAny, IIncludableQueryable>>(), - false)) + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false)) .ReturnsAsync(news); // Act @@ -138,5 +138,41 @@ public async Task Handle_ShouldReturnNewsDtoWithOutImage_WhenNewsExistsButImageN bs => bs.FindFileInStorageAsBase64(It.IsAny()), Times.Never); } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewsWithImageExistsButBlobNotExists() + { + // Arrange + var now = DateTime.Now; + + var news = new NewsEntity + { + Title = "Test Title", + Text = "Sample text", + URL = "https://github.com/", + CreationDate = now, + Image = new Image + { + BlobName = "BlobName", + }, + }; + + var url = "https://github.com/"; + var request = new GetNewsByUrlQuery(url); + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false)) + .ReturnsAsync(news); + + // Act + var res = await _handler.Handle(request, CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle( + Messages.Error_MediaBlobNotFound.Format(nameof(Image), news.Image.BlobName)); + } } } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/SortedByDateTimeHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/SortedByDateTimeHandlerTests.cs index 2fe940f..0da9df7 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/SortedByDateTimeHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/SortedByDateTimeHandlerTests.cs @@ -92,7 +92,7 @@ public async Task Handle_ShouldReturnSortedNewsDtosWithImage_WhenNewsExists() .ReturnsAsync(allNews); _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(expectedBase64); + .ReturnsAsync(expectedBase64); var request = new SortedByDateTimeQuery(); @@ -142,5 +142,42 @@ public async Task Handle_ShouldReturnSortedNewsDtosWithOutImage_WhenNewsExists() Times.Never); res.Value.Should().BeInDescendingOrder(x => x.CreationDate); } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewsFoundWithImageButBlobNotExists() + { + // Arrange + var newsWithImage = new NewsEntity + { + Id = 1, + URL = "url1", + CreationDate = DateTime.Now, + Image = new Image + { + BlobName = "BlobName", + }, + }; + + _repositoryWrapperMock.Setup(r => r.NewsRepository.GetAllAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + false + )) + .ReturnsAsync(new List { newsWithImage }); + + _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string)null); + + var request = new SortedByDateTimeQuery(); + + // Act + var res = await _handler.Handle(request, CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle(Messages.Error_MediaBlobNotFound.Format( + nameof(Image), + newsWithImage.Image.BlobName)); + } } } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs index 49e5da1..d798f61 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs @@ -11,6 +11,7 @@ using Streetcode.DAL.Entities.Media.Images; using Streetcode.DAL.Repositories.Interfaces.Base; using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; using Streetcode.Resources; using Streetcode.Shared.Extensions; using Xunit; @@ -48,7 +49,7 @@ public UpdateNewsHandlerTests() } [Fact] - public async Task Handle_ShouldReturnFail_WhenNoNews() + public async Task Handle_ShouldReturnFail_WhenDTOIsNull() { // Arrange var req = new UpdateNewsCommand(null); @@ -63,7 +64,7 @@ public async Task Handle_ShouldReturnFail_WhenNoNews() } [Fact] - public async Task Handle_ShouldReturnFail_WhenCouldntUpdateNews() + public async Task Handle_ShouldReturnFail_WhenNewsWithIdNotFound() { // Arrange var newsDto = new NewsDTO @@ -73,16 +74,65 @@ public async Task Handle_ShouldReturnFail_WhenCouldntUpdateNews() ImageId = 0, }; - _repositoryWrapperMock.Setup(repo => repo.SaveChangesAsync()) - .ReturnsAsync(0); + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + null, + false)) + .ReturnsAsync((NewsEntity)null); - _repositoryWrapperMock.Setup(repo => repo.NewsRepository.Update(It.IsAny())); + var req = new UpdateNewsCommand(newsDto); + + // Act + var res = await _handler.Handle(req, CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle(Messages.Error_EntityWithIdNotFound.Format( + nameof(DAL.Entities.News.News), newsDto.Id)); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenCouldntUpdateNews() + { + // Arrange + var fakeBase = "fake_base_64"; + var newsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 1, + Image = new ImageDTO + { + Id = 1, + }, + }; + + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + }; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( - It.IsAny>>(), - null, - false)) - .ReturnsAsync((Image)null); + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync((Image)null!); + + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync(fakeBase); + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.Update(It.IsAny())); + + _repositoryWrapperMock.Setup(repo => repo.SaveChangesAsync()) + .ReturnsAsync(0); var req = new UpdateNewsCommand(newsDto); @@ -95,69 +145,263 @@ public async Task Handle_ShouldReturnFail_WhenCouldntUpdateNews() repo => repo.NewsRepository.Update(It.IsAny()), Times.Once()); + _repositoryWrapperMock.Verify( + repo => repo.ImageRepository.Delete(It.IsAny()), + Times.Never); + res.Errors.Should().ContainSingle(Messages.Error_FailedToUpdateEntity.Format( nameof(DAL.Entities.News.News))); } [Fact] - public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewsAndImageExist() + public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewImageExists() { // Arrange - var fakeBase = "fake_base64"; - + var fakeBase = "fake_base_64"; var newsDto = new NewsDTO { Id = 1, URL = "url1", - ImageId = 0, - Image = new ImageDTO(), + ImageId = 2, + Image = new ImageDTO + { + Id = 2, + }, }; - _blobServiceMock.Setup(bs => bs.FindFileInStorageAsBase64(It.IsAny())) - .Returns(fakeBase); + var expectedNewsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + Image = new ImageDTO + { + Id = 2, + Base64 = fakeBase, + }, + }; - _repositoryWrapperMock.Setup(repo => repo.SaveChangesAsync()) - .ReturnsAsync(1); + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + Image = new Image + { + Id = 1, + }, + }; - _repositoryWrapperMock.Setup(repo => repo.NewsRepository.Update(It.IsAny())); + var newImage = new Image { Id = 2 }; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); + + _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(newImage); + + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync(fakeBase); + + _repositoryWrapperMock + .Setup(repo => repo.ImageRepository.Delete(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.NewsRepository.Update(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.SaveChangesAsync()) + .ReturnsAsync(1); var req = new UpdateNewsCommand(newsDto); // Act var res = await _handler.Handle(req, CancellationToken.None); - // Assert res.IsSuccess.Should().BeTrue(); _repositoryWrapperMock.Verify( repo => repo.NewsRepository.Update(It.IsAny()), Times.Once()); - res.Value.Image.Base64.Should().Be(fakeBase); + _repositoryWrapperMock.Verify( + repo => repo.ImageRepository.Delete(It.IsAny()), + Times.Once()); + + res.Value.Should().BeEquivalentTo(expectedNewsDto); } [Fact] - public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull_AndOldImageExists() + public async Task Handle_ShouldReturnFail_WhenNewImageNotExists() { // Arrange var newsDto = new NewsDTO { Id = 1, URL = "url1", - ImageId = 10, - Image = null, + ImageId = 2, + Image = new ImageDTO + { + Id = 2, + }, + }; + + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + Image = new Image + { + Id = 1, + }, }; - var oldImage = new Image { Id = 10 }; + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( - It.IsAny>>(), - null, - false)) - .ReturnsAsync(oldImage); + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync((Image)null!); - _repositoryWrapperMock.Setup(repo => repo.NewsRepository.Update(It.IsAny())); - _repositoryWrapperMock.Setup(repo => repo.SaveChangesAsync()).ReturnsAsync(1); + var req = new UpdateNewsCommand(newsDto); + + // Act + var res = await _handler.Handle(req, CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle(Messages.Error_EntityWithIdNotFound.Format( + nameof(Image), + newsDto.ImageId)); + } + + [Fact] + public async Task Handle_ShouldReturnFail_WhenNewImageExistsButBlobNotExists() + { + // Arrange + var newsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + Image = new ImageDTO + { + Id = 2, + }, + }; + + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + Image = new Image + { + Id = 1, + }, + }; + + var newImage = new Image + { + Id = 2, + BlobName = "BlobName", + }; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); + + _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(newImage); + + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string?)null); + + var req = new UpdateNewsCommand(newsDto); + + // Act + var res = await _handler.Handle(req, CancellationToken.None); + + // Assert + res.IsFailed.Should().BeTrue(); + res.Errors.Should().ContainSingle(Messages.Error_MediaBlobNotFound.Format( + nameof(Image), + newImage.BlobName)); + } + + [Fact] + public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull() + { + // Arrange + var fakeBase = "fake_base_64"; + var newsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + }; + + var expectedNewsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + }; + + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + Image = new Image + { + Id = 1, + }, + }; + + var newImage = new Image { Id = 2 }; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); + + _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(newImage); + + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync(fakeBase); + + _repositoryWrapperMock + .Setup(repo => repo.ImageRepository.Delete(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.NewsRepository.Update(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.SaveChangesAsync()) + .ReturnsAsync(1); var req = new UpdateNewsCommand(newsDto); @@ -166,12 +410,15 @@ public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull_AndOldImageExis // Assert res.IsSuccess.Should().BeTrue(); + _repositoryWrapperMock.Verify( + repo => repo.NewsRepository.Update(It.IsAny()), + Times.Once()); _repositoryWrapperMock.Verify( - repo => repo.ImageRepository.Delete(oldImage), - Times.Once); + repo => repo.ImageRepository.Delete(It.IsAny()), + Times.Once()); - _repositoryWrapperMock.Verify(repo => repo.NewsRepository.Update(It.IsAny()), Times.Once); + res.Value.Should().BeEquivalentTo(expectedNewsDto); } } } From 0270e56efb23a38ee98ec4bea053243060b0af74 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 26 Feb 2026 14:21:52 +0200 Subject: [PATCH 03/10] fix\SSAD-0099: use modern random bytes generator instead of obsolete one --- Streetcode/Streetcode.Shared/Services/FileService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Streetcode/Streetcode.Shared/Services/FileService.cs b/Streetcode/Streetcode.Shared/Services/FileService.cs index 4f0d3eb..5cbdc6d 100644 --- a/Streetcode/Streetcode.Shared/Services/FileService.cs +++ b/Streetcode/Streetcode.Shared/Services/FileService.cs @@ -20,7 +20,7 @@ public static byte[] EncryptFile(byte[] imageBytes, string keyCrypt) byte[] keyBytes = Encoding.UTF8.GetBytes(keyCrypt); byte[] iv = new byte[16]; - using (var rng = new RNGCryptoServiceProvider()) + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(iv); } From 2f7a79c2b8501a237e5be0ed38dae0661a5ef9c0 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Fri, 27 Feb 2026 15:28:02 +0200 Subject: [PATCH 04/10] Refactor blob storage cleanup, error handling, and tests - Optimize AzureBlobService.CleanBlobStorage with batching and logging; delete only unreferenced blobs - Use EncryptBytes/DecryptBytes for clearer encryption logic - Add early continue in MediatR handlers after successful Base64 retrieval - Update UpdateNewsHandler to avoid deleting images linked to Facts - Improve and expand unit tests for blob and news update logic - Register blob cleanup Hangfire job via IBlobService interface - Add StyleCop suppressions and enhance logging throughout blob services #99 --- .../GetArtsByStreetcodeIdHandler.cs | 1 + .../Media/Audio/GetAll/GetAllAudiosHandler.cs | 1 + .../Media/Image/GetAll/GetAllImagesHandler.cs | 1 + .../GetImageByStreetcodeIdHandler.cs | 1 + .../GetStreetcodeArtByStreetcodeIdHandler.cs | 1 + .../MediatR/News/Update/UpdateNewsHandler.cs | 30 ++++- .../GetAll/GetAllCategoriesHandler.cs | 1 + .../GetCategoriesByStreetcodeIdHandler.cs | 1 + .../BlobStorageService/AzureBlobService.cs | 78 +++++++++--- .../BlobStorageService/BlobService.cs | 16 ++- .../Streetcode.Shared/Services/FileService.cs | 6 +- Streetcode/Streetcode.WebApi/Program.cs | 11 +- .../Art/GetArtsByStreetcodeIdHandlerTests.cs | 2 +- .../Audio/Create/CreateAudioHandlerTests.cs | 5 +- .../Audio/Get/GetAllAudiosHandlerTests.cs | 55 ++++++-- .../Audio/Get/GetAudioByIdHandlerTests.cs | 2 +- .../Get/GetAudioByStreetcodeIdHandlerTests.cs | 2 +- .../Audio/Get/GetBaseAudioHandlerTests.cs | 2 +- .../MediatR/News/UpdateNewsHandlerTests.cs | 120 +++++++++++++++++- 19 files changed, 287 insertions(+), 49 deletions(-) diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs index 57b83fe..f3cb9f7 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Art/GetByStreetcodeId/GetArtsByStreetcodeIdHandler.cs @@ -61,6 +61,7 @@ public async Task>> Handle(GetArtsByStreetcodeIdQuery if (imageBase64 is not null) { artDto.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs index 0ca9f1e..72b07c6 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Audio/GetAll/GetAllAudiosHandler.cs @@ -44,6 +44,7 @@ public async Task>> Handle(GetAllAudiosQuery reques if (audioBase64 is not null) { audio.Base64 = audioBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs index 5eca0bd..5c6c382 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetAll/GetAllImagesHandler.cs @@ -44,6 +44,7 @@ public async Task>> Handle(GetAllImagesQuery reques if (imageBase64 is not null) { image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs index ca992ca..effce5f 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/Image/GetByStreetcodeId/GetImageByStreetcodeIdHandler.cs @@ -56,6 +56,7 @@ public async Task>> Handle(GetImageByStreetcodeIdQu if (imageBase64 is not null) { image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs index f930ab0..d34f6f4 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Media/StreetcodeArt/GetByStreetcodeId/GetStreetcodeArtByStreetcodeIdHandler.cs @@ -57,6 +57,7 @@ public async Task>> Handle(GetStreetcodeArt if (imageBase64 is not null) { artDto.Art.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs index 7caf0ab..5d739f0 100644 --- a/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/News/Update/UpdateNewsHandler.cs @@ -5,6 +5,7 @@ using Streetcode.BLL.DTO.News; using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.BLL.Interfaces.Logging; +using Streetcode.DAL.Entities.Media.Images; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Resources; using Streetcode.Shared.Extensions; @@ -49,6 +50,7 @@ public async Task> Handle(UpdateNewsCommand request, Cancellatio return Result.Fail(new Error(errorNotFoundMsg)); } + var isImageDeleted = false; if (request.News.Image is not null && newsEntity.ImageId != request.News.ImageId) { var img = await _repositoryWrapper.ImageRepository @@ -78,13 +80,14 @@ public async Task> Handle(UpdateNewsCommand request, Cancellatio } request.News.Image.Base64 = imageBase64; - _repositoryWrapper.ImageRepository.Delete(newsEntity.Image); + isImageDeleted = await DeleteImageAsync(newsEntity.Image); } else if (request.News.Image is null) { - _repositoryWrapper.ImageRepository.Delete(newsEntity.Image); + isImageDeleted = await DeleteImageAsync(newsEntity.Image); } + var oldBlobName = newsEntity.Image?.BlobName; _mapper.Map(request.News, newsEntity); _repositoryWrapper.NewsRepository.Update(newsEntity); @@ -92,6 +95,11 @@ public async Task> Handle(UpdateNewsCommand request, Cancellatio if (resultIsSuccess) { + if (isImageDeleted) + { + await _blobService.DeleteFileInStorage(oldBlobName); + } + return Result.Ok(_mapper.Map(newsEntity)); } @@ -99,5 +107,23 @@ public async Task> Handle(UpdateNewsCommand request, Cancellatio _logger.LogError(request, errorMsg); return Result.Fail(new Error(errorMsg)); } + + private async Task DeleteImageAsync(Image? image) + { + if (image is null) + { + return false; + } + + var fact = await _repositoryWrapper.FactRepository.GetFirstOrDefaultAsync(f => f.ImageId == image.Id); + + if (fact is not null) + { + return false; + } + + _repositoryWrapper.ImageRepository.Delete(image); + return true; + } } } diff --git a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs index 4b9a88b..5d45a6b 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetAll/GetAllCategoriesHandler.cs @@ -48,6 +48,7 @@ public async Task>> Handle(GetAllCateg if (imageBase64 is not null) { dto.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs index 3f200ba..756e5c5 100644 --- a/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs +++ b/Streetcode/Streetcode.BLL/MediatR/Sources/SourceLinkCategory/GetCategoriesByStreetcodeId/GetCategoriesByStreetcodeIdHandler.cs @@ -58,6 +58,7 @@ public async Task>> Handle( if (imageBase64 is not null) { srcCategory.Image.Base64 = imageBase64; + continue; } var errorNotFoundMsg = Messages.Error_MediaBlobNotFound.Format( diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs index 506dfe4..2c1b904 100644 --- a/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/AzureBlobService.cs @@ -1,7 +1,9 @@ -using Azure; +using System.Diagnostics.CodeAnalysis; +using Azure; using Azure.Storage.Blobs; using Microsoft.Extensions.Options; using Streetcode.BLL.Interfaces.BlobStorage; +using Streetcode.BLL.Interfaces.Logging; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Shared.Services; @@ -12,15 +14,18 @@ public class AzureBlobService : IBlobService private readonly AzureBlobEnvironmentVariables _options; private readonly IRepositoryWrapper _repositoryWrapper; private readonly BlobServiceClient _blobServiceClient; + private readonly ILoggerService _logger; public AzureBlobService( IOptions options, IRepositoryWrapper repositoryWrapper, - BlobServiceClient blobServiceClient) + BlobServiceClient blobServiceClient, + ILoggerService logger) { _options = options.Value; _repositoryWrapper = repositoryWrapper; _blobServiceClient = blobServiceClient; + _logger = logger; } public async Task SaveFileInStorage(string base64, string name, string extension) @@ -31,7 +36,7 @@ public async Task SaveFileInStorage(string base64, string name, string e string hashBlobName = FileService.HashFunction(createdFileName); string fullBlobName = $"{hashBlobName}.{extension}"; - byte[] encryptedData = FileService.EncryptFile(imageBytes, _options.EncryptionKey); + byte[] encryptedData = FileService.EncryptBytes(imageBytes, _options.EncryptionKey); var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); await containerClient.CreateIfNotExistsAsync(); @@ -78,37 +83,74 @@ public async Task DeleteFileInStorage(string name) { BlobContainerClient containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); BlobClient blobClient = containerClient.GetBlobClient(name); + _logger.LogInformation($"Deleting {name} from Azure..."); await blobClient.DeleteIfExistsAsync(); } public async Task CleanBlobStorage() { + const int BatchSize = 250; var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); - List blobsInAzure = new (); + List currentBatch = new (); + await foreach (var blobItem in containerClient.GetBlobsAsync()) { - blobsInAzure.Add(blobItem.Name); + currentBatch.Add(blobItem.Name); + + // When we hit our limit, process the batch + if (currentBatch.Count >= BatchSize) + { + await ProcessBatch(currentBatch, containerClient); + currentBatch.Clear(); + } } - var existingImages = await _repositoryWrapper.ImageRepository.GetAllAsync(); - var existingAudios = await _repositoryWrapper.AudioRepository.GetAllAsync(); + if (currentBatch.Any()) + { + await ProcessBatch(currentBatch, containerClient); + } + } - var existingMedia = existingImages.Select(img => img.BlobName) - .Concat(existingAudios.Select(aud => aud.BlobName)) - .ToHashSet(); // HashSet makes the lookup/Except logic much faster + private async Task ProcessBatch(List blobNames, BlobContainerClient container) + { + try + { + var foundInImages = (await _repositoryWrapper.ImageRepository + .GetAllAsync(img => blobNames.Contains(img.BlobName))) + .Select(img => img.BlobName) + .ToList(); + + var foundInAudios = (await _repositoryWrapper.AudioRepository + .GetAllAsync(aud => blobNames.Contains(aud.BlobName))) + .Select(aud => aud.BlobName) + .ToList(); + + var existingInDb = foundInImages.Concat(foundInAudios).ToHashSet(); - var filesToRemove = blobsInAzure.Except(existingMedia).ToList(); + var orphans = blobNames.Except(existingInDb); - foreach (var fileName in filesToRemove) + foreach (var orphan in orphans) + { + try + { + _logger.LogInformation($"Deleting orphaned blob: {orphan}"); + await container.GetBlobClient(orphan).DeleteIfExistsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete blob {orphan}. Skipping..."); + } + } + } + catch (Exception ex) { - Console.WriteLine($"Deleting {fileName} from Azure..."); - var blobClient = containerClient.GetBlobClient(fileName); - await blobClient.DeleteIfExistsAsync(); + _logger.LogError(ex, $"Error during blob cleanup: {ex.Message}"); } } - private async Task FindFileInStorageAsBytes(string name) + [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1011:ClosingSquareBracketsMustBeSpacedCorrectly", Justification = "Reviewed.")] + private async Task FindFileInStorageAsBytes(string name) { var containerClient = _blobServiceClient.GetBlobContainerClient(_options.ContainerName); @@ -120,13 +162,13 @@ private async Task FindFileInStorageAsBytes(string name) await blobClient.DownloadToAsync(ms); byte[] encryptedBytes = ms.ToArray(); - return FileService.DecryptFile(encryptedBytes, _options.EncryptionKey); + return FileService.DecryptBytes(encryptedBytes, _options.EncryptionKey); } catch (RequestFailedException ex) { if (ex.Status == 404) { - return null!; + return null; } throw; diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs index 5a28c93..fbf13d1 100644 --- a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Options; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Shared.Services; @@ -54,7 +55,7 @@ public Task SaveFileInStorage(string base64, string name, string extensi string hashBlobStorageName = FileService.HashFunction(createdFileName); Directory.CreateDirectory(_blobPath); - byte[] encryptedData = FileService.EncryptFile(imageBytes, _keyCrypt); + byte[] encryptedData = FileService.EncryptBytes(imageBytes, _keyCrypt); File.WriteAllBytes($"{_blobPath}{hashBlobStorageName}.{extension}", encryptedData); return Task.FromResult(hashBlobStorageName); @@ -64,8 +65,8 @@ public Task SaveFileInStorageBase64(string base64, string name, string extension { byte[] imageBytes = Convert.FromBase64String(base64); Directory.CreateDirectory(_blobPath); - FileService.EncryptFile(imageBytes, _keyCrypt); - File.WriteAllBytes($"{_blobPath}{name}.{extension}", imageBytes); + var encryptedBytes = FileService.EncryptBytes(imageBytes, _keyCrypt); + File.WriteAllBytes($"{_blobPath}{name}.{extension}", encryptedBytes); return Task.CompletedTask; } @@ -118,14 +119,15 @@ private IEnumerable GetAllBlobNames() return paths.Select(p => Path.GetFileName(p)); } - private byte[] GetDecryptedFile(string name) + [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1011:ClosingSquareBracketsMustBeSpacedCorrectly", Justification = "Reviewed.")] + private byte[]? GetDecryptedFile(string name) { if (!File.Exists($"{_blobPath}{name}")) { - return null!; + return null; } byte[] encryptedData = File.ReadAllBytes($"{_blobPath}{name}"); - return FileService.DecryptFile(encryptedData, _keyCrypt); + return FileService.DecryptBytes(encryptedData, _keyCrypt); } } \ No newline at end of file diff --git a/Streetcode/Streetcode.Shared/Services/FileService.cs b/Streetcode/Streetcode.Shared/Services/FileService.cs index 5cbdc6d..5e856a8 100644 --- a/Streetcode/Streetcode.Shared/Services/FileService.cs +++ b/Streetcode/Streetcode.Shared/Services/FileService.cs @@ -15,7 +15,7 @@ public static string HashFunction(string createdFileName) } } - public static byte[] EncryptFile(byte[] imageBytes, string keyCrypt) + public static byte[] EncryptBytes(byte[] plainBytes, string keyCrypt) { byte[] keyBytes = Encoding.UTF8.GetBytes(keyCrypt); byte[] iv = new byte[16]; @@ -33,7 +33,7 @@ public static byte[] EncryptFile(byte[] imageBytes, string keyCrypt) using (ICryptoTransform encryptor = aes.CreateEncryptor()) { - byte[] encryptedBytes = encryptor.TransformFinalBlock(imageBytes, 0, imageBytes.Length); + byte[] encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); byte[] encryptedData = new byte[iv.Length + encryptedBytes.Length]; Buffer.BlockCopy(iv, 0, encryptedData, 0, iv.Length); @@ -44,7 +44,7 @@ public static byte[] EncryptFile(byte[] imageBytes, string keyCrypt) } } - public static byte[] DecryptFile(byte[] encryptedData, string keyCrypt) + public static byte[] DecryptBytes(byte[] encryptedData, string keyCrypt) { byte[] keyBytes = Encoding.UTF8.GetBytes(keyCrypt); diff --git a/Streetcode/Streetcode.WebApi/Program.cs b/Streetcode/Streetcode.WebApi/Program.cs index 5cbc816..c72ee7c 100644 --- a/Streetcode/Streetcode.WebApi/Program.cs +++ b/Streetcode/Streetcode.WebApi/Program.cs @@ -1,4 +1,5 @@ using Hangfire; +using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.BLL.Services.BlobStorageService; using Streetcode.WebApi.Extensions; using Streetcode.WebApi.Middleware; @@ -47,9 +48,13 @@ BackgroundJob.Schedule( wp => wp.ParseZipFileFromWebAsync(bypassSslValidation), TimeSpan.FromMinutes(1)); RecurringJob.AddOrUpdate( - wp => wp.ParseZipFileFromWebAsync(bypassSslValidation), Cron.Monthly); - RecurringJob.AddOrUpdate( - b => b.CleanBlobStorage(), Cron.Monthly); + "parse-zip-file-job", + wp => wp.ParseZipFileFromWebAsync(bypassSslValidation), + Cron.Monthly); + RecurringJob.AddOrUpdate( + "clean-blobs-job", + b => b.CleanBlobStorage(), + Cron.Monthly); } app.MapControllers(); diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs index ccc8cf2..f28aa7f 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Art/GetArtsByStreetcodeIdHandlerTests.cs @@ -160,7 +160,7 @@ public async Task Handle_ReturnsFail_WhenBlobNotExists() // Assert Assert.True(result.IsFailed); Assert.Equal( - Messages.Error_MediaBlobNotFound.Format(nameof(Image), arts[0].Image.BlobName), + Messages.Error_MediaBlobNotFound.Format(nameof(Image), arts[0].Image!.BlobName!), result.Errors[0].Message); } diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Create/CreateAudioHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Create/CreateAudioHandlerTests.cs index f119093..06c8c36 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Create/CreateAudioHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Create/CreateAudioHandlerTests.cs @@ -56,7 +56,7 @@ public async Task Handle_ValidData_ReturnsOkResult() this.mockBlobService .Setup(b => b.SaveFileInStorage(createDto.BaseFormat, createDto.Title, createDto.Extension)) - .Returns(hashName); + .ReturnsAsync(hashName); this.mockRepositoryWrapper .Setup(r => r.AudioRepository.CreateAsync(It.IsAny())) @@ -103,7 +103,7 @@ public async Task Handle_DatabaseSaveFails_ReturnsFailResult() this.mockBlobService .Setup(b => b.SaveFileInStorage(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns("some-hash"); + .ReturnsAsync("some-hash"); this.mockRepositoryWrapper .Setup(r => r.AudioRepository.CreateAsync(It.IsAny())) @@ -121,7 +121,6 @@ public async Task Handle_DatabaseSaveFails_ReturnsFailResult() // Assert result.IsFailed.Should().BeTrue(); result.Errors.First().Message.Should().Be(expectedErrorMsg); - } } } \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAllAudiosHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAllAudiosHandlerTests.cs index 8761bfd..e9f36e2 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAllAudiosHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAllAudiosHandlerTests.cs @@ -1,4 +1,8 @@ -namespace Streetcode.XUnitTest.MediatR.Media.Audio.Get { +using Streetcode.Shared.Extensions; + +namespace Streetcode.XUnitTest.MediatR.Media.Audio.Get +{ + using System.Linq.Expressions; using AutoMapper; using FluentAssertions; using Microsoft.EntityFrameworkCore.Query; @@ -10,7 +14,6 @@ using Streetcode.BLL.MediatR.Media.Audio.GetAll; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Resources; - using System.Linq.Expressions; using Xunit; using AudioEntity = Streetcode.DAL.Entities.Media.Audio; @@ -71,10 +74,10 @@ public async Task Handle_AudiosExist_ReturnsOkResultWithBase64() // Arrange var query = new GetAllAudiosQuery(); var audios = new List - { - new () { Id = 1, BlobName = "audio1.mp3" }, - new () { Id = 2, BlobName = "audio2.mp3" } - }; + { + new () { Id = 1, BlobName = "audio1.mp3" }, + new () { Id = 2, BlobName = "audio2.mp3" } + }; this.mockRepositoryWrapper .Setup(r => r.AudioRepository.GetAllAsync( @@ -85,7 +88,7 @@ public async Task Handle_AudiosExist_ReturnsOkResultWithBase64() this.mockBlobService .Setup(b => b.FindFileInStorageAsBase64(It.IsAny())) - .Returns((string blobName) => $"base64-content-of-{blobName}"); + .ReturnsAsync((string blobName) => $"base64-content-of-{blobName}"); // Act var result = await this.handler.Handle(query, CancellationToken.None); @@ -107,7 +110,11 @@ public async Task Handle_ReturnsCorrectType() It.IsAny>>(), It.IsAny, IIncludableQueryable>>(), It.IsAny())) - .ReturnsAsync(new List { new() }); + .ReturnsAsync(new List { new () }); + + this.mockBlobService + .Setup(b => b.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync("base64"); // Act var result = await this.handler.Handle(new GetAllAudiosQuery(), CancellationToken.None); @@ -115,5 +122,37 @@ public async Task Handle_ReturnsCorrectType() // Assert result.Value.Should().BeAssignableTo>(); } + + [Fact] + public async Task Handle_BlobNotFound_ReturnsFailResult() + { + // Arrange + var query = new GetAllAudiosQuery(); + var audios = new List + { + new () { Id = 1, BlobName = "audio1.mp3" }, + new () { Id = 2, BlobName = "audio2.mp3" }, + }; + + this.mockRepositoryWrapper + .Setup(r => r.AudioRepository.GetAllAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(audios); + + this.mockBlobService + .Setup(b => b.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync((string blobName) => null); + + // Act + var result = await this.handler.Handle(query, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(Messages.Error_MediaBlobNotFound.Format( + nameof(AudioEntity), + audios[0].BlobName!)); + } } } \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByIdHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByIdHandlerTests.cs index 701472f..0059f10 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByIdHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByIdHandlerTests.cs @@ -62,7 +62,7 @@ public async Task Handle_AudioExists_ReturnsOkResultWithAudioDTO() this.mockBlobService .Setup(b => b.FindFileInStorageAsBase64(audioEntity.BlobName)) - .Returns(expectedBase64); + .ReturnsAsync(expectedBase64); // Act var result = await this.handler.Handle(query, CancellationToken.None); diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByStreetcodeIdHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByStreetcodeIdHandlerTests.cs index 23f6267..6524d62 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByStreetcodeIdHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetAudioByStreetcodeIdHandlerTests.cs @@ -88,7 +88,7 @@ public async Task Handle_AudioExists_ReturnsOkResultWithBase64() this.mockBlobService .Setup(b => b.FindFileInStorageAsBase64(audioEntity.BlobName)) - .Returns(expectedBase64); + .ReturnsAsync(expectedBase64); // Act var result = await this.handler.Handle(query, CancellationToken.None); diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetBaseAudioHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetBaseAudioHandlerTests.cs index 7c453ef..3843c08 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetBaseAudioHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/Media/Audio/Get/GetBaseAudioHandlerTests.cs @@ -49,7 +49,7 @@ public async Task Handle_AudioExists_ReturnsMemoryStream() this.mockBlobService .Setup(b => b.FindFileInStorageAsMemoryStream(audioEntity.BlobName)) - .Returns(memoryStream); + .ReturnsAsync(memoryStream); // Act var result = await this.handler.Handle(query, CancellationToken.None); diff --git a/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs b/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs index d798f61..faccbcf 100644 --- a/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs +++ b/Streetcode/Streetcode.XUnitTest/MediatR/News/UpdateNewsHandlerTests.cs @@ -16,6 +16,7 @@ using Streetcode.Shared.Extensions; using Xunit; using NewsEntity = Streetcode.DAL.Entities.News.News; +using FactEntity = Streetcode.DAL.Entities.Streetcode.TextContent.Fact; namespace Streetcode.XUnitTest.MediatR.News { @@ -206,9 +207,17 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewImageExists() It.IsAny())) .ReturnsAsync(newImage); + _repositoryWrapperMock.Setup(repo => repo.FactRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync((FactEntity)null!); + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) .ReturnsAsync(fakeBase); + _blobServiceMock.Setup(s => s.DeleteFileInStorage(It.IsAny())); + _repositoryWrapperMock .Setup(repo => repo.ImageRepository.Delete(It.IsAny())); @@ -234,6 +243,10 @@ public async Task Handle_ShouldReturnNewsDtoWithImage_WhenNewImageExists() repo => repo.ImageRepository.Delete(It.IsAny()), Times.Once()); + _blobServiceMock.Verify( + b => b.DeleteFileInStorage(It.IsAny()), + Times.Once()); + res.Value.Should().BeEquivalentTo(expectedNewsDto); } @@ -347,7 +360,7 @@ public async Task Handle_ShouldReturnFail_WhenNewImageExistsButBlobNotExists() } [Fact] - public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull() + public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNullAndFactNotLinkedToImage() { // Arrange var fakeBase = "fake_base_64"; @@ -390,9 +403,17 @@ public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull() It.IsAny())) .ReturnsAsync(newImage); + _repositoryWrapperMock.Setup(repo => repo.FactRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync((FactEntity)null!); + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) .ReturnsAsync(fakeBase); + _blobServiceMock.Setup(s => s.DeleteFileInStorage(It.IsAny())); + _repositoryWrapperMock .Setup(repo => repo.ImageRepository.Delete(It.IsAny())); @@ -418,6 +439,103 @@ public async Task Handle_ShouldDeleteOldImage_WhenNewImageIsNull() repo => repo.ImageRepository.Delete(It.IsAny()), Times.Once()); + _blobServiceMock.Verify( + b => b.DeleteFileInStorage(It.IsAny()), + Times.Once()); + + res.Value.Should().BeEquivalentTo(expectedNewsDto); + } + + [Fact] + public async Task Handle_ShouldNotDeleteOldImage_WhenNewImageIsNullAndFactLinkedToImage() + { + // Arrange + var fakeBase = "fake_base_64"; + var newsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + }; + + var expectedNewsDto = new NewsDTO + { + Id = 1, + URL = "url1", + ImageId = 2, + }; + + var news = new NewsEntity + { + Id = 1, + URL = "url", + ImageId = 1, + Image = new Image + { + Id = 1, + }, + }; + + var newImage = new Image { Id = 2 }; + + var fact = new FactEntity + { + Id = 1, + ImageId = 1, + }; + + _repositoryWrapperMock.Setup(repo => repo.NewsRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(news); + + _repositoryWrapperMock.Setup(repo => repo.ImageRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(newImage); + + _repositoryWrapperMock.Setup(repo => repo.FactRepository.GetFirstOrDefaultAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(fact); + + _blobServiceMock.Setup(s => s.FindFileInStorageAsBase64(It.IsAny())) + .ReturnsAsync(fakeBase); + + _blobServiceMock.Setup(s => s.DeleteFileInStorage(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.ImageRepository.Delete(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.NewsRepository.Update(It.IsAny())); + + _repositoryWrapperMock + .Setup(repo => repo.SaveChangesAsync()) + .ReturnsAsync(1); + + var req = new UpdateNewsCommand(newsDto); + + // Act + var res = await _handler.Handle(req, CancellationToken.None); + + // Assert + res.IsSuccess.Should().BeTrue(); + _repositoryWrapperMock.Verify( + repo => repo.NewsRepository.Update(It.IsAny()), + Times.Once()); + + _repositoryWrapperMock.Verify( + repo => repo.ImageRepository.Delete(It.IsAny()), + Times.Never); + + _blobServiceMock.Verify( + b => b.DeleteFileInStorage(It.IsAny()), + Times.Never); + res.Value.Should().BeEquivalentTo(expectedNewsDto); } } From 5c71ff906d1be2dce2a084a8443c7ee8238512ea Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 28 Feb 2026 18:28:22 +0200 Subject: [PATCH 05/10] tests/SSAD-0099: add tests for Azure blob storage and File service --- .../AzureBlobService/AzureBlobServiceTests.cs | 224 ++++++++++++++++++ .../Services/FileService/FileServiceTests.cs | 55 +++++ .../Streetcode.XUnitTest.csproj | 4 + 3 files changed, 283 insertions(+) create mode 100644 Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs create mode 100644 Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs diff --git a/Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs new file mode 100644 index 0000000..1c2bbbf --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs @@ -0,0 +1,224 @@ +using Streetcode.BLL.Interfaces.Logging; +using Streetcode.BLL.Services.BlobStorageService; +using Streetcode.DAL.Repositories.Interfaces.Base; +using Streetcode.Shared.Services; +using Streetcode.BLL.Interfaces.Logging; +using Streetcode.BLL.Services.BlobStorageService; +using Streetcode.DAL.Repositories.Interfaces.Base; +using Streetcode.Shared.Services; +using Azure.Storage.Blobs.Models; +using Azure; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Streetcode.XUnitTest.Services.AzureBlobService; + +public class AzureBlobServiceTests +{ + private readonly Mock _repositoryWrapperMock; + private readonly Mock _blobServiceClientMock; + private readonly Mock _containerClientMock; + private readonly Mock _blobClientMock; + private readonly Mock _loggerMock; + private readonly IOptions _options; + private readonly BLL.Services.BlobStorageService.AzureBlobService _service; + + public AzureBlobServiceTests() + { + _repositoryWrapperMock = new Mock(); + _blobServiceClientMock = new Mock(); + _containerClientMock = new Mock(); + _blobClientMock = new Mock(); + _loggerMock = new Mock(); + + var envVars = new AzureBlobEnvironmentVariables + { + ContainerName = "test-container", + EncryptionKey = "test-key-12345678901234567890-32", // Ensure 32 bytes(characters) + }; + _options = Options.Create(envVars); + + _blobServiceClientMock + .Setup(x => x.GetBlobContainerClient(It.IsAny())) + .Returns(_containerClientMock.Object); + + _containerClientMock + .Setup(x => x.GetBlobClient(It.IsAny())) + .Returns(_blobClientMock.Object); + + _service = new BLL.Services.BlobStorageService.AzureBlobService( + _options, + _repositoryWrapperMock.Object, + _blobServiceClientMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task SaveFileInStorage_ShouldReturnHashNameAndUploadBlob() + { + // Arrange + string base64 = Convert.ToBase64String(new byte[] { 1, 2, 3 }); + string name = "testfile"; + string extension = "jpg"; + + // Act + var result = await _service.SaveFileInStorage(base64, name, extension); + + // Assert + Assert.False(string.IsNullOrEmpty(result)); + _containerClientMock.Verify( + x => x.CreateIfNotExistsAsync(default,default, default, default), + Times.Once); + + _blobClientMock.Verify( + x => x.UploadAsync(It.IsAny(), It.IsAny(), default), + Times.Once); + } + + [Fact] + public async Task SaveFileInStorage_ShouldEncryptBeforeUploading() + { + // Arrange + var rawData = new byte[] { 7, 8, 9 }; + var base64 = Convert.ToBase64String(rawData); + byte[]? uploadedBytes = null; + + _blobClientMock + .Setup(x => x.UploadAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((s, _, _) => + { + using var ms = new MemoryStream(); + s.CopyTo(ms); + uploadedBytes = ms.ToArray(); + }) + .ReturnsAsync(Mock.Of>()); + + // Act + await _service.SaveFileInStorage(base64, "testfile", "png"); + + // Assert + Assert.NotNull(uploadedBytes); + // Verify it's actually encrypted (the uploaded size should be IV(16) + EncryptedData) + Assert.NotEqual(rawData, uploadedBytes); + Assert.True(uploadedBytes.Length > rawData.Length); + } + + [Fact] + public async Task DeleteFileInStorage_ShouldCallDeleteIfExists() + { + // Arrange + string fileName = "test-blob-name"; + + // Act + await _service.DeleteFileInStorage(fileName); + + // Assert + _blobClientMock.Verify( + x => x.DeleteIfExistsAsync(default, default, default), + Times.Once); + + _loggerMock.Verify( + x => x.LogInformation(It.Is(s => s.Contains(fileName))), + Times.Once); + } + + [Fact] + public async Task FindFileInStorageAsBase64_ReturnNull_WhenBlobNotFound() + { + // Arrange + _blobClientMock + .Setup(x => x.DownloadToAsync(It.IsAny())) + .Throws(new RequestFailedException(404, "Not Found")); + + // Act + var result = await _service.FindFileInStorageAsBase64("non-existent"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindFileInStorageAsMemoryStream_ReturnNull_WhenBlobNotFound() + { + // Arrange + _blobClientMock + .Setup(x => x.DownloadToAsync(It.IsAny())) + .Throws(new RequestFailedException(404, "Not Found")); + + // Act + var result = await _service.FindFileInStorageAsMemoryStream("non-existent"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindFileInStorageAsBase64_ReturnsOriginalString_WhenBlobExists() + { + // Arrange + var originalBytes = new byte[] { 1, 2, 3, 4, 5 }; + var base64Input = Convert.ToBase64String(originalBytes); + + var encryptedDataWithIv = FileService.EncryptBytes(originalBytes, _options.Value.EncryptionKey); + + _blobClientMock + .Setup(x => x.DownloadToAsync(It.IsAny())) + .Callback(s => s.Write(encryptedDataWithIv, 0, encryptedDataWithIv.Length)) + .ReturnsAsync(Mock.Of()); + + // Act + var result = await _service.FindFileInStorageAsBase64("some-blob-name"); + + // Assert + Assert.NotNull(result); + Assert.Equal(base64Input, result); + } + + [Fact] + public async Task FindFileInStorageAsMemoryStream_ReturnsDecryptedStream_WhenBlobExists() + { + // Arrange + var originalBytes = new byte[] { 10, 20, 30, 40, 50 }; + var key = _options.Value.EncryptionKey; + var encryptedDataWithIv = FileService.EncryptBytes(originalBytes, key); + + _blobClientMock + .Setup(x => x.DownloadToAsync(It.IsAny())) + .Callback(s => s.Write(encryptedDataWithIv, 0, encryptedDataWithIv.Length)) + .ReturnsAsync(Mock.Of()); + + // Act + using var resultStream = await _service.FindFileInStorageAsMemoryStream("some-blob-name"); + + // Assert + Assert.NotNull(resultStream); + + byte[] actualBytes = resultStream.ToArray(); + + Assert.Equal(originalBytes.Length, actualBytes.Length); + Assert.Equal(originalBytes, actualBytes); + } + + [Fact] + public async Task UpdateFileInStorage_ShouldDeleteOldAndUploadNew() + { + // Arrange + string oldName = "old.png"; + string base64 = Convert.ToBase64String(new byte[] { 4, 5, 6 }); + + // Act + await _service.UpdateFileInStorage(oldName, base64, "newFile", "png"); + + // Assert + _blobClientMock.Verify( + x => x.DeleteIfExistsAsync(default, default, default), + Times.Once); + + _blobClientMock.Verify( + x => x.UploadAsync(It.IsAny(), true, default), + Times.AtLeastOnce); + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs new file mode 100644 index 0000000..1c4be8c --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs @@ -0,0 +1,55 @@ +namespace Streetcode.XUnitTest.Shared.Services.FileService; + +using System.Text; +using Xunit; +using FileService = global::Streetcode.Shared.Services.FileService; + +public class FileServiceTests +{ + private const string TestKey = "12345678901234561234567890123456"; // 32 bytes + + [Fact] + public void EncryptAndDecrypt_ShouldReturnOriginalBytes() + { + // Arrange + byte[] original = Encoding.UTF8.GetBytes("Hello World"); + + // Act + byte[] encrypted = FileService.EncryptBytes(original, TestKey); + byte[] decrypted = FileService.DecryptBytes(encrypted, TestKey); + + // Assert + Assert.NotEqual(original, encrypted); + Assert.Equal(original, decrypted); + Assert.Equal(16 + 16, encrypted.Length); // IV (16) + Padded AES block (16) + } + + [Fact] + public void HashFunction_ShouldReplaceSlashes() + { + // Arrange + string input = "some-input"; + + // Act + string hash = FileService.HashFunction(input); + + // Assert + Assert.DoesNotContain("/", hash); + Assert.Contains("_", hash); + } + + [Fact] + public void PrepareFileStorageName_ShouldRemoveInvalidCharacters() + { + // Arrange + string input = "my.photo:test.png"; + + // Act + string result = FileService.PrepareFileStorageName(input); + + // Assert + Assert.DoesNotContain(".", result); + Assert.DoesNotContain(":", result); + Assert.DoesNotContain(" ", result); + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj index 86bce63..1f507be 100644 --- a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj +++ b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj @@ -47,4 +47,8 @@ + + + + \ No newline at end of file From 48a5ca2ba5d423e0b77a6188230e2d3f546033af Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 28 Feb 2026 18:42:35 +0200 Subject: [PATCH 06/10] fix/SSAD-0099: fix tests folder structure --- .../{AzureBlobService => }/AzureBlobServiceTests.cs | 4 ---- .../Streetcode.XUnitTest/Services/BlobServiceTests.cs | 6 ++++++ .../Shared/Services/{FileService => }/FileServiceTests.cs | 0 Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj | 4 ---- 4 files changed, 6 insertions(+), 8 deletions(-) rename Streetcode/Streetcode.XUnitTest/Services/{AzureBlobService => }/AzureBlobServiceTests.cs (97%) create mode 100644 Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs rename Streetcode/Streetcode.XUnitTest/Shared/Services/{FileService => }/FileServiceTests.cs (100%) diff --git a/Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs similarity index 97% rename from Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs rename to Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs index 1c2bbbf..209f7ce 100644 --- a/Streetcode/Streetcode.XUnitTest/Services/AzureBlobService/AzureBlobServiceTests.cs +++ b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs @@ -2,10 +2,6 @@ using Streetcode.BLL.Services.BlobStorageService; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Shared.Services; -using Streetcode.BLL.Interfaces.Logging; -using Streetcode.BLL.Services.BlobStorageService; -using Streetcode.DAL.Repositories.Interfaces.Base; -using Streetcode.Shared.Services; using Azure.Storage.Blobs.Models; using Azure; using Azure.Storage.Blobs; diff --git a/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs new file mode 100644 index 0000000..92145a9 --- /dev/null +++ b/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs @@ -0,0 +1,6 @@ +namespace Streetcode.XUnitTest.Services.AzureBlobService; + +public class BlobServiceTests +{ + +} \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Shared/Services/FileServiceTests.cs similarity index 100% rename from Streetcode/Streetcode.XUnitTest/Shared/Services/FileService/FileServiceTests.cs rename to Streetcode/Streetcode.XUnitTest/Shared/Services/FileServiceTests.cs diff --git a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj index 1f507be..86bce63 100644 --- a/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj +++ b/Streetcode/Streetcode.XUnitTest/Streetcode.XUnitTest.csproj @@ -47,8 +47,4 @@ - - - - \ No newline at end of file From 24d30eb8e16827286f159d366287bea803bc7dc7 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 28 Feb 2026 18:43:10 +0200 Subject: [PATCH 07/10] tests/SSAD-0099: add blob service tests --- .../Services/BlobServiceTests.cs | 98 ++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs index 92145a9..fbdd86a 100644 --- a/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs +++ b/Streetcode/Streetcode.XUnitTest/Services/BlobServiceTests.cs @@ -1,6 +1,98 @@ -namespace Streetcode.XUnitTest.Services.AzureBlobService; +using Microsoft.Extensions.Options; +using Moq; +using Streetcode.BLL.Services.BlobStorageService; +using Streetcode.DAL.Repositories.Interfaces.Base; +using Streetcode.Shared.Services; +using Xunit; -public class BlobServiceTests +public class BlobServiceTests : IDisposable { - + private readonly Mock _mockRepo; + private readonly string _testTempPath; + private readonly string _testKey = "12345678901234561234567890123456"; // 32 bytes for AES + private readonly BlobService _service; + + public BlobServiceTests() + { + _mockRepo = new Mock(); + + // Create a unique temp folder for each test run to avoid collision + _testTempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "BlobStore/"); + Directory.CreateDirectory(_testTempPath); + + var options = Options.Create(new BlobEnvironmentVariables + { + BlobStoreKey = _testKey, + BlobStorePath = _testTempPath + }); + + _service = new BlobService(options, _mockRepo.Object); + } + + // Cleanup after every test + public void Dispose() + { + if (Directory.Exists(_testTempPath)) + { + Directory.Delete(_testTempPath, true); + } + } + + [Fact] + public async Task SaveFileInStorage_ShouldCreateFileOnDisk() + { + // Arrange + var content = "Hello World"; + var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)); + + // Act + var fileName = await _service.SaveFileInStorage(base64, "testfile", "txt"); + + // Assert + var expectedPath = Path.Combine(_testTempPath, $"{fileName}.txt"); + Assert.True(File.Exists(expectedPath)); + } + + [Fact] + public async Task FindFileInStorageAsBase64_ShouldReturnCorrectString_WhenFileExists() + { + // Arrange + var originalData = new byte[] { 1, 2, 3, 4, 5 }; + var fileName = "existingFile.bin"; + var encrypted = FileService.EncryptBytes(originalData, _testKey); + + File.WriteAllBytes(Path.Combine(_testTempPath, fileName), encrypted); + + // Act + var result = await _service.FindFileInStorageAsBase64(fileName); + + // Assert + Assert.NotNull(result); + Assert.Equal(Convert.ToBase64String(originalData), result); + } + + [Fact] + public async Task FindFileInStorageAsMemoryStream_ReturnsNull_WhenFileDoesNotExist() + { + // Act + var result = await _service.FindFileInStorageAsMemoryStream("non-existent.png"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task DeleteFileInStorage_ShouldRemoveFileFromDisk() + { + // Arrange + var fileName = "toDelete.txt"; + var fullPath = Path.Combine(_testTempPath, fileName); + File.WriteAllText(fullPath, "content"); + + // Act + await _service.DeleteFileInStorage(fileName); + + // Assert + Assert.False(File.Exists(fullPath)); + } } \ No newline at end of file From abb465a8b54fd18454b2392ddc2df9568160c240 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 28 Feb 2026 18:47:08 +0200 Subject: [PATCH 08/10] await deletion of files in blob service --- .../Streetcode.BLL/Services/BlobStorageService/BlobService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs index fbf13d1..6d77797 100644 --- a/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs +++ b/Streetcode/Streetcode.BLL/Services/BlobStorageService/BlobService.cs @@ -108,7 +108,7 @@ public async Task CleanBlobStorage() foreach (var file in filesToRemove) { Console.WriteLine($"Deleting {file}..."); - DeleteFileInStorage(file); + await DeleteFileInStorage(file); } } From 4fada74295bd58fd5635e51b91287714cc934c34 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 28 Feb 2026 19:02:32 +0200 Subject: [PATCH 09/10] tets/SSAD-0099: add test for cleanup --- .../Extensions/AsyncEnumerableExtensions.cs | 14 ++++++ .../Services/AzureBlobServiceTests.cs | 48 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 Streetcode/Streetcode.Shared/Extensions/AsyncEnumerableExtensions.cs diff --git a/Streetcode/Streetcode.Shared/Extensions/AsyncEnumerableExtensions.cs b/Streetcode/Streetcode.Shared/Extensions/AsyncEnumerableExtensions.cs new file mode 100644 index 0000000..8da6938 --- /dev/null +++ b/Streetcode/Streetcode.Shared/Extensions/AsyncEnumerableExtensions.cs @@ -0,0 +1,14 @@ +namespace Streetcode.Shared.Extensions; + +public static class AsyncEnumerableExtensions +{ + public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable enumeration) + { + foreach (var item in enumeration) + { + yield return item; + } + + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs index 209f7ce..3c5d0e4 100644 --- a/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs +++ b/Streetcode/Streetcode.XUnitTest/Services/AzureBlobServiceTests.cs @@ -1,12 +1,17 @@ -using Streetcode.BLL.Interfaces.Logging; +using System.Linq.Expressions; +using Streetcode.BLL.Interfaces.Logging; using Streetcode.BLL.Services.BlobStorageService; using Streetcode.DAL.Repositories.Interfaces.Base; using Streetcode.Shared.Services; using Azure.Storage.Blobs.Models; using Azure; using Azure.Storage.Blobs; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Options; using Moq; +using Streetcode.DAL.Entities.Media; +using Streetcode.DAL.Entities.Media.Images; +using Streetcode.Shared.Extensions; using Xunit; namespace Streetcode.XUnitTest.Services.AzureBlobService; @@ -217,4 +222,45 @@ public async Task UpdateFileInStorage_ShouldDeleteOldAndUploadNew() x => x.UploadAsync(It.IsAny(), true, default), Times.AtLeastOnce); } + + [Fact] + public async Task CleanBlobStorage_Azure_ShouldBatchDeleteOrphans() + { + // Arrange + var blobName1 = "db_image.png"; + var blobName2 = "orphan.png"; + + var blobItems = new List + { + BlobsModelFactory.BlobItem(blobName1), + BlobsModelFactory.BlobItem(blobName2), + }; + + var mockAsyncPageable = new Mock>(); + mockAsyncPageable.Setup(x => x.GetAsyncEnumerator(default)) + .Returns(blobItems.ToAsyncEnumerable().GetAsyncEnumerator(CancellationToken.None)); + + _containerClientMock.Setup(x => x.GetBlobsAsync(default, default, default, default)) + .Returns(mockAsyncPageable.Object); + + _repositoryWrapperMock.Setup(r => r.ImageRepository.GetAllAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(new List { new () { BlobName = blobName1 } }); + + _repositoryWrapperMock + .Setup(r => r.AudioRepository.GetAllAsync( + It.IsAny>>(), + It.IsAny, IIncludableQueryable>>(), + It.IsAny())) + .ReturnsAsync(new List