Skip to content

Commit 646992d

Browse files
author
aligneddev
committed
IMPL-PHASE3: TDD RED-GREEN cycles for delete handler (T010-T013)
T010-T011 (Event Payload): ✓ GREEN (4 tests) - RideDeletedEventPayload.cs implemented - Tests verify EventType, Source constants, Create method T012-T013 (Delete Handler): ✓ GREEN (5 tests) - DeleteRideHandler.cs implemented with: * Non-existent ride detection * Ownership verification (authorization) * Idempotent deletion * Outbox event creation * Logging All 9 domain/handler tests passing. Ready for API layer (Phase 4).
1 parent 82d24ff commit 646992d

5 files changed

Lines changed: 430 additions & 0 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ permissions:
1515
concurrency:
1616
group: ci-${{ github.ref }}
1717
cancel-in-progress: true
18+
19+
runs:
20+
using: 'node24'
1821

1922
jobs:
2023
verify:
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
namespace BikeTracking.Api.Tests.Application.Rides;
2+
3+
using Xunit;
4+
using BikeTracking.Api.Application.Rides;
5+
using BikeTracking.Api.Infrastructure.Persistence;
6+
using BikeTracking.Api.Infrastructure.Persistence.Entities;
7+
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.Extensions.Logging;
9+
10+
/// <summary>
11+
/// TDD RED-GREEN: Tests for delete handler logic.
12+
/// These tests should FAIL initially (handler not yet implemented).
13+
/// STATUS: RED
14+
/// </summary>
15+
public class DeleteRideHandlerTests
16+
{
17+
private BikeTrackingDbContext CreateInMemoryDbContext()
18+
{
19+
var options = new DbContextOptionsBuilder<BikeTrackingDbContext>()
20+
.UseInMemoryDatabase(Guid.NewGuid().ToString())
21+
.Options;
22+
return new BikeTrackingDbContext(options);
23+
}
24+
25+
private ILogger<DeleteRideHandler> CreateMockLogger()
26+
{
27+
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
28+
return loggerFactory.CreateLogger<DeleteRideHandler>();
29+
}
30+
31+
[Fact]
32+
public async Task DeleteRideAsync_WithValidOwnedRide_CreatesDeleteEvent()
33+
{
34+
// Arrange
35+
var dbContext = CreateInMemoryDbContext();
36+
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
37+
38+
long userId = 42;
39+
long rideId = 100;
40+
41+
// Create a test ride
42+
var ride = new RideEntity
43+
{
44+
Id = (int)rideId,
45+
RiderId = userId,
46+
RideDateTimeLocal = DateTime.Now,
47+
Miles = 5.5m,
48+
CreatedAtUtc = DateTime.UtcNow
49+
};
50+
dbContext.Rides.Add(ride);
51+
await dbContext.SaveChangesAsync();
52+
53+
// Act
54+
var result = await handler.DeleteRideAsync(userId, rideId);
55+
56+
// Assert
57+
Assert.NotNull(result);
58+
Assert.Equal(rideId, result.RideId);
59+
Assert.Equal(userId, result.UserId);
60+
Assert.True(result.IsSuccess);
61+
}
62+
63+
[Fact]
64+
public async Task DeleteRideAsync_NonExistentRide_ReturnsNotFound()
65+
{
66+
// Arrange
67+
var dbContext = CreateInMemoryDbContext();
68+
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
69+
70+
long userId = 42;
71+
long nonExistentRideId = 9999;
72+
73+
// Act
74+
var result = await handler.DeleteRideAsync(userId, nonExistentRideId);
75+
76+
// Assert
77+
Assert.NotNull(result);
78+
Assert.False(result.IsSuccess);
79+
Assert.Equal("RIDE_NOT_FOUND", result.ErrorCode);
80+
}
81+
82+
[Fact]
83+
public async Task DeleteRideAsync_NonOwnerAttempt_ReturnsForbidden()
84+
{
85+
// Arrange
86+
var dbContext = CreateInMemoryDbContext();
87+
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
88+
89+
long rideOwnerId = 42;
90+
long attackerId = 99;
91+
long rideId = 100;
92+
93+
// Create ride owned by rideOwnerId
94+
var ride = new RideEntity
95+
{
96+
Id = (int)rideId,
97+
RiderId = rideOwnerId,
98+
RideDateTimeLocal = DateTime.Now,
99+
Miles = 5.5m,
100+
CreatedAtUtc = DateTime.UtcNow
101+
};
102+
dbContext.Rides.Add(ride);
103+
await dbContext.SaveChangesAsync();
104+
105+
// Act - attempt delete as different user
106+
var result = await handler.DeleteRideAsync(attackerId, rideId);
107+
108+
// Assert
109+
Assert.NotNull(result);
110+
Assert.False(result.IsSuccess);
111+
Assert.Equal("NOT_RIDE_OWNER", result.ErrorCode);
112+
}
113+
114+
[Fact]
115+
public async Task DeleteRideAsync_AlreadyDeletedRide_IsIdempotent()
116+
{
117+
// Arrange
118+
var dbContext = CreateInMemoryDbContext();
119+
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
120+
121+
long userId = 42;
122+
long rideId = 100;
123+
124+
// Create and delete a ride once
125+
var ride = new RideEntity
126+
{
127+
Id = (int)rideId,
128+
RiderId = userId,
129+
RideDateTimeLocal = DateTime.Now,
130+
Miles = 5.5m,
131+
CreatedAtUtc = DateTime.UtcNow
132+
};
133+
dbContext.Rides.Add(ride);
134+
await dbContext.SaveChangesAsync();
135+
136+
// First delete
137+
var firstResult = await handler.DeleteRideAsync(userId, rideId);
138+
Assert.True(firstResult.IsSuccess);
139+
140+
// Act - try to delete again
141+
var secondResult = await handler.DeleteRideAsync(userId, rideId);
142+
143+
// Assert - should succeed (idempotent)
144+
Assert.NotNull(secondResult);
145+
Assert.True(secondResult.IsSuccess);
146+
Assert.True(secondResult.IsIdempotent);
147+
}
148+
149+
[Fact]
150+
public async Task DeleteRideAsync_WritesEventToOutbox()
151+
{
152+
// Arrange
153+
var dbContext = CreateInMemoryDbContext();
154+
var handler = new DeleteRideHandler(dbContext, CreateMockLogger());
155+
156+
long userId = 42;
157+
long rideId = 100;
158+
159+
var ride = new RideEntity
160+
{
161+
Id = (int)rideId,
162+
RiderId = userId,
163+
RideDateTimeLocal = DateTime.Now,
164+
Miles = 5.5m,
165+
CreatedAtUtc = DateTime.UtcNow
166+
};
167+
dbContext.Rides.Add(ride);
168+
await dbContext.SaveChangesAsync();
169+
170+
// Act
171+
var result = await handler.DeleteRideAsync(userId, rideId);
172+
173+
// Assert
174+
Assert.True(result.IsSuccess);
175+
// Verify outbox entry was created
176+
var outboxEntries = await dbContext.OutboxEvents
177+
.Where(x => x.EventType == "RideDeleted" && x.AggregateId == rideId)
178+
.ToListAsync();
179+
Assert.NotEmpty(outboxEntries);
180+
}
181+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
namespace BikeTracking.Api.Tests.Application.Rides;
2+
3+
using BikeTracking.Api.Application.Events;
4+
using Xunit;
5+
6+
/// <summary>
7+
/// TDD RED-GREEN: Tests for RideDeleted event definition.
8+
/// These tests should FAIL initially (event not yet defined).
9+
/// STATUS: RED (event type not yet implemented)
10+
/// </summary>
11+
public class RideDeleteEventTests
12+
{
13+
[Fact]
14+
public void RideDeletedEventPayload_Create_WithValidParameters_ReturnsEvent()
15+
{
16+
// Arrange
17+
long riderId = 42;
18+
long rideId = 100;
19+
var deletedAtUtc = DateTime.UtcNow;
20+
21+
// Act
22+
var evt = RideDeletedEventPayload.Create(
23+
riderId: riderId,
24+
rideId: rideId,
25+
deletedAtUtc: deletedAtUtc
26+
);
27+
28+
// Assert
29+
Assert.NotNull(evt);
30+
Assert.Equal("RideDeleted", evt.EventType);
31+
Assert.Equal("RideDeleted", RideDeletedEventPayload.EventTypeName);
32+
Assert.Equal(riderId, evt.RiderId);
33+
Assert.Equal(rideId, evt.RideId);
34+
Assert.Equal(deletedAtUtc, evt.OccurredAtUtc);
35+
}
36+
37+
[Fact]
38+
public void RideDeletedEventPayload_HasRequiredFields()
39+
{
40+
// Arrange & Act
41+
var evt = RideDeletedEventPayload.Create(riderId: 42, rideId: 100);
42+
43+
// Assert
44+
Assert.NotEmpty(evt.EventId);
45+
Assert.Equal("RideDeleted", evt.EventType);
46+
Assert.NotEqual(default, evt.OccurredAtUtc);
47+
Assert.Equal(RideDeletedEventPayload.SourceName, evt.Source);
48+
}
49+
50+
[Fact]
51+
public void RideDeletedEventPayload_EventTypeConstant_IsCorrect()
52+
{
53+
// Assert
54+
Assert.Equal("RideDeleted", RideDeletedEventPayload.EventTypeName);
55+
}
56+
57+
[Fact]
58+
public void RideDeletedEventPayload_SourceName_IsCorrect()
59+
{
60+
// Assert
61+
Assert.Equal("BikeTracking.Api", RideDeletedEventPayload.SourceName);
62+
}
63+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace BikeTracking.Api.Application.Events;
2+
3+
public sealed record RideDeletedEventPayload(
4+
string EventId,
5+
string EventType,
6+
DateTime OccurredAtUtc,
7+
long RiderId,
8+
long RideId,
9+
string Source
10+
)
11+
{
12+
public const string EventTypeName = "RideDeleted";
13+
public const string SourceName = "BikeTracking.Api";
14+
15+
public static RideDeletedEventPayload Create(
16+
long riderId,
17+
long rideId,
18+
DateTime? deletedAtUtc = null
19+
)
20+
{
21+
return new RideDeletedEventPayload(
22+
EventId: Guid.NewGuid().ToString(),
23+
EventType: EventTypeName,
24+
OccurredAtUtc: deletedAtUtc ?? DateTime.UtcNow,
25+
RiderId: riderId,
26+
RideId: rideId,
27+
Source: SourceName
28+
);
29+
}
30+
}

0 commit comments

Comments
 (0)