diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4ba1ecd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +end_of_line = crlf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.csproj] +indent_size = 2 + +[*.json] +indent_size += 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index eeff256..a915aee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .vs/* .idea/* +Logs/* */obj/* */bin/* -*/Logs/* *.user *.dat *.mp3 diff --git a/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj b/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj new file mode 100644 index 0000000..9393114 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/ArmaForces.Boderator.BotService.Tests.csproj @@ -0,0 +1,34 @@ + + + + net6.0 + enable + false + Library + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs new file mode 100644 index 0000000..350f069 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/Features/Health/HealthControllerTests.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.Features.Health +{ + [Trait("Category", "Integration")] + public class HealthControllerTests : ApiTestBase + { + public HealthControllerTests(TestApiServiceFixture testApi) + : base(testApi) { } + + [Fact] + public async Task Ping_AllOk_ReturnsPong() + { + var result = await HttpGetAsync("api/health/ping"); + + result.ShouldBeSuccess("pong"); + } + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs new file mode 100644 index 0000000..89e8b08 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/CreateMissionsControllerTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.Features.Missions +{ + [Trait("Category", "Integration")] + public class MissionsControllerTests : ApiTestBase + { + public MissionsControllerTests(TestApiServiceFixture testApi) + : base(testApi) { } + + [Theory, ClassData(typeof(CreateMissionInvalidRequestTestData)), Trait("Category", "Integration")] + public async Task CreateMission_InvalidRequest_ReturnsBadRequest(MissionCreateRequestDto missionCreateRequestDto) + { + var result = await HttpPostAsync("api/missions", missionCreateRequestDto); + + result.ShouldBeFailure(); + } + + [Theory, ClassData(typeof(CreateMissionValidRequestTestData)), Trait("Category", "Integration")] + public async Task CreateMission_ValidRequest_MissionCreated(MissionCreateRequestDto missionCreateRequestDto) + { + var result = await HttpPostAsync("api/missions", missionCreateRequestDto); + + result.ShouldBeSuccess(missionCreateRequestDto); + } + + private class CreateMissionValidRequestTestData : TheoryData + { + private readonly MissionCreateRequestDto _minimalRequest = new() + { + Title = "Test mission title", + Owner = "Test mission owner" + }; + + public CreateMissionValidRequestTestData() + { + var testCases = new List + { + _minimalRequest, + _minimalRequest with + { + Description = "Test mission description" + }, + _minimalRequest with + { + MissionDate = DateTime.Now.AddHours(-1) + }, + _minimalRequest with + { + ModsetName = "Test-mission-modset" + }, + _minimalRequest with + { + Description = "Test mission description", + MissionDate = DateTime.Now.AddHours(-1), + ModsetName = "Test-mission-modset" + } + }; + + foreach (var testCase in testCases) Add(testCase); + } + } + + private class CreateMissionInvalidRequestTestData : TheoryData + { + public CreateMissionInvalidRequestTestData() + { + var testCases = new List + { + new() + { + Title = "Test mission title without owner" + }, + new() + { + Owner = "Test mission owner without title" + }, + new() + { + Title = "Test mission title", + Owner = "Test mission owner", + ModsetName = "Modset name with whitespace characters" + } + }; + + foreach (var testCase in testCases) Add(testCase); + } + } + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs new file mode 100644 index 0000000..32c3ab4 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/GetMissionsControllerTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using AutoFixture; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.Features.Missions; + +[Trait("Category", "Integration")] +public class GetMissionsControllerTests : ApiTestBase +{ + public GetMissionsControllerTests(TestApiServiceFixture testApi) + : base(testApi) { } + + [Fact] + public async Task GetMission_MissionExists_ReturnsExistingMission() + { + var missionCreateRequest = new MissionCreateRequestDto + { + Title = Fixture.Create(), + Owner = Fixture.Create(), + Description = Fixture.Create() + }; + + var missionCreateResult = await HttpPostAsync("api/missions", missionCreateRequest); + + var expectedMission = new MissionDto + { + Title = missionCreateResult.Value.Title, + Description = missionCreateRequest.Description, + Owner = missionCreateRequest.Owner, + MissionDate = missionCreateResult.Value.MissionDate, + MissionId = missionCreateResult.Value.MissionId + }; + + var result = await HttpGetAsync($"api/missions/{expectedMission.MissionId}"); + + result.ShouldBeSuccess(expectedMission); + } + + [Fact] + public async Task GetMissions_SomeMissionsExist_ReturnsAllMissions() + { + var createdMissions = await CreateSomeMissions(count: 5); + var creationResult = createdMissions.Combine(); + creationResult.ShouldBeSuccess(); + + var expectedMissions = creationResult.Value + .Select(x => new MissionDto + { + Title = x.createdMission.Title, + Description = x.createdMission.Description, + Owner = x.createdMission.Owner, + MissionDate = x.createdMission.MissionDate, + MissionId = x.createdMission.MissionId + }) + .ToList(); + + var result = await HttpGetAsync>($"api/missions"); + + result.ShouldBeSuccess(x => x.Should().Contain(expectedMissions)); + } + + private async Task>> CreateSomeMissions(int count = 1) + { + return await AsyncEnumerable.Range(0, count) + .Select(x => new MissionCreateRequestDto + { + Title = Fixture.Create(), + Owner = Fixture.Create(), + Description = Fixture.Create() + }) + .SelectAwait(async createRequest => { + var createResult = await HttpPostAsync("api/missions", createRequest); + return (createResult, createRequest); + }) + .Select(x => x.createResult.IsSuccess + ? Result.Success((x.createResult.Value, x.createRequest)) + : x.createResult.ConvertFailure<(MissionDto, MissionCreateRequestDto)>()) + .ToListAsync(); + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs b/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs new file mode 100644 index 0000000..2b76b1f --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/Features/Missions/SignupsControllerTests.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.Features.Missions +{ + public class SignupsControllerTests : ApiTestBase + { + public SignupsControllerTests(TestApiServiceFixture testApi) + : base(testApi) { } + + [Theory, ClassData(typeof(CreateSignupsInvalidRequestTestData)), Trait("Category", "Integration")] + public async Task CreateSignups_InvalidRequest_ReturnsBadRequest( + SignupsCreateRequestDto signupsCreateRequestDto) + { + var result = + await HttpPostAsync("api/signups", signupsCreateRequestDto); + + result.ShouldBeFailure(); + } + + private class CreateSignupsInvalidRequestTestData : TheoryData + { + public CreateSignupsInvalidRequestTestData() + { + var testCases = new List + { + new() + { + Teams = new List() + }, + new() + { + Mission = new MissionCreateRequestDto(), + Teams = new List() + }, + new() + { + MissionId = 0, + Teams = new List() + }, + new() + { + MissionId = 0, + Mission = new MissionCreateRequestDto(), + Teams = new List() + } + }; + + foreach (var testCase in testCases) Add(testCase); + } + } + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs new file mode 100644 index 0000000..2753561 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/LegacySignupsImportTest.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Tests.LegacyImportTest.MissionsApi; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.LegacyImportTest; + +[Trait("Category", "Functional")] +public class LegacySignupsImportTest : ApiTestBase +{ + private const string MissionsResource = @"https://boderator.armaforces.com/api/missions?includeArchive=true"; + + public LegacySignupsImportTest(TestApiServiceFixture testApi) : base(testApi) + { + } + + [Fact(Skip = "Manual test")] + public async Task ImportLegacySignups_OnlyMissionData_Imported() + { + var legacyMissionsResult = await HttpGetAsync>(MissionsResource); + + var createdMissions = await legacyMissionsResult + .Map(missions => missions + .OrderBy(x => x.Date) + .Select( + mission => new SignupsCreateRequestDto + { + CloseDate = mission.CloseDate, + SignupsStatus = mission.Archive ? SignupsStatus.Closed : SignupsStatus.Open, + StartDate = mission.CloseDate, // TODO: Try to figure signups start date possibly? + Mission = new MissionCreateRequestDto + { + Title = mission.Title, + ModsetName = mission.Modlist.Replace(" ", "-"), + Description = mission.Description, + MissionDate = mission.Date, + Owner = "AF" + } + })) + .Bind(async newMissions => + { + var creationResults = new List>(); + + foreach (var signupsCreateRequestDto in newMissions) + { + var result = + await HttpPostAsync("api/signups", + signupsCreateRequestDto); + + creationResults.Add(result); + } + + return creationResults.Combine(); + }); + + var missions = await HttpGetAsync>($"api/missions"); + + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs new file mode 100644 index 0000000..440a5bf --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/LegacyImportTest/MissionsApi/WebMission.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; + +namespace ArmaForces.Boderator.BotService.Tests.LegacyImportTest.MissionsApi; + +public class WebMission +{ + public string Title { get; set; } = string.Empty; + + public DateTime Date { get; set; } + + public DateTime CloseDate { get; set; } + + public string Description { get; set; } = string.Empty; + + public string Modlist + { + get => _modlist; + set => _modlist = value.Split('/').Last(); + } + + public bool Archive { get; set; } + + private string _modlist = string.Empty; +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs new file mode 100644 index 0000000..33c1494 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/CollectionsNames.cs @@ -0,0 +1,7 @@ +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections +{ + internal static class CollectionsNames + { + public const string ApiTest = "TestAPI"; + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs new file mode 100644 index 0000000..d65920c --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/Collections/Definitions/TestApiCollection.cs @@ -0,0 +1,13 @@ +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections.Definitions +{ + [CollectionDefinition(CollectionsNames.ApiTest)] + public class TestApiCollection : ICollectionFixture + { + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs new file mode 100644 index 0000000..366d0a0 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestBases/ApiTestBase.cs @@ -0,0 +1,108 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Transactions; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.Collections; +using ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using AutoFixture; +using CSharpFunctionalExtensions; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestBases +{ + /// + /// Base class for integration tests involving API. + /// Provider test server and methods to invoke endpoints. + /// + [Collection(CollectionsNames.ApiTest)] + public abstract class ApiTestBase : IDisposable + { + private readonly HttpClient _httpClient; + + protected Fixture Fixture { get; } = new Fixture(); + + protected IServiceProvider Provider { get; } + + public void Dispose() => TransactionScope?.Dispose(); + private TransactionScope? TransactionScope { get; } + + protected ApiTestBase(TestApiServiceFixture testApi) + { + _httpClient = testApi.HttpClient; + Provider = testApi.ServiceProvider; + + // TODO: This doesn't and probably won't work + TransactionScope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions + { + IsolationLevel = IsolationLevel.ReadCommitted + }, + TransactionScopeAsyncFlowOption.Enabled); + } + + protected async Task> HttpGetAsync(string path) + { + return await HttpGetAsync(path) + .Bind(DeserializeContent); + } + + protected async Task> HttpGetAsync(string path) + { + var httpResponseMessage = await _httpClient.GetAsync(path); + if (httpResponseMessage.IsSuccessStatusCode) + { + return await httpResponseMessage.Content.ReadAsStringAsync(); + } + + var responseBody = await httpResponseMessage.Content.ReadAsStringAsync(); + var error = string.IsNullOrWhiteSpace(responseBody) + ? httpResponseMessage.ReasonPhrase + : responseBody; + + return Result.Failure(error); + } + + protected async Task HttpPostAsync(string path, T body) + { + var stringContent = new StringContent(JsonConvert.SerializeObject(body), Encoding.Default, "application/json"); + var httpResponseMessage = await _httpClient.PostAsync(path, stringContent); + if (httpResponseMessage.IsSuccessStatusCode) + { + return Result.Success(); + } + + var responseBody = await httpResponseMessage.Content.ReadAsStringAsync(); + var error = string.IsNullOrWhiteSpace(responseBody) + ? httpResponseMessage.ReasonPhrase + : responseBody; + + return Result.Failure(error); + } + + protected async Task> HttpPostAsync(string path, TRequest body) + { + var stringContent = new StringContent(JsonConvert.SerializeObject(body), Encoding.Default, "application/json"); + var httpResponseMessage = await _httpClient.PostAsync(path, stringContent); + if (httpResponseMessage.IsSuccessStatusCode) + { + return DeserializeContent(await httpResponseMessage.Content.ReadAsStringAsync()); + } + + var responseBody = await httpResponseMessage.Content.ReadAsStringAsync(); + var error = string.IsNullOrWhiteSpace(responseBody) + ? httpResponseMessage.ReasonPhrase + : responseBody; + + return Result.Failure(error); + } + + private static Result DeserializeContent(string content) + => JsonConvert.DeserializeObject(content); + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs new file mode 100644 index 0000000..4921b82 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestConfigurationFactory.cs @@ -0,0 +1,14 @@ +using ArmaForces.Boderator.BotService.Configuration; +using ArmaForces.Boderator.Core.Tests.TestUtilities; + +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities +{ + internal class TestConfigurationFactory : IBoderatorConfigurationFactory + { + public BoderatorConfiguration CreateConfiguration() => new BoderatorConfiguration + { + ConnectionString = TestDatabaseConstants.TestDbConnectionString, + DiscordToken = string.Empty + }; + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs new file mode 100644 index 0000000..107cc10 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApiServiceFixture.cs @@ -0,0 +1,30 @@ +using System; +using System.Net.Http; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures +{ + // This class is created by XUnit test runner once for TestApiCollection + // ReSharper disable once ClassNeverInstantiated.Global + public class TestApiServiceFixture : IDisposable + { + private readonly WebApplicationFactory _testAppFactory; + + public HttpClient HttpClient { get; } + + internal IServiceProvider ServiceProvider => _testAppFactory.Services; + + public TestApiServiceFixture() + { + _testAppFactory = new TestApplicationFactory(); + HttpClient = _testAppFactory.CreateClient(); + } + + public void Dispose() + { + HttpClient.Dispose(); + _testAppFactory.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs new file mode 100644 index 0000000..85e1538 --- /dev/null +++ b/ArmaForces.Boderator.BotService.Tests/TestUtilities/TestFixtures/TestApplicationFactory.cs @@ -0,0 +1,15 @@ +using ArmaForces.Boderator.Core.DependencyInjection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace ArmaForces.Boderator.BotService.Tests.TestUtilities.TestFixtures; + +internal class TestApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices( + x => x.AddOrReplaceSingleton(new TestConfigurationFactory().CreateConfiguration())); + } +} diff --git a/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj b/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj index 61351d0..072fc86 100644 --- a/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj +++ b/ArmaForces.Boderator.BotService/ArmaForces.Boderator.BotService.csproj @@ -1,35 +1,38 @@ - net5.0 + net6.0 a9e33227-0f21-4863-9830-8aa69ac1e928 Linux + enable + true - + - - - - + - + - - + + + - + - - - + + + - - - + + + + - + + <_Parameter1>$(MSBuildProjectName).Tests + diff --git a/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs new file mode 100644 index 0000000..430adf4 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfiguration.cs @@ -0,0 +1,9 @@ +namespace ArmaForces.Boderator.BotService.Configuration +{ + internal record BoderatorConfiguration + { + public string ConnectionString { get; init; } = string.Empty; + + public string DiscordToken { get; init; } = string.Empty; + } +} diff --git a/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs new file mode 100644 index 0000000..557a264 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Configuration/BoderatorConfigurationFactory.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Configuration; +using Microsoft.Extensions.Configuration; + +namespace ArmaForces.Boderator.BotService.Configuration +{ + internal class BoderatorConfigurationFactory : IBoderatorConfigurationFactory + { + private readonly IDictionary _environmentVariables; + + public BoderatorConfigurationFactory() + { + _environmentVariables = Environment.GetEnvironmentVariables(); + } + + public BoderatorConfigurationFactory(IConfiguration configuration) + { + _environmentVariables = new Dictionary + { + {$"AF_Boderator_{nameof(BoderatorConfiguration.ConnectionString)}", configuration.GetConnectionString("DefaultConnection")}, + {$"AF_Boderator_{nameof(BoderatorConfiguration.DiscordToken)}", ""} + }; + } + + // TODO: Consider making this a bit more automatic so configuration is easily extensible + public BoderatorConfiguration CreateConfiguration() => new BoderatorConfiguration + { + ConnectionString = GetStringValue(nameof(BoderatorConfiguration.ConnectionString)), + DiscordToken = GetStringValue(nameof(BoderatorConfiguration.DiscordToken)) + }; + + private string GetStringValue(string variableName) + { + var fullVariableName = $"AF_Boderator_{variableName}"; + var value = _environmentVariables[fullVariableName]; + + return value is not null + ? (string) value + : throw new ConfigurationErrorsException($"Variable {fullVariableName} does not exist."); + } + } + + internal interface IBoderatorConfigurationFactory + { + BoderatorConfiguration CreateConfiguration(); + } +} diff --git a/ArmaForces.Boderator.BotService/Controllers/DebugController.cs b/ArmaForces.Boderator.BotService/Controllers/DebugController.cs deleted file mode 100644 index cd7e1b1..0000000 --- a/ArmaForces.Boderator.BotService/Controllers/DebugController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ArmaForces.Boderator.BotService.Discord; -using Discord; -using Microsoft.AspNetCore.Mvc; - -namespace ArmaForces.Boderator.BotService.Controllers -{ - [Route("api/[controller]")] - public class DebugController : Controller - { - private readonly IDiscordService _discordService; - - public DebugController(IDiscordService discordService) - { - _discordService = discordService; - } - - [HttpGet("Test")] - public async Task Test(CancellationToken cToken) - { - return Ok(); - } - - [HttpGet("GetDiscordStatus")] - public ActionResult GetDiscordStatus() => Ok(_discordService.GetDiscordClientStatus()); - - [HttpPut("SetBotStatus/{status}")] - public async Task SetBotStatus(string status, ActivityType type = ActivityType.Playing) - { - await _discordService.SetBotStatus(status, type); - return Ok(); - } - } -} diff --git a/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs b/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs deleted file mode 100644 index b808323..0000000 --- a/ArmaForces.Boderator.BotService/DTOs/DiscordServiceStatus.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ArmaForces.Boderator.BotService.DTOs -{ - public class DiscordServiceStatus - { - public string ConnectionState { get; init; } - public string LoginState { get; init; } - public string ClientState { get; init; } - } -} diff --git a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs b/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs deleted file mode 100644 index 8fd4325..0000000 --- a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/DiscordServiceBuilder.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace ArmaForces.Boderator.BotService.Discord.DependencyInjection -{ - internal class DiscordServiceBuilder : IDiscordServiceBuilder - { - private IServiceCollection Services { get; } - - public DiscordServiceBuilder(IServiceCollection services) => Services = services; - } -} diff --git a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs b/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs deleted file mode 100644 index c45d93e..0000000 --- a/ArmaForces.Boderator.BotService/Discord/DependencyInjection/IDiscordServiceBuilder.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ArmaForces.Boderator.BotService.Discord.DependencyInjection -{ - internal interface IDiscordServiceBuilder - { - } -} diff --git a/ArmaForces.Boderator.BotService/Discord/DiscordService.cs b/ArmaForces.Boderator.BotService/Discord/DiscordService.cs deleted file mode 100644 index a9a0fd5..0000000 --- a/ArmaForces.Boderator.BotService/Discord/DiscordService.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ArmaForces.Boderator.BotService.DTOs; -using Discord; -using Discord.WebSocket; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ArmaForces.Boderator.BotService.Discord -{ - public sealed class DiscordService : IDiscordService, IHostedService - { - private readonly ILogger _log; - private readonly DiscordSocketClient _discordClient; - private string _token; - - public DiscordService(ILogger logger, string token) - { - _log = logger; - _discordClient = new DiscordSocketClient(); - _discordClient.Log += message => Task.Run(() => _log.Log(MapSeverity(message.Severity), "[Discord.NET Log] " + message.Message)); - _discordClient.Connected += () => Task.Run(() => _log.LogInformation("Discord connected")); - _token = token; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - _log.LogInformation("Discord Service started"); - await _discordClient.LoginAsync(TokenType.Bot, _token, true); - await _discordClient.StartAsync(); - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.Run(() => - { - _discordClient.Dispose(); - _log.LogInformation("Discord Service stopped"); - }, cancellationToken); - - public DiscordServiceStatus GetDiscordClientStatus() - { - _log.LogInformation($"Current Discord Bot status: Login: {_discordClient.LoginState} | " + - $"Connection: {_discordClient.ConnectionState} | " + - $"Status: {_discordClient.Status}"); - return new DiscordServiceStatus - { - ConnectionState = _discordClient.ConnectionState.ToString(), - LoginState = _discordClient.LoginState.ToString(), - ClientState = _discordClient.Status.ToString() - }; - } - - public async Task SetBotStatus(string newStatus, ActivityType statusType) => - await _discordClient.SetGameAsync(newStatus, type: statusType); - - private static LogLevel MapSeverity(LogSeverity severity) => - severity switch - { - LogSeverity.Verbose => LogLevel.Trace, - LogSeverity.Debug => LogLevel.Debug, - LogSeverity.Info => LogLevel.Information, - LogSeverity.Warning => LogLevel.Warning, - LogSeverity.Error => LogLevel.Error, - LogSeverity.Critical => LogLevel.Critical - }; - } -} diff --git a/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs b/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs deleted file mode 100644 index 3d62dc2..0000000 --- a/ArmaForces.Boderator.BotService/Discord/DiscordServiceCollectionExtentions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ArmaForces.Boderator.BotService.Discord.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace ArmaForces.Boderator.BotService.Discord -{ - internal static class DiscordServiceCollectionExtentions - { - public static IDiscordServiceBuilder AddDiscordService(this IServiceCollection serviceDescriptors, string token) - { - serviceDescriptors.AddSingleton(sP => new DiscordService(sP.GetService>(), token)); - serviceDescriptors.AddHostedService(sP => sP.GetRequiredService() as DiscordService); - return new DiscordServiceBuilder(serviceDescriptors); - } - } -} diff --git a/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs b/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs deleted file mode 100644 index 1f64ba1..0000000 --- a/ArmaForces.Boderator.BotService/Discord/Interfaces/IDiscordService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Threading.Tasks; -using ArmaForces.Boderator.BotService.DTOs; -using Discord; - -namespace ArmaForces.Boderator.BotService.Discord -{ - public interface IDiscordService - { - DiscordServiceStatus GetDiscordClientStatus(); - Task SetBotStatus(string newStatus, ActivityType statusType); - } -} diff --git a/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs b/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs index 1f1c59b..883acce 100644 --- a/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs +++ b/ArmaForces.Boderator.BotService/Documentation/DocumentationExtensions.cs @@ -1,3 +1,5 @@ +using System.IO; +using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; @@ -14,6 +16,11 @@ public static IServiceCollection AddDocumentation(this IServiceCollection servic options => { options.SwaggerDoc(openApiConfig.Version, openApiConfig); + options.EnableAnnotations(); + options.UseAllOfToExtendReferenceSchemas(); + + var filePath = Path.Combine(System.AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"); + options.IncludeXmlComments(filePath); }); } @@ -31,6 +38,7 @@ public static IApplicationBuilder AddDocumentation( options => { options.DocumentTitle = openApiConfig.Title; + options.ExpandResponses(""); options.SpecUrl = url; }); } diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs new file mode 100644 index 0000000..def56b2 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/DTOs/DiscordServiceStatusDto.cs @@ -0,0 +1,11 @@ +using Discord; + +namespace ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs +{ + public class DiscordServiceStatusDto + { + public ConnectionState ConnectionState { get; init; } + public LoginState LoginState { get; init; } + public UserStatus ClientState { get; init; } + } +} diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs new file mode 100644 index 0000000..510e992 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/DiscordService.cs @@ -0,0 +1,63 @@ +using System.Threading; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Configuration; +using ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ArmaForces.Boderator.BotService.Features.DiscordClient +{ + internal sealed class DiscordService : IDiscordService, IHostedService + { + private readonly DiscordSocketClient _discordClient; + private readonly ILogger _logger; + + private readonly string _token; + + public DiscordService( + DiscordSocketClient discordClient, + BoderatorConfiguration configuration, + ILogger logger) + { + _logger = logger; + _discordClient = discordClient; + _token = configuration.DiscordToken; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Discord Service started"); + // await _discordClient.LoginAsync(TokenType.Bot, _token); + // await _discordClient.StartAsync(); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.Run(() => + { + _discordClient.Dispose(); + _logger.LogInformation("Discord Service stopped"); + }, cancellationToken); + + public DiscordServiceStatusDto GetDiscordClientStatus() + { + _logger.LogInformation( + "Current Discord Bot status: Login: {BotStatus} | " + + "Connection: {ConnectionStatus} | " + + "Status: {ClientStatus}", + _discordClient.LoginState, + _discordClient.ConnectionState, + _discordClient.Status); + + return new DiscordServiceStatusDto + { + ConnectionState = _discordClient.ConnectionState, + LoginState = _discordClient.LoginState, + ClientState = _discordClient.Status + }; + } + + public async Task SetBotStatus(string newStatus, ActivityType statusType) => + await _discordClient.SetGameAsync(newStatus, type: statusType); + } +} diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs new file mode 100644 index 0000000..3cb4f27 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/IDiscordService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.DiscordClient.DTOs; +using Discord; + +namespace ArmaForces.Boderator.BotService.Features.DiscordClient +{ + internal interface IDiscordService + { + DiscordServiceStatusDto GetDiscordClientStatus(); + + Task SetBotStatus(string newStatus, ActivityType statusType); + } +} diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs new file mode 100644 index 0000000..3c1c233 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection +{ + internal static class DiscordServiceCollectionExtensions + { + public static IServiceCollection AddDiscordClient(this IServiceCollection services) + { + return services + .AddSingleton(DiscordSocketClientFactory.CreateDiscordClient) + .AddSingleton() + .AddHostedService(); + } + } +} diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs new file mode 100644 index 0000000..a694db1 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/DependencyInjection/DiscordSocketClientFactory.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.Logging; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection +{ + internal static class DiscordSocketClientFactory + { + private static bool _isClientCreated; + + public static DiscordSocketClient CreateDiscordClient(IServiceProvider serviceProvider) + { + if (_isClientCreated) + throw new InvalidOperationException("Only one Discord client can be created!"); + + _isClientCreated = true; + + var discordSocketConfig = new DiscordSocketConfig + { + AlwaysDownloadUsers = true, + MessageCacheSize = 100000 + }; + var client = new DiscordSocketClient(discordSocketConfig); + + ConfigureLogging(client, serviceProvider); + + return client; + } + + private static void ConfigureLogging(DiscordSocketClient client, IServiceProvider serviceProvider) + { + var logger = serviceProvider.GetRequiredService>(); + client.Log += message => Task.Run(() => message.LogWithLoggerAsync(logger)); + client.Connected += () => Task.Run(() => logger.LogInformation("Discord connected")); + } + } +} diff --git a/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs new file mode 100644 index 0000000..dc33aa1 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/DiscordClient/Infrastructure/Logging/LogMessageExtensions.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Microsoft.Extensions.Logging; + +// Disabled warning as messages are coming from Discord logger +// ReSharper disable TemplateIsNotCompileTimeConstantProblem +namespace ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.Logging +{ + internal static class LogMessageExtensions + { + public static Task LogWithLoggerAsync(this LogMessage logMessage, ILogger logger) + { + LogWithLogger(logMessage, logger); + return Task.CompletedTask; + } + + private static void LogWithLogger(this LogMessage logMessage, ILogger logger) + { + if (logMessage.Exception != null) + { + LogExceptionWithLogger(logMessage, logger); + return; + } + + switch (logMessage.Severity) + { + case LogSeverity.Critical: + logger.LogCritical(logMessage.Message); + break; + case LogSeverity.Error: + logger.LogError(logMessage.Message); + break; + case LogSeverity.Warning: + logger.LogWarning(logMessage.Message); + break; + case LogSeverity.Info: + logger.LogInformation(logMessage.Message); + break; + case LogSeverity.Verbose: + logger.LogDebug(logMessage.Message); + break; + case LogSeverity.Debug: + logger.LogTrace(logMessage.Message); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static void LogExceptionWithLogger(this LogMessage logMessage, ILogger logger) + { + switch (logMessage.Severity) + { + case LogSeverity.Critical: + logger.LogCritical(logMessage.Exception, logMessage.Message); + break; + case LogSeverity.Error: + logger.LogError(logMessage.Exception, logMessage.Message); + break; + case LogSeverity.Warning: + logger.LogWarning(logMessage.Exception, logMessage.Message); + break; + case LogSeverity.Info: + logger.LogInformation(logMessage.Exception, logMessage.Message); + break; + case LogSeverity.Verbose: + logger.LogDebug(logMessage.Exception, logMessage.Message); + break; + case LogSeverity.Debug: + logger.LogTrace(logMessage.Exception, logMessage.Message); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs b/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs new file mode 100644 index 0000000..fae740b --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Health/HealthController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ArmaForces.Boderator.BotService.Features.Health +{ + /// + /// Allows obtaining application status. + /// + [Route("api/[controller]")] + public class HealthController : Controller + { + /// + /// Responds to a ping. + /// + [HttpGet("ping", Name = "Ping")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Ping() => Ok("pong"); + } +} diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs new file mode 100644 index 0000000..3532bac --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionCreateRequestDto.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Mission creation request. +/// +public record MissionCreateRequestDto +{ + /// + /// Mission title. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Title { get; set; } = string.Empty; + + /// + /// Optional mission description. It's required before signups for the mission can be opened. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = true)] + public string? Description { get; set; } + + /// + /// Mission start time. It's required before signups for the mission can be opened. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = true)] + public DateTime? MissionDate { get; set; } + + /// + /// Name of the modset. It's required before signups for the mission can be opened. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = true)] + public string? ModsetName { get; set; } + + /// + /// Owner of the mission. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Owner { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs new file mode 100644 index 0000000..3d6708d --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/MissionDto.cs @@ -0,0 +1,63 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Represents a mission. +/// +public record MissionDto +{ + /// + /// Id of a mission. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public long MissionId { get; init; } + + /// + /// Mission title. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Title { get; init; } = string.Empty; + + /// + /// Description for a mission. + /// Must be provided before signups can be created. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public string? Description { get; init; } + + /// + /// Planned mission start date. + /// Must be provided before signups can be created. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public DateTime? MissionDate { get; init; } + + /// + /// Name of modset for a mission. + /// Must be provided before signups can be created. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public string? ModsetName { get; init; } + + /// + /// Mission owner/organizer. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Owner { get; init; } = string.Empty; + + /// + /// Signups for a mission. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public SignupsDto? Signups { get; init; } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs new file mode 100644 index 0000000..8988b6a --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignOutRequestDto.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Player sign out request from a slot or signups. +/// +public record PlayerSignOutRequestDto +{ + /// + /// Optional id of a slot. If not provided, player will be signed out from all slots. + /// + [JsonProperty(Required = Required.DisallowNull)] + [SwaggerSchema(Nullable = false)] + public long SlotId { get; set; } + + /// + /// Player name. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Player { get; set; } = string.Empty; +} diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs new file mode 100644 index 0000000..cac70f9 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/PlayerSignUpRequestDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Player sign-up request for a slot. +/// +public record PlayerSignUpRequestDto +{ + /// + /// Id of a slot. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public long SlotId { get; set; } + + /// + /// Player name. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Player { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs new file mode 100644 index 0000000..787d251 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsCreateRequestDto.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using ArmaForces.Boderator.Core.Missions.Models; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Signups create request. +/// +public class SignupsCreateRequestDto +{ + /// + /// Id of the mission for which the signups will be created. + /// It can't be specified if "mission" is specified. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = false)] + public int? MissionId { get; set; } + + /// + /// Mission for which the signups will be created. + /// Mission will be created before creating signups and must be ready for signups creation. + /// It can't be specified if "missionId" is specified. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = false)] + public MissionCreateRequestDto? Mission { get; set; } + + /// + /// Desired status of signups. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [DefaultValue(SignupsStatus.Created)] + public SignupsStatus SignupsStatus { get; set; } = SignupsStatus.Created; + + /// + /// Starting date of signups. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public DateTime? StartDate { get; set; } + + /// + /// Closing date of signups. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public DateTime? CloseDate { get; set; } + + /// + /// Teams available in signups. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public List Teams { get; set; } = new(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs new file mode 100644 index 0000000..d27bf2b --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SignupsDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ArmaForces.Boderator.Core.Missions.Models; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Represents a signups for a mission. +/// +public record SignupsDto +{ + /// + /// Id of a signups. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public long SignupsId { get; init; } + + /// + /// Current signups status. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public SignupsStatus Status { get; init; } + + /// + /// Scheduled start time for signups. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public DateTime? StartDate { get; init; } + + /// + /// Scheduled close time for signups. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public DateTime? CloseDate { get; init; } + + /// + /// List of teams available for players to sign up. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public List Teams { get; init; } = new(); + + /// + /// Id of a mission which the signups are for. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public long MissionId { get; init; } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs new file mode 100644 index 0000000..9c477a8 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/SlotDto.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ArmaForces.Boderator.Core.Dlcs.Models; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Represents a slot in game. +/// +public record SlotDto +{ + /// + /// Id of a slot. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = true)] + public long? SlotId { get; init; } + + /// + /// Name of a slot. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Name { get; init; } = string.Empty; + + /// + /// List of required DLCs to play on this slot. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = false)] + public List RequiredDlcs { get; init; } = new(); + + /// + /// Optional vehicle information + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public string? Vehicle { get; init; } + + /// + /// Optional name of a player who occupies the slot. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Include)] + public string? Occupant { get; init; } +} diff --git a/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs new file mode 100644 index 0000000..c007302 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/DTOs/TeamDto.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ArmaForces.Boderator.Core.Dlcs.Models; +using Newtonsoft.Json; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions.DTOs; + +/// +/// Represents a team in game. +/// +public record TeamDto +{ + /// + /// Name of a team. + /// Must be unique within signups. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public string Name { get; init; } = string.Empty; + + /// + /// List of required DLCs to play in this team. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + [SwaggerSchema(Nullable = false)] + public List RequiredDlcs { get; init; } = new(); + + /// + /// Optional vehicle information. + /// + [JsonProperty(Required = Required.DisallowNull, NullValueHandling = NullValueHandling.Ignore)] + public string? Vehicle { get; init; } + + /// + /// Slots within a team. + /// + [JsonProperty(Required = Required.Always)] + [SwaggerSchema(Nullable = false)] + [Required] + public List Slots { get; init; } = new(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs new file mode 100644 index 0000000..694990c --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/MissionMapper.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.BotService.Features.Missions.Mappers; + +public static class MissionMapper +{ + public static MissionDto Map(Mission mission) + => new() + { + Title = mission.Title, + Description = mission.Description, + Owner = mission.Owner, + ModsetName = mission.ModsetName, + MissionDate = mission.MissionDate, + MissionId = mission.MissionId, + Signups = mission.Signups is null + ? null + : SignupsMapper.Map(mission.Signups) + }; + + public static List Map(List missions) + => missions.Select(Map).ToList(); + + public static MissionCreateRequest Map(MissionCreateRequestDto request) + => new() + { + Title = request.Title, + Description = request.Description, + Owner = request.Owner, + ModsetName = request.ModsetName, + MissionDate = request.MissionDate + }; +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs new file mode 100644 index 0000000..869c209 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/Mappers/SignupsMapper.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.BotService.Features.Missions.Mappers; + +public static class SignupsMapper +{ + public static SignupsDto Map(Signups signups) + => new() + { + SignupsId = signups.SignupsId, + Status = signups.Status, + StartDate = signups.StartDate, + CloseDate = signups.CloseDate, + Teams = Map(signups.Teams) + }; + + public static TeamDto Map(Team team) + => new() + { + Name = team.Name, + Slots = Map(team.Slots), + Vehicle = team.Vehicle + }; + + public static List Map(IEnumerable teams) + => teams.Select(Map).ToList(); + + public static List Map(IEnumerable teams) + => teams.Select(Map).ToList(); + + public static Team Map(TeamDto team) + => new() + { + Name = team.Name, + Vehicle = team.Vehicle, + Slots = Map(team.Slots) + }; + + public static SlotDto Map(Slot slot) + => new() + { + SlotId = slot.SlotId, + Name = slot.Name, + Occupant = slot.Occupant, + Vehicle = slot.Vehicle + }; + + public static List Map(IEnumerable slots) + => slots.Select(Map).ToList(); + + public static List Map(IEnumerable slots) + => slots.Select(Map).ToList(); + + public static Slot Map(SlotDto slot) => new() + { + SlotId = slot.SlotId, + Name = slot.Name, + Occupant = slot.Occupant, + Vehicle = slot.Vehicle + }; + + public static SignupsCreateRequest Map(SignupsCreateRequestDto request) + { + return new SignupsCreateRequest + { + MissionId = request.MissionId, + MissionCreateRequest = request.Mission is not null ? new MissionCreateRequest + { + Title = request.Mission.Title, + Description = request.Mission.Description, + Owner = request.Mission.Owner, + MissionDate = request.Mission.MissionDate, + ModsetName = request.Mission.ModsetName + } : null, + StartDate = request.StartDate, + CloseDate = request.CloseDate, + SignupsStatus = request.SignupsStatus, + Teams = Map(request.Teams) + }; + } +} diff --git a/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs b/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs new file mode 100644 index 0000000..eb0751e --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/MissionsController.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Net.Mime; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Features.Missions.Mappers; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions; + +/// +/// Allows missions data retrieval and creation. +/// +[Route("api/[controller]")] +[Produces(MediaTypeNames.Application.Json)] +public class MissionsController : Controller +{ + private readonly IMissionCommandService _missionCommandService; + private readonly IMissionQueryService _missionQueryService; + + /// + public MissionsController(IMissionCommandService missionCommandService, IMissionQueryService missionQueryService) + { + _missionCommandService = missionCommandService; + _missionQueryService = missionQueryService; + } + + /// Create Mission + /// Creates requested mission. + /// Mission creation request + [HttpPost(Name = "CreateMission")] + [SwaggerResponse(StatusCodes.Status201Created, "The mission was created")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + public async Task> CreateMission([FromBody] MissionCreateRequestDto request) + => await _missionCommandService.CreateMission(MissionMapper.Map(request)) + .Map(MissionMapper.Map) + .Match, MissionDto>( + onSuccess: mission => Created(mission.MissionId.ToString(), mission), + onFailure: error => BadRequest(error)); + + /// Update Mission + /// Updates given mission. + /// Id of a mission to update + [HttpPatch("{missionId:int}", Name = "UpdateMission")] + [SwaggerResponse(StatusCodes.Status204NoContent, "The mission was updated")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to update the mission")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")] + public ActionResult UpdateMission(int missionId) + { + throw new NotImplementedException(); + } + + /// Delete Mission + /// Deletes given mission. + /// Id of a mission to deleted. + [HttpDelete("{missionId:int}", Name = "DeleteMission")] + [SwaggerResponse(StatusCodes.Status204NoContent, "The mission was deleted")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to delete the mission")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")] + public ActionResult DeleteMission(int missionId) + { + throw new NotImplementedException(); + } + + /// Get Missions + /// Retrieves missions satisfying query parameters. + /// Include only missions after given date. Missions without a date are treated as always after. + /// Include only missions before given date. + /// Include missions only by given user. + [HttpGet(Name = "GetMissions")] + [SwaggerResponse(StatusCodes.Status200OK, "Missions retrieved")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + public async Task>> GetMissions( + [FromQuery] DateTime? after = null, + [FromQuery] DateTime? before = null, + [FromQuery] string? owner = null) + { + var query = new MissionQuerySpecification(after, before, owner); + + return await _missionQueryService.GetMissions(query) + .Map(MissionMapper.Map) + .Match>, List>( + onSuccess: missions => Ok(missions), + onFailure: error => BadRequest(error)); + } + + /// Get Mission + /// Retrieves mission with given . + /// Id of a mission to retrieve + [HttpGet("{missionId:int}", Name = "GetMission")] + [SwaggerResponse(StatusCodes.Status200OK, "Mission retrieved")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Mission not found")] + public async Task> GetMission(int missionId) + => await _missionQueryService.GetMission(missionId) + .Map(MissionMapper.Map) + .Match, MissionDto>( + onSuccess: mission => Ok(mission), + onFailure: error => NotFound(error)); + + private ActionResult ReturnSomething(Result result) + => result.Match( + onSuccess: x => Ok(x), + onFailure: error => (ActionResult) BadRequest(error)); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs b/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs new file mode 100644 index 0000000..0d6f4d6 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Features/Missions/SignupsController.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Net.Mime; +using System.Threading.Tasks; +using ArmaForces.Boderator.BotService.Features.Missions.DTOs; +using ArmaForces.Boderator.BotService.Features.Missions.Mappers; +using ArmaForces.Boderator.Core.Missions; +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace ArmaForces.Boderator.BotService.Features.Missions; + +/// +/// Allows signups retrieval and creation. +/// +[Route("api/[controller]")] +[Produces(MediaTypeNames.Application.Json)] +public class SignupsController : ControllerBase +{ + private readonly ISignupsCommandService _signupsCommandService; + private readonly ISignupsQueryService _signupsQueryService; + + /// + public SignupsController(ISignupsQueryService signupsQueryService, ISignupsCommandService signupsCommandService) + { + _signupsQueryService = signupsQueryService; + _signupsCommandService = signupsCommandService; + } + + /// Create Signups + /// + /// Creates signups for a mission. Mission can have only one signups attached. + /// MissionId of an existing mission, which is valid for opening signups (has all details filled), must be provided. + /// Alternatively, Mission can be created alongside Signups through "mission" property but it must still be valid for opening signups. + /// + [HttpPost(Name = "CreateSignups")] + [SwaggerResponse(StatusCodes.Status201Created, "Signups created")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + public async Task> CreateSignups([FromBody] SignupsCreateRequestDto request) + => await _signupsCommandService.CreateSignups(SignupsMapper.Map(request)) + .Map(SignupsMapper.Map) + .Match, SignupsDto>( + onSuccess: signups => Created(signups.SignupsId.ToString(), signups), + onFailure: error => BadRequest(error)); + + /// Update Signups + /// Updates signups with given . + /// Id of signups to update + [HttpPatch("{signupsId:long}", Name = "UpdateSignups")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Signups updated")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to update signups")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult UpdateSignups(long signupsId) + => throw new NotImplementedException(); + + /// Delete Signups + /// Deletes signups with given . + /// Id of signups to delete. + [HttpDelete("{signupsId:long}", Name = "DeleteSignups")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Signups deleted")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to delete signups")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult DeleteSignups(long signupsId) + => throw new NotImplementedException(); + + /// Get Signups + /// Retrieves signups with given . + /// Id of signups to retrieve. + [HttpGet("{signupsId:long}", Name = "GetSignups")] + [SwaggerResponse(StatusCodes.Status200OK, "Signups retrieved")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public async Task> GetSignups(long signupsId) + => await _signupsQueryService.GetSignups(signupsId) + .Map(SignupsMapper.Map) + .Match, SignupsDto>( + onSuccess: signups => Ok(signups), + onFailure: error => BadRequest(error)); + + /// Lookup Signups + /// Returns all signups satisfying query parameters. + [HttpGet(Name = "LookupSignups")] + [SwaggerResponse(StatusCodes.Status200OK, "Signups retrieved")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult> LookupSignups() + => throw new NotImplementedException(); + + /// Sign Up Player + /// Signs up a player for a given slot. + /// Id of signups to sign-up player + /// Sign-up details + [HttpPost("{signupsId:long}/SignUp", Name = "SignUpPlayer")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Player signed up")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to sign up player")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult SignUpPlayer(long signupsId, [FromBody] PlayerSignUpRequestDto request) + => throw new NotImplementedException(); + + /// Sign Out Player + /// Signs out a player from a slot or whole signups. + /// Id of signups to sign out player + /// Sign out details + [HttpPost("{signupsId:long}/SignOut", Name = "SignOutPlayer")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Player signed out")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to sign out player")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult SignOutPlayer(long signupsId, [FromBody] PlayerSignOutRequestDto request) + => throw new NotImplementedException(); + + /// Open Signups + /// Immediately opens given signups allowing all players to sign-up. + /// Id of signups to open + [HttpPost("{signupsId:long}/Open", Name = "OpenSignups")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Signups opened")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to open signups")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult OpenSignups(long signupsId) + => throw new NotImplementedException(); + + /// Close Signups + /// Immediately closes given signups. + /// Id of signups to close + [HttpPost("{signupsId:long}/Close", Name = "CloseSignups")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Signups closed")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request is invalid")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Not authorized to close signups")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Signups not found")] + public ActionResult CloseSignups(long signupsId) + => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs b/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs new file mode 100644 index 0000000..3b9d299 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Filters/ExceptionFilter.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Newtonsoft.Json; + +namespace ArmaForces.Boderator.BotService.Filters; + +public class ExceptionFilter : IExceptionFilter, IAsyncExceptionFilter +{ + public Task OnExceptionAsync(ExceptionContext context) + { + OnException(context); + return Task.CompletedTask; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is ArgumentNullException) HandleValidationError(context); + if (context.Exception is NotImplementedException) HandleNotImplemented(context); + if (context.ExceptionHandled is false) HandleOtherException(context); + } + + private static void HandleNotImplemented(ExceptionContext context) + { + context.Result = new NotImplementedResult(); + context.ExceptionHandled = true; + } + + private static void HandleValidationError(ExceptionContext context) + { + var error = new + { + Message = "Validation error", + Details = context.Exception.Message + }; + + context.Result = new BadRequestObjectResult(error); + context.ExceptionHandled = true; + } + + private static void HandleOtherException(ExceptionContext context) + { + var error = new + { + Message = "Internal Server Error", + Details = context.Exception.Message, + Timestamp = DateTimeOffset.Now + }; + + context.Result = new ContentResult + { + Content = JsonConvert.SerializeObject(error), + StatusCode = 500 + }; + context.ExceptionHandled = true; + } +} diff --git a/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs b/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs new file mode 100644 index 0000000..b038111 --- /dev/null +++ b/ArmaForces.Boderator.BotService/Filters/NotImplementedResult.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace ArmaForces.Boderator.BotService.Filters; + +/// +/// A that when +/// executed will produce a Not Implemented (501) response. +/// +[DefaultStatusCode(DefaultStatusCode)] +public class NotImplementedResult : StatusCodeResult +{ + private const int DefaultStatusCode = StatusCodes.Status501NotImplemented; + + /// + /// Creates a new instance. + /// + public NotImplementedResult() + : base(DefaultStatusCode) + { + } +} diff --git a/ArmaForces.Boderator.BotService/Helpers/Configuration.cs b/ArmaForces.Boderator.BotService/Helpers/Configuration.cs deleted file mode 100644 index 744cff1..0000000 --- a/ArmaForces.Boderator.BotService/Helpers/Configuration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections; - -namespace ArmaForces.Boderator.BotService.Helpers -{ - public static class Configuration - { - public static string DiscordToken => GetParameter("DISCORD_TOKEN"); - - private static IDictionary Parameters { get; } - - static Configuration() - { - Parameters = Environment.GetEnvironmentVariables(); - } - - public static string GetParameter(string key) => (string)Parameters[key]; - } -} diff --git a/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs b/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs new file mode 100644 index 0000000..4bb2f8b --- /dev/null +++ b/ArmaForces.Boderator.BotService/Logging/SerilogConfigurationExtensions.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace ArmaForces.Boderator.BotService.Logging +{ + internal static class SerilogConfigurationExtensions + { + public static IHostBuilder AddSerilog(this IHostBuilder hostBuilder) + => hostBuilder.UseSerilog(ConfigureLogging()); + + private static Action ConfigureLogging() => (context, configuration) => configuration + .ReadFrom.Configuration(context.Configuration); + } +} diff --git a/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs b/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs new file mode 100644 index 0000000..a26587e --- /dev/null +++ b/ArmaForces.Boderator.BotService/Middleware/TransactionMiddleware.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using System.Transactions; +using Microsoft.AspNetCore.Http; + +namespace ArmaForces.Boderator.BotService.Middleware +{ + internal class TransactionMiddleware + { + private readonly RequestDelegate _next; + + public TransactionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method != "GET") + { + using var transaction = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions + { + IsolationLevel = IsolationLevel.ReadCommitted + }, + TransactionScopeAsyncFlowOption.Enabled); + + await _next.Invoke(context); + + transaction.Complete(); + } + else + { + await _next.Invoke(context); + } + } + } +} diff --git a/ArmaForces.Boderator.BotService/Program.cs b/ArmaForces.Boderator.BotService/Program.cs index 635f4ee..e0818f4 100644 --- a/ArmaForces.Boderator.BotService/Program.cs +++ b/ArmaForces.Boderator.BotService/Program.cs @@ -1,19 +1,19 @@ +using ArmaForces.Boderator.BotService.Logging; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -using Serilog; namespace ArmaForces.Boderator.BotService { - public class Program + internal class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } - public static IHostBuilder CreateHostBuilder(string[] args) => + private static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) - .UseSerilog((hbc, lc) => lc.ReadFrom.Configuration(hbc.Configuration)) + .AddSerilog() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/ArmaForces.Boderator.BotService/Properties/launchSettings.json b/ArmaForces.Boderator.BotService/Properties/launchSettings.json index b86381a..b4178bd 100644 --- a/ArmaForces.Boderator.BotService/Properties/launchSettings.json +++ b/ArmaForces.Boderator.BotService/Properties/launchSettings.json @@ -20,7 +20,9 @@ "launchBrowser": true, "launchUrl": "api-docs/", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "AF_Boderator_DiscordToken": "", + "AF_Boderator_ConnectionString": "Data Source=" }, "applicationUrl": "https://localhost:5001;http://localhost:5000" } diff --git a/ArmaForces.Boderator.BotService/Startup.cs b/ArmaForces.Boderator.BotService/Startup.cs index 3a0ce5f..c4b83a4 100644 --- a/ArmaForces.Boderator.BotService/Startup.cs +++ b/ArmaForces.Boderator.BotService/Startup.cs @@ -1,6 +1,14 @@ using System; -using ArmaForces.Boderator.BotService.Discord; +using System.ComponentModel; +using System.Text.Json.Serialization; +using ArmaForces.Boderator.BotService.Configuration; using ArmaForces.Boderator.BotService.Documentation; +using ArmaForces.Boderator.BotService.Features.DiscordClient.Infrastructure.DependencyInjection; +using ArmaForces.Boderator.BotService.Features.Health; +using ArmaForces.Boderator.BotService.Filters; +using ArmaForces.Boderator.BotService.Middleware; +using ArmaForces.Boderator.Core.DependencyInjection; +using Discord.WebSocket; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -10,15 +18,8 @@ namespace ArmaForces.Boderator.BotService { - public class Startup + internal class Startup { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - private OpenApiInfo OpenApiConfiguration { get; } = new() { Title = "ArmaForces Boderator API", @@ -36,8 +37,19 @@ public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddDocumentation(OpenApiConfiguration); + services.AddBoderatorCore(serviceProvider => serviceProvider.GetRequiredService().ConnectionString); + services.AddSingleton(serviceProvider => new BoderatorConfigurationFactory(serviceProvider.GetRequiredService()).CreateConfiguration()); + services.AddDiscordClient(); + services.AutoAddInterfacesAsScoped(typeof(Startup).Assembly); - services.AddDiscordService(Helpers.Configuration.DiscordToken); + services.AddMvc(options => options + .Filters.Add(new ExceptionFilter())) + .AddJsonOptions(opt => + { + opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + opt.JsonSerializerOptions.WriteIndented = true; + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -48,16 +60,18 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseDeveloperExceptionPage(); app.AddDocumentation(OpenApiConfiguration); } - + app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthorization(); + + app.UseMiddleware(); app.UseEndpoints(endpoints => { - endpoints.MapControllerRoute("default", "api/{controller}/{action}"); + endpoints.MapControllers(); }); } } diff --git a/ArmaForces.Boderator.BotService/appsettings.Development.json b/ArmaForces.Boderator.BotService/appsettings.Development.json index 5ed56cc..d3c3868 100644 --- a/ArmaForces.Boderator.BotService/appsettings.Development.json +++ b/ArmaForces.Boderator.BotService/appsettings.Development.json @@ -5,24 +5,27 @@ "Name": "Console", "Args": { "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}" } }, { "Name": "Debug", - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}" }, { "Name": "File", "Args": { - "path": "Logs/log.txt", + "path": "../Logs/log.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7, - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}" } } ], - "Enrich": [ "WithThreadId" ] + "Enrich": [ + "FromLogContext", + "WithThreadId" + ] }, "Logging": { "LogLevel": { diff --git a/ArmaForces.Boderator.BotService/appsettings.json b/ArmaForces.Boderator.BotService/appsettings.json index 35581c1..626c509 100644 --- a/ArmaForces.Boderator.BotService/appsettings.json +++ b/ArmaForces.Boderator.BotService/appsettings.json @@ -1,24 +1,39 @@ { + "ConnectionStrings": { + "DefaultConnection": "Data Source=127.0.0.1,49443; User ID=Boderator; Password=boderator-test1; Database=BODERATOR_TEST" + }, "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "ArmaForces": "Verbose", + "Microsoft": "Warning", + "Microsoft.AspNetCore.Hosting.Diagnostics" : "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, "WriteTo": [ { "Name": "Console", "Args": { "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj} {Properties}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}" } }, { "Name": "File", "Args": { - "path": "log.txt", + "path": "Logs/log.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7, - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {Message:lj} {Properties}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{ThreadId}] {SourceContext} {Message:lj} {Properties}{NewLine}{Exception}" } } ], - "Enrich": [ "WithThreadId" ] + "Enrich": [ + "FromLogContext", + "WithThreadId" + ] }, "Logging": { "LogLevel": { diff --git a/ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj b/ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj similarity index 55% rename from ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj rename to ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj index ab67797..1b5581a 100644 --- a/ArmaForces.Boderator.Tests/ArmaForces.Boderator.Tests.csproj +++ b/ArmaForces.Boderator.Core.Tests/ArmaForces.Boderator.Core.Tests.csproj @@ -1,26 +1,31 @@ - net5.0 - + net6.0 + enable false + Library - + + + + + - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - diff --git a/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..8aff358 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using ArmaForces.Boderator.Core.DependencyInjection; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.DependencyInjection +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + [Trait("Category", "Unit")] + public void AddOrReplaceSingleton() + { + var replacedInstance = new Test1(); + + var serviceProvider = new ServiceCollection() + .AddSingleton(replacedInstance) + .AddOrReplaceSingleton() + .BuildServiceProvider(); + + using (new AssertionScope()) + { + serviceProvider.GetService().Should().NotBe(replacedInstance); + } + } + + [Fact] + [Trait("Category", "Unit")] + public void AutoAddInterfacesAsScoped_EmptyCollection_RegistersOnlyInterfacesWithOneImplementation() + { + var serviceProvider = new ServiceCollection() + .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly) + .BuildServiceProvider(); + + using (new AssertionScope()) + { + serviceProvider.GetService().Should().BeNull(); + serviceProvider.GetService().Should().NotBeNull(); + serviceProvider.GetService().Should().BeNull(); + } + } + + [Fact] + [Trait("Category", "Unit")] + public void AutoAddInterfacesAsScoped_ImplementationRegisteredAsSingleton_DoesNotReplaceExistingRegistration() + { + var serviceCollection = new ServiceCollection() + .AddSingleton() + .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly); + + AssertServiceRegisteredCorrectly(serviceCollection, ServiceLifetime.Singleton); + } + + [Fact, Trait("Category", "Unit")] + public void AutoAddInterfacesAsScoped_EmptyCollection_RegistersOnlyInterfacesFromThisAssembly() + { + var serviceCollection = new ServiceCollection() + .AutoAddInterfacesAsScoped(typeof(ServiceCollectionExtensionsTests).Assembly); + + using (new AssertionScope()) + { + serviceCollection.Should() + .OnlyContain(x => x.ServiceType.Assembly == typeof(ServiceCollectionExtensionsTests).Assembly); + } + } + + private static void AssertServiceRegisteredCorrectly(IServiceCollection serviceCollection, ServiceLifetime expectedLifetime) + { + var expectedServiceDescriptor = new ServiceDescriptor(typeof(TService), typeof(TExpectedImplementation), expectedLifetime); + + serviceCollection.Should() + .ContainSingle(descriptor => descriptor.ServiceType == typeof(TService)) + .Which.Should() + .BeEquivalentTo(expectedServiceDescriptor, $"the service {nameof(TService)} was registered as {expectedLifetime} and should not be replaced"); + } + + /// + /// Used to check if interfaces from other assemblies aren't registered automatically when there is one implementation. + /// + private enum TestEnum { } + + private interface ITest1 { } + + private interface ITest2 { } + + private interface ITest3 { } + + private class Test1 : ITest1 { } + + private class Test2 : ITest1, ITest2 { } + + /// + /// Used to check if interfaces from other assemblies aren't registered automatically when there is one implementation. + /// + private class Test3 : ICloneable2 + { + public object Clone() => throw new NotImplementedException(); + } + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs new file mode 100644 index 0000000..c7e0c2b --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsDbHelper.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; + +internal class MissionsDbHelper +{ + private readonly MissionContext _missionContext; + + public MissionsDbHelper(MissionContext missionContext) + { + _missionContext = missionContext; + } + + public async Task CreateTestMission() + { + var mission = MissionsFixture.CreateTestMission(); + + var addedEntry = _missionContext.Missions.Add(mission); + await _missionContext.SaveChangesAsync(); + _missionContext.ChangeTracker.Clear(); + + return addedEntry.Entity; + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs new file mode 100644 index 0000000..cce59a8 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/MissionsFixture.cs @@ -0,0 +1,35 @@ +using System; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; + +internal static class MissionsFixture +{ + public static MissionCreateRequest PrepareCreateRequest( + string? modsetName = null) + { + var fixtureMission = CreateTestMission(modsetName); + + return new MissionCreateRequest + { + Title = fixtureMission.Title, + Description = fixtureMission.Description, + Owner = fixtureMission.Owner, + MissionDate = fixtureMission.MissionDate, + ModsetName = fixtureMission.ModsetName + }; + } + + public static Mission CreateTestMission( + string? modsetName = null) + { + return new Mission + { + Title = "Test mission", + Owner = "Tester", + MissionDate = DateTime.Today.AddHours(20), + ModsetName = modsetName ?? "Test-modset", + Description = "Test description" + }; + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs new file mode 100644 index 0000000..dc103e8 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsDbHelper.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; +using FluentAssertions; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; + +internal class SignupsDbHelper +{ + private readonly MissionsDbHelper _missionsDbHelper; + private readonly MissionContext _missionContext; + + public SignupsDbHelper( + MissionsDbHelper missionsDbHelper, + MissionContext missionContext) + { + _missionsDbHelper = missionsDbHelper; + _missionContext = missionContext; + } + + public async Task CreateTestSignups(Mission mission) + { + var signups = SignupsFixture.CreateTestSignups(); + + var updatedMission = mission with + { + Signups = signups + }; + + _missionContext.Attach(updatedMission); + _missionContext.Entry(updatedMission).Reference(x => x.Signups).IsModified = true; + + await _missionContext.SaveChangesAsync(); + _missionContext.ChangeTracker.Clear(); + + var addedEntry = await _missionContext.Signups.FindAsync(signups.SignupsId); + + addedEntry.Should().NotBeNull(); + + return addedEntry!; + } + + public async Task CreateTestSignups() + { + var mission = await _missionsDbHelper.CreateTestMission(); + return await CreateTestSignups(mission); + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs new file mode 100644 index 0000000..9fa674f --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Helpers/SignupsFixture.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; + +internal static class SignupsFixture +{ + public static Signups CreateTestSignups() + { + return new Signups + { + Status = SignupsStatus.Open, + StartDate = DateTime.Now, + CloseDate = DateTime.Now.AddHours(1), + Teams = new List() + }; + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs new file mode 100644 index 0000000..77f060c --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionCommandServiceIntegrationTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation; + +public class MissionCommandServiceIntegrationTests : DatabaseTestBase +{ + private readonly MissionsDbHelper _missionsDbHelper; + private readonly IMissionQueryService _missionQueryService; + private readonly IMissionCommandService _missionCommandService; + + public MissionCommandServiceIntegrationTests() + { + _missionsDbHelper = ServiceProvider.GetRequiredService(); + _missionQueryService = ServiceProvider.GetRequiredService(); + _missionCommandService = ServiceProvider.GetRequiredService(); + } + + [Fact, Trait("Category", "Integration")] + public async Task CreateMission_ValidCreateRequest_MissionCreatedAndReturned() + { + var request = MissionsFixture.PrepareCreateRequest(); + + var expectedMission = new Mission + { + Title = request.Title, + Description = request.Description, + Owner = request.Owner, + MissionDate = request.MissionDate, + ModsetName = request.ModsetName + }; + + var result = await _missionCommandService.CreateMission(request); + + result.ShouldBeSuccess(expectedMission, opt => opt.Excluding(x => x.MissionId)); + } + + [Fact, Trait("Category", "Integration")] + public async Task CreateMission_InvalidModsetNameWithWhitespace_Failure() + { + var request = MissionsFixture.PrepareCreateRequest(modsetName: "Modset with whitespaces"); + + const string expectedError = $"{nameof(MissionCreateRequest.ModsetName)} cannot contain whitespace characters."; + + var result = await _missionCommandService.CreateMission(request); + + result.ShouldBeFailure(expectedError); + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs new file mode 100644 index 0000000..89d6ee1 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceIntegrationTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation; + +[Trait("Category", "Integration")] +public class MissionQueryServiceIntegrationTests : DatabaseTestBase +{ + private readonly MissionsDbHelper _missionsDbHelper; + private readonly IMissionQueryService _missionQueryService; + + public MissionQueryServiceIntegrationTests() + { + _missionsDbHelper = ServiceProvider.GetRequiredService(); + _missionQueryService = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task GetMissions_NoMissionsInDatabase_ReturnsEmptyList() + { + var result = await _missionQueryService.GetMissions(); + result.ShouldBeSuccess(new List()); + } + + [Fact] + public async Task GetMissions_OneMissionInDatabase_ReturnsOneMission() + { + var createdMission = await _missionsDbHelper.CreateTestMission(); + + var result = await _missionQueryService.GetMissions(); + + result.ShouldBeSuccess(x => x.Should().Contain(createdMission)); + } + + [Fact] + public async Task GetMission_MissionIdInDatabase_ReturnsMission() + { + var createdMission = await _missionsDbHelper.CreateTestMission(); + + var result = await _missionQueryService.GetMission(createdMission.MissionId); + + result.ShouldBeSuccess(createdMission); + } + + [Fact] + public async Task GetMission_MissionIdNotInDatabase_ReturnsFailure() + { + var createdMission = await _missionsDbHelper.CreateTestMission(); + var notExistingMissionId = createdMission.MissionId + 1; + + var result = await _missionQueryService.GetMission(notExistingMissionId); + + result.ShouldBeFailure($"Mission with ID {notExistingMissionId} does not exist."); + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs new file mode 100644 index 0000000..159fd94 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/MissionQueryServiceUnitTests.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions.Implementation; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using AutoFixture; +using Moq; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation; + +public class MissionQueryServiceUnitTests +{ + private readonly Fixture _fixture = new(); + + [Fact, Trait("Category", "Unit")] + public async Task GetMissions_RepositoryEmpty_ReturnsEmptyList() + { + var missionQueryRepository = CreateRepositoryMock(new List()); + var missionQueryService = new MissionQueryService(missionQueryRepository); + + var result = await missionQueryService.GetMissions(); + + result.ShouldBeSuccess(new List()); + } + + [Fact, Trait("Category", "Unit")] + public async Task GetMissions_RepositoryNotEmpty_ReturnsExpectedMissions() + { + var missionsInRepository = _fixture.CreateMany(5).ToList(); + var missionQueryRepository = CreateRepositoryMock(missionsInRepository); + var missionQueryService = new MissionQueryService(missionQueryRepository); + + var result = await missionQueryService.GetMissions(); + + result.ShouldBeSuccess(missionsInRepository); + } + + private static IMissionQueryRepository CreateRepositoryMock(IEnumerable missions) + { + var missionQueryRepositoryMock = new Mock(); + missionQueryRepositoryMock + .Setup(x => x.GetMissions(It.IsAny?>())) + .Returns(Task.FromResult(missions.ToList())); + + return missionQueryRepositoryMock.Object; + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs new file mode 100644 index 0000000..4a6248d --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsCommandServiceIntegrationTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation; + +public class SignupsCommandServiceIntegrationTests : DatabaseTestBase +{ + private readonly MissionsDbHelper _missionsDbHelper; + private readonly IMissionCommandService _missionCommandService; + private readonly ISignupsCommandService _signupsCommandService; + + public SignupsCommandServiceIntegrationTests() + { + _missionsDbHelper = ServiceProvider.GetRequiredService(); + _missionCommandService = ServiceProvider.GetRequiredService(); + _signupsCommandService = ServiceProvider.GetRequiredService(); + } + + [Fact, Trait("Category", "Integration")] + public async Task CreateSignups_ValidCreateRequest_SignupsCreatedAndReturned() + { + var missionCreationResult = await _missionCommandService.CreateMission(MissionsFixture.PrepareCreateRequest()); + + missionCreationResult.ShouldBeSuccess(); + + var request = PrepareRequest(missionCreationResult.Value.MissionId); + + var expectedSignups = new Signups() + { + Status = request.SignupsStatus, + StartDate = request.StartDate, + CloseDate = request.CloseDate, + Teams = request.Teams + }; + + await _missionsDbHelper.CreateTestMission(); + + var result = await _signupsCommandService.CreateSignups(request); + + result.ShouldBeSuccess(expectedSignups, opt => opt.Excluding(x => x.SignupsId)); + } + + [Fact, Trait("Category", "Integration")] + public async Task CreateSignups_ValidCreateRequestWithMission_SignupsAndMissionCreatedAndReturned() + { + var request = WithMissionCreation(PrepareRequest()); + + var expectedSignups = new Signups + { + Status = request.SignupsStatus, + StartDate = request.StartDate, + CloseDate = request.CloseDate, + Teams = request.Teams + }; + + var result = await _signupsCommandService.CreateSignups(request); + + result.ShouldBeSuccess(expectedSignups, opt => opt.Excluding(x => x.SignupsId)); + } + + // [Fact, Trait("Category", "Integration")] + // public async Task CreateMission_InvalidModsetNameWithWhitespace_Failure() + // { + // var request = PrepareRequest(modsetName: "Modset with whitespaces"); + // + // const string expectedError = $"{nameof(MissionCreateRequest.ModsetName)} cannot contain whitespace characters."; + // + // var result = await _signupsCommandService.CreateMission(request); + // + // result.ShouldBeFailure(expectedError); + // } + + private static SignupsCreateRequest PrepareRequest(long missionId = 1) + { + var fixtureSignups = SignupsFixture.CreateTestSignups(); + + return new SignupsCreateRequest + { + MissionId = missionId, + SignupsStatus = fixtureSignups.Status, + StartDate = fixtureSignups.StartDate, + CloseDate = fixtureSignups.CloseDate, + Teams = fixtureSignups.Teams.ToList() + }; + } + + private static SignupsCreateRequest WithMissionCreation(SignupsCreateRequest signupsCreateRequest) + { + var missionCreateRequest = MissionsFixture.PrepareCreateRequest(); + + return signupsCreateRequest with + { + MissionId = null, + MissionCreateRequest = missionCreateRequest + }; + } +} + diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs new file mode 100644 index 0000000..1b48c39 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/SignupsQueryServiceIntegrationTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; +using ArmaForces.Boderator.Core.Tests.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation; + +public class SignupsQueryServiceIntegrationTests : DatabaseTestBase +{ + private readonly MissionsDbHelper _missionsDbHelper; + private readonly SignupsDbHelper _signupsDbHelper; + private readonly ISignupsQueryService _signupsQueryService; + + public SignupsQueryServiceIntegrationTests() + { + _missionsDbHelper = ServiceProvider.GetRequiredService(); + _signupsDbHelper = ServiceProvider.GetRequiredService(); + _signupsQueryService = ServiceProvider.GetRequiredService(); + } + + [Fact, Trait("Category", "Integration")] + public async Task GetOpenSignups_NoSignupsInDatabase_ReturnsEmptyList() + { + var result = await _signupsQueryService.GetOpenSignups(); + result.ShouldBeSuccess(new List()); + } + + [Fact, Trait("Category", "Integration")] + public async Task GetOpenSignups_NoOpenSignupsInDatabase_ReturnsEmptyList() + { + var testMission = await _missionsDbHelper.CreateTestMission(); + var signup = await _signupsDbHelper.CreateTestSignups(testMission); + + var result = await _signupsQueryService.GetOpenSignups(); + result.ShouldBeSuccess(new List{signup}); + } + + [Fact, Trait("Category", "Integration")] + public async Task GetSignups_SignupsWithGivenIdDoesntExist_ReturnsFailure() + { + const int nonExistingSignupsId = 0; + var result = await _signupsQueryService.GetSignups(nonExistingSignupsId); + + result.ShouldBeFailure($"Signup with ID {nonExistingSignupsId} not found"); + } + + [Fact, Trait("Category", "Integration")] + public async Task GetSignups_SignupsWithGivenIdExists_ReturnsSignups() + { + var signups = await _signupsDbHelper.CreateTestSignups(); + var result = await _signupsQueryService.GetSignups(signups.SignupsId); + + result.ShouldBeSuccess(signups); + } +} diff --git a/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs new file mode 100644 index 0000000..fe37fb7 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/Features/Missions/Implementation/Specification/MissionSpecificationTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Specifications; +using ArmaForces.Boderator.Core.Modsets.Specification; +using ArmaForces.Boderator.Core.Users; +using AutoFixture; +using FluentAssertions; +using Xunit; + +namespace ArmaForces.Boderator.Core.Tests.Features.Missions.Implementation.Specification; + +[Trait("Category", "Unit")] +public class MissionSpecificationTests +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void CreateSpecification_FullSpecification_ValidMissionBuilt() + { + var expectedOwner = new User + { + Name = _fixture.Create() + }; + + var expectedMission = new Mission + { + Title = _fixture.Create(), + Description = _fixture.Create(), + MissionDate = _fixture.Create(), + ModsetName = _fixture.Create(), + Owner = expectedOwner.Name, + Signups = new Signups + { + StartDate = _fixture.Create(), + CloseDate = _fixture.Create(), + Status = SignupsStatus.Created, + Teams = new List + { + new() + { + Name = "Alpha", + Slots = new List + { + new() + { + Name = "SL" + } + } + }, + new() + { + Name = "Bravo", + Slots = new List + { + new() + { + Name = "SL" + } + } + } + } + } + }; + + var specification = MissionSpecification + .OwnedBy(new User(expectedMission.Owner)) + .Titled(expectedMission.Title) + .WithDescription(expectedMission.Description) + .WithModset(ModsetSpecification + .Named(expectedMission.ModsetName)) + .ScheduledAt(expectedMission.MissionDate.Value) + .WithSignups(SignupsSpecification + .StartingAt(expectedMission.Signups.StartDate.Value) + .ClosingAt(expectedMission.Signups.CloseDate.Value) + .WithTeam(TeamSpecification + .Named("Alpha") + .WithoutVehicle() + .WithSlot(SlotSpecification + .UnoccupiedWithoutVehicleNamed("SL")) + .AndNoMoreSlots()) + .AndTeam(TeamSpecification + .Named("Bravo") + .WithoutVehicle() + .WithSlot(SlotSpecification + .UnoccupiedWithoutVehicleNamed("SL")) + .AndNoMoreSlots()) + .AnoNoMoreTeams()); + + var mission = specification.Build(); + + mission.Should().BeEquivalentTo(expectedMission); + } +} diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs new file mode 100644 index 0000000..a3e5ab5 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/DatabaseTestBase.cs @@ -0,0 +1,38 @@ +using System; +using System.Transactions; +using ArmaForces.Boderator.Core.DependencyInjection; +using ArmaForces.Boderator.Core.Tests.Features.Missions.Helpers; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace ArmaForces.Boderator.Core.Tests.TestUtilities; + +public class DatabaseTestBase : IDisposable +{ + protected readonly IServiceProvider ServiceProvider = new ServiceCollection() + .AddBoderatorCore(_ => TestDatabaseConstants.TestDbConnectionString) + .AddScoped() + .AddScoped() + .BuildServiceProvider(); + + // protected IDbContextTransaction? DbContextTransaction { get; init; } + private TransactionScope? TransactionScope { get; } + + protected DatabaseTestBase() + { + TransactionScope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions + { + IsolationLevel = IsolationLevel.ReadCommitted + }, + TransactionScopeAsyncFlowOption.Enabled); + } + + public void Dispose() + { + TransactionScope?.Dispose(); + // DbContextTransaction?.Rollback(); + // DbContextTransaction?.Dispose(); + } +} diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs new file mode 100644 index 0000000..7ad64c0 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/ResultAssertionsExtensions.cs @@ -0,0 +1,132 @@ +using System; +using CSharpFunctionalExtensions; +using FluentAssertions; +using FluentAssertions.Equivalency; +using FluentAssertions.Execution; + +namespace ArmaForces.Boderator.Core.Tests.TestUtilities +{ + public static class ResultAssertionsExtensions + { + public static void ShouldBeFailure(this Result result) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.IsSuccess.Should().BeFalse(); + } + } + + public static void ShouldBeFailure(this Result result) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.IsSuccess.Should().BeFalse(); + result.Value.Should().BeNull(); + } + } + + public static void ShouldBeFailure(this Result result, string expectedError) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.IsSuccess.Should().BeFalse(); + } + else + { + result.Error.Should().Be(expectedError); + } + } + + public static void ShouldBeFailure(this Result result, string expectedError) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.IsSuccess.Should().BeFalse(); + } + else + { + result.Error.Should().Be(expectedError); + } + } + + public static void ShouldBeSuccess(this Result result) + { + using var scope = new AssertionScope(); + + if (result.IsFailure) + { + result.IsSuccess.Should().BeTrue(); + result.Error.Should().BeNull(); + } + } + + public static void ShouldBeSuccess(this Result result) + { + using var scope = new AssertionScope(); + + if (result.IsFailure) + { + result.IsSuccess.Should().BeTrue(); + result.Error.Should().BeNull(); + } + + result.Value.Should().NotBeNull(); + } + + public static void ShouldBeSuccess(this Result result, T2 expectedValue) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.Value.Should().BeEquivalentTo(expectedValue); + } + else + { + result.IsSuccess.Should().BeTrue(); + result.Error.Should().BeNull(); + } + } + + public static void ShouldBeSuccess( + this Result result, + T2 expectedValue, + Func,EquivalencyAssertionOptions> config) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + result.Value.Should().BeEquivalentTo(expectedValue, config); + } + else + { + result.IsSuccess.Should().BeTrue(); + result.Error.Should().BeNull(); + } + } + + public static void ShouldBeSuccess(this Result result, Action valueAssertion) + { + using var scope = new AssertionScope(); + + if (result.IsSuccess) + { + valueAssertion(result.Value); + } + else + { + result.IsSuccess.Should().BeTrue(); + result.Error.Should().BeNull(); + } + } + } +} diff --git a/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs b/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs new file mode 100644 index 0000000..5fb8882 --- /dev/null +++ b/ArmaForces.Boderator.Core.Tests/TestUtilities/TestDatabaseConstants.cs @@ -0,0 +1,13 @@ +using System; +using System.IO; + +namespace ArmaForces.Boderator.Core.Tests.TestUtilities; + +public static class TestDatabaseConstants +{ + public static string TestDbConnectionString => TestSqlServerConnectionString ?? throw new InvalidOperationException("Connection string is empty"); + + private static readonly string TestSqliteConnectionString = "Data Source=" + Path.Join(Directory.GetCurrentDirectory(), "test.db"); + + private const string TestSqlServerConnectionString = "Data Source=127.0.0.1,49443; User ID=Boderator; Password=boderator-test1; Database=BODERATOR_TEST"; +} diff --git a/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj new file mode 100644 index 0000000..e96bf26 --- /dev/null +++ b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + <_Parameter1>$(MSBuildProjectName).Tests + + + <_Parameter1>ArmaForces.Boderator.BotService.Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + diff --git a/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings new file mode 100644 index 0000000..791ecd0 --- /dev/null +++ b/ArmaForces.Boderator.Core/ArmaForces.Boderator.Core.csproj.DotSettings @@ -0,0 +1,6 @@ + + True + True + True + True + True \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs b/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs new file mode 100644 index 0000000..7282ff2 --- /dev/null +++ b/ArmaForces.Boderator.Core/Common/Specifications/BaseQuerySpecification.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Common.Specifications; + +public class MissionQuerySpecification : BaseQuerySpecification +{ + public MissionQuerySpecification(DateTime? after = null, DateTime? before = null, string? owner = null) + : base(x => IsMissionAfter(x, after) && IsMissionBefore(x, after) && IsMissionBy(x, owner)) + { } + + /// + /// If a date was given, checks if mission is after that date. Returns true for missions without date. + /// + private static bool IsMissionAfter(Mission mission, DateTime? after) + => after is null || mission.MissionDate is null || mission.MissionDate > after; + + /// + /// If a date was given, checks if mission is before that date. + /// + private static bool IsMissionBefore(Mission mission, DateTime? before) + => before is null || mission.MissionDate is not null && mission.MissionDate < before; + + /// + /// If an owner was given, checks if mission is created by given user. + /// + private static bool IsMissionBy(Mission mission, string? owner) + => owner is null || mission.Owner == owner; +} + +public abstract class BaseQuerySpecification : IQuerySpecification +{ + protected BaseQuerySpecification() { } + + protected BaseQuerySpecification(Expression> criteria) + { + Criteria = criteria; + } + public Expression>? Criteria { get; } + public List>> Includes { get; } = new List>>(); + public Expression>? OrderBy { get; private set; } + public Expression>? OrderByDescending { get; private set; } + + protected void AddInclude(Expression> includeExpression) + { + Includes.Add(includeExpression); + } + + protected void AddOrderBy(Expression> orderByExpression) + { + OrderBy = orderByExpression; + } + + protected void AddOrderByDescending(Expression> orderByDescExpression) + { + OrderByDescending = orderByDescExpression; + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs b/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs new file mode 100644 index 0000000..af77495 --- /dev/null +++ b/ArmaForces.Boderator.Core/Common/Specifications/IQuerySpecification.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace ArmaForces.Boderator.Core.Common.Specifications; + +public interface IQuerySpecification +{ + Expression>? Criteria { get; } + + List>> Includes { get; } + + Expression>? OrderBy { get; } + + Expression>? OrderByDescending { get; } +} diff --git a/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs b/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs new file mode 100644 index 0000000..34dee9a --- /dev/null +++ b/ArmaForces.Boderator.Core/Common/Specifications/SpecificationEvaluator.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace ArmaForces.Boderator.Core.Common.Specifications; + +public static class SpecificationEvaluator where TEntity : class +{ + public static IQueryable GetQuery(IQueryable query, IQuerySpecification specifications) + { + // Do not apply anything if specifications is null + if (specifications == null) + { + return query; + } + + // Modify the IQueryable + // Apply filter conditions + if (specifications.Criteria != null) + { + query = query.Where(specifications.Criteria); + } + + // Includes + query = specifications.Includes + .Aggregate(query, (current, include) => current.Include(include)); + + // Apply ordering + if (specifications.OrderBy != null) + { + query = query.OrderBy(specifications.OrderBy); + } + else if (specifications.OrderByDescending != null) + { + query = query.OrderByDescending(specifications.OrderByDescending); + } + + return query; + } +} diff --git a/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs b/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs new file mode 100644 index 0000000..6df1302 --- /dev/null +++ b/ArmaForces.Boderator.Core/DependencyInjection/BoderatorCoreServiceExtensions.cs @@ -0,0 +1,24 @@ +using System; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace ArmaForces.Boderator.Core.DependencyInjection +{ + public static class BoderatorCoreServiceExtensions + { + public static IServiceCollection AddBoderatorCore(this IServiceCollection services, + Func connectionStringFactory) + => services + .AddDbContext(connectionStringFactory) + .AutoAddInterfacesAsScoped(typeof(BoderatorCoreServiceExtensions).Assembly); + + private static IServiceCollection AddDbContext(this IServiceCollection services, + Func connectionStringFactory) + where T : DbContext + => services.AddDbContext((serviceProvider, options) => + options + .UseSqlServer(connectionStringFactory(serviceProvider))); + //.UseSqlite(connectionStringFactory(serviceProvider))); + } +} diff --git a/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs b/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ee4502d --- /dev/null +++ b/ArmaForces.Boderator.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ArmaForces.Boderator.Core.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddOrReplaceSingleton(this IServiceCollection services) + where TImplementation : class + { + return services.RemoveAll(typeof(TService)) + .AddSingleton(); + } + + public static IServiceCollection AddOrReplaceSingleton(this IServiceCollection services, TService implementation) + where TService : class + { + return services.RemoveAll(typeof(TService)) + .AddSingleton(implementation); + } + + /// + /// Adds all interfaces from with single implementation type as scoped service. + /// Does not replace existing registrations. + /// Excludes specification interfaces. + /// + /// Service collection where services will be registered + /// Assembly which will be searched for interfaces and implementations. + /// Service collection for method chaining. + public static IServiceCollection AutoAddInterfacesAsScoped(this IServiceCollection services, Assembly assembly) + { + assembly.DefinedTypes + .Where(x => x.ImplementedInterfaces.Any(interfaceType => interfaceType.Assembly == assembly)) + .SelectMany( + implementingClass => implementingClass.ImplementedInterfaces, + (implementingClass, implementedInterface) => new {implementedInterface, implementingClass}) + .GroupBy(x => x.implementedInterface) + .Where(x => x.Count() == 1) + .Select(x => x.Single()) + .Where(x => services.IsNotServiceRegistered(x.implementedInterface)) + .Where(x => x.implementedInterface.IsNotSpecification()) + .ToList() + .ForEach(x => services.AddScoped(x.implementedInterface, x.implementingClass)); + + return services; + } + + private static bool IsNotServiceRegistered(this IServiceCollection services, Type serviceType) + => !IsServiceRegistered(services, serviceType); + + private static bool IsServiceRegistered(this IServiceCollection services, Type serviceType) + => services.Any(descriptor => descriptor.ServiceType == serviceType); + + private static bool IsNotSpecification(this Type interfaceType) + => !interfaceType.FullName?.Contains("Specification") ?? !interfaceType.Name.Contains("Specification"); + } +} diff --git a/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs b/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs new file mode 100644 index 0000000..0e4ec8a --- /dev/null +++ b/ArmaForces.Boderator.Core/Extensions/EntityTypeBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ArmaForces.Boderator.Core.Extensions; + +internal static class EntityTypeBuilderExtensions +{ + public static PropertyBuilder IsNotRequired(this PropertyBuilder propertyBuilder) + => propertyBuilder.IsRequired(false); + + public static ReferenceCollectionBuilder IsNotRequired(this ReferenceCollectionBuilder builder) + where T1 : class + where T2 : class + => builder.IsRequired(false); +} diff --git a/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs b/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs new file mode 100644 index 0000000..95da5be --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Dlcs/Models/Dlc.cs @@ -0,0 +1,14 @@ +namespace ArmaForces.Boderator.Core.Dlcs.Models; + +public class Dlc +{ + /// + /// Id of a DLC. + /// + public int DlcId { get; init; } + + /// + /// Name of a DLC. + /// + public string Name { get; init; } = string.Empty; +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs new file mode 100644 index 0000000..69727b1 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/IMissionCommandService.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions; + +public interface IMissionCommandService +{ + Task> CreateMission(MissionCreateRequest missionCreateRequest); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs new file mode 100644 index 0000000..8cb4c91 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/IMissionQueryService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions; + +public interface IMissionQueryService +{ + Task> GetMission(long missionId); + + Task>> GetMissions(IQuerySpecification? query = null); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs new file mode 100644 index 0000000..1597518 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/ISignupsCommandService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions; + +public interface ISignupsCommandService +{ + Task> CreateSignups(SignupsCreateRequest signupsCreateRequest); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs new file mode 100644 index 0000000..3d4a86f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/ISignupsQueryService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions; + +public interface ISignupsQueryService +{ + Task> GetSignups(long signupId); + + Task>> GetOpenSignups(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs new file mode 100644 index 0000000..fcc50fd --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionCommandService.cs @@ -0,0 +1,32 @@ +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Validators; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation; + +internal class MissionCommandService : IMissionCommandService +{ + private readonly IMissionCommandRepository _missionCommandRepository; + + public MissionCommandService(IMissionCommandRepository missionCommandRepository) + { + _missionCommandRepository = missionCommandRepository; + } + + public async Task> CreateMission(MissionCreateRequest missionCreateRequest) + { + return await missionCreateRequest.ValidateRequest() + .Bind(() => _missionCommandRepository.CreateMission( + new Mission + { + Title = missionCreateRequest.Title, + Description = missionCreateRequest.Description, + Owner = missionCreateRequest.Owner, + MissionDate = missionCreateRequest.MissionDate, + ModsetName = missionCreateRequest.ModsetName + })); + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs new file mode 100644 index 0000000..62c3aca --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/MissionQueryService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation; + +internal class MissionQueryService : IMissionQueryService +{ + private readonly IMissionQueryRepository _missionQueryRepository; + + public MissionQueryService(IMissionQueryRepository missionQueryRepository) + { + _missionQueryRepository = missionQueryRepository; + } + + public async Task> GetMission(long missionId) + => await _missionQueryRepository.GetMission(missionId) + ?? Result.Failure($"Mission with ID {missionId} does not exist."); + + public async Task>> GetMissions(IQuerySpecification? query = null) + => await _missionQueryRepository.GetMissions(query); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs new file mode 100644 index 0000000..ab23baf --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/IMissionCommandRepository.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; + +internal interface IMissionCommandRepository +{ + Task> CreateMission(Mission missionToCreate); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs new file mode 100644 index 0000000..a8dc931 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/ISignupsCommandRepository.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; + +internal interface ISignupsCommandRepository +{ + Task> CreateSignups(long missionId, Signups signups); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs new file mode 100644 index 0000000..3994240 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/MissionCommandRepository.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; + +internal class MissionCommandRepository : IMissionCommandRepository +{ + private readonly MissionContext _context; + + public MissionCommandRepository(MissionContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> CreateMission(Mission missionToCreate) + { + var missionEntityEntry = await _context.Missions.AddAsync(missionToCreate); + + if (missionEntityEntry is null) return Result.Failure("Failure creating mission."); + + await _context.SaveChangesAsync(); + return missionEntityEntry.Entity; + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs new file mode 100644 index 0000000..bea7b6a --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Command/SignupsCommandRepository.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; +using Microsoft.EntityFrameworkCore; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; + +internal class SignupsCommandRepository : ISignupsCommandRepository +{ + private readonly MissionContext _context; + + public SignupsCommandRepository(MissionContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> CreateSignups(long missionId, Signups signups) + { + var signupsEntityEntry = await _context.Signups.AddAsync(signups); + + if (signupsEntityEntry is null) return Result.Failure("Failure creating signups."); + + var mission = await _context.Missions.SingleOrDefaultAsync(x => x.MissionId == missionId); + if (mission is null) return Result.Failure($"Mission with ID {missionId} doesn't exist."); + + var updatedMission = mission with + { + Signups = signupsEntityEntry.Entity + }; + + _context.Entry(mission).State = EntityState.Detached; + _context.Attach(updatedMission); + _context.Entry(updatedMission).Reference(x => x.Signups).IsModified = true; + + await _context.SaveChangesAsync(); + return signupsEntityEntry.Entity; + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs new file mode 100644 index 0000000..32d1c2b --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/MissionEntityTypeConfiguration.cs @@ -0,0 +1,23 @@ +using ArmaForces.Boderator.Core.Extensions; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations; + +internal class MissionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder missionConfiguration) + { + missionConfiguration.HasKey(x => x.MissionId); + + missionConfiguration.Property(x => x.Title) + .IsRequired(); + + missionConfiguration.Property(x => x.Description) + .IsNotRequired(); + + missionConfiguration.Property(x => x.Owner) + .IsRequired(); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs new file mode 100644 index 0000000..2790f7c --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SignupsEntityTypeConfiguration.cs @@ -0,0 +1,30 @@ +using ArmaForces.Boderator.Core.Extensions; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations; + +internal class SignupsEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder signupsConfiguration) + { + signupsConfiguration.HasKey(x => x.SignupsId); + + signupsConfiguration.Property(x => x.Status) + .HasDefaultValue(SignupsStatus.Created) + .HasConversion() + .IsRequired(); + + signupsConfiguration.Property("MissionId") + .IsRequired(); + + signupsConfiguration.HasOne() + .WithOne(x => x.Signups) + .IsRequired(); + + signupsConfiguration.HasMany() + .WithOne() + .IsNotRequired(); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs new file mode 100644 index 0000000..1c698e7 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/SlotEntityTypeConfiguration.cs @@ -0,0 +1,27 @@ +using ArmaForces.Boderator.Core.Extensions; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations; + +internal class SlotEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder slotConfiguration) + { + slotConfiguration.HasKey(x => x.SlotId); + + slotConfiguration.Property("Name") + .IsRequired(); + + slotConfiguration.Property("Vehicle") + .IsNotRequired(); + + slotConfiguration.Property("Occupant") + .IsNotRequired(); + + slotConfiguration.HasOne() + .WithMany(x => x.Slots) + .HasForeignKey("SignupsId", "TeamName"); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs new file mode 100644 index 0000000..f077a2f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/EntityConfigurations/TeamEntityTypeConfiguration.cs @@ -0,0 +1,25 @@ +using ArmaForces.Boderator.Core.Extensions; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations; + +internal class TeamEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder teamConfiguration) + { + teamConfiguration.HasKey("SignupsId", "Name"); + + teamConfiguration.Property("SignupsId") + .IsRequired(); + + teamConfiguration.Property("Vehicle") + .IsNotRequired(); + + teamConfiguration.HasOne() + .WithMany(x => x.Teams) + .HasForeignKey("SignupsId") + .IsRequired(); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs new file mode 100644 index 0000000..dc9d06b --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/MissionContext.cs @@ -0,0 +1,28 @@ +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.EntityConfigurations; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence; + +internal sealed class MissionContext : DbContext +{ + public MissionContext(DbContextOptions options) + : base(options) + { + Database.EnsureCreated(); + } + + public DbSet Missions { get; set; } + public DbSet Signups { get; set; } + public DbSet Teams { get; set; } + public DbSet Slots { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .ApplyConfiguration(new MissionEntityTypeConfiguration()) + .ApplyConfiguration(new SignupsEntityTypeConfiguration()) + .ApplyConfiguration(new TeamEntityTypeConfiguration()) + .ApplyConfiguration(new SlotEntityTypeConfiguration()); + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs new file mode 100644 index 0000000..af39e7f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/IMissionQueryRepository.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; + +internal interface IMissionQueryRepository +{ + Task GetMission(long missionId); + + Task> GetMissions(IQuerySpecification? query = null); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs new file mode 100644 index 0000000..6f0b8d7 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/ISignupsQueryRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; + +internal interface ISignupsQueryRepository +{ + public Task> GetAllSignups(); + + public Task> GetOpenSignups(); + + public Task GetSignups(long signupId); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs new file mode 100644 index 0000000..7a6bd5d --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/MissionQueryRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Common.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; + +internal class MissionQueryRepository : IMissionQueryRepository +{ + private readonly MissionContext _context; + + public MissionQueryRepository(MissionContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetMission(long missionId) + => await _context.Missions.FindAsync(missionId); + + public async Task> GetMissions(IQuerySpecification? query = null) + { + return await SpecificationEvaluator.GetQuery(_context.Set().AsQueryable(), query ?? new MissionQuerySpecification()) + .ToListAsync(); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs new file mode 100644 index 0000000..8b3e8df --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/Persistence/Query/SignupsQueryRepository.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Models; +using Microsoft.EntityFrameworkCore; + +namespace ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; + +internal class SignupsQueryRepository : ISignupsQueryRepository +{ + private readonly MissionContext _context; + + public SignupsQueryRepository(MissionContext context) + { + _context = context; + } + + public async Task> GetAllSignups() + { + return await _context.Signups.ToListAsync(); + } + + public async Task> GetOpenSignups() + => await _context.Signups + .Where(x => x.CloseDate > DateTime.Now) + .ToListAsync(); + + public async Task GetSignups(long signupId) + => await _context.Signups + .FindAsync(signupId) + .AsTask(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs new file mode 100644 index 0000000..26c349a --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsCommandService.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Command; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Validators; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation; + +internal class SignupsCommandService : ISignupsCommandService +{ + private readonly IMissionCommandService _missionCommandService; + private readonly ISignupsCommandRepository _signupsCommandRepository; + + public SignupsCommandService( + IMissionCommandService missionCommandService, + ISignupsCommandRepository signupsCommandRepository) + { + _missionCommandService = missionCommandService; + _signupsCommandRepository = signupsCommandRepository; + } + + public async Task> CreateSignups(SignupsCreateRequest signupsCreateRequest) + { + return await signupsCreateRequest.ValidateRequest() + .Bind(() => CreateOrGetMissionId(signupsCreateRequest)) + .Bind(missionId => _signupsCommandRepository.CreateSignups(missionId, new Signups + { + Status = signupsCreateRequest.SignupsStatus, + StartDate = signupsCreateRequest.StartDate ?? + (signupsCreateRequest.SignupsStatus == SignupsStatus.Open ? DateTime.Now : null), + CloseDate = signupsCreateRequest.CloseDate, + Teams = signupsCreateRequest.Teams + })); + } + + private async Task> CreateOrGetMissionId(SignupsCreateRequest signupsCreateRequest) + { + if (signupsCreateRequest.MissionCreateRequest is not null) + return await _missionCommandService.CreateMission(signupsCreateRequest.MissionCreateRequest) + .Map(x => x.MissionId); + return Result.Success(signupsCreateRequest.MissionId!.Value); + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs new file mode 100644 index 0000000..81b305f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Implementation/SignupsQueryService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence; +using ArmaForces.Boderator.Core.Missions.Implementation.Persistence.Query; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Implementation; + +internal class SignupsQueryService : ISignupsQueryService +{ + private readonly ISignupsQueryRepository _signupsQueryRepository; + + public SignupsQueryService(ISignupsQueryRepository signupsQueryRepository) + { + _signupsQueryRepository = signupsQueryRepository; + } + + public async Task> GetSignups(long signupId) + => await _signupsQueryRepository.GetSignups(signupId) + ?? Result.Failure($"Signup with ID {signupId} not found"); + + public async Task>> GetOpenSignups() + => await _signupsQueryRepository.GetOpenSignups(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs new file mode 100644 index 0000000..1ef68b9 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Mission.cs @@ -0,0 +1,20 @@ +using System; + +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record Mission +{ + public long MissionId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } = string.Empty; + + public DateTime? MissionDate { get; init; } + + public string? ModsetName { get; init; } + + public string Owner { get; init; } = string.Empty; + + public Signups? Signups { get; init; } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs new file mode 100644 index 0000000..cbc9b0e --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/MissionCreateRequest.cs @@ -0,0 +1,32 @@ +using System; + +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record MissionCreateRequest +{ + private readonly string _title = null!; + private readonly string _owner = null!; + + public string Title + { + get => _title; + init => _title = ValidateStringNotEmpty(value, nameof(Title)); + } + + public string? Description { get; init; } + + public DateTime? MissionDate { get; init; } + + public string? ModsetName { get; init; } + + public string Owner + { + get => _owner; + init => _owner = ValidateStringNotEmpty(value, nameof(Owner)); + } + + private static string ValidateStringNotEmpty(string? value, string propertyName) + => !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentNullException(propertyName); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs new file mode 100644 index 0000000..f6c5c8d --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Signups.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record Signups +{ + public long SignupsId { get; init; } + + public SignupsStatus Status { get; init; } = SignupsStatus.Created; + + public DateTime? StartDate { get; init; } + + public DateTime? CloseDate { get; init; } + + public IReadOnlyList Teams { get; init; } = new List(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsCreateRequest.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsCreateRequest.cs new file mode 100644 index 0000000..7374530 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsCreateRequest.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record SignupsCreateRequest +{ + public long? MissionId { get; init; } + + public MissionCreateRequest? MissionCreateRequest { get; init; } + + public SignupsStatus SignupsStatus { get; init; } = SignupsStatus.Created; + + public DateTime? StartDate { get; init; } + + public DateTime? CloseDate { get; init; } + + public List Teams { get; init; } = new(); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsStatus.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsStatus.cs new file mode 100644 index 0000000..3e187fe --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/SignupsStatus.cs @@ -0,0 +1,24 @@ +namespace ArmaForces.Boderator.Core.Missions.Models; + +public enum SignupsStatus : ushort +{ + /// + /// Signups are created and waiting for opening. + /// + Created, + + /// + /// Signups are open for some selected players. + /// + Preconcrete, + + /// + /// Signups are open for all players. + /// + Open, + + /// + /// Signups are closed. + /// + Closed +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Slot.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Slot.cs new file mode 100644 index 0000000..bf35883 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Slot.cs @@ -0,0 +1,12 @@ +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record Slot +{ + public long? SlotId { get; init; } + + public string Name { get; init; } = string.Empty; + + public string? Vehicle { get; init; } + + public string? Occupant { get; init; } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Models/Team.cs b/ArmaForces.Boderator.Core/Features/Missions/Models/Team.cs new file mode 100644 index 0000000..d229aab --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Models/Team.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ArmaForces.Boderator.Core.Missions.Models; + +public record Team +{ + public string Name { get; init; } = string.Empty; + + public string? Vehicle { get; init; } + + public IReadOnlyList Slots { get; init; } = new List(); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDateSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDateSpecification.cs new file mode 100644 index 0000000..c0c4f19 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDateSpecification.cs @@ -0,0 +1,8 @@ +using System; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectMissionDateSpecification +{ + IExpectMissionSignupsSpecification ScheduledAt(DateTimeOffset dateTime); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDescriptionSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDescriptionSpecification.cs new file mode 100644 index 0000000..5e0a8ae --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionDescriptionSpecification.cs @@ -0,0 +1,6 @@ +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectMissionDescriptionSpecification +{ + IExpectModsetSpecification WithDescription(string description); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionSignupsSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionSignupsSpecification.cs new file mode 100644 index 0000000..23dd8e0 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionSignupsSpecification.cs @@ -0,0 +1,9 @@ +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectMissionSignupsSpecification +{ + IBuildingSpecification WithSignups(IBuildingSpecification signupsSpecification); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionTitleSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionTitleSpecification.cs new file mode 100644 index 0000000..5a419c4 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectMissionTitleSpecification.cs @@ -0,0 +1,6 @@ +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectMissionTitleSpecification +{ + IExpectMissionDescriptionSpecification Titled(string title); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectModsetSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectModsetSpecification.cs new file mode 100644 index 0000000..e7cbc56 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/Interfaces/IExpectModsetSpecification.cs @@ -0,0 +1,9 @@ +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Modsets.Specification; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectModsetSpecification +{ + IExpectMissionDateSpecification WithModset(IBuildingSpecification modsetSpecification); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/MissionSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/MissionSpecification.cs new file mode 100644 index 0000000..ad65795 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Mission/MissionSpecification.cs @@ -0,0 +1,102 @@ +using System; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; +using ArmaForces.Boderator.Core.Modsets.Specification; +using ArmaForces.Boderator.Core.Users; + +namespace ArmaForces.Boderator.Core.Missions.Specifications; + +public record MissionSpecification : + IExpectMissionTitleSpecification, + IExpectMissionDescriptionSpecification, + IExpectModsetSpecification, + IExpectMissionDateSpecification, + IExpectMissionSignupsSpecification, + IBuildingSpecification +{ + private MissionSpecification() { } + + private User User { get; init; } = new(); + + private string Title { get; init; } = string.Empty; + + private string Description { get; init; } = string.Empty; + + private IBuildingSpecification? ModsetSpecification { get; init; } + + private DateTimeOffset Time { get; init; } + + private IBuildingSpecification? SignupsSpecification { get; init; } + + public static IExpectMissionTitleSpecification OwnedBy(User user) + { + return new MissionSpecification + { + User = user + }; + } + + public IExpectMissionDescriptionSpecification Titled(string title) + { + if (string.IsNullOrEmpty(title)) + throw new ArgumentException("Mission title cannot be null or empty", nameof(title)); + + return this with + { + Title = title + }; + } + + public IExpectModsetSpecification WithDescription(string description) + { + if (string.IsNullOrEmpty(description)) + throw new ArgumentException("Mission title cannot be null or empty", nameof(description)); + + return this with + { + Description = description + }; + } + + public IExpectMissionDateSpecification WithModset(IBuildingSpecification modsetSpecification) + { + if (modsetSpecification is null) + throw new ArgumentNullException(nameof(modsetSpecification)); + + return this with + { + ModsetSpecification = modsetSpecification + }; + } + + public IExpectMissionSignupsSpecification ScheduledAt(DateTimeOffset dateTime) + { + return this with + { + Time = dateTime + }; + } + + public IBuildingSpecification WithSignups(IBuildingSpecification signupsSpecification) + { + if (signupsSpecification is null) + throw new ArgumentNullException(nameof(signupsSpecification)); + + return this with + { + SignupsSpecification = signupsSpecification + }; + } + + public Mission Build() + => new() + { + Owner = User.ToString(), + Title = Title, + Description = Description, + MissionDate = Time.DateTime, + ModsetName = ModsetSpecification!.Build().Name, + Signups = SignupsSpecification!.Build() + }; +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectAnotherTeamSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectAnotherTeamSpecification.cs new file mode 100644 index 0000000..721b02a --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectAnotherTeamSpecification.cs @@ -0,0 +1,13 @@ +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectAnotherTeamSpecification +{ + bool CanAdd(IBuildingSpecification? team); + + IExpectAnotherTeamSpecification AndTeam(IBuildingSpecification team); + + IBuildingSpecification AnoNoMoreTeams(); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectCloseDateSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectCloseDateSpecification.cs new file mode 100644 index 0000000..cdc1fa1 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectCloseDateSpecification.cs @@ -0,0 +1,8 @@ +using System; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectCloseDateSpecification +{ + IExpectTeamSpecification ClosingAt(DateTimeOffset dateTime); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectTeamSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectTeamSpecification.cs new file mode 100644 index 0000000..99c8b7c --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/Interfaces/IExpectTeamSpecification.cs @@ -0,0 +1,11 @@ +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectTeamSpecification +{ + bool CanAdd(IBuildingSpecification? team); + + IExpectAnotherTeamSpecification WithTeam(IBuildingSpecification team); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/SignupsSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/SignupsSpecification.cs new file mode 100644 index 0000000..148b3f8 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Signups/SignupsSpecification.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +namespace ArmaForces.Boderator.Core.Missions.Specifications; + +public record SignupsSpecification : + IExpectCloseDateSpecification, + IExpectTeamSpecification, + IExpectAnotherTeamSpecification, + IBuildingSpecification +{ + private SignupsSpecification() { } + + private DateTimeOffset StartAt { get; init; } + + private DateTimeOffset CloseAt { get; init; } + + private IReadOnlyList> Teams { get; init; } = new List>(); + + public static IExpectCloseDateSpecification StartingAt(DateTimeOffset dateTime) + { + // TODO: Add date validation + return new SignupsSpecification + { + StartAt = dateTime + }; + } + + public IExpectTeamSpecification ClosingAt(DateTimeOffset dateTime) + { + // TODO: Add date validation + return this with + { + CloseAt = dateTime + }; + } + + public bool CanAdd(IBuildingSpecification? team) => + team is not null && + Teams.All(x => x.Build().Name != team.Build().Name); + + public IExpectAnotherTeamSpecification WithTeam(IBuildingSpecification team) => AddTeam(team); + + public IExpectAnotherTeamSpecification AndTeam(IBuildingSpecification team) => AddTeam(team); + + public IBuildingSpecification AnoNoMoreTeams() + { + return Teams.Any() + ? this + : throw new InvalidOperationException("At least one team must be added"); + } + + public Signups Build() + => new() + { + Status = SignupsStatus.Created, + StartDate = StartAt.DateTime, + CloseDate = CloseAt.DateTime, + Teams = Teams.Select(x => x.Build()).ToList() + }; + + private SignupsSpecification AddTeam(IBuildingSpecification team) + { + if (!CanAdd(team)) + throw new ArgumentException( + "Team cannot be added as it's either null or other team with the same name was already added"); + + return this with + { + Teams = new List>(Teams) {team} + }; + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Slot/SlotSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Slot/SlotSpecification.cs new file mode 100644 index 0000000..dc09e7c --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Slot/SlotSpecification.cs @@ -0,0 +1,87 @@ +using System; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Users; + +namespace ArmaForces.Boderator.Core.Missions.Specifications; + +public record SlotSpecification : + IExpectOptionalSlotOccupantSpecification, + IExpectSlotOptionalVehicleSpecification, + IBuildingSpecification +{ + private SlotSpecification() { } + + private string Name { get; init; } = string.Empty; + + private string? Occupant { get; init; } + + private string? Vehicle { get; init; } + + public static IExpectOptionalSlotOccupantSpecification Named(string slotName) + { + if (string.IsNullOrEmpty(slotName)) + throw new ArgumentException("Slot name cannot be null or empty."); + + return new SlotSpecification + { + Name = slotName + }; + } + + public static IBuildingSpecification UnoccupiedWithoutVehicleNamed(string slotName) + { + if (string.IsNullOrEmpty(slotName)) + throw new ArgumentException("Slot name cannot be null or empty."); + + return new SlotSpecification + { + Name = slotName + }; + } + + public IExpectSlotOptionalVehicleSpecification OccupiedBy(User user) => + this with + { + Occupant = user.Name + }; + + public IExpectSlotOptionalVehicleSpecification Unoccupied() => this; + + public IBuildingSpecification WithVehicle(string vehicleName) + { + if (string.IsNullOrEmpty(vehicleName)) + throw new ArgumentException("Specified vehicle name is null or empty."); + + return this with + { + Vehicle = vehicleName + }; + } + + public IBuildingSpecification WithoutVehicle() => this; + + public Slot Build() + { + return new Slot + { + Name = Name, + Occupant = Occupant, + Vehicle = Vehicle + }; + } +} + +public interface IExpectSlotOptionalVehicleSpecification +{ + IBuildingSpecification WithVehicle(string vehicleName); + + IBuildingSpecification WithoutVehicle(); +} + +public interface IExpectOptionalSlotOccupantSpecification +{ + IExpectSlotOptionalVehicleSpecification OccupiedBy(User user); + + IExpectSlotOptionalVehicleSpecification Unoccupied(); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectAnotherSlotSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectAnotherSlotSpecification.cs new file mode 100644 index 0000000..0818f63 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectAnotherSlotSpecification.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectAnotherSlotSpecification +{ + bool CanAdd(IBuildingSpecification? slot); + + IExpectAnotherSlotSpecification AndSlot(IBuildingSpecification slot); + + IExpectAnotherSlotSpecification WithSlots(List> slots); + + IBuildingSpecification AndNoMoreSlots(); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectOptionalTeamVehicleSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectOptionalTeamVehicleSpecification.cs new file mode 100644 index 0000000..01073aa --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectOptionalTeamVehicleSpecification.cs @@ -0,0 +1,8 @@ +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectOptionalTeamVehicleSpecification +{ + IExpectSlotSpecification WithVehicle(string vehicleName); + + IExpectSlotSpecification WithoutVehicle(); +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectSlotSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectSlotSpecification.cs new file mode 100644 index 0000000..bb7145a --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/Interfaces/IExpectSlotSpecification.cs @@ -0,0 +1,11 @@ +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; + +namespace ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +public interface IExpectSlotSpecification +{ + bool CanAdd(IBuildingSpecification? slot); + + IExpectAnotherSlotSpecification WithSlot(IBuildingSpecification slot); +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/TeamSpecification.cs b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/TeamSpecification.cs new file mode 100644 index 0000000..b39f385 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Specifications/Team/TeamSpecification.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; +using ArmaForces.Boderator.Core.Missions.Models; +using ArmaForces.Boderator.Core.Missions.Specifications.Interfaces; + +namespace ArmaForces.Boderator.Core.Missions.Specifications; + +public record TeamSpecification : + IExpectSlotSpecification, + IExpectAnotherSlotSpecification, + IExpectOptionalTeamVehicleSpecification, + IBuildingSpecification +{ + private TeamSpecification() { } + + private string Name { get; init; } = string.Empty; + + private string? Vehicle { get; init; } + + private IReadOnlyList> Slots { get; init; } = new List>(); + + public static IExpectOptionalTeamVehicleSpecification Named(string teamName) + { + if (string.IsNullOrEmpty(teamName)) + throw new ArgumentException("Team name cannot be empty", nameof(teamName)); + + return new TeamSpecification + { + Name = teamName + }; + } + + public IExpectSlotSpecification WithVehicle(string vehicleName) + { + if (string.IsNullOrEmpty(vehicleName)) + throw new ArgumentException("Specified vehicle name is null or empty"); + + return this with + { + Vehicle = vehicleName + }; + } + + public IExpectSlotSpecification WithoutVehicle() => this; + + public bool CanAdd(IBuildingSpecification? slot) + { + var builtSlotToAdd = slot?.Build(); + + return builtSlotToAdd is not null && Slots.All(x => + { + var builtSlot = x.Build(); + return (builtSlotToAdd.SlotId is null || + builtSlot.SlotId != builtSlotToAdd.SlotId) && + (builtSlot.Occupant is null || + builtSlot.Occupant != builtSlotToAdd.Occupant); + }); + } + + + public IExpectAnotherSlotSpecification WithSlot(IBuildingSpecification slot) => AddSlot(slot); + + public IExpectAnotherSlotSpecification WithSlots(List> slots) + => slots.Aggregate(this, (currentTeam, slot) => currentTeam.AddSlot(slot)); + + public IExpectAnotherSlotSpecification AndSlot(IBuildingSpecification slot) => AddSlot(slot); + + public IBuildingSpecification AndNoMoreSlots() => this; + + public Team Build() + { + return new Team + { + Name = Name, + Vehicle = Vehicle, + Slots = Slots.Select(x => x.Build()).ToList() + }; + } + + private TeamSpecification AddSlot(IBuildingSpecification slot) + { + if (!CanAdd(slot)) + throw new ArgumentException("Slot cannot be added due to slot ID or occupant duplication."); + + return this with + { + Slots = new List>(Slots) {slot} + }; + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Missions/Validators/MissionCreateRequestValidators.cs b/ArmaForces.Boderator.Core/Features/Missions/Validators/MissionCreateRequestValidators.cs new file mode 100644 index 0000000..9d52295 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Validators/MissionCreateRequestValidators.cs @@ -0,0 +1,16 @@ +using System.Linq; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Validators; + +internal static class MissionCreateRequestValidators +{ + public static Result ValidateRequest(this MissionCreateRequest missionCreateRequest) + { + if (missionCreateRequest.ModsetName?.Any(char.IsWhiteSpace) ?? false) + return Result.Failure($"{nameof(MissionCreateRequest.ModsetName)} cannot contain whitespace characters."); + + return Result.Success(); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Missions/Validators/SignupsCreateRequestValidators.cs b/ArmaForces.Boderator.Core/Features/Missions/Validators/SignupsCreateRequestValidators.cs new file mode 100644 index 0000000..9e0453b --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Missions/Validators/SignupsCreateRequestValidators.cs @@ -0,0 +1,24 @@ +using ArmaForces.Boderator.Core.Missions.Implementation; +using ArmaForces.Boderator.Core.Missions.Models; +using CSharpFunctionalExtensions; + +namespace ArmaForces.Boderator.Core.Missions.Validators; + +internal static class SignupsCreateRequestValidators +{ + public static Result ValidateRequest(this SignupsCreateRequest signupsCreateRequest) + { + if (signupsCreateRequest.MissionId.HasValue && signupsCreateRequest.MissionCreateRequest is not null) + { + return Result.Failure($"Only one of {nameof(SignupsCreateRequest.MissionId)} and {nameof(SignupsCreateRequest.MissionCreateRequest)} can be specified."); + } + + if (signupsCreateRequest.MissionCreateRequest is not null) + return signupsCreateRequest.MissionCreateRequest.ValidateRequest(); + + if (signupsCreateRequest.MissionId > 0) + return Result.Success(); + + return Result.Failure("Signups creation request validation failure"); + } +} diff --git a/ArmaForces.Boderator.Core/Features/Modsets/Specification/Modset.cs b/ArmaForces.Boderator.Core/Features/Modsets/Specification/Modset.cs new file mode 100644 index 0000000..b854720 --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Modsets/Specification/Modset.cs @@ -0,0 +1,11 @@ +namespace ArmaForces.Boderator.Core.Modsets.Specification; + +public record Modset +{ + /// + /// Internal constructor to block construction of invalid object. + /// + internal Modset() { } + + public string Name { get; init; } = string.Empty; +} diff --git a/ArmaForces.Boderator.Core/Features/Modsets/Specification/ModsetSpecification.cs b/ArmaForces.Boderator.Core/Features/Modsets/Specification/ModsetSpecification.cs new file mode 100644 index 0000000..a6ad28f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Modsets/Specification/ModsetSpecification.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using ArmaForces.Boderator.Core.Infrastructure.Specifications; + +namespace ArmaForces.Boderator.Core.Modsets.Specification; + +public class ModsetSpecification : IBuildingSpecification +{ + private ModsetSpecification() { } + + private string Name { get; init; } = string.Empty; + + public static IBuildingSpecification Named(string modsetName) + { + return new ModsetSpecification + { + Name = modsetName + }; + } + + public static IBuildingSpecification ByUrl(Uri modsetUrl) + { + return new ModsetSpecification + { + Name = modsetUrl.Segments.Last() + }; + } + + public Modset Build() + { + return new Modset + { + Name = Name + }; + } +} \ No newline at end of file diff --git a/ArmaForces.Boderator.Core/Features/Users/User.cs b/ArmaForces.Boderator.Core/Features/Users/User.cs new file mode 100644 index 0000000..fc44b1f --- /dev/null +++ b/ArmaForces.Boderator.Core/Features/Users/User.cs @@ -0,0 +1,18 @@ +namespace ArmaForces.Boderator.Core.Users; + +public record User +{ + /// + /// Internal constructor to block construction of invalid object. + /// + internal User() { } + + internal User(string userName) + { + Name = userName; + } + + public string Name { get; init; } = string.Empty; + + public override string ToString() => Name; +} diff --git a/ArmaForces.Boderator.Core/Infrastructure/Specifications/IBuildingSpecification.cs b/ArmaForces.Boderator.Core/Infrastructure/Specifications/IBuildingSpecification.cs new file mode 100644 index 0000000..6e1fb37 --- /dev/null +++ b/ArmaForces.Boderator.Core/Infrastructure/Specifications/IBuildingSpecification.cs @@ -0,0 +1,6 @@ +namespace ArmaForces.Boderator.Core.Infrastructure.Specifications; + +public interface IBuildingSpecification +{ + T Build(); +} diff --git a/ArmaForces.Boderator.DAO/ArmaForces.Boderator.DAO.csproj b/ArmaForces.Boderator.DAO/ArmaForces.Boderator.DAO.csproj deleted file mode 100644 index ae64065..0000000 --- a/ArmaForces.Boderator.DAO/ArmaForces.Boderator.DAO.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net5.0 - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/ArmaForces.Boderator.DAO/BoderatorContext.cs b/ArmaForces.Boderator.DAO/BoderatorContext.cs deleted file mode 100644 index 3dc23f1..0000000 --- a/ArmaForces.Boderator.DAO/BoderatorContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace ArmaForces.Boderator.DAO -{ - public class BoderatorContext : DbContext - { - public BoderatorContext() : base() - { - - } - } -} diff --git a/ArmaForces.Boderator.Tests/TestBase.cs b/ArmaForces.Boderator.Tests/TestBase.cs deleted file mode 100644 index abea680..0000000 --- a/ArmaForces.Boderator.Tests/TestBase.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; - -namespace ArmaForces.Boderator.Tests -{ - public class TestBase - { - protected IServiceProvider Provider { get; } - - public TestBase() - { - Provider = new ServiceCollection() - .BuildServiceProvider(); - } - } -} diff --git a/ArmaForces.Boderator.sln b/ArmaForces.Boderator.sln index 60328a7..02791b5 100644 --- a/ArmaForces.Boderator.sln +++ b/ArmaForces.Boderator.sln @@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.31205.134 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArmaForces.Boderator.DAO", "ArmaForces.Boderator.DAO\ArmaForces.Boderator.DAO.csproj", "{A2849677-3590-45E2-BD3C-2C21B2C59BC6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArmaForces.Boderator.Core", "ArmaForces.Boderator.Core\ArmaForces.Boderator.Core.csproj", "{A2849677-3590-45E2-BD3C-2C21B2C59BC6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArmaForces.Boderator.Tests", "ArmaForces.Boderator.Tests\ArmaForces.Boderator.Tests.csproj", "{85762E30-4C0D-4735-B40F-C8C40F73F4C3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArmaForces.Boderator.BotService.Tests", "ArmaForces.Boderator.BotService.Tests\ArmaForces.Boderator.BotService.Tests.csproj", "{85762E30-4C0D-4735-B40F-C8C40F73F4C3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArmaForces.Boderator.BotService", "ArmaForces.Boderator.BotService\ArmaForces.Boderator.BotService.csproj", "{250BF5C2-0750-4BE0-BFEA-35EAA9AB24F1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArmaForces.Boderator.Core.Tests", "ArmaForces.Boderator.Core.Tests\ArmaForces.Boderator.Core.Tests.csproj", "{7DE1D733-EBE7-4800-A5E2-6F0425EC93E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {250BF5C2-0750-4BE0-BFEA-35EAA9AB24F1}.Debug|Any CPU.Build.0 = Debug|Any CPU {250BF5C2-0750-4BE0-BFEA-35EAA9AB24F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {250BF5C2-0750-4BE0-BFEA-35EAA9AB24F1}.Release|Any CPU.Build.0 = Release|Any CPU + {7DE1D733-EBE7-4800-A5E2-6F0425EC93E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DE1D733-EBE7-4800-A5E2-6F0425EC93E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DE1D733-EBE7-4800-A5E2-6F0425EC93E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DE1D733-EBE7-4800-A5E2-6F0425EC93E9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE