diff --git a/src/Registration/Registration.Tests/Controllers/AdminControllerTests.cs b/src/Registration/Registration.Tests/Controllers/AdminControllerTests.cs new file mode 100644 index 0000000..c0b545b --- /dev/null +++ b/src/Registration/Registration.Tests/Controllers/AdminControllerTests.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Registration.API.Controllers; + +namespace Registration.Tests.Controllers; + +public class AdminControllerTests +{ + private static IConfiguration BuildConfiguration(string? adminPassword) + { + var dict = new Dictionary(); + if (adminPassword is not null) + { + dict["Admin:Password"] = adminPassword; + } + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + + [Fact] + public void Login_returns_Ok_with_valid_credentials_from_configuration() + { + var controller = new AdminController(BuildConfiguration("s3cret")); + + var result = controller.Login(new AdminController.LoginRequest + { + Username = "admin", + Password = "s3cret" + }); + + Assert.IsType(result); + } + + [Fact] + public void Login_returns_Unauthorized_with_bad_password() + { + var controller = new AdminController(BuildConfiguration("s3cret")); + + var result = controller.Login(new AdminController.LoginRequest + { + Username = "admin", + Password = "wrong" + }); + + Assert.IsType(result); + } + + [Fact] + public void Login_returns_Unauthorized_with_wrong_username() + { + var controller = new AdminController(BuildConfiguration("s3cret")); + + var result = controller.Login(new AdminController.LoginRequest + { + Username = "root", + Password = "s3cret" + }); + + Assert.IsType(result); + } + + [Fact] + public void Login_falls_back_to_default_password_when_config_missing() + { + var controller = new AdminController(BuildConfiguration(null)); + + var result = controller.Login(new AdminController.LoginRequest + { + Username = "admin", + Password = "admin123" + }); + + Assert.IsType(result); + } +} diff --git a/src/Registration/Registration.Tests/Controllers/ContentControllerTests.cs b/src/Registration/Registration.Tests/Controllers/ContentControllerTests.cs new file mode 100644 index 0000000..2e7ed5d --- /dev/null +++ b/src/Registration/Registration.Tests/Controllers/ContentControllerTests.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Registration.API.Controllers; +using Registration.Domain.Models; +using Registration.Infra.Repositories; + +namespace Registration.Tests.Controllers; + +public class ContentControllerTests +{ + private readonly Mock _repo = new(); + + private ContentController Sut() => new(_repo.Object); + + [Fact] + public async Task GetContent_returns_Ok_with_content_from_repository() + { + var content = new EventContent { Title = "Hello", SubTitle = "Sub" }; + _repo.Setup(r => r.GetEventContent()).ReturnsAsync(content); + + var result = await Sut().GetContent(); + + var ok = Assert.IsType(result); + Assert.Same(content, ok.Value); + } + + [Fact] + public async Task UpdateContent_forwards_payload_and_returns_Ok() + { + var content = new EventContent { Title = "New" }; + + var result = await Sut().UpdateContent(content); + + Assert.IsType(result); + _repo.Verify(r => r.UpdateEventContent(content), Times.Once); + } +} diff --git a/src/Registration/Registration.Tests/Controllers/ParticipantControllerTests.cs b/src/Registration/Registration.Tests/Controllers/ParticipantControllerTests.cs new file mode 100644 index 0000000..febf4cc --- /dev/null +++ b/src/Registration/Registration.Tests/Controllers/ParticipantControllerTests.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Registration.API.Controllers; +using Registration.API.RequestModels; +using Registration.API.Services; +using Registration.Domain.Models; + +namespace Registration.Tests.Controllers; + +public class ParticipantControllerTests +{ + private readonly Mock _relay = new(); + + private ParticipantController Sut() => new(_relay.Object); + + private static ParticipantRegistrationRequest ValidRequest() => new() + { + IsMember = true, + FirstName = " Anna ", + SurName = " Andersson ", + Grade = " 9 ", + PhoneNumber = "+46 70 111 22 33", + Email = " anna@example.com ", + GuardianName = " Bertil Andersson ", + GuardianPhoneNumber = "+46 70 999 88 77", + GuardianEmail = " bertil@example.com ", + IsVisitor = false, + HasApprovedGdpr = true, + Friends = " Maria ", + SpecialDiet = " Vegan " + }; + + [Fact] + public async Task RegisterForLan_returns_Ok_when_relay_succeeds() + { + Participant? captured = null; + _relay.Setup(r => r.RegisterParticipantAsync(It.IsAny(), It.IsAny())) + .Callback((p, _) => captured = p) + .ReturnsAsync(new VbytesRelayResult(true, 200, "ok")); + + var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(captured); + Assert.Equal("Anna", captured!.FirstName); + Assert.Equal("Andersson", captured.SurName); + Assert.Equal("9", captured.Grade); + Assert.Equal("anna@example.com", captured.Email); + Assert.Equal("bertil@example.com", captured.GuardianEmail); + Assert.Equal("Bertil Andersson", captured.GuardianName); + Assert.Equal("0701112233", captured.PhoneNumber); + Assert.Equal("0709998877", captured.GuardianPhoneNumber); + Assert.Equal("Maria", captured.Friends); + Assert.Equal("Vegan", captured.SpecialDiet); + Assert.True(captured.IsMember); + Assert.False(captured.IsVisitor); + Assert.True(captured.HasApprovedGdpr); + } + + [Fact] + public async Task RegisterForLan_normalizes_missing_optional_phone_to_null() + { + Participant? captured = null; + _relay.Setup(r => r.RegisterParticipantAsync(It.IsAny(), It.IsAny())) + .Callback((p, _) => captured = p) + .ReturnsAsync(new VbytesRelayResult(true, 200, "ok")); + + var req = ValidRequest(); + req.PhoneNumber = " "; + + await Sut().RegisterForLan(req, CancellationToken.None); + + Assert.Null(captured!.PhoneNumber); + } + + [Fact] + public async Task RegisterForLan_returns_Unauthorized_when_relay_fails() + { + _relay.Setup(r => r.RegisterParticipantAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VbytesRelayResult(false, 401, "401 Unauthorized")); + + var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task RegisterForLan_returns_Unauthorized_when_success_message_contains_401() + { + _relay.Setup(r => r.RegisterParticipantAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VbytesRelayResult(true, 200, "downstream returned 401")); + + var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + } +} diff --git a/src/Registration/Registration.Tests/Controllers/RegistrationControllerTests.cs b/src/Registration/Registration.Tests/Controllers/RegistrationControllerTests.cs new file mode 100644 index 0000000..ad61e09 --- /dev/null +++ b/src/Registration/Registration.Tests/Controllers/RegistrationControllerTests.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Registration.API.Controllers; +using Registration.API.Services; +using Registration.Infra.Repositories; + +namespace Registration.Tests.Controllers; + +public class RegistrationControllerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _auth = new(); + + private RegistrationController Sut() => new(_repo.Object, _auth.Object); + + [Fact] + public async Task ValidateSsn_returns_400_for_invalid_ssn() + { + var result = await Sut().ValidateSsn("123", CancellationToken.None); + + Assert.IsType(result); + _auth.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ValidateSsn_returns_Ok_when_member() + { + _auth.Setup(a => a.IsMemberAsync("199001011234", It.IsAny())) + .ReturnsAsync(true); + + var result = await Sut().ValidateSsn("19900101-1234", CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task ValidateSsn_returns_NotFound_when_not_member() + { + _auth.Setup(a => a.IsMemberAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await Sut().ValidateSsn("19900101-1234", CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task RegisterMember_returns_400_for_invalid_ssn() + { + var result = await Sut().RegisterMember("nope", CancellationToken.None); + + Assert.IsType(result); + _repo.Verify(r => r.AddRegistration(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RegisterMember_returns_Ok_when_added() + { + _repo.Setup(r => r.AddRegistration("199001011234")).ReturnsAsync(true); + + var result = await Sut().RegisterMember("19900101-1234", CancellationToken.None); + + Assert.IsType(result); + _repo.Verify(r => r.AddRegistration("199001011234"), Times.Once); + } + + [Fact] + public async Task RegisterMember_returns_Conflict_when_already_registered() + { + _repo.Setup(r => r.AddRegistration(It.IsAny())).ReturnsAsync(false); + + var result = await Sut().RegisterMember("19900101-1234", CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task IsMemberRegistered_returns_400_for_invalid_ssn() + { + var result = await Sut().IsMemberRegistered("bad"); + + Assert.IsType(result); + } + + [Fact] + public async Task IsMemberRegistered_returns_Conflict_when_already_registered() + { + _repo.Setup(r => r.GetIsRegistered("199001011234")).ReturnsAsync(true); + + var result = await Sut().IsMemberRegistered("19900101-1234"); + + Assert.IsType(result); + } + + [Fact] + public async Task IsMemberRegistered_returns_Ok_when_not_yet_registered() + { + _repo.Setup(r => r.GetIsRegistered(It.IsAny())).ReturnsAsync(false); + + var result = await Sut().IsMemberRegistered("19900101-1234"); + + Assert.IsType(result); + } + + [Fact] + public async Task ClearRegistrations_calls_repository_and_returns_Ok() + { + var result = await Sut().ClearRegistrations(CancellationToken.None); + + Assert.IsType(result); + _repo.Verify(r => r.ClearRegistrations(), Times.Once); + } +} diff --git a/src/Registration/Registration.Tests/Controllers/VolunteerControllerTests.cs b/src/Registration/Registration.Tests/Controllers/VolunteerControllerTests.cs new file mode 100644 index 0000000..9a98b89 --- /dev/null +++ b/src/Registration/Registration.Tests/Controllers/VolunteerControllerTests.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Registration.API.Controllers; +using Registration.API.RequestModels; +using Registration.API.Services; +using Registration.Domain.Models; + +namespace Registration.Tests.Controllers; + +public class VolunteerControllerTests +{ + private readonly Mock _relay = new(); + + private VolunteerController Sut() => new(_relay.Object); + + private static VolunteerRegistrationRequest ValidRequest() => new() + { + FirstName = " Eva ", + SurName = " Svensson ", + PhoneNumber = "+46 70 111 22 33", + Email = " eva@example.com ", + HasApprovedGdpr = true, + AreasOfInterest = + [ + new AreaOfInterestRequest { Name = " Kiosk & Kök " }, + new AreaOfInterestRequest { Name = "Städning" } + ] + }; + + [Fact] + public async Task RegisterVolunteer_returns_Ok_when_relay_succeeds() + { + Volunteer? captured = null; + _relay.Setup(r => r.RegisterVolunteerAsync(It.IsAny(), It.IsAny())) + .Callback((v, _) => captured = v) + .ReturnsAsync(new VbytesRelayResult(true, 200, "ok")); + + var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(captured); + Assert.Equal("Eva", captured!.FirstName); + Assert.Equal("Svensson", captured.SurName); + Assert.Equal("eva@example.com", captured.Email); + Assert.Equal("0701112233", captured.PhoneNumber); + Assert.Equal(2, captured.AreasOfInterest.Count); + Assert.Equal("Kiosk & Kök", captured.AreasOfInterest[0].Name); + Assert.Equal("Städning", captured.AreasOfInterest[1].Name); + } + + [Fact] + public async Task RegisterVolunteer_filters_empty_areas_and_returns_validation_problem_when_none_remain() + { + var controller = Sut(); + var req = new VolunteerRegistrationRequest + { + FirstName = "Eva", + SurName = "Svensson", + PhoneNumber = "0701112233", + Email = "eva@example.com", + HasApprovedGdpr = true, + AreasOfInterest = + [ + new AreaOfInterestRequest { Name = " " } + ] + }; + + var result = await controller.RegisterVolunteer(req, CancellationToken.None); + + var obj = Assert.IsType(result); + Assert.IsType(obj.Value); + _relay.Verify(r => r.RegisterVolunteerAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RegisterVolunteer_returns_Unauthorized_when_relay_fails() + { + _relay.Setup(r => r.RegisterVolunteerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VbytesRelayResult(false, 401, "401")); + + var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task RegisterVolunteer_returns_Unauthorized_when_success_message_contains_401() + { + _relay.Setup(r => r.RegisterVolunteerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VbytesRelayResult(true, 200, "401 something")); + + var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None); + + Assert.IsType(result); + } +} diff --git a/src/Registration/Registration.Tests/Registration.Tests.csproj b/src/Registration/Registration.Tests/Registration.Tests.csproj index 627a60e..86d7bda 100644 --- a/src/Registration/Registration.Tests/Registration.Tests.csproj +++ b/src/Registration/Registration.Tests/Registration.Tests.csproj @@ -1,21 +1,30 @@ - - - - net9.0 - enable - enable - false - - - - - - - - - - - - - - + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Registration/Registration.Tests/Repositories/RegistrationRepositoryTests.cs b/src/Registration/Registration.Tests/Repositories/RegistrationRepositoryTests.cs new file mode 100644 index 0000000..bada507 --- /dev/null +++ b/src/Registration/Registration.Tests/Repositories/RegistrationRepositoryTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Configuration; +using Moq; +using Registration.Domain.Models; +using Registration.Infra.Repositories; + +namespace Registration.Tests.Repositories; + +public class RegistrationRepositoryTests +{ + private static IConfiguration BuildConfig(string? connection, string? pepper) + { + var dict = new Dictionary(); + if (connection is not null) dict["ConnectionStrings:DefaultConnection"] = connection; + if (pepper is not null) dict["Security:SsnPepper"] = pepper; + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + + [Fact] + public void Constructor_throws_when_connection_string_missing() + { + var ex = Assert.Throws(() => + new RegistrationRepository(BuildConfig(null, "pepper"))); + + Assert.Contains("DefaultConnection", ex.Message); + } + + [Fact] + public void Constructor_throws_when_pepper_missing() + { + var ex = Assert.Throws(() => + new RegistrationRepository(BuildConfig("Host=localhost", null))); + + Assert.Contains("SsnPepper", ex.Message); + } + + [Fact] + public void Constructor_succeeds_when_required_values_present() + { + var repo = new RegistrationRepository(BuildConfig("Host=localhost", "pepper")); + + Assert.NotNull(repo); + } +} + +public class RegistrationRepositoryContractTests +{ + [Fact] + public async Task Mocked_repository_supports_full_add_check_clear_cycle() + { + var registered = new HashSet(); + var mock = new Mock(); + mock.Setup(r => r.AddRegistration(It.IsAny())) + .ReturnsAsync((string ssn) => registered.Add(ssn)); + mock.Setup(r => r.GetIsRegistered(It.IsAny())) + .ReturnsAsync((string ssn) => registered.Contains(ssn)); + mock.Setup(r => r.ClearRegistrations()) + .Returns(() => { registered.Clear(); return Task.CompletedTask; }); + + var repo = mock.Object; + + Assert.False(await repo.GetIsRegistered("199001011234")); + Assert.True(await repo.AddRegistration("199001011234")); + Assert.False(await repo.AddRegistration("199001011234")); + Assert.True(await repo.GetIsRegistered("199001011234")); + + await repo.ClearRegistrations(); + + Assert.False(await repo.GetIsRegistered("199001011234")); + } + + [Fact] + public async Task Mocked_repository_supports_event_content_roundtrip() + { + EventContent stored = new(); + var mock = new Mock(); + mock.Setup(r => r.GetEventContent()).ReturnsAsync(() => stored); + mock.Setup(r => r.UpdateEventContent(It.IsAny())) + .Returns((EventContent c) => { stored = c; return Task.CompletedTask; }); + + var repo = mock.Object; + + var content = new EventContent + { + Title = "VBytes LAN", + EventDate = "2026-05-26", + RegistrationEnabled = false, + VolunteerAreas = "A\nB" + }; + await repo.UpdateEventContent(content); + var loaded = await repo.GetEventContent(); + + Assert.Equal(content.Title, loaded.Title); + Assert.Equal(content.EventDate, loaded.EventDate); + Assert.False(loaded.RegistrationEnabled); + Assert.Equal("A\nB", loaded.VolunteerAreas); + } +} diff --git a/src/Registration/Registration.Tests/Services/FakeHttpMessageHandler.cs b/src/Registration/Registration.Tests/Services/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..1482728 --- /dev/null +++ b/src/Registration/Registration.Tests/Services/FakeHttpMessageHandler.cs @@ -0,0 +1,36 @@ +using System.Net; + +namespace Registration.Tests.Services; + +internal sealed class FakeHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> _handler; + + public List Requests { get; } = []; + public List RequestBodies { get; } = []; + + public FakeHttpMessageHandler(Func> handler) + { + _handler = handler; + } + + public static FakeHttpMessageHandler RespondWith(HttpStatusCode status, string body = "") + { + return new FakeHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body) + })); + } + + public static FakeHttpMessageHandler Throws(Exception exception) + { + return new FakeHttpMessageHandler((_, _) => Task.FromException(exception)); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + RequestBodies.Add(request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken)); + return await _handler(request, cancellationToken); + } +} diff --git a/src/Registration/Registration.Tests/Services/VbytesParticipantRelayServiceTests.cs b/src/Registration/Registration.Tests/Services/VbytesParticipantRelayServiceTests.cs new file mode 100644 index 0000000..72e093c --- /dev/null +++ b/src/Registration/Registration.Tests/Services/VbytesParticipantRelayServiceTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Registration.API.Configuration; +using Registration.API.Services; +using Registration.Domain.Models; + +namespace Registration.Tests.Services; + +public class VbytesParticipantRelayServiceTests +{ + private static VbytesRelayOptions ValidOptions() => new() + { + BaseUrl = "https://relay.example", + ParticipantRegisterPath = "/api/participant", + VolunteerRegisterPath = "/api/volunteer", + ApiKeyHeaderName = "X-Api-Key", + ApiKey = "secret" + }; + + private static Participant SampleParticipant() => new() + { + IsMember = true, + FirstName = "Anna", + SurName = "Andersson", + Grade = "9", + PhoneNumber = "0701112233", + Email = "anna@example.com", + GuardianName = "Bertil", + GuardianPhoneNumber = "0709998877", + GuardianEmail = "bertil@example.com", + IsVisitor = false, + HasApprovedGdpr = true, + Friends = null, + SpecialDiet = null + }; + + private static VbytesParticipantRelayService Build(FakeHttpMessageHandler handler, VbytesRelayOptions options) + { + var client = new HttpClient(handler) { BaseAddress = new Uri(options.BaseUrl) }; + return new VbytesParticipantRelayService( + client, + Options.Create(options), + NullLogger.Instance); + } + + [Fact] + public async Task RegisterParticipantAsync_returns_500_when_base_url_missing() + { + var opts = ValidOptions(); + opts.BaseUrl = ""; + // BaseAddress can't be empty on HttpClient — use any URI, ValidateConfiguration runs first. + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK); + var client = new HttpClient(handler); + var sut = new VbytesParticipantRelayService( + client, Options.Create(opts), NullLogger.Instance); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task RegisterParticipantAsync_returns_500_when_api_key_missing_or_placeholder() + { + var opts = ValidOptions(); + opts.ApiKey = "__SET_IN_USER_SECRETS__"; + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task RegisterParticipantAsync_posts_to_configured_path_with_api_key_header() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK, "ok"); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.True(result.Success); + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.EndsWith(opts.ParticipantRegisterPath, request.RequestUri!.AbsolutePath); + Assert.True(request.Headers.TryGetValues(opts.ApiKeyHeaderName, out var headerValues)); + Assert.Equal(opts.ApiKey, headerValues!.First()); + } + + [Fact] + public async Task RegisterParticipantAsync_serializes_payload_in_snake_case() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK); + var sut = Build(handler, opts); + + await sut.RegisterParticipantAsync(SampleParticipant()); + + var body = Assert.Single(handler.RequestBodies); + Assert.Contains("\"first_name\"", body); + Assert.Contains("\"guardian_phone\"", body); + Assert.Contains("\"is_visiting\"", body); + Assert.Contains("\"gdpr\"", body); + } + + [Fact] + public async Task RegisterParticipantAsync_returns_504_on_TaskCanceledException() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.Throws(new TaskCanceledException("simulated timeout")); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status504GatewayTimeout, result.StatusCode); + } + + [Fact] + public async Task RegisterParticipantAsync_returns_502_on_generic_exception() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.Throws(new HttpRequestException("boom")); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status502BadGateway, result.StatusCode); + } + + [Fact] + public async Task RegisterParticipantAsync_returns_failure_with_upstream_status_when_non_success() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.Unauthorized, "401 token expired"); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode); + Assert.Contains("401", result.Message); + } + + [Fact] + public async Task RegisterParticipantAsync_truncates_huge_body_in_logged_message_path() + { + // The relay caps the message to 600 chars before returning — verify non-success path keeps a reasonable length. + var opts = ValidOptions(); + var bigBody = new string('x', 5000); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.BadGateway, bigBody); + var sut = Build(handler, opts); + + var result = await sut.RegisterParticipantAsync(SampleParticipant()); + + Assert.False(result.Success); + // Success path returns full body, failure path returns the (untruncated) body too, + // but the log-safe truncation is applied only to the logged message. Assert the + // returned message echoes the upstream body so callers can surface it. + Assert.Contains("x", result.Message); + } +} diff --git a/src/Registration/Registration.Tests/Services/VbytesVolunteerRelayServiceTests.cs b/src/Registration/Registration.Tests/Services/VbytesVolunteerRelayServiceTests.cs new file mode 100644 index 0000000..447bcd8 --- /dev/null +++ b/src/Registration/Registration.Tests/Services/VbytesVolunteerRelayServiceTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Registration.API.Configuration; +using Registration.API.Services; +using Registration.Domain.Models; + +namespace Registration.Tests.Services; + +public class VbytesVolunteerRelayServiceTests +{ + private static VbytesRelayOptions ValidOptions() => new() + { + BaseUrl = "https://relay.example", + ParticipantRegisterPath = "/api/participant", + VolunteerRegisterPath = "/api/volunteer", + ApiKeyHeaderName = "X-Api-Key", + ApiKey = "secret" + }; + + private static Volunteer SampleVolunteer() => new() + { + FirstName = "Eva", + SurName = "Svensson", + PhoneNumber = "0701112233", + Email = "eva@example.com", + HasApprovedGdpr = true, + AreasOfInterest = + [ + new AreaOfInterest { Name = "Kiosk & Kök" }, + new AreaOfInterest { Name = "Städning" } + ] + }; + + private static VbytesVolunteerRelayService Build(FakeHttpMessageHandler handler, VbytesRelayOptions options) + { + var client = new HttpClient(handler) { BaseAddress = new Uri(options.BaseUrl) }; + return new VbytesVolunteerRelayService( + client, + Options.Create(options), + NullLogger.Instance); + } + + [Fact] + public async Task RegisterVolunteerAsync_returns_500_when_configuration_invalid() + { + var opts = ValidOptions(); + opts.ApiKey = ""; + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK); + var sut = Build(handler, opts); + + var result = await sut.RegisterVolunteerAsync(SampleVolunteer()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task RegisterVolunteerAsync_posts_to_configured_path_with_serialized_payload() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.OK); + var sut = Build(handler, opts); + + var result = await sut.RegisterVolunteerAsync(SampleVolunteer()); + + Assert.True(result.Success); + var request = Assert.Single(handler.Requests); + Assert.EndsWith(opts.VolunteerRegisterPath, request.RequestUri!.AbsolutePath); + var body = Assert.Single(handler.RequestBodies); + Assert.Contains("\"first_name\"", body); + Assert.Contains("\"areas\"", body); + using var doc = JsonDocument.Parse(body!); + var areas = doc.RootElement.GetProperty("areas").EnumerateArray() + .Select(e => e.GetString() ?? string.Empty).ToArray(); + Assert.Equal(new[] { "Kiosk & Kök", "Städning" }, areas); + } + + [Fact] + public async Task RegisterVolunteerAsync_returns_504_on_timeout() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.Throws(new TaskCanceledException()); + var sut = Build(handler, opts); + + var result = await sut.RegisterVolunteerAsync(SampleVolunteer()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status504GatewayTimeout, result.StatusCode); + } + + [Fact] + public async Task RegisterVolunteerAsync_returns_502_on_generic_exception() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.Throws(new InvalidOperationException("nope")); + var sut = Build(handler, opts); + + var result = await sut.RegisterVolunteerAsync(SampleVolunteer()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status502BadGateway, result.StatusCode); + } + + [Fact] + public async Task RegisterVolunteerAsync_returns_failure_for_non_success_upstream() + { + var opts = ValidOptions(); + var handler = FakeHttpMessageHandler.RespondWith(HttpStatusCode.BadRequest, "bad data"); + var sut = Build(handler, opts); + + var result = await sut.RegisterVolunteerAsync(SampleVolunteer()); + + Assert.False(result.Success); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal("bad data", result.Message); + } +} diff --git a/src/Registration/Registration.Tests/UnitTest1.cs b/src/Registration/Registration.Tests/UnitTest1.cs deleted file mode 100644 index d5708e9..0000000 --- a/src/Registration/Registration.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Registration.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/Registration/Registration.Tests/Validation/InputNormalizationExtensionsTests.cs b/src/Registration/Registration.Tests/Validation/InputNormalizationExtensionsTests.cs new file mode 100644 index 0000000..dee395e --- /dev/null +++ b/src/Registration/Registration.Tests/Validation/InputNormalizationExtensionsTests.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Registration.API.Validation; + +namespace Registration.Tests.Validation; + +public class InputNormalizationExtensionsTests +{ + [Fact] + public void NormalizePhone_delegates_to_NormalizeSwedishMobile() + { + Assert.Equal("0701234567", "+46 70 123 45 67".NormalizePhone()); + } + + [Fact] + public void NormalizeOptionalPhone_returns_null_for_null_or_whitespace() + { + Assert.Null(((string?)null).NormalizeOptionalPhone()); + Assert.Null("".NormalizeOptionalPhone()); + Assert.Null(" ".NormalizeOptionalPhone()); + } + + [Fact] + public void NormalizeOptionalPhone_normalizes_when_value_present() + { + Assert.Equal("0701234567", "+46701234567".NormalizeOptionalPhone()); + } + + [Theory] + [InlineData("19900101-1234", "199001011234")] + [InlineData("900101-1234", "9001011234")] + public void TryNormalizeSwedishSsn_returns_true_for_valid_ssn(string input, string expected) + { + var ok = input.TryNormalizeSwedishSsn(out var normalized, out var errorResult); + + Assert.True(ok); + Assert.Equal(expected, normalized); + Assert.Null(errorResult); + } + + [Theory] + [InlineData("")] + [InlineData("123")] + [InlineData("12345678901")] + public void TryNormalizeSwedishSsn_returns_false_with_400_for_invalid_ssn(string input) + { + var ok = input.TryNormalizeSwedishSsn(out _, out var errorResult); + + Assert.False(ok); + var bad = Assert.IsType(errorResult); + var problem = Assert.IsType(bad.Value); + Assert.Equal(StatusCodes.Status400BadRequest, problem.Status); + Assert.True(problem.Errors.ContainsKey("ssn")); + } +} diff --git a/src/Registration/Registration.Tests/Validation/InputNormalizationTests.cs b/src/Registration/Registration.Tests/Validation/InputNormalizationTests.cs new file mode 100644 index 0000000..52f859b --- /dev/null +++ b/src/Registration/Registration.Tests/Validation/InputNormalizationTests.cs @@ -0,0 +1,48 @@ +using Registration.API.Validation; + +namespace Registration.Tests.Validation; + +public class InputNormalizationTests +{ + [Theory] + [InlineData("19900101-1234", "199001011234")] + [InlineData("900101-1234", "9001011234")] + [InlineData("19900101 1234", "199001011234")] + [InlineData("199001011234", "199001011234")] + [InlineData("abc19-90-01-01-1234xyz", "199001011234")] + [InlineData("", "")] + public void NormalizeSsn_strips_non_digits(string input, string expected) + { + Assert.Equal(expected, InputNormalization.NormalizeSsn(input)); + } + + [Theory] + [InlineData("9001011234", true)] + [InlineData("199001011234", true)] + [InlineData("", false)] + [InlineData("123", false)] + [InlineData("12345678901", false)] + [InlineData("1234567890123", false)] + public void IsValidSsn_accepts_only_length_10_or_12(string input, bool expected) + { + Assert.Equal(expected, InputNormalization.IsValidSsn(input)); + } + + [Theory] + [InlineData("0701234567", "0701234567")] + [InlineData("+46701234567", "0701234567")] + [InlineData("46701234567", "0701234567")] + [InlineData("0046701234567", "0701234567")] + [InlineData("+46-70-123 45 67", "0701234567")] + [InlineData("701234567", "0701234567")] + public void NormalizeSwedishMobile_returns_leading_zero_format(string input, string expected) + { + Assert.Equal(expected, InputNormalization.NormalizeSwedishMobile(input)); + } + + [Fact] + public void NormalizeSwedishMobile_handles_empty_input() + { + Assert.Equal(string.Empty, InputNormalization.NormalizeSwedishMobile(string.Empty)); + } +}