diff --git a/Booking/Booking.Api/Booking.Api.csproj b/Booking/Booking.Api/Booking.Api.csproj new file mode 100644 index 00000000..3cf96a68 --- /dev/null +++ b/Booking/Booking.Api/Booking.Api.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/Booking/Booking.Api/Contracts/CreateAppointmentRequest.cs b/Booking/Booking.Api/Contracts/CreateAppointmentRequest.cs new file mode 100644 index 00000000..62121fe6 --- /dev/null +++ b/Booking/Booking.Api/Contracts/CreateAppointmentRequest.cs @@ -0,0 +1,7 @@ +namespace Booking.Api.Contracts; + +public sealed record CreateAppointmentRequest( + DateTime StartUtc, + DateTime EndUtc, + string Customer +); \ No newline at end of file diff --git a/Booking/Booking.Api/Program.cs b/Booking/Booking.Api/Program.cs new file mode 100644 index 00000000..fab1c69e --- /dev/null +++ b/Booking/Booking.Api/Program.cs @@ -0,0 +1,69 @@ +using Booking.Api.Contracts; +using Booking.Core; +using Booking.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +// OpenAPI + Swagger UI +builder.Services.AddOpenApi(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Swagger UI in Development +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +// ------------------------- +// Endpoints (per spec) +// ------------------------- + +app.MapPost("/appointments", async ( + CreateAppointmentRequest req, + BookingService service, + CancellationToken ct) => +{ + try + { + var created = await service.CreateAsync( + startUtc: req.StartUtc, + endUtc: req.EndUtc, + customer: req.Customer, + ct: ct); + + return Results.Ok(created); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) when (ex.Message == BookingErrors.Conflict) + { + return Results.Conflict(new { error = ex.Message }); + } +}) +.WithName("CreateAppointment") +.WithOpenApi(); + +app.MapGet("/appointments", async ( + IAppointmentRepository repo, + CancellationToken ct) => +{ + var items = await repo.GetAllAsync(ct); + return Results.Ok(items); +}) +.WithName("GetAppointments") +.WithOpenApi(); + +app.Run(); \ No newline at end of file diff --git a/Booking/Booking.Core/Appointment.cs b/Booking/Booking.Core/Appointment.cs new file mode 100644 index 00000000..bac3399f --- /dev/null +++ b/Booking/Booking.Core/Appointment.cs @@ -0,0 +1,8 @@ +namespace Booking.Core; + +public sealed record Appointment( + Guid Id, + DateTime StartUtc, + DateTime EndUtc, + string Customer +); \ No newline at end of file diff --git a/Booking/Booking.Core/Booking.Core.csproj b/Booking/Booking.Core/Booking.Core.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/Booking/Booking.Core/Booking.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Booking/Booking.Core/BookingErrors.cs b/Booking/Booking.Core/BookingErrors.cs new file mode 100644 index 00000000..2ce34b85 --- /dev/null +++ b/Booking/Booking.Core/BookingErrors.cs @@ -0,0 +1,10 @@ +namespace Booking.Core; + +public static class BookingErrors +{ + public const string CustomerRequired = "Customer is required."; + public const string InvalidRange = "EndUtc must be after StartUtc."; + public const string TooShort = "Minimum duration is 15 minutes."; + public const string InPast = "Cannot book an appointment in the past."; + public const string Conflict = "Appointment time conflicts with an existing booking."; +} \ No newline at end of file diff --git a/Booking/Booking.Core/BookingService.cs b/Booking/Booking.Core/BookingService.cs new file mode 100644 index 00000000..81d8b756 --- /dev/null +++ b/Booking/Booking.Core/BookingService.cs @@ -0,0 +1,50 @@ +namespace Booking.Core; + +public sealed class BookingService +{ + private readonly IAppointmentRepository _repo; + private readonly ITimeProvider _time; + + public BookingService(IAppointmentRepository repo, ITimeProvider time) + { + _repo = repo; + _time = time; + } + + public async Task CreateAsync( + DateTime startUtc, + DateTime endUtc, + string customer, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(customer)) + throw new ArgumentException(BookingErrors.CustomerRequired); + + // (Spec) End must be after Start + if (endUtc <= startUtc) + throw new ArgumentException(BookingErrors.InvalidRange); + + // (Spec) Min duration 15 minutes + if (endUtc - startUtc < TimeSpan.FromMinutes(15)) + throw new ArgumentException(BookingErrors.TooShort); + + // (Spec) Cannot book in the past + if (startUtc < _time.UtcNow) + throw new ArgumentException(BookingErrors.InPast); + + // (Spec) No overlap allowed + var conflict = await _repo.ExistsOverlapAsync(startUtc, endUtc, ct); + if (conflict) + throw new InvalidOperationException(BookingErrors.Conflict); + + var appointment = new Appointment( + Id: Guid.NewGuid(), + StartUtc: startUtc, + EndUtc: endUtc, + Customer: customer.Trim() + ); + + await _repo.AddAsync(appointment, ct); + return appointment; + } +} \ No newline at end of file diff --git a/Booking/Booking.Core/IAppointmentRepository.cs b/Booking/Booking.Core/IAppointmentRepository.cs new file mode 100644 index 00000000..36c93a4a --- /dev/null +++ b/Booking/Booking.Core/IAppointmentRepository.cs @@ -0,0 +1,8 @@ +namespace Booking.Core; + +public interface IAppointmentRepository +{ + Task AddAsync(Appointment appointment, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task ExistsOverlapAsync(DateTime startUtc, DateTime endUtc, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Booking/Booking.Core/TimeProvider.cs b/Booking/Booking.Core/TimeProvider.cs new file mode 100644 index 00000000..5f61436e --- /dev/null +++ b/Booking/Booking.Core/TimeProvider.cs @@ -0,0 +1,11 @@ +namespace Booking.Core; + +public interface ITimeProvider +{ + DateTime UtcNow { get; } +} + +public sealed class SystemTimeProvider : ITimeProvider +{ + public DateTime UtcNow => DateTime.UtcNow; +} \ No newline at end of file diff --git a/Booking/Booking.Infrastructure/Booking.Infrastructure.csproj b/Booking/Booking.Infrastructure/Booking.Infrastructure.csproj new file mode 100644 index 00000000..eaba4021 --- /dev/null +++ b/Booking/Booking.Infrastructure/Booking.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Booking/Booking.Infrastructure/InMemoryAppointmentRepository.cs b/Booking/Booking.Infrastructure/InMemoryAppointmentRepository.cs new file mode 100644 index 00000000..c292374c --- /dev/null +++ b/Booking/Booking.Infrastructure/InMemoryAppointmentRepository.cs @@ -0,0 +1,37 @@ +using Booking.Core; + +namespace Booking.Infrastructure; + +public sealed class InMemoryAppointmentRepository : IAppointmentRepository +{ + private readonly List _items = new(); + private readonly object _lock = new(); + + public Task AddAsync(Appointment appointment, CancellationToken ct = default) + { + lock (_lock) + { + _items.Add(appointment); + } + return Task.CompletedTask; + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + lock (_lock) + { + return Task.FromResult((IReadOnlyList)_items.ToList()); + } + } + + public Task ExistsOverlapAsync(DateTime startUtc, DateTime endUtc, CancellationToken ct = default) + { + lock (_lock) + { + // Spec overlap rule: + // overlap when newStart < existingEnd AND newEnd > existingStart + var exists = _items.Any(a => startUtc < a.EndUtc && endUtc > a.StartUtc); + return Task.FromResult(exists); + } + } +} \ No newline at end of file diff --git a/Booking/Booking.Tests/Booking.Tests.csproj b/Booking/Booking.Tests/Booking.Tests.csproj new file mode 100644 index 00000000..86519048 --- /dev/null +++ b/Booking/Booking.Tests/Booking.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Booking/Booking.Tests/BookingServiceTests.cs b/Booking/Booking.Tests/BookingServiceTests.cs new file mode 100644 index 00000000..5deeff87 --- /dev/null +++ b/Booking/Booking.Tests/BookingServiceTests.cs @@ -0,0 +1,138 @@ +using Booking.Core; +using Booking.Infrastructure; +using FluentAssertions; + +namespace Booking.Tests; + +public class BookingServiceTests +{ + private static (BookingService service, InMemoryAppointmentRepository repo, FakeTimeProvider time) CreateSut(DateTime nowUtc) + { + var repo = new InMemoryAppointmentRepository(); + var time = new FakeTimeProvider { UtcNow = nowUtc }; + var service = new BookingService(repo, time); + return (service, repo, time); + } + + [Fact] + public async Task CreateAsync_Should_Create_Appointment_When_Valid() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, repo, _) = CreateSut(now); + + var start = now.AddHours(2); + var end = start.AddMinutes(30); + + var appt = await service.CreateAsync(start, end, "John"); + + appt.Id.Should().NotBeEmpty(); + appt.Customer.Should().Be("John"); + appt.StartUtc.Should().Be(start); + appt.EndUtc.Should().Be(end); + + var all = await repo.GetAllAsync(); + all.Should().HaveCount(1); + } + + [Fact] + public async Task CreateAsync_Should_Reject_Empty_Customer() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, _, _) = CreateSut(now); + + var start = now.AddHours(1); + var end = start.AddMinutes(30); + + Func act = () => service.CreateAsync(start, end, " "); + + await act.Should().ThrowAsync() + .WithMessage(BookingErrors.CustomerRequired); + } + + [Fact] + public async Task CreateAsync_Should_Reject_Invalid_Range_When_End_Before_Or_Equal_Start() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, _, _) = CreateSut(now); + + var start = now.AddHours(1); + var end = start; // equal + + Func act = () => service.CreateAsync(start, end, "A"); + + await act.Should().ThrowAsync() + .WithMessage(BookingErrors.InvalidRange); + } + + [Fact] + public async Task CreateAsync_Should_Reject_Too_Short_Duration() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, _, _) = CreateSut(now); + + var start = now.AddHours(1); + var end = start.AddMinutes(10); + + Func act = () => service.CreateAsync(start, end, "A"); + + await act.Should().ThrowAsync() + .WithMessage(BookingErrors.TooShort); + } + + [Fact] + public async Task CreateAsync_Should_Reject_Start_In_Past() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, _, _) = CreateSut(now); + + var start = now.AddMinutes(-1); + var end = now.AddMinutes(30); + + Func act = () => service.CreateAsync(start, end, "A"); + + await act.Should().ThrowAsync() + .WithMessage(BookingErrors.InPast); + } + + [Fact] + public async Task CreateAsync_Should_Reject_Overlap() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, _, _) = CreateSut(now); + + var start1 = now.AddHours(1); + var end1 = start1.AddMinutes(30); + + await service.CreateAsync(start1, end1, "A"); + + // overlaps: starts inside existing + Func act = () => service.CreateAsync( + start1.AddMinutes(10), + end1.AddMinutes(10), + "B"); + + await act.Should().ThrowAsync() + .WithMessage(BookingErrors.Conflict); + } + + [Fact] + public async Task CreateAsync_Should_Allow_Boundary_Touching_End_Equals_Next_Start() + { + var now = new DateTime(2026, 02, 25, 12, 00, 00, DateTimeKind.Utc); + var (service, repo, _) = CreateSut(now); + + var start1 = now.AddHours(1); + var end1 = start1.AddMinutes(30); + + await service.CreateAsync(start1, end1, "A"); + + // boundary touch: start == existing end => NOT overlap per spec + var start2 = end1; + var end2 = start2.AddMinutes(30); + + var appt2 = await service.CreateAsync(start2, end2, "B"); + + appt2.Customer.Should().Be("B"); + (await repo.GetAllAsync()).Should().HaveCount(2); + } +} \ No newline at end of file diff --git a/Booking/Booking.Tests/FakeTimeProvider.cs b/Booking/Booking.Tests/FakeTimeProvider.cs new file mode 100644 index 00000000..df2fdc09 --- /dev/null +++ b/Booking/Booking.Tests/FakeTimeProvider.cs @@ -0,0 +1,8 @@ +using Booking.Core; + +namespace Booking.Tests; + +public sealed class FakeTimeProvider : ITimeProvider +{ + public DateTime UtcNow { get; set; } +} \ No newline at end of file diff --git a/Booking/README.md b/Booking/README.md new file mode 100644 index 00000000..bb37e498 --- /dev/null +++ b/Booking/README.md @@ -0,0 +1,74 @@ +# Booking API (Spec-Driven + Codegen + Tests) + +A small appointment booking API built using a spec-driven workflow and code generation tools. + +## What it does (workflow) + +- Create an appointment (POST /appointments) +- View appointments (GET /appointments) +- Enforces domain rules: + - EndUtc must be after StartUtc + - Minimum duration is 15 minutes + - Cannot book in the past + - No overlapping appointments + +Data is stored in-memory (no database required). + +## Spec and codegen evidence + +- Spec: `Booking/booking.spec.md` +- Codegen log: `Booking/docs/codegen-log.md` + +## Tech + +- .NET 10 Minimal API +- xUnit + FluentAssertions +- Swagger UI + +## Run the API locally + +From the repo root: + +```bash +dotnet run --project Booking/Booking.Api +``` + +Open Swagger UI: + +``` +https://localhost:xxxx/swagger +``` + +(The exact port is printed in the console) + +## Example requests + +### Create appointment + +POST /appointments + +```json +{ + "startUtc": "2026-03-01T18:00:00Z", + "endUtc": "2026-03-01T18:30:00Z", + "customer": "John" +} +``` + +### List appointments + +GET /appointments + +## Run tests + +From the repo root: + +```bash +dotnet test +``` + +## Notes + +- No secrets/credentials are used or required. +- Business rules are implemented in Booking.Core. +- Data storage is in-memory for simplicity. \ No newline at end of file diff --git a/Booking/booking.spec.md b/Booking/booking.spec.md new file mode 100644 index 00000000..15f1d3c3 --- /dev/null +++ b/Booking/booking.spec.md @@ -0,0 +1,36 @@ +# Booking Spec (Spec-Driven) + +## Feature: Appointment booking + +### Domain rules +1) Appointment has StartUtc and EndUtc (UTC). +2) EndUtc must be after StartUtc. +3) Minimum duration: 15 minutes. +4) Cannot book in the past: StartUtc < UtcNow is invalid. +5) No overlaps allowed: + Overlap exists when: newStart < existingEnd AND newEnd > existingStart. + +### Data model +Appointment: +- id: guid +- startUtc: datetime (UTC) +- endUtc: datetime (UTC) +- customer: string + +### API +POST /appointments +Request JSON: +{ + "startUtc": "2026-02-24T18:00:00Z", + "endUtc": "2026-02-24T18:30:00Z", + "customer": "John" +} + +Responses: +- 200 OK: returns created Appointment +- 400 BadRequest: validation errors (range, duration, past, empty customer) +- 409 Conflict: overlaps an existing appointment + +GET /appointments +Responses: +- 200 OK: returns list of Appointment \ No newline at end of file diff --git a/Booking/codegen-log.md b/Booking/codegen-log.md new file mode 100644 index 00000000..1f19c5e3 --- /dev/null +++ b/Booking/codegen-log.md @@ -0,0 +1,45 @@ +# Codegen Log (Evidence) + +## Tools used +- Visual Studio 2026 +- GitHub Copilot (and/or ChatGPT GPT-5.2 / Codex) + +## Workflow +1) Wrote the spec first: booking.spec.md +2) Used codegen to scaffold: domain model, repo interface, service, API endpoints, tests +3) Reviewed and refined generated code to match the spec exactly + +## Prompts (copy/paste) +### P1 - Domain model + repo interface +"Generate C# code for Booking.Core: +- Appointment record (Id, StartUtc, EndUtc, Customer) +- IAppointmentRepository with AddAsync, GetAllAsync, ExistsOverlapAsync +based on booking.spec.md" + +### P2 - Domain service +"Generate BookingService enforcing spec rules: +- end after start +- min 15 min +- not in past using injectable time provider +- no overlap using repository +Throw ArgumentException for validation and InvalidOperationException for conflict" + +### P3 - In-memory repo +"Generate InMemoryAppointmentRepository implementing IAppointmentRepository with overlap check: +newStart < existingEnd && newEnd > existingStart +Use thread-safety." + +### P4 - Minimal API endpoints +"Generate Minimal API endpoints in Booking.Api Program.cs: +POST /appointments and GET /appointments +Return 400 for validation, 409 for overlap, 200 for success. +Include Swagger." + +### P5 - Tests from spec +"Generate xUnit tests using FluentAssertions: +- valid booking +- past booking rejected +- invalid range rejected +- too short rejected +- overlap rejected +- boundary touching allowed (end == next start)" \ No newline at end of file