Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
// File Upload Errors
public const string NoFilesProvided = "NO_FILES_PROVIDED";
public const string InvalidFileType = "INVALID_FILE_TYPE";
public const string InvalidDemoFile = "INVALID_DEMO_FILE";

Check warning on line 30 in src/XtremeIdiots.Portal.Repository.Abstractions.V1/Constants/V1/ApiErrorCodes.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'ApiErrorCodes.InvalidDemoFile'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED7_avp_0QL30dQ5&open=AaEDED7_avp_0QL30dQ5&pullRequest=885

// Date/Time Validation Errors
public const string InvalidCutoffDate = "INVALID_CUTOFF_DATE";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
// File Upload Error Messages
public const string NoFilesProvidedMessage = "Request did not contain any files";
public const string InvalidFileTypeMessage = "Invalid file type extension";
public const string InvalidDemoFileMessage = "The uploaded file could not be parsed as a valid demo";

Check warning on line 47 in src/XtremeIdiots.Portal.Repository.Abstractions.V1/Constants/V1/ApiErrorMessages.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'ApiErrorMessages.InvalidDemoFileMessage'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED67avp_0QL30dQ4&open=AaEDED67avp_0QL30dQ4&pullRequest=885

// Server Error Messages
public const string InternalServerErrorMessage = "An internal server error occurred";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
using System.Net;
using Moq;
using Microsoft.Extensions.Configuration;
using Xunit;
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
using XtremeIdiots.Portal.Repository.Abstractions.Interfaces.V1;
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Demos;
using XtremeIdiots.Portal.Repository.Api.Tests.V1.TestHelpers;
using XtremeIdiots.Portal.Repository.Api.V1.Services;
using XtremeIdiots.Portal.Repository.DataLib;
using XtremeIdiots.Portal.RepositoryWebApi.Controllers.V1;

namespace XtremeIdiots.Portal.Repository.Api.Tests.V1.Controllers.V1;

public class DemosControllerTests
{
private DemosController CreateController(PortalDbContext context)
private static DemosController CreateController(
PortalDbContext context,
Mock<IDemoFileProcessor>? demoFileProcessor = null)
{
var config = new Mock<IConfiguration>();
return new DemosController(context, config.Object);
demoFileProcessor ??= new Mock<IDemoFileProcessor>();
return new DemosController(context, demoFileProcessor.Object);
}

[Fact]
Expand Down Expand Up @@ -115,4 +117,81 @@

Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
}

[Fact]
public async Task SetDemoFile_WithValidDemo_PersistsParsedMetadata()

Check warning on line 122 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'DemosControllerTests.SetDemoFile_WithValidDemo_PersistsParsedMetadata()'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ6&open=AaEDED9Iavp_0QL30dQ6&pullRequest=885
{
using var context = DbContextHelper.CreateInMemoryContext();
var demoId = Guid.NewGuid();
context.Demos.Add(new Demo
{
DemoId = demoId,
GameType = (int)GameType.CallOfDuty5
});

Check warning on line 130 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ8&open=AaEDED9Iavp_0QL30dQ8&pullRequest=885
await context.SaveChangesAsync();

Check warning on line 131 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ9&open=AaEDED9Iavp_0QL30dQ9&pullRequest=885

var processedDemo = new DemoFileProcessingResult(
"stored.dm_6",
new Uri("https://storage.example/demos/stored.dm_6"),
new DateTime(2026, 10, 3, 12, 0, 0, DateTimeKind.Utc),
"mp_cassino5",
"xidmace3",
"dm",
"^1>XI< ^3DM3",
798868);
var processor = new Mock<IDemoFileProcessor>();
processor
.Setup(x => x.ProcessAsync("demo.tmp", GameType.CallOfDuty5, It.IsAny<CancellationToken>()))
.ReturnsAsync(processedDemo);

Check warning on line 145 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ-&open=AaEDED9Iavp_0QL30dQ-&pullRequest=885
var controller = CreateController(context, processor);
var api = (IDemosApi)controller;

var result = await api.SetDemoFile(demoId, "uploaded.dm_6", "demo.tmp");

Assert.Equal(HttpStatusCode.OK, result.StatusCode);
var demo = Assert.Single(context.Demos);
Assert.Equal("uploaded", demo.Title);
Assert.Equal(processedDemo.BlobKey, demo.FileName);
Assert.Equal(processedDemo.Created, demo.Created);
Assert.Equal(processedDemo.Map, demo.Map);
Assert.Equal(processedDemo.Mod, demo.Mod);
Assert.Equal(processedDemo.GameMode, demo.GameMode);
Assert.Equal(processedDemo.ServerName, demo.ServerName);
Assert.Equal(processedDemo.FileSize, demo.FileSize);
Assert.Equal(processedDemo.BlobUri.ToString(), demo.FileUri);
}

[Fact]
public async Task SetDemoFile_WithInvalidDemo_ReturnsUnprocessableEntityWithoutPersistingSentinels()

Check warning on line 165 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'DemosControllerTests.SetDemoFile_WithInvalidDemo_ReturnsUnprocessableEntityWithoutPersistingSentinels()'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ7&open=AaEDED9Iavp_0QL30dQ7&pullRequest=885
{
using var context = DbContextHelper.CreateInMemoryContext();
var demoId = Guid.NewGuid();
context.Demos.Add(new Demo
{
DemoId = demoId,
GameType = (int)GameType.CallOfDuty5
});

Check warning on line 173 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dQ_&open=AaEDED9Iavp_0QL30dQ_&pullRequest=885
await context.SaveChangesAsync();

Check warning on line 174 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dRA&open=AaEDED9Iavp_0QL30dRA&pullRequest=885

var processor = new Mock<IDemoFileProcessor>();
processor
.Setup(x => x.ProcessAsync("demo.tmp", GameType.CallOfDuty5, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidDataException("Invalid demo"));

Check warning on line 179 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/DemosControllerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Iavp_0QL30dRB&open=AaEDED9Iavp_0QL30dRB&pullRequest=885
var controller = CreateController(context, processor);
var api = (IDemosApi)controller;

var result = await api.SetDemoFile(demoId, "uploaded.dm_6", "demo.tmp");

Assert.Equal(HttpStatusCode.UnprocessableEntity, result.StatusCode);
Comment thread
frasermolyneux marked this conversation as resolved.
Assert.Equal(ApiErrorCodes.InvalidDemoFile, result.Result?.Errors?.FirstOrDefault()?.Code);
var demo = Assert.Single(context.Demos);
Assert.Null(demo.Title);
Assert.Null(demo.FileName);
Assert.Null(demo.Map);
Assert.Null(demo.Mod);
Assert.Null(demo.GameMode);
Assert.Null(demo.ServerName);
Assert.Null(demo.FileUri);
Assert.Equal(0, demo.FileSize);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
using XtremeIdiots.Portal.Repository.Api.V1.Services;

namespace XtremeIdiots.Portal.Repository.Api.Tests.V1.Services;

public class DemoFileProcessorTests

Check warning on line 10 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Services/DemoFileProcessorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'DemoFileProcessorTests'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Oavp_0QL30dRC&open=AaEDED9Oavp_0QL30dRC&pullRequest=885
{
[Fact]
public async Task ProcessAsync_WithInvalidDemo_ThrowsBeforeStorageAccess()

Check warning on line 13 in src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Services/DemoFileProcessorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Missing XML comment for publicly visible type or member 'DemoFileProcessorTests.ProcessAsync_WithInvalidDemo_ThrowsBeforeStorageAccess()'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED9Oavp_0QL30dRD&open=AaEDED9Oavp_0QL30dRD&pullRequest=885
{
var filePath = Path.GetTempFileName();
try
{
var configuration = new ConfigurationBuilder().Build();
var processor = new DemoFileProcessor(
configuration,
Mock.Of<ILogger<DemoFileProcessor>>());

_ = await Assert.ThrowsAsync<InvalidDataException>(() =>
processor.ProcessAsync(filePath, GameType.CallOfDuty5, CancellationToken.None));
}
finally
{
File.Delete(filePath);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
using System.Net;
using Asp.Versioning;

using Azure.Identity;
using Azure.Storage.Blobs;

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
Expand All @@ -13,13 +10,13 @@

using Newtonsoft.Json;

using MX.CodDemoReader.Models;
using XtremeIdiots.Portal.Repository.DataLib;
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
using XtremeIdiots.Portal.Repository.Abstractions.Interfaces.V1;
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Demos;
using XtremeIdiots.Portal.Repository.Api.V1.Extensions;
using XtremeIdiots.Portal.Repository.Api.V1.Mapping;
using XtremeIdiots.Portal.Repository.Api.V1.Services;

namespace XtremeIdiots.Portal.RepositoryWebApi.Controllers.V1
{
Expand All @@ -31,16 +28,16 @@ public class DemosController : ControllerBase, IDemosApi
{
private readonly PortalDbContext context;

private readonly IConfiguration configuration;
private readonly IDemoFileProcessor demoFileProcessor;

public DemosController(
PortalDbContext context,
IConfiguration configuration)
IDemoFileProcessor demoFileProcessor)
{
ArgumentNullException.ThrowIfNull(context);
this.context = context;
ArgumentNullException.ThrowIfNull(configuration);
this.configuration = configuration;
ArgumentNullException.ThrowIfNull(demoFileProcessor);
this.demoFileProcessor = demoFileProcessor;
}

/// <summary>
Expand Down Expand Up @@ -199,6 +196,7 @@ async Task<ApiResult<DemoDto>> IDemosApi.CreateDemo(CreateDemoDto createDemoDto,
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> SetDemoFile(Guid demoId, CancellationToken cancellationToken = default)
{
if (Request.Form.Files.Count == 0)
Expand All @@ -214,15 +212,22 @@ public async Task<IActionResult> SetDemoFile(Guid demoId, CancellationToken canc
return new ApiResult(HttpStatusCode.BadRequest, new ApiResponse(new ApiError(ApiErrorCodes.InvalidFileType, ApiErrorMessages.InvalidFileTypeMessage))).ToHttpResult();
}

var filePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
using (var stream = System.IO.File.Create(filePath))
var tempFile = new FileInfo(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()));
try
{
await file.CopyToAsync(stream, cancellationToken).ConfigureAwait(false);
}
using (var stream = tempFile.Create())
{
await file.CopyToAsync(stream, cancellationToken).ConfigureAwait(false);
}

var response = await ((IDemosApi)this).SetDemoFile(demoId, file.FileName, filePath, cancellationToken).ConfigureAwait(false);
var response = await ((IDemosApi)this).SetDemoFile(demoId, file.FileName, tempFile.FullName, cancellationToken).ConfigureAwait(false);

return response.ToHttpResult();
return response.ToHttpResult();
}
finally
{
tempFile.Delete();
}
}

/// <summary>
Expand All @@ -243,24 +248,32 @@ async Task<ApiResult> IDemosApi.SetDemoFile(Guid demoId, string fileName, string
return new ApiResult(HttpStatusCode.NotFound);
}

var blobServiceClient = new BlobServiceClient(new Uri(configuration["appdata_storage_blob_endpoint"]!), new DefaultAzureCredential());
var containerClient = blobServiceClient.GetBlobContainerClient("demos");

var blobKey = $"{Guid.NewGuid()}.{demo.GameType.ToGameType().DemoExtension()}";
var blobClient = containerClient.GetBlobClient(blobKey);
await blobClient.UploadAsync(filePath, cancellationToken).ConfigureAwait(false);

var localDemo = new LocalDemo(filePath, demo.GameType.ToCodDemoReaderGameVersion());
DemoFileProcessingResult processedDemo;
try
{
processedDemo = await demoFileProcessor.ProcessAsync(
filePath,
demo.GameType.ToGameType(),
cancellationToken).ConfigureAwait(false);
}
catch (InvalidDataException)
{
return new ApiResult(
HttpStatusCode.UnprocessableEntity,
new ApiResponse(new ApiError(
ApiErrorCodes.InvalidDemoFile,
ApiErrorMessages.InvalidDemoFileMessage)));
Comment thread
frasermolyneux marked this conversation as resolved.
}

demo.Title = Path.GetFileNameWithoutExtension(fileName);
demo.FileName = blobKey;
demo.Created = localDemo.Created;
demo.Map = localDemo.Map;
demo.Mod = localDemo.Mod;
demo.GameMode = localDemo.GameMode;
demo.ServerName = localDemo.ServerName;
demo.FileSize = localDemo.FileSize;
demo.FileUri = blobClient.Uri.ToString();
demo.FileName = processedDemo.BlobKey;
demo.Created = processedDemo.Created;
demo.Map = processedDemo.Map;
demo.Mod = processedDemo.Mod;
demo.GameMode = processedDemo.GameMode;
demo.ServerName = processedDemo.ServerName;
demo.FileSize = processedDemo.FileSize;
demo.FileUri = processedDemo.BlobUri.ToString();

await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

Expand Down Expand Up @@ -348,4 +361,3 @@ private IQueryable<Demo> ApplyOrderingAndPagination(IQueryable<Demo> query, int
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Azure.Identity;
using Azure.Storage.Blobs;
using MX.CodDemoReader.Models;
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
using XtremeIdiots.Portal.Repository.Api.V1.Extensions;

namespace XtremeIdiots.Portal.Repository.Api.V1.Services

Check warning on line 7 in src/XtremeIdiots.Portal.Repository.Api.V1/Services/DemoFileProcessor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Convert to file-scoped namespace

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED4iavp_0QL30dQ0&open=AaEDED4iavp_0QL30dQ0&pullRequest=885
{
/// <summary>
/// Parses demo metadata before uploading valid files to blob storage.
/// </summary>
public sealed class DemoFileProcessor : IDemoFileProcessor
{
private readonly IConfiguration configuration;
private readonly ILogger<DemoFileProcessor> logger;

/// <summary>
/// Initializes a new instance of the <see cref="DemoFileProcessor"/> class.
/// </summary>
public DemoFileProcessor(
IConfiguration configuration,
ILogger<DemoFileProcessor> logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
this.configuration = configuration;
this.logger = logger;
}

/// <inheritdoc />
public async Task<DemoFileProcessingResult> ProcessAsync(
string filePath,
GameType gameType,
CancellationToken cancellationToken)
{
var localDemo = new LocalDemo(filePath, gameType.ToGameTypeInt().ToCodDemoReaderGameVersion());
if (!localDemo.IsValid)
{
logger.LogWarning("Unable to parse uploaded demo for game type {GameType}", gameType);

Check warning on line 39 in src/XtremeIdiots.Portal.Repository.Api.V1/Services/DemoFileProcessor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

For improved performance, use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogWarning(ILogger, string?, params object?[])'

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED4iavp_0QL30dQ1&open=AaEDED4iavp_0QL30dQ1&pullRequest=885
throw new InvalidDataException("The uploaded file could not be parsed as a valid demo");
}

var blobEndpoint = configuration["appdata_storage_blob_endpoint"];
if (string.IsNullOrWhiteSpace(blobEndpoint))
{
throw new InvalidOperationException("Demo blob storage endpoint is not configured");
}

var blobServiceClient = new BlobServiceClient(new Uri(blobEndpoint), new DefaultAzureCredential());
var containerClient = blobServiceClient.GetBlobContainerClient("demos");
var blobKey = $"{Guid.NewGuid()}.{gameType.DemoExtension()}";
var blobClient = containerClient.GetBlobClient(blobKey);

await blobClient.UploadAsync(filePath, cancellationToken).ConfigureAwait(false);

Check warning on line 54 in src/XtremeIdiots.Portal.Repository.Api.V1/Services/DemoFileProcessor.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expression value is never used

See more on https://sonarcloud.io/project/issues?id=frasermolyneux_portal-repository&issues=AaEDED4iavp_0QL30dQ2&open=AaEDED4iavp_0QL30dQ2&pullRequest=885

return new DemoFileProcessingResult(
blobKey,
blobClient.Uri,
localDemo.Created,
localDemo.Map,
localDemo.Mod,
localDemo.GameMode,
localDemo.ServerName,
localDemo.FileSize);
}
}
}
Loading
Loading