Add media image handling and related endpoints
- Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications.
This commit is contained in:
+2
@@ -17,6 +17,7 @@ using PnvPanel.Domain.Instructions;
|
||||
using PnvPanel.Domain.News;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Plans;
|
||||
using PnvPanel.Domain.Media;
|
||||
using PnvPanel.Domain.Pricing;
|
||||
using PnvPanel.Domain.Support;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
@@ -45,6 +46,7 @@ public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext
|
||||
public DbSet<TicketAttachment> TicketAttachments => inner.TicketAttachments;
|
||||
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
|
||||
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
|
||||
public DbSet<MediaImage> MediaImages => inner.MediaImages;
|
||||
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
|
||||
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
|
||||
public DbSet<BillingSettings> BillingSettings => inner.BillingSettings;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Media;
|
||||
using PnvPanel.Application.Media.GetImage;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Media;
|
||||
|
||||
public class GetMediaImageQueryHandlerTests
|
||||
{
|
||||
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenImageExists_ReturnsContentStream()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var image = MediaImage.Create("shot.png", "stored-name", "image/png", 10, Guid.NewGuid());
|
||||
dbContext.MediaImages.Add(image);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_fileStorage
|
||||
.OpenReadAsync("stored-name", Arg.Any<CancellationToken>())
|
||||
.Returns(new MemoryStream([1, 2, 3]));
|
||||
|
||||
var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetMediaImageQuery(image.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("image/png", result.Value.ContentType);
|
||||
Assert.Equal("shot.png", result.Value.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRowExistsButFileMissing_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var image = MediaImage.Create("shot.png", "stored-name", "image/png", 10, Guid.NewGuid());
|
||||
dbContext.MediaImages.Add(image);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_fileStorage
|
||||
.OpenReadAsync("stored-name", Arg.Any<CancellationToken>())
|
||||
.Returns((Stream?)null);
|
||||
|
||||
var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetMediaImageQuery(image.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(MediaErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenImageUnknown_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetMediaImageQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(MediaErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Media;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Media;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Media;
|
||||
|
||||
public class UploadMediaImageCommandHandlerTests
|
||||
{
|
||||
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||
|
||||
private static MediaImageUpload Upload(
|
||||
string contentType = "image/png",
|
||||
long sizeBytes = 1024,
|
||||
string fileName = "screenshot.png"
|
||||
) => new(new MemoryStream([1, 2, 3]), fileName, contentType, sizeBytes);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidImage_SavesFileAndRow()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
_fileStorage
|
||||
.SaveAsync(Arg.Any<Stream>(), Arg.Any<CancellationToken>())
|
||||
.Returns("stored-name");
|
||||
|
||||
var handler = new UploadMediaImageCommandHandler(
|
||||
dbContext,
|
||||
_fileStorage,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new UploadMediaImageCommand(Upload()),
|
||||
CancellationToken.None
|
||||
);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("screenshot.png", result.Value.FileName);
|
||||
var image = Assert.Single(dbContext.MediaImages);
|
||||
Assert.Equal("stored-name", image.StoredFileName);
|
||||
Assert.Equal(adminId, image.UploadedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithUnsupportedContentType_ReturnsValidationErrorWithoutSaving()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new UploadMediaImageCommandHandler(
|
||||
dbContext,
|
||||
_fileStorage,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid())
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new UploadMediaImageCommand(Upload(contentType: "image/svg+xml", fileName: "x.svg")),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(MediaErrors.UnsupportedImageType, result.Error);
|
||||
Assert.Empty(dbContext.MediaImages);
|
||||
await _fileStorage
|
||||
.DidNotReceive()
|
||||
.SaveAsync(Arg.Any<Stream>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Лимит продублирован из MediaImageValidation — она internal (см. Application).</summary>
|
||||
private const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithTooLargeImage_ReturnsValidationError()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new UploadMediaImageCommandHandler(
|
||||
dbContext,
|
||||
_fileStorage,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid())
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new UploadMediaImageCommand(Upload(sizeBytes: MaxSizeBytes + 1)),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(MediaErrors.ImageTooLarge, result.Error);
|
||||
Assert.Empty(dbContext.MediaImages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using PnvPanel.IntegrationTests.TestSupport;
|
||||
using Xunit;
|
||||
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.Media;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class MediaImageFlowTests(PnvPanelWebApplicationFactory factory)
|
||||
{
|
||||
private sealed record MediaImageResponse(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes
|
||||
);
|
||||
|
||||
/// <summary>Минимальный валидный PNG (1×1) — не важно содержимое, важен путь загрузка → отдача.</summary>
|
||||
private static readonly byte[] PngBytes = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
);
|
||||
|
||||
private static MultipartFormDataContent ImageContent(
|
||||
byte[] bytes,
|
||||
string contentType,
|
||||
string fileName
|
||||
)
|
||||
{
|
||||
var file = new ByteArrayContent(bytes);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
return new MultipartFormDataContent { { file, "file", fileName } };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUploadsImage_ThenAnyoneCanFetchItWithoutAuth()
|
||||
{
|
||||
using var adminClient = factory.CreateClient();
|
||||
adminClient.UseBearerToken(await LoginAsAdminAsync(adminClient));
|
||||
|
||||
var uploadResponse = await adminClient.PostAsync(
|
||||
"/api/admin/media/images",
|
||||
ImageContent(PngBytes, "image/png", "shot.png")
|
||||
);
|
||||
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
|
||||
var image = await uploadResponse.ReadAsAsync<MediaImageResponse>();
|
||||
Assert.NotNull(image);
|
||||
Assert.Equal("shot.png", image!.FileName);
|
||||
|
||||
// Анонимный клиент: markdown-картинку грузит обычный <img> без Authorization.
|
||||
using var anonymousClient = factory.CreateClient();
|
||||
var getResponse = await anonymousClient.GetAsync($"/api/media/images/{image.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
Assert.Equal("image/png", getResponse.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(PngBytes, await getResponse.Content.ReadAsByteArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_WhenNotAdmin_ReturnsForbidden()
|
||||
{
|
||||
using var userClient = factory.CreateClient();
|
||||
var userName = $"mia_{Guid.NewGuid():N}"[..20];
|
||||
var (_, token) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||
userClient.UseBearerToken(token);
|
||||
|
||||
var response = await userClient.PostAsync(
|
||||
"/api/admin/media/images",
|
||||
ImageContent(PngBytes, "image/png", "shot.png")
|
||||
);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_WithUnsupportedType_ReturnsBadRequest()
|
||||
{
|
||||
using var adminClient = factory.CreateClient();
|
||||
adminClient.UseBearerToken(await LoginAsAdminAsync(adminClient));
|
||||
|
||||
var response = await adminClient.PostAsync(
|
||||
"/api/admin/media/images",
|
||||
ImageContent("<svg/>"u8.ToArray(), "image/svg+xml", "x.svg")
|
||||
);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetImage_WhenUnknownId_ReturnsNotFound()
|
||||
{
|
||||
using var anonymousClient = factory.CreateClient();
|
||||
|
||||
var response = await anonymousClient.GetAsync($"/api/media/images/{Guid.NewGuid()}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user