From ec2df0adf1d0d6b199f81a6192cc4f43f62d1288 Mon Sep 17 00:00:00 2001 From: Vadim S <20091002+va-deem@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:43:20 +1300 Subject: [PATCH 1/2] Set up tests --- .../Controllers/VehiclesControllerTests.cs | 86 ++++++++++++++++ .../InternalApiSecretMiddlewareTests.cs | 67 +++++++++++++ .../VehicleHistoryQueryServiceTests.cs | 99 +++++++++++++++++++ .../VehicleLatestQueryServiceTests.cs | 59 +++++++++++ .../Services/VehiclePositionMapperTests.cs | 67 +++++++++++++ .../FakeVehicleMetadataLookupService.cs | 20 ++++ .../TestHelpers/TestDbContextFactory.cs | 23 +++++ .../TestHelpers/TestWebHostEnvironment.cs | 19 ++++ .../TransitAnalyticsAPI.Tests.csproj | 24 +++++ TransitAnalyticsAPI.csproj | 6 ++ 10 files changed, 470 insertions(+) create mode 100644 TransitAnalyticsAPI.Tests/Controllers/VehiclesControllerTests.cs create mode 100644 TransitAnalyticsAPI.Tests/Middleware/InternalApiSecretMiddlewareTests.cs create mode 100644 TransitAnalyticsAPI.Tests/Services/VehicleHistoryQueryServiceTests.cs create mode 100644 TransitAnalyticsAPI.Tests/Services/VehicleLatestQueryServiceTests.cs create mode 100644 TransitAnalyticsAPI.Tests/Services/VehiclePositionMapperTests.cs create mode 100644 TransitAnalyticsAPI.Tests/TestHelpers/FakeVehicleMetadataLookupService.cs create mode 100644 TransitAnalyticsAPI.Tests/TestHelpers/TestDbContextFactory.cs create mode 100644 TransitAnalyticsAPI.Tests/TestHelpers/TestWebHostEnvironment.cs create mode 100644 TransitAnalyticsAPI.Tests/TransitAnalyticsAPI.Tests.csproj diff --git a/TransitAnalyticsAPI.Tests/Controllers/VehiclesControllerTests.cs b/TransitAnalyticsAPI.Tests/Controllers/VehiclesControllerTests.cs new file mode 100644 index 0000000..43d0465 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/Controllers/VehiclesControllerTests.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc; +using TransitAnalyticsAPI.Controllers; +using TransitAnalyticsAPI.Models.Dto; +using TransitAnalyticsAPI.Services; +using Xunit; + +namespace TransitAnalyticsAPI.Tests.Controllers; + +public class VehiclesControllerTests +{ + [Fact] + public async Task GetHistory_ReturnsBadRequest_WhenStartOrEndIsMissing() + { + var controller = new VehiclesController( + new StubVehicleLatestQueryService(), + new StubVehicleHistoryQueryService()); + + var result = await controller.GetHistory("vehicle-1", null, DateTimeOffset.UtcNow, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal(400, badRequest.StatusCode); + } + + [Fact] + public async Task GetHistory_ReturnsBadRequest_WhenEndIsBeforeStart() + { + var controller = new VehiclesController( + new StubVehicleLatestQueryService(), + new StubVehicleHistoryQueryService()); + + var start = DateTimeOffset.UtcNow; + var end = start.AddMinutes(-1); + + var result = await controller.GetHistory("vehicle-1", start, end, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal(400, badRequest.StatusCode); + } + + [Fact] + public async Task GetRange_ReturnsBadRequest_WhenWindowExceedsSixHours() + { + var controller = new VehiclesController( + new StubVehicleLatestQueryService(), + new StubVehicleHistoryQueryService()); + + var start = DateTimeOffset.UtcNow; + var end = start.AddHours(6).AddMinutes(1); + + var result = await controller.GetRange(start, end, null, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal(400, badRequest.StatusCode); + } + + private sealed class StubVehicleLatestQueryService : IVehicleLatestQueryService + { + public Task> GetLatestAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(new List()); + } + } + + private sealed class StubVehicleHistoryQueryService : IVehicleHistoryQueryService + { + public Task> GetVehicleHistoryAsync( + string vehicleId, + DateTime startUtc, + DateTime endUtc, + int maxResults, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new List()); + } + + public Task> GetRangeAsync( + DateTime startUtc, + DateTime endUtc, + string? routeId, + int maxResults, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new List()); + } + } +} diff --git a/TransitAnalyticsAPI.Tests/Middleware/InternalApiSecretMiddlewareTests.cs b/TransitAnalyticsAPI.Tests/Middleware/InternalApiSecretMiddlewareTests.cs new file mode 100644 index 0000000..d0e0226 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/Middleware/InternalApiSecretMiddlewareTests.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using TransitAnalyticsAPI.Configuration; +using TransitAnalyticsAPI.Middleware; +using TransitAnalyticsAPI.Tests.TestHelpers; +using Xunit; + +namespace TransitAnalyticsAPI.Tests.Middleware; + +public class InternalApiSecretMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_ReturnsForbidden_ForProtectedPathWithoutValidSecret() + { + var nextWasCalled = false; + var middleware = new InternalApiSecretMiddleware( + context => + { + nextWasCalled = true; + return Task.CompletedTask; + }, + Options.Create(new InternalApiOptions + { + Secret = "expected-secret", + HeaderName = "X-Internal-Secret" + }), + new TestWebHostEnvironment { EnvironmentName = "Production" }); + + var context = new DefaultHttpContext(); + context.Request.Path = "/vehicles/latest"; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + Assert.False(nextWasCalled); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + [Theory] + [InlineData("/health")] + [InlineData("/admin/login")] + [InlineData("/ws/vehicles")] + public async Task InvokeAsync_AllowsExcludedPaths_WithoutSecret(string path) + { + var nextWasCalled = false; + var middleware = new InternalApiSecretMiddleware( + context => + { + nextWasCalled = true; + return Task.CompletedTask; + }, + Options.Create(new InternalApiOptions + { + Secret = "expected-secret", + HeaderName = "X-Internal-Secret" + }), + new TestWebHostEnvironment { EnvironmentName = "Production" }); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + Assert.True(nextWasCalled); + } +} diff --git a/TransitAnalyticsAPI.Tests/Services/VehicleHistoryQueryServiceTests.cs b/TransitAnalyticsAPI.Tests/Services/VehicleHistoryQueryServiceTests.cs new file mode 100644 index 0000000..ce85289 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/Services/VehicleHistoryQueryServiceTests.cs @@ -0,0 +1,99 @@ +using TransitAnalyticsAPI.Models.Entities; +using TransitAnalyticsAPI.Services; +using TransitAnalyticsAPI.Tests.TestHelpers; +using Xunit; + +namespace TransitAnalyticsAPI.Tests.Services; + +public class VehicleHistoryQueryServiceTests +{ + [Fact] + public async Task GetVehicleHistoryAsync_ReturnsPointsOrderedByRecordedAtThenId() + { + await using var dbContext = TestDbContextFactory.CreateSqliteDbContext(); + var recordedAt = new DateTime(2026, 3, 25, 12, 0, 0, DateTimeKind.Utc); + + dbContext.VehiclePositions.AddRange( + new VehiclePosition + { + Id = 30, + VehicleId = "bus-1", + Latitude = -36.1, + Longitude = 174.1, + RecordedAtUtc = recordedAt.AddMinutes(1), + IngestedAtUtc = recordedAt.AddMinutes(1) + }, + new VehiclePosition + { + Id = 20, + VehicleId = "bus-1", + Latitude = -36.2, + Longitude = 174.2, + RecordedAtUtc = recordedAt, + IngestedAtUtc = recordedAt + }, + new VehiclePosition + { + Id = 10, + VehicleId = "bus-1", + Latitude = -36.3, + Longitude = 174.3, + RecordedAtUtc = recordedAt, + IngestedAtUtc = recordedAt.AddSeconds(10) + }); + await dbContext.SaveChangesAsync(); + + var service = new VehicleHistoryQueryService(dbContext, new FakeVehicleMetadataLookupService()); + + var result = await service.GetVehicleHistoryAsync( + "bus-1", + recordedAt.AddMinutes(-5), + recordedAt.AddMinutes(5), + 10, + CancellationToken.None); + + Assert.Equal(3, result.Count); + Assert.Equal(new[] { -36.3, -36.2, -36.1 }, result.Select(item => item.Latitude)); + } + + [Fact] + public async Task GetRangeAsync_FiltersByRouteId() + { + await using var dbContext = TestDbContextFactory.CreateSqliteDbContext(); + var recordedAt = new DateTime(2026, 3, 25, 12, 0, 0, DateTimeKind.Utc); + + dbContext.VehiclePositions.AddRange( + new VehiclePosition + { + VehicleId = "bus-1", + RouteId = "route-a", + Latitude = -36.1, + Longitude = 174.1, + RecordedAtUtc = recordedAt, + IngestedAtUtc = recordedAt + }, + new VehiclePosition + { + VehicleId = "bus-2", + RouteId = "route-b", + Latitude = -36.2, + Longitude = 174.2, + RecordedAtUtc = recordedAt, + IngestedAtUtc = recordedAt + }); + await dbContext.SaveChangesAsync(); + + var service = new VehicleHistoryQueryService(dbContext, new FakeVehicleMetadataLookupService()); + + var result = await service.GetRangeAsync( + recordedAt.AddMinutes(-5), + recordedAt.AddMinutes(5), + "route-a", + 10, + CancellationToken.None); + + var item = Assert.Single(result); + Assert.Equal("bus-1", item.VehicleId); + Assert.Equal("route-a", item.RouteId); + } +} diff --git a/TransitAnalyticsAPI.Tests/Services/VehicleLatestQueryServiceTests.cs b/TransitAnalyticsAPI.Tests/Services/VehicleLatestQueryServiceTests.cs new file mode 100644 index 0000000..ee57179 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/Services/VehicleLatestQueryServiceTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; +using TransitAnalyticsAPI.Configuration; +using TransitAnalyticsAPI.Models.Entities; +using TransitAnalyticsAPI.Services; +using TransitAnalyticsAPI.Tests.TestHelpers; +using Xunit; + +namespace TransitAnalyticsAPI.Tests.Services; + +public class VehicleLatestQueryServiceTests +{ + [Fact] + public async Task GetLatestAsync_ReturnsNewestFreshRowPerVehicle() + { + await using var dbContext = TestDbContextFactory.CreateSqliteDbContext(); + var now = DateTime.UtcNow; + + dbContext.VehiclePositions.AddRange( + new VehiclePosition + { + VehicleId = "bus-1", + Latitude = -36.1, + Longitude = 174.1, + RecordedAtUtc = now.AddMinutes(-2), + IngestedAtUtc = now.AddMinutes(-2) + }, + new VehiclePosition + { + VehicleId = "bus-1", + Latitude = -36.2, + Longitude = 174.2, + RecordedAtUtc = now.AddMinutes(-1), + IngestedAtUtc = now.AddMinutes(-1) + }, + new VehiclePosition + { + VehicleId = "bus-2", + Latitude = -36.3, + Longitude = 174.3, + RecordedAtUtc = now.AddMinutes(-45), + IngestedAtUtc = now.AddMinutes(-45) + }); + await dbContext.SaveChangesAsync(); + + var service = new VehicleLatestQueryService( + dbContext, + new FakeVehicleMetadataLookupService(), + Options.Create(new VehicleOptions + { + LatestPositionMaxAgeMinutes = 5 + })); + + var result = await service.GetLatestAsync(CancellationToken.None); + + var item = Assert.Single(result); + Assert.Equal("bus-1", item.VehicleId); + Assert.Equal(-36.2, item.Latitude); + } +} diff --git a/TransitAnalyticsAPI.Tests/Services/VehiclePositionMapperTests.cs b/TransitAnalyticsAPI.Tests/Services/VehiclePositionMapperTests.cs new file mode 100644 index 0000000..fcbb4c8 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/Services/VehiclePositionMapperTests.cs @@ -0,0 +1,67 @@ +using TransitAnalyticsAPI.Clients.AucklandTransport.Models; +using TransitAnalyticsAPI.Services; +using Xunit; + +namespace TransitAnalyticsAPI.Tests.Services; + +public class VehiclePositionMapperTests +{ + [Fact] + public void Map_SkipsDeletedAndIncompleteEntities_AndMapsValidOnes() + { + var mapper = new VehiclePositionMapper(); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + var result = mapper.Map( + new[] + { + new AucklandTransportFeedEntity + { + Id = "deleted", + IsDeleted = true, + Vehicle = new AucklandTransportVehicleWrapper + { + Vehicle = new AucklandTransportVehicleDescriptor { Id = "bus-0" }, + Position = new AucklandTransportPosition { Latitude = -36.0, Longitude = 174.0 }, + Timestamp = timestamp + } + }, + new AucklandTransportFeedEntity + { + Id = "missing-position", + Vehicle = new AucklandTransportVehicleWrapper + { + Vehicle = new AucklandTransportVehicleDescriptor { Id = "bus-1" }, + Timestamp = timestamp + } + }, + new AucklandTransportFeedEntity + { + Id = "valid", + Vehicle = new AucklandTransportVehicleWrapper + { + Vehicle = new AucklandTransportVehicleDescriptor { Id = "bus-2" }, + Trip = new AucklandTransportTripDescriptor + { + TripId = "trip-1", + RouteId = "route-1" + }, + Position = new AucklandTransportPosition + { + Latitude = -36.85, + Longitude = 174.76, + Speed = 12.5 + }, + Timestamp = timestamp + } + } + }); + + var item = Assert.Single(result); + Assert.Equal("bus-2", item.VehicleId); + Assert.Equal("trip-1", item.TripId); + Assert.Equal("route-1", item.RouteId); + Assert.Equal("valid", item.SourceEntityId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime, item.RecordedAtUtc); + } +} diff --git a/TransitAnalyticsAPI.Tests/TestHelpers/FakeVehicleMetadataLookupService.cs b/TransitAnalyticsAPI.Tests/TestHelpers/FakeVehicleMetadataLookupService.cs new file mode 100644 index 0000000..c21c907 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/TestHelpers/FakeVehicleMetadataLookupService.cs @@ -0,0 +1,20 @@ +using TransitAnalyticsAPI.Services; + +namespace TransitAnalyticsAPI.Tests.TestHelpers; + +internal sealed class FakeVehicleMetadataLookupService : IVehicleMetadataLookupService +{ + private readonly VehicleMetadataLookup _lookup; + + public FakeVehicleMetadataLookupService(VehicleMetadataLookup? lookup = null) + { + _lookup = lookup ?? VehicleMetadataLookup.Empty; + } + + public Task BuildAsync( + IEnumerable keys, + CancellationToken cancellationToken = default) + { + return Task.FromResult(_lookup); + } +} diff --git a/TransitAnalyticsAPI.Tests/TestHelpers/TestDbContextFactory.cs b/TransitAnalyticsAPI.Tests/TestHelpers/TestDbContextFactory.cs new file mode 100644 index 0000000..57c2594 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/TestHelpers/TestDbContextFactory.cs @@ -0,0 +1,23 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TransitAnalyticsAPI.Persistence; + +namespace TransitAnalyticsAPI.Tests.TestHelpers; + +internal static class TestDbContextFactory +{ + public static AppDbContext CreateSqliteDbContext() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var context = new AppDbContext(options); + context.Database.EnsureCreated(); + + return context; + } +} diff --git a/TransitAnalyticsAPI.Tests/TestHelpers/TestWebHostEnvironment.cs b/TransitAnalyticsAPI.Tests/TestHelpers/TestWebHostEnvironment.cs new file mode 100644 index 0000000..b085557 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/TestHelpers/TestWebHostEnvironment.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; + +namespace TransitAnalyticsAPI.Tests.TestHelpers; + +internal sealed class TestWebHostEnvironment : IWebHostEnvironment +{ + public string EnvironmentName { get; set; } = string.Empty; + + public string ApplicationName { get; set; } = "TransitAnalyticsAPI.Tests"; + + public string WebRootPath { get; set; } = string.Empty; + + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + + public string ContentRootPath { get; set; } = string.Empty; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); +} diff --git a/TransitAnalyticsAPI.Tests/TransitAnalyticsAPI.Tests.csproj b/TransitAnalyticsAPI.Tests/TransitAnalyticsAPI.Tests.csproj new file mode 100644 index 0000000..dd2f445 --- /dev/null +++ b/TransitAnalyticsAPI.Tests/TransitAnalyticsAPI.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/TransitAnalyticsAPI.csproj b/TransitAnalyticsAPI.csproj index 64d6b51..956c6cb 100644 --- a/TransitAnalyticsAPI.csproj +++ b/TransitAnalyticsAPI.csproj @@ -18,4 +18,10 @@ + + + + + + From 0e8b2798f139d7b479b1aa5e069ac77e4b955c33 Mon Sep 17 00:00:00 2001 From: Vadim S <20091002+va-deem@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:45:03 +1300 Subject: [PATCH 2/2] Add solution file to include main project and test project --- TransitAnalyticsAPI.slnx | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 TransitAnalyticsAPI.slnx diff --git a/TransitAnalyticsAPI.slnx b/TransitAnalyticsAPI.slnx new file mode 100644 index 0000000..bc74750 --- /dev/null +++ b/TransitAnalyticsAPI.slnx @@ -0,0 +1,4 @@ + + + +