mirror of https://github.com/abpframework/abp.git
203 changed files with 3336 additions and 1981 deletions
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 9.4 KiB |
@ -0,0 +1,62 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Learn how ABP Studio's modern Modular Monolith solution template organizes a main application and reusable modules in a single deployable solution." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# ABP Studio: Modular Monolith Solution Template |
||||
|
|
||||
|
````json |
||||
|
//[doc-nav] |
||||
|
{ |
||||
|
"Previous": { |
||||
|
"Name": "Layered Solution", |
||||
|
"Path": "solution-templates/layered-web-application/index.md" |
||||
|
}, |
||||
|
"Next": { |
||||
|
"Name": "Microservice Solution", |
||||
|
"Path": "solution-templates/microservice/index.md" |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
ABP Studio's modern solution wizard includes a dedicated **Modular Monolith** architecture. Under the hood, it uses the modern no-layers application template for the main application, then enables modularity automatically. |
||||
|
|
||||
|
> **This page documents the modern modular monolith path. If you are working with the classic template family, see the [Solution Template Selection Guide](../guide.md) for the host + module composition approach.** |
||||
|
|
||||
|
## What ABP Studio Creates |
||||
|
|
||||
|
When you choose `Modular Monolith` in the modern solution wizard, ABP Studio: |
||||
|
|
||||
|
* creates the main application by using the modern no-layers host template. |
||||
|
* locks the solution into modular mode. |
||||
|
* creates a `main` folder in the solution model and moves the main application into it. |
||||
|
* creates a `modules` folder for reusable module solutions. |
||||
|
* lets you add additional modules during solution creation and optionally install them into the main application immediately. |
||||
|
|
||||
|
## Typical Layout |
||||
|
|
||||
|
In ABP Studio's *Solution Explorer*, the solution is organized around these areas: |
||||
|
|
||||
|
* `main`: the main application and its host-side assets. |
||||
|
* `modules`: one module solution per business capability. |
||||
|
* `etc`: shared infrastructure, run profiles, and deployment files. |
||||
|
|
||||
|
Additional modules are generated under the solution's `modules/<module-name>` directory, while the main application remains the single deployable host. |
||||
|
|
||||
|
## How Modules Fit In |
||||
|
|
||||
|
The module solutions created for a modular monolith use the same reusable module concepts documented in the [Application Module Template](../application-module/index.md) page. |
||||
|
|
||||
|
Use this template when you want: |
||||
|
|
||||
|
* clear module boundaries without a distributed deployment model. |
||||
|
* separate module solutions for teams or business domains. |
||||
|
* a monolith today, with a cleaner path toward microservices later. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [Solution Template Selection Guide](../guide.md) |
||||
|
* [Application Module Template](../application-module/index.md) |
||||
|
* [Modular Monolith Application Development Tutorial](../../tutorials/modular-crm/index.md) |
||||
@ -0,0 +1,11 @@ |
|||||
|
namespace Volo.Abp.Imaging; |
||||
|
|
||||
|
public class SkiaSharpCompressOptions |
||||
|
{ |
||||
|
public int Quality { get; set; } |
||||
|
|
||||
|
public SkiaSharpCompressOptions() |
||||
|
{ |
||||
|
Quality = 75; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,152 @@ |
|||||
|
using System.IO; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using SkiaSharp; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.Http; |
||||
|
|
||||
|
namespace Volo.Abp.Imaging; |
||||
|
|
||||
|
public class SkiaSharpImageCompressorContributor : IImageCompressorContributor, ITransientDependency |
||||
|
{ |
||||
|
protected SkiaSharpCompressOptions Options { get; } |
||||
|
|
||||
|
public SkiaSharpImageCompressorContributor(IOptions<SkiaSharpCompressOptions> options) |
||||
|
{ |
||||
|
Options = options.Value; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<ImageCompressResult<Stream>> TryCompressAsync( |
||||
|
Stream stream, |
||||
|
string? mimeType = null, |
||||
|
CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType)) |
||||
|
{ |
||||
|
return new ImageCompressResult<Stream>(stream, ImageProcessState.Unsupported); |
||||
|
} |
||||
|
|
||||
|
var (memoryBitmapStream, memorySkCodecStream) = await CreateMemoryStream(stream, cancellationToken); |
||||
|
var originalLength = memoryBitmapStream.Length; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var codec = SKCodec.Create(memorySkCodecStream); |
||||
|
if (codec == null || !CanEncodeFormat(codec.EncodedFormat)) |
||||
|
{ |
||||
|
return new ImageCompressResult<Stream>(stream, ImageProcessState.Unsupported); |
||||
|
} |
||||
|
|
||||
|
using var bitmap = SKBitmap.Decode(memoryBitmapStream); |
||||
|
if (bitmap == null) |
||||
|
{ |
||||
|
return new ImageCompressResult<Stream>(stream, ImageProcessState.Unsupported); |
||||
|
} |
||||
|
|
||||
|
using var image = SKImage.FromBitmap(bitmap); |
||||
|
using var encoded = image.Encode(codec.EncodedFormat, Options.Quality); |
||||
|
|
||||
|
var output = new MemoryStream(); |
||||
|
try |
||||
|
{ |
||||
|
encoded.SaveTo(output); |
||||
|
output.Position = 0; |
||||
|
|
||||
|
if (output.Length < originalLength) |
||||
|
{ |
||||
|
return new ImageCompressResult<Stream>(output, ImageProcessState.Done); |
||||
|
} |
||||
|
|
||||
|
output.Dispose(); |
||||
|
return new ImageCompressResult<Stream>(stream, ImageProcessState.Canceled); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
output.Dispose(); |
||||
|
throw; |
||||
|
} |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
memoryBitmapStream.Dispose(); |
||||
|
memorySkCodecStream.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<ImageCompressResult<byte[]>> TryCompressAsync( |
||||
|
byte[] bytes, |
||||
|
string? mimeType = null, |
||||
|
CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType)) |
||||
|
{ |
||||
|
return new ImageCompressResult<byte[]>(bytes, ImageProcessState.Unsupported); |
||||
|
} |
||||
|
|
||||
|
using var ms = new MemoryStream(bytes); |
||||
|
var result = await TryCompressAsync(ms, mimeType, cancellationToken); |
||||
|
|
||||
|
if (result.State != ImageProcessState.Done) |
||||
|
{ |
||||
|
return new ImageCompressResult<byte[]>(bytes, result.State); |
||||
|
} |
||||
|
|
||||
|
var newBytes = await result.Result.GetAllBytesAsync(cancellationToken); |
||||
|
result.Result.Dispose(); |
||||
|
return new ImageCompressResult<byte[]>(newBytes, result.State); |
||||
|
} |
||||
|
|
||||
|
protected virtual bool CanCompress(string? mimeType) |
||||
|
{ |
||||
|
return mimeType switch { |
||||
|
MimeTypes.Image.Jpeg => true, |
||||
|
MimeTypes.Image.Png => true, |
||||
|
MimeTypes.Image.Webp => true, |
||||
|
_ => false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected virtual bool CanEncodeFormat(SKEncodedImageFormat format) |
||||
|
{ |
||||
|
return format switch |
||||
|
{ |
||||
|
SKEncodedImageFormat.Jpeg => true, |
||||
|
SKEncodedImageFormat.Png => true, |
||||
|
SKEncodedImageFormat.Webp => true, |
||||
|
_ => false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<(MemoryStream, MemoryStream)> CreateMemoryStream(Stream stream, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
var streamPosition = stream.CanSeek ? stream.Position : 0; |
||||
|
|
||||
|
var memoryBitmapStream = new MemoryStream(); |
||||
|
var memorySkCodecStream = new MemoryStream(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await stream.CopyToAsync(memoryBitmapStream, cancellationToken); |
||||
|
|
||||
|
if (stream.CanSeek) |
||||
|
{ |
||||
|
stream.Position = streamPosition; |
||||
|
} |
||||
|
|
||||
|
memoryBitmapStream.Position = 0; |
||||
|
await memoryBitmapStream.CopyToAsync(memorySkCodecStream, cancellationToken); |
||||
|
|
||||
|
memoryBitmapStream.Position = 0; |
||||
|
memorySkCodecStream.Position = 0; |
||||
|
|
||||
|
return (memoryBitmapStream, memorySkCodecStream); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
memoryBitmapStream.Dispose(); |
||||
|
memorySkCodecStream.Dispose(); |
||||
|
throw; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,193 @@ |
|||||
|
using System; |
||||
|
using System.Net; |
||||
|
using System.Net.Http; |
||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.Cli.ProjectBuilding; |
||||
|
using Volo.Abp.Json; |
||||
|
using Volo.Abp.Json.SystemTextJson; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.ProjectBuilding; |
||||
|
|
||||
|
public class RemoteServiceExceptionHandler_Tests |
||||
|
{ |
||||
|
private readonly RemoteServiceExceptionHandler _handler; |
||||
|
|
||||
|
public RemoteServiceExceptionHandler_Tests() |
||||
|
{ |
||||
|
var jsonSerializer = new AbpSystemTextJsonSerializer( |
||||
|
Microsoft.Extensions.Options.Options.Create(new AbpSystemTextJsonSerializerOptions()) |
||||
|
); |
||||
|
_handler = new RemoteServiceExceptionHandler(jsonSerializer); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task EnsureSuccessfulHttpResponseAsync_Should_Not_Throw_On_Success() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK) |
||||
|
{ |
||||
|
Content = new StringContent("{}") |
||||
|
}; |
||||
|
|
||||
|
await _handler.EnsureSuccessfulHttpResponseAsync(response); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task EnsureSuccessfulHttpResponseAsync_Should_Not_Throw_When_Response_Is_Null() |
||||
|
{ |
||||
|
await _handler.EnsureSuccessfulHttpResponseAsync(null); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Wrap_Html_Body_Without_Json_Parse_Exception() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
ReasonPhrase = "Forbidden", |
||||
|
Content = new StringContent("<!DOCTYPE html><html><body>Forbidden</body></html>", System.Text.Encoding.UTF8, "text/html") |
||||
|
}; |
||||
|
|
||||
|
var exception = await Should.ThrowAsync<Exception>(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); |
||||
|
|
||||
|
exception.Message.ShouldContain("403-Forbidden"); |
||||
|
exception.Message.ShouldNotContain("invalid start of a value"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Surface_Server_Error_Message_When_Body_Is_Valid_Json() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
ReasonPhrase = "Forbidden", |
||||
|
Content = new StringContent( |
||||
|
"{\"error\":{\"code\":\"LicenseExpired\",\"message\":\"Your ABP license has expired.\"}}", |
||||
|
System.Text.Encoding.UTF8, |
||||
|
"application/json") |
||||
|
}; |
||||
|
|
||||
|
var exception = await Should.ThrowAsync<Exception>(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); |
||||
|
|
||||
|
exception.Message.ShouldContain("403-Forbidden"); |
||||
|
exception.Message.ShouldContain("LicenseExpired"); |
||||
|
exception.Message.ShouldContain("Your ABP license has expired."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Surface_Server_Error_Message_For_5xx_With_Json_Body() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) |
||||
|
{ |
||||
|
ReasonPhrase = "Internal Server Error", |
||||
|
Content = new StringContent( |
||||
|
"{\"error\":{\"code\":\"InternalError\",\"message\":\"Database connection failed\"}}", |
||||
|
System.Text.Encoding.UTF8, |
||||
|
"application/json") |
||||
|
}; |
||||
|
|
||||
|
var exception = await Should.ThrowAsync<Exception>(() => _handler.EnsureSuccessfulHttpResponseAsync(response)); |
||||
|
|
||||
|
exception.Message.ShouldContain("500-Internal Server Error"); |
||||
|
exception.Message.ShouldContain("InternalError"); |
||||
|
exception.Message.ShouldContain("Database connection failed"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task GetAbpRemoteServiceErrorAsync_Should_Propagate_OperationCanceledException() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
Content = new CanceledStringContent() |
||||
|
}; |
||||
|
|
||||
|
await Should.ThrowAsync<OperationCanceledException>( |
||||
|
() => _handler.GetAbpRemoteServiceErrorAsync(response) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task GetAbpRemoteServiceErrorAsync_Should_Return_Null_For_Html_Body() |
||||
|
{ |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
Content = new StringContent("<!DOCTYPE html><html></html>", System.Text.Encoding.UTF8, "text/html") |
||||
|
}; |
||||
|
|
||||
|
var result = await _handler.GetAbpRemoteServiceErrorAsync(response); |
||||
|
|
||||
|
result.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task GetAbpRemoteServiceErrorAsync_Should_Return_Null_For_Newtonsoft_JsonException() |
||||
|
{ |
||||
|
var handler = new RemoteServiceExceptionHandler( |
||||
|
new ThrowingJsonSerializer(new Newtonsoft.Json.JsonException("Invalid JSON")) |
||||
|
); |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
Content = new StringContent("{}") |
||||
|
}; |
||||
|
|
||||
|
var result = await handler.GetAbpRemoteServiceErrorAsync(response); |
||||
|
|
||||
|
result.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task GetAbpRemoteServiceErrorAsync_Should_Propagate_Non_Json_Exceptions() |
||||
|
{ |
||||
|
var handler = new RemoteServiceExceptionHandler( |
||||
|
new ThrowingJsonSerializer(new InvalidOperationException("Unexpected serializer failure")) |
||||
|
); |
||||
|
var response = new HttpResponseMessage(HttpStatusCode.Forbidden) |
||||
|
{ |
||||
|
Content = new StringContent("{}") |
||||
|
}; |
||||
|
|
||||
|
var exception = await Should.ThrowAsync<InvalidOperationException>( |
||||
|
() => handler.GetAbpRemoteServiceErrorAsync(response) |
||||
|
); |
||||
|
|
||||
|
exception.Message.ShouldBe("Unexpected serializer failure"); |
||||
|
} |
||||
|
|
||||
|
private class CanceledStringContent : HttpContent |
||||
|
{ |
||||
|
protected override Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext context) |
||||
|
{ |
||||
|
throw new OperationCanceledException(); |
||||
|
} |
||||
|
|
||||
|
protected override bool TryComputeLength(out long length) |
||||
|
{ |
||||
|
length = 0; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private class ThrowingJsonSerializer : IJsonSerializer |
||||
|
{ |
||||
|
private readonly Exception _exception; |
||||
|
|
||||
|
public ThrowingJsonSerializer(Exception exception) |
||||
|
{ |
||||
|
_exception = exception; |
||||
|
} |
||||
|
|
||||
|
public string Serialize(object obj, bool camelCase = true, bool indented = false) |
||||
|
{ |
||||
|
throw new NotImplementedException(); |
||||
|
} |
||||
|
|
||||
|
public T Deserialize<T>(string jsonString, bool camelCase = true) |
||||
|
{ |
||||
|
throw _exception; |
||||
|
} |
||||
|
|
||||
|
public object Deserialize(Type type, string jsonString, bool camelCase = true) |
||||
|
{ |
||||
|
throw _exception; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.SimpleStateChecking; |
||||
|
|
||||
|
public class SimpleStateChecker_AndCombineResults_Tests : SimpleStateCheckerTestBase |
||||
|
{ |
||||
|
[Fact] |
||||
|
public async Task False_Result_From_One_Batch_Checker_Should_Not_Be_Overwritten_By_Later_True_Checker() |
||||
|
{ |
||||
|
// Regression test for the bug fixed in SimpleStateCheckerManager.IsEnabledAsync:
|
||||
|
// when a state had multiple batch checkers the manager used to OVERWRITE
|
||||
|
// result[x.Key] on each checker, so a later "true" silently masked an earlier "false".
|
||||
|
// The fix AND-combines results.
|
||||
|
//
|
||||
|
// To make sure we actually hit the second batch checker (and not the early-return
|
||||
|
// `result.Values.All(x => !x)` short-circuit), we use two states:
|
||||
|
// - state1: [falseChecker, trueChecker] - the target case
|
||||
|
// - state2: [trueChecker] - keeps result.Values not all-false
|
||||
|
// so the loop continues to the next checker
|
||||
|
|
||||
|
var falseChecker = new AlwaysFalseBatchStateChecker(); |
||||
|
var trueChecker = new AlwaysTrueBatchStateChecker(); |
||||
|
|
||||
|
var state1 = new MyStateEntity(); |
||||
|
state1.AddSimpleStateChecker(falseChecker); |
||||
|
state1.AddSimpleStateChecker(trueChecker); |
||||
|
|
||||
|
var state2 = new MyStateEntity(); |
||||
|
state2.AddSimpleStateChecker(trueChecker); |
||||
|
|
||||
|
var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { state1, state2 }); |
||||
|
|
||||
|
result[state1].ShouldBeFalse(); // before the fix this was True (bug)
|
||||
|
result[state2].ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task True_Result_From_One_Batch_Checker_Should_Be_AND_Combined_With_Later_False_Checker() |
||||
|
{ |
||||
|
// Reverse order: true first then false. Add a second always-true state to keep
|
||||
|
// result.Values not all-false after both checkers ran.
|
||||
|
var trueChecker = new AlwaysTrueBatchStateChecker(); |
||||
|
var falseChecker = new AlwaysFalseBatchStateChecker(); |
||||
|
|
||||
|
var state1 = new MyStateEntity(); |
||||
|
state1.AddSimpleStateChecker(trueChecker); |
||||
|
state1.AddSimpleStateChecker(falseChecker); |
||||
|
|
||||
|
var state2 = new MyStateEntity(); |
||||
|
state2.AddSimpleStateChecker(trueChecker); |
||||
|
|
||||
|
var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { state1, state2 }); |
||||
|
|
||||
|
result[state1].ShouldBeFalse(); |
||||
|
result[state2].ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task All_True_Batch_Checkers_Should_Produce_True() |
||||
|
{ |
||||
|
var trueChecker1 = new AlwaysTrueBatchStateChecker(); |
||||
|
var trueChecker2 = new AlwaysTrueBatchStateChecker(); |
||||
|
|
||||
|
var state = new MyStateEntity(); |
||||
|
state.AddSimpleStateChecker(trueChecker1); |
||||
|
state.AddSimpleStateChecker(trueChecker2); |
||||
|
|
||||
|
var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { state }); |
||||
|
|
||||
|
result[state].ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
public class AlwaysFalseBatchStateChecker : SimpleBatchStateCheckerBase<MyStateEntity>, ITransientDependency |
||||
|
{ |
||||
|
public override Task<SimpleStateCheckerResult<MyStateEntity>> IsEnabledAsync(SimpleBatchStateCheckerContext<MyStateEntity> context) |
||||
|
{ |
||||
|
var result = new SimpleStateCheckerResult<MyStateEntity>(context.States); |
||||
|
foreach (var x in result) |
||||
|
{ |
||||
|
result[x.Key] = false; |
||||
|
} |
||||
|
return Task.FromResult(result); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public class AlwaysTrueBatchStateChecker : SimpleBatchStateCheckerBase<MyStateEntity>, ITransientDependency |
||||
|
{ |
||||
|
public override Task<SimpleStateCheckerResult<MyStateEntity>> IsEnabledAsync(SimpleBatchStateCheckerContext<MyStateEntity> context) |
||||
|
{ |
||||
|
var result = new SimpleStateCheckerResult<MyStateEntity>(context.States); |
||||
|
foreach (var x in result) |
||||
|
{ |
||||
|
result[x.Key] = true; |
||||
|
} |
||||
|
return Task.FromResult(result); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 197 KiB |
@ -0,0 +1,179 @@ |
|||||
|
using System; |
||||
|
using System.IO; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Shouldly; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.Imaging; |
||||
|
|
||||
|
public class SkiaSharpImageCompressorTests : AbpImagingSkiaSharpTestBase |
||||
|
{ |
||||
|
public IImageCompressor ImageCompressor { get; } |
||||
|
|
||||
|
public SkiaSharpImageCompressorTests() |
||||
|
{ |
||||
|
ImageCompressor = GetRequiredService<IImageCompressor>(); |
||||
|
} |
||||
|
|
||||
|
protected override void AfterAddApplication(IServiceCollection services) |
||||
|
{ |
||||
|
services.Configure<SkiaSharpCompressOptions>(options => |
||||
|
{ |
||||
|
options.Quality = 50; |
||||
|
}); |
||||
|
|
||||
|
base.AfterAddApplication(services); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Compress_Jpg() |
||||
|
{ |
||||
|
await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(jpegImage); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Done); |
||||
|
compressedImage.Result.Length.ShouldBeLessThan(jpegImage.Length); |
||||
|
compressedImage.Result.Dispose(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Compress_Png() |
||||
|
{ |
||||
|
await using var pngImage = ImageFileHelper.GetPngTestFileStream(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(pngImage); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
|
||||
|
if (compressedImage.State == ImageProcessState.Done) |
||||
|
{ |
||||
|
compressedImage.Result.Length.ShouldBeLessThan(pngImage.Length); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Canceled); |
||||
|
compressedImage.Result.Length.ShouldBe(pngImage.Length); |
||||
|
} |
||||
|
|
||||
|
compressedImage.Result.Dispose(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Compress_Webp() |
||||
|
{ |
||||
|
await using var webpImage = ImageFileHelper.GetWebpTestFileStream(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(webpImage); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Done); |
||||
|
compressedImage.Result.Length.ShouldBeLessThan(webpImage.Length); |
||||
|
compressedImage.Result.Dispose(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Compress_Stream_And_Byte_Array_The_Same() |
||||
|
{ |
||||
|
await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); |
||||
|
var byteArr = await jpegImage.GetAllBytesAsync(); |
||||
|
|
||||
|
var compressedImage1 = await ImageCompressor.CompressAsync(jpegImage); |
||||
|
var compressedImage2 = await ImageCompressor.CompressAsync(byteArr); |
||||
|
|
||||
|
compressedImage1.ShouldNotBeNull(); |
||||
|
compressedImage1.State.ShouldBe(ImageProcessState.Done); |
||||
|
|
||||
|
compressedImage2.ShouldNotBeNull(); |
||||
|
compressedImage2.State.ShouldBe(ImageProcessState.Done); |
||||
|
|
||||
|
compressedImage1.Result.Length.ShouldBeLessThan(jpegImage.Length); |
||||
|
compressedImage2.Result.LongLength.ShouldBeLessThan(jpegImage.Length); |
||||
|
|
||||
|
compressedImage1.Result.Length.ShouldBe(compressedImage2.Result.LongLength); |
||||
|
|
||||
|
compressedImage1.Result.Dispose(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Compressed_Stream_Positioned_At_Start() |
||||
|
{ |
||||
|
await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(jpegImage); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Done); |
||||
|
compressedImage.Result.Position.ShouldBe(0); |
||||
|
|
||||
|
using var copy = new MemoryStream(); |
||||
|
await compressedImage.Result.CopyToAsync(copy); |
||||
|
copy.Length.ShouldBe(compressedImage.Result.Length); |
||||
|
|
||||
|
compressedImage.Result.Dispose(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Unsupported_For_Gif_Stream() |
||||
|
{ |
||||
|
await using var gifImage = ImageFileHelper.GetGifTestFileStream(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(gifImage); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Unsupported); |
||||
|
compressedImage.Result.ShouldBe(gifImage); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Unsupported_For_Gif_Bytes() |
||||
|
{ |
||||
|
await using var gifImage = ImageFileHelper.GetGifTestFileStream(); |
||||
|
var bytes = await gifImage.GetAllBytesAsync(); |
||||
|
var compressedImage = await ImageCompressor.CompressAsync(bytes); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBe(ImageProcessState.Unsupported); |
||||
|
compressedImage.Result.ShouldBe(bytes); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Handle_Non_Seekable_Stream_Directly_On_Contributor() |
||||
|
{ |
||||
|
var contributor = GetRequiredService<IImageCompressorContributor>(); |
||||
|
|
||||
|
await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); |
||||
|
var bytes = await jpegImage.GetAllBytesAsync(); |
||||
|
await using var nonSeekable = new NonSeekableStream(new MemoryStream(bytes)); |
||||
|
|
||||
|
var compressedImage = await contributor.TryCompressAsync(nonSeekable, "image/jpeg"); |
||||
|
|
||||
|
compressedImage.ShouldNotBeNull(); |
||||
|
compressedImage.State.ShouldBeOneOf(ImageProcessState.Done, ImageProcessState.Canceled); |
||||
|
if (compressedImage.State == ImageProcessState.Done) |
||||
|
{ |
||||
|
compressedImage.Result.ShouldNotBe(nonSeekable); |
||||
|
compressedImage.Result.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private sealed class NonSeekableStream : Stream |
||||
|
{ |
||||
|
private readonly Stream _inner; |
||||
|
public NonSeekableStream(Stream inner) { _inner = inner; } |
||||
|
public override bool CanRead => _inner.CanRead; |
||||
|
public override bool CanSeek => false; |
||||
|
public override bool CanWrite => false; |
||||
|
public override long Length => throw new NotSupportedException(); |
||||
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } |
||||
|
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); |
||||
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inner.ReadAsync(buffer, offset, count, cancellationToken); |
||||
|
public override void Flush() { } |
||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); |
||||
|
public override void SetLength(long value) => throw new NotSupportedException(); |
||||
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); |
||||
|
protected override void Dispose(bool disposing) |
||||
|
{ |
||||
|
if (disposing) _inner.Dispose(); |
||||
|
base.Dispose(disposing); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue