added tests

This commit is contained in:
Kruille 2026-05-26 15:27:10 +02:00
parent 21db1ffea3
commit 204176e40a
13 changed files with 975 additions and 31 deletions

View file

@ -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<string, string?>();
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<OkObjectResult>(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<UnauthorizedObjectResult>(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<UnauthorizedObjectResult>(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<OkObjectResult>(result);
}
}

View file

@ -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<IRegistrationRepository> _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<OkObjectResult>(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<OkResult>(result);
_repo.Verify(r => r.UpdateEventContent(content), Times.Once);
}
}

View file

@ -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<IVbytesParticipantRelayService> _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<Participant>(), It.IsAny<CancellationToken>()))
.Callback<Participant, CancellationToken>((p, _) => captured = p)
.ReturnsAsync(new VbytesRelayResult(true, 200, "ok"));
var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None);
Assert.IsType<OkResult>(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<Participant>(), It.IsAny<CancellationToken>()))
.Callback<Participant, CancellationToken>((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<Participant>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VbytesRelayResult(false, 401, "401 Unauthorized"));
var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
[Fact]
public async Task RegisterForLan_returns_Unauthorized_when_success_message_contains_401()
{
_relay.Setup(r => r.RegisterParticipantAsync(It.IsAny<Participant>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VbytesRelayResult(true, 200, "downstream returned 401"));
var result = await Sut().RegisterForLan(ValidRequest(), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
}

View file

@ -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<IRegistrationRepository> _repo = new();
private readonly Mock<IAuthService> _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<BadRequestObjectResult>(result);
_auth.VerifyNoOtherCalls();
}
[Fact]
public async Task ValidateSsn_returns_Ok_when_member()
{
_auth.Setup(a => a.IsMemberAsync("199001011234", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var result = await Sut().ValidateSsn("19900101-1234", CancellationToken.None);
Assert.IsType<OkResult>(result);
}
[Fact]
public async Task ValidateSsn_returns_NotFound_when_not_member()
{
_auth.Setup(a => a.IsMemberAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await Sut().ValidateSsn("19900101-1234", CancellationToken.None);
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public async Task RegisterMember_returns_400_for_invalid_ssn()
{
var result = await Sut().RegisterMember("nope", CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
_repo.Verify(r => r.AddRegistration(It.IsAny<string>()), 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<OkResult>(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<string>())).ReturnsAsync(false);
var result = await Sut().RegisterMember("19900101-1234", CancellationToken.None);
Assert.IsType<ConflictResult>(result);
}
[Fact]
public async Task IsMemberRegistered_returns_400_for_invalid_ssn()
{
var result = await Sut().IsMemberRegistered("bad");
Assert.IsType<BadRequestObjectResult>(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<ConflictResult>(result);
}
[Fact]
public async Task IsMemberRegistered_returns_Ok_when_not_yet_registered()
{
_repo.Setup(r => r.GetIsRegistered(It.IsAny<string>())).ReturnsAsync(false);
var result = await Sut().IsMemberRegistered("19900101-1234");
Assert.IsType<OkResult>(result);
}
[Fact]
public async Task ClearRegistrations_calls_repository_and_returns_Ok()
{
var result = await Sut().ClearRegistrations(CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
_repo.Verify(r => r.ClearRegistrations(), Times.Once);
}
}

View file

@ -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<IVbytesVolunteerRelayService> _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<Volunteer>(), It.IsAny<CancellationToken>()))
.Callback<Volunteer, CancellationToken>((v, _) => captured = v)
.ReturnsAsync(new VbytesRelayResult(true, 200, "ok"));
var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None);
Assert.IsType<OkResult>(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<ObjectResult>(result);
Assert.IsType<ValidationProblemDetails>(obj.Value);
_relay.Verify(r => r.RegisterVolunteerAsync(It.IsAny<Volunteer>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RegisterVolunteer_returns_Unauthorized_when_relay_fails()
{
_relay.Setup(r => r.RegisterVolunteerAsync(It.IsAny<Volunteer>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VbytesRelayResult(false, 401, "401"));
var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
[Fact]
public async Task RegisterVolunteer_returns_Unauthorized_when_success_message_contains_401()
{
_relay.Setup(r => r.RegisterVolunteerAsync(It.IsAny<Volunteer>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VbytesRelayResult(true, 200, "401 something"));
var result = await Sut().RegisterVolunteer(ValidRequest(), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@ -9,11 +9,20 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Registration.API\Registration.API.csproj" />
<ProjectReference Include="..\Registration.Infra\Registration.Infra.csproj" />
<ProjectReference Include="..\Registration.Domain\Registration.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -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<string, string?>();
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<InvalidOperationException>(() =>
new RegistrationRepository(BuildConfig(null, "pepper")));
Assert.Contains("DefaultConnection", ex.Message);
}
[Fact]
public void Constructor_throws_when_pepper_missing()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
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<string>();
var mock = new Mock<IRegistrationRepository>();
mock.Setup(r => r.AddRegistration(It.IsAny<string>()))
.ReturnsAsync((string ssn) => registered.Add(ssn));
mock.Setup(r => r.GetIsRegistered(It.IsAny<string>()))
.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<IRegistrationRepository>();
mock.Setup(r => r.GetEventContent()).ReturnsAsync(() => stored);
mock.Setup(r => r.UpdateEventContent(It.IsAny<EventContent>()))
.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);
}
}

View file

@ -0,0 +1,36 @@
using System.Net;
namespace Registration.Tests.Services;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public List<HttpRequestMessage> Requests { get; } = [];
public List<string?> RequestBodies { get; } = [];
public FakeHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> 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<HttpResponseMessage>(exception));
}
protected override async Task<HttpResponseMessage> 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);
}
}

View file

@ -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<VbytesParticipantRelayService>.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<VbytesParticipantRelayService>.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);
}
}

View file

@ -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<VbytesVolunteerRelayService>.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);
}
}

View file

@ -1,10 +0,0 @@
namespace Registration.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View file

@ -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<BadRequestObjectResult>(errorResult);
var problem = Assert.IsType<ValidationProblemDetails>(bad.Value);
Assert.Equal(StatusCodes.Status400BadRequest, problem.Status);
Assert.True(problem.Errors.ContainsKey("ssn"));
}
}

View file

@ -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));
}
}