Skip to content

Commit 823500d

Browse files
author
aligneddev
committed
IMPL-PHASE4: TDD GREEN cycle for delete endpoint (T040-T044)
1 parent 646992d commit 823500d

7 files changed

Lines changed: 444 additions & 48 deletions

File tree

src/BikeTracking.Api.Tests/Application/Rides/DeleteRideHandlerTests.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
namespace BikeTracking.Api.Tests.Application.Rides;
22

3-
using Xunit;
43
using BikeTracking.Api.Application.Rides;
54
using BikeTracking.Api.Infrastructure.Persistence;
65
using BikeTracking.Api.Infrastructure.Persistence.Entities;
76
using Microsoft.EntityFrameworkCore;
87
using Microsoft.Extensions.Logging;
8+
using Xunit;
99

1010
/// <summary>
1111
/// TDD RED-GREEN: Tests for delete handler logic.
@@ -34,18 +34,18 @@ public async Task DeleteRideAsync_WithValidOwnedRide_CreatesDeleteEvent()
3434
// Arrange
3535
var dbContext = CreateInMemoryDbContext();
3636
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
37-
37+
3838
long userId = 42;
3939
long rideId = 100;
40-
40+
4141
// Create a test ride
4242
var ride = new RideEntity
4343
{
4444
Id = (int)rideId,
4545
RiderId = userId,
4646
RideDateTimeLocal = DateTime.Now,
4747
Miles = 5.5m,
48-
CreatedAtUtc = DateTime.UtcNow
48+
CreatedAtUtc = DateTime.UtcNow,
4949
};
5050
dbContext.Rides.Add(ride);
5151
await dbContext.SaveChangesAsync();
@@ -66,7 +66,7 @@ public async Task DeleteRideAsync_NonExistentRide_ReturnsNotFound()
6666
// Arrange
6767
var dbContext = CreateInMemoryDbContext();
6868
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
69-
69+
7070
long userId = 42;
7171
long nonExistentRideId = 9999;
7272

@@ -85,19 +85,19 @@ public async Task DeleteRideAsync_NonOwnerAttempt_ReturnsForbidden()
8585
// Arrange
8686
var dbContext = CreateInMemoryDbContext();
8787
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
88-
88+
8989
long rideOwnerId = 42;
9090
long attackerId = 99;
9191
long rideId = 100;
92-
92+
9393
// Create ride owned by rideOwnerId
9494
var ride = new RideEntity
9595
{
9696
Id = (int)rideId,
9797
RiderId = rideOwnerId,
9898
RideDateTimeLocal = DateTime.Now,
9999
Miles = 5.5m,
100-
CreatedAtUtc = DateTime.UtcNow
100+
CreatedAtUtc = DateTime.UtcNow,
101101
};
102102
dbContext.Rides.Add(ride);
103103
await dbContext.SaveChangesAsync();
@@ -117,18 +117,18 @@ public async Task DeleteRideAsync_AlreadyDeletedRide_IsIdempotent()
117117
// Arrange
118118
var dbContext = CreateInMemoryDbContext();
119119
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
120-
120+
121121
long userId = 42;
122122
long rideId = 100;
123-
123+
124124
// Create and delete a ride once
125125
var ride = new RideEntity
126126
{
127127
Id = (int)rideId,
128128
RiderId = userId,
129129
RideDateTimeLocal = DateTime.Now,
130130
Miles = 5.5m,
131-
CreatedAtUtc = DateTime.UtcNow
131+
CreatedAtUtc = DateTime.UtcNow,
132132
};
133133
dbContext.Rides.Add(ride);
134134
await dbContext.SaveChangesAsync();
@@ -152,17 +152,17 @@ public async Task DeleteRideAsync_WritesEventToOutbox()
152152
// Arrange
153153
var dbContext = CreateInMemoryDbContext();
154154
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
155-
155+
156156
long userId = 42;
157157
long rideId = 100;
158-
158+
159159
var ride = new RideEntity
160160
{
161161
Id = (int)rideId,
162162
RiderId = userId,
163163
RideDateTimeLocal = DateTime.Now,
164164
Miles = 5.5m,
165-
CreatedAtUtc = DateTime.UtcNow
165+
CreatedAtUtc = DateTime.UtcNow,
166166
};
167167
dbContext.Rides.Add(ride);
168168
await dbContext.SaveChangesAsync();
@@ -173,8 +173,8 @@ public async Task DeleteRideAsync_WritesEventToOutbox()
173173
// Assert
174174
Assert.True(result.IsSuccess);
175175
// Verify outbox entry was created
176-
var outboxEntries = await dbContext.OutboxEvents
177-
.Where(x => x.EventType == "RideDeleted" && x.AggregateId == rideId)
176+
var outboxEntries = await dbContext
177+
.OutboxEvents.Where(x => x.EventType == "RideDeleted" && x.AggregateId == rideId)
178178
.ToListAsync();
179179
Assert.NotEmpty(outboxEntries);
180180
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
namespace BikeTracking.Api.Tests.Endpoints.Rides;
2+
3+
using System.Net;
4+
using System.Net.Http.Json;
5+
using System.Security.Claims;
6+
using BikeTracking.Api.Application.Rides;
7+
using BikeTracking.Api.Contracts;
8+
using BikeTracking.Api.Endpoints;
9+
using BikeTracking.Api.Infrastructure.Persistence;
10+
using BikeTracking.Api.Infrastructure.Persistence.Entities;
11+
using Microsoft.AspNetCore.Authentication;
12+
using Microsoft.AspNetCore.Builder;
13+
using Microsoft.AspNetCore.TestHost;
14+
using Microsoft.EntityFrameworkCore;
15+
using Microsoft.Extensions.DependencyInjection;
16+
using Microsoft.Extensions.Options;
17+
using Xunit;
18+
19+
/// <summary>
20+
/// TDD RED-GREEN: Tests for DELETE /api/rides/{rideId} endpoint.
21+
/// These tests should FAIL initially (endpoint not yet implemented).
22+
/// STATUS: RED (endpoint not yet wired)
23+
/// </summary>
24+
public sealed class DeleteRideEndpointTests
25+
{
26+
[Fact]
27+
public async Task DeleteRide_WithMissingAuthHeader_Returns401Unauthorized()
28+
{
29+
await using var host = await DeleteRideApiHost.StartAsync();
30+
var userId = await host.SeedUserAsync("Alice");
31+
var rideId = await host.RecordRideAsync(userId, miles: 5.5m);
32+
33+
var response = await host.Client.DeleteAsync($"/api/rides/{rideId}");
34+
35+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
36+
}
37+
38+
[Fact]
39+
public async Task DeleteRide_WithValidRequest_Returns200Ok()
40+
{
41+
await using var host = await DeleteRideApiHost.StartAsync();
42+
var userId = await host.SeedUserAsync("Bob");
43+
var rideId = await host.RecordRideAsync(userId, miles: 7.2m);
44+
45+
var response = await host.Client.DeleteWithAuthAsync($"/api/rides/{rideId}", userId);
46+
47+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
48+
var payload = await response.Content.ReadFromJsonAsync<DeleteRideSuccessResponse>();
49+
Assert.NotNull(payload);
50+
Assert.Equal(rideId, payload.RideId);
51+
}
52+
53+
[Fact]
54+
public async Task DeleteRide_AsNonOwner_Returns403Forbidden()
55+
{
56+
await using var host = await DeleteRideApiHost.StartAsync();
57+
var ownerUserId = await host.SeedUserAsync("Owner");
58+
var attackerUserId = await host.SeedUserAsync("Attacker");
59+
var rideId = await host.RecordRideAsync(ownerUserId, miles: 6.0m);
60+
61+
var response = await host.Client.DeleteWithAuthAsync(
62+
$"/api/rides/{rideId}",
63+
attackerUserId
64+
);
65+
66+
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
67+
}
68+
69+
[Fact]
70+
public async Task DeleteRide_WithNonExistentRide_Returns404NotFound()
71+
{
72+
await using var host = await DeleteRideApiHost.StartAsync();
73+
var userId = await host.SeedUserAsync("Charlie");
74+
75+
var response = await host.Client.DeleteWithAuthAsync("/api/rides/9999", userId);
76+
77+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
78+
}
79+
80+
[Fact]
81+
public async Task DeleteRide_AlreadyDeleted_ReturnsIdempotent200Ok()
82+
{
83+
await using var host = await DeleteRideApiHost.StartAsync();
84+
var userId = await host.SeedUserAsync("Diana");
85+
var rideId = await host.RecordRideAsync(userId, miles: 8.1m);
86+
87+
var response1 = await host.Client.DeleteWithAuthAsync($"/api/rides/{rideId}", userId);
88+
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
89+
90+
var response2 = await host.Client.DeleteWithAuthAsync($"/api/rides/{rideId}", userId);
91+
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
92+
var payload = await response2.Content.ReadFromJsonAsync<DeleteRideSuccessResponse>();
93+
Assert.NotNull(payload);
94+
Assert.True(payload.IsIdempotent);
95+
}
96+
97+
private sealed class DeleteRideApiHost(WebApplication app) : IAsyncDisposable
98+
{
99+
public HttpClient Client { get; } = app.GetTestClient();
100+
101+
public static async Task<DeleteRideApiHost> StartAsync()
102+
{
103+
var builder = WebApplication.CreateBuilder();
104+
builder.WebHost.UseTestServer();
105+
var databaseName = Guid.NewGuid().ToString();
106+
107+
builder.Services.AddDbContext<BikeTrackingDbContext>(options =>
108+
options.UseInMemoryDatabase(databaseName)
109+
);
110+
builder
111+
.Services.AddAuthentication("test")
112+
.AddScheme<TestAuthenticationSchemeOptions, TestAuthenticationHandler>(
113+
"test",
114+
_ => { }
115+
);
116+
builder.Services.AddAuthorization();
117+
118+
// Add Rides services
119+
builder.Services.AddScoped<RecordRideService>();
120+
builder.Services.AddScoped<GetRideDefaultsService>();
121+
builder.Services.AddScoped<GetRideHistoryService>();
122+
builder.Services.AddScoped<EditRideService>();
123+
builder.Services.AddScoped<DeleteRideService>();
124+
125+
var app = builder.Build();
126+
app.UseAuthentication();
127+
app.UseAuthorization();
128+
app.MapRidesEndpoints();
129+
await app.StartAsync();
130+
131+
return new DeleteRideApiHost(app);
132+
}
133+
134+
public async Task<long> SeedUserAsync(string displayName)
135+
{
136+
using var scope = app.Services.CreateScope();
137+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
138+
139+
var user = new UserEntity
140+
{
141+
DisplayName = displayName,
142+
NormalizedName = displayName.ToLower(),
143+
CreatedAtUtc = DateTime.UtcNow,
144+
IsActive = true,
145+
};
146+
147+
dbContext.Users.Add(user);
148+
await dbContext.SaveChangesAsync();
149+
return user.UserId;
150+
}
151+
152+
public async Task<int> RecordRideAsync(
153+
long userId,
154+
decimal miles,
155+
int? rideMinutes = null,
156+
decimal? temperature = null
157+
)
158+
{
159+
using var scope = app.Services.CreateScope();
160+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
161+
162+
var ride = new RideEntity
163+
{
164+
RiderId = userId,
165+
RideDateTimeLocal = DateTime.Now,
166+
Miles = miles,
167+
RideMinutes = rideMinutes,
168+
Temperature = temperature,
169+
CreatedAtUtc = DateTime.UtcNow,
170+
};
171+
172+
dbContext.Add(ride);
173+
await dbContext.SaveChangesAsync();
174+
return ride.Id;
175+
}
176+
177+
public async ValueTask DisposeAsync()
178+
{
179+
Client.Dispose();
180+
await app.StopAsync();
181+
await app.DisposeAsync();
182+
}
183+
}
184+
}
185+
186+
internal sealed record DeleteRideSuccessResponse(
187+
int RideId,
188+
DateTime DeletedAtUtc,
189+
bool IsIdempotent = false
190+
);
191+
192+
internal class TestAuthenticationSchemeOptions
193+
: Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions { }
194+
195+
internal class TestAuthenticationHandler
196+
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<TestAuthenticationSchemeOptions>
197+
{
198+
public TestAuthenticationHandler(
199+
IOptionsMonitor<TestAuthenticationSchemeOptions> options,
200+
Microsoft.Extensions.Logging.ILoggerFactory logger,
201+
System.Text.Encodings.Web.UrlEncoder encoder
202+
)
203+
: base(options, logger, encoder) { }
204+
205+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
206+
{
207+
var userIdString = Request.Headers["X-User-Id"].FirstOrDefault();
208+
if (string.IsNullOrEmpty(userIdString))
209+
return Task.FromResult(AuthenticateResult.NoResult());
210+
211+
var claims = new[] { new Claim("sub", userIdString) };
212+
var identity = new ClaimsIdentity(claims, Scheme.Name);
213+
var principal = new System.Security.Principal.GenericPrincipal(identity, null);
214+
var ticket = new AuthenticationTicket(principal, Scheme.Name);
215+
return Task.FromResult(AuthenticateResult.Success(ticket));
216+
}
217+
}
218+
219+
internal static class HttpClientExtensions
220+
{
221+
public static async Task<HttpResponseMessage> DeleteWithAuthAsync(
222+
this HttpClient client,
223+
string requestUri,
224+
long userId
225+
)
226+
{
227+
var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
228+
request.Headers.Add("X-User-Id", userId.ToString());
229+
return await client.SendAsync(request);
230+
}
231+
}

0 commit comments

Comments
 (0)