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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions TransitAnalyticsAPI.Tests/Controllers/VehiclesControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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<BadRequestObjectResult>(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<BadRequestObjectResult>(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<BadRequestObjectResult>(result.Result);
Assert.Equal(400, badRequest.StatusCode);
}

private sealed class StubVehicleLatestQueryService : IVehicleLatestQueryService
{
public Task<List<VehicleLatestDto>> GetLatestAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(new List<VehicleLatestDto>());
}
}

private sealed class StubVehicleHistoryQueryService : IVehicleHistoryQueryService
{
public Task<List<VehicleHistoryPointDto>> GetVehicleHistoryAsync(
string vehicleId,
DateTime startUtc,
DateTime endUtc,
int maxResults,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new List<VehicleHistoryPointDto>());
}

public Task<List<VehicleHistoryPointDto>> GetRangeAsync(
DateTime startUtc,
DateTime endUtc,
string? routeId,
int maxResults,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new List<VehicleHistoryPointDto>());
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
67 changes: 67 additions & 0 deletions TransitAnalyticsAPI.Tests/Services/VehiclePositionMapperTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading