From 1402f9478d437b0da9d4a256af8c20f3a98ec4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anta=CC=83o=20Almada?= Date: Thu, 2 Apr 2026 09:02:00 +0100 Subject: [PATCH 1/3] feat: Implement sales management features and update related components - Added sales scheduling functionality in DatabaseSeeder with success checks. - Introduced SalesEndpoints for managing sales retrieval and caching. - Enhanced DatabaseExtensions to invalidate cache entries after sales projection updates. - Updated IBooksClient to include proper DateTime formatting for sale cancellation. - Modified SaleDto to reflect new sale properties and structure. - Revamped SalesManagement.razor to display sales with new attributes and actions. - Created ScheduleSaleDialog.razor for scheduling sales with user input. - Added integration tests for sales management, ensuring proper functionality across tenants. - Implemented pagination and visibility checks for sales based on tenant context. --- Directory.Build.targets | 20 + .../Endpoints/SalesEndpoints.cs | 82 ++++ .../Infrastructure/DatabaseSeeder.cs | 35 +- .../Extensions/DatabaseExtensions.cs | 8 + .../Extensions/EndpointMappingExtensions.cs | 4 + src/BookStore.Client/IBooksClient.cs | 4 +- src/BookStore.Shared/SaleDto.cs | 9 +- .../Pages/Admin/SalesManagement.razor | 174 +++++++-- .../Pages/Admin/ScheduleSaleDialog.razor | 142 +++++++ .../BookStore.AppHost.Tests.csproj | 1 + .../Helpers/SseEventHelpers.cs | 28 +- .../SalesManagementTests.cs | 358 ++++++++++++++++++ 12 files changed, 815 insertions(+), 50 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 src/BookStore.ApiService/Endpoints/SalesEndpoints.cs create mode 100644 src/BookStore.Web/Components/Pages/Admin/ScheduleSaleDialog.razor create mode 100644 tests/BookStore.AppHost.Tests/SalesManagementTests.cs diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..c867f68f --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/BookStore.ApiService/Endpoints/SalesEndpoints.cs b/src/BookStore.ApiService/Endpoints/SalesEndpoints.cs new file mode 100644 index 00000000..2ddabaa9 --- /dev/null +++ b/src/BookStore.ApiService/Endpoints/SalesEndpoints.cs @@ -0,0 +1,82 @@ +using BookStore.ApiService.Infrastructure; +using BookStore.ApiService.Infrastructure.Extensions; +using BookStore.ApiService.Infrastructure.Tenant; +using BookStore.ApiService.Projections; +using BookStore.Shared.Models; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Options; + +namespace BookStore.ApiService.Endpoints; + +public static class SalesEndpoints +{ + public static RouteGroupBuilder MapSalesEndpoints(this RouteGroupBuilder group) + { + _ = group.MapGet("/", GetSales) + .WithName("GetSales") + .WithSummary("Get all scheduled sales across all books (Admin only)") + .RequireAuthorization("Admin"); + + return group; + } + + static async Task>> GetSales( + [FromServices] IDocumentStore store, + [FromServices] ITenantContext tenantContext, + [FromServices] IOptions paginationOptions, + [FromServices] HybridCache cache, + [AsParameters] PagedRequest request, + CancellationToken cancellationToken) + { + var paging = request.Normalize(paginationOptions.Value); + var cacheKey = $"sales:page={paging.Page}:size={paging.PageSize}:tenant={tenantContext.TenantId}"; + + var response = await cache.GetOrCreateLocalizedAsync( + cacheKey, + async cancel => + { + await using var session = store.QuerySession(tenantContext.TenantId); + + var books = await session.Query() + .Where(b => !b.Deleted) + .ToListAsync(cancel); + + var now = DateTimeOffset.UtcNow; + + var allSales = books + .Where(b => b.Sales.Count > 0) + .SelectMany(b => b.Sales.Select(s => new SaleDto + { + BookId = b.Id, + BookTitle = b.Title, + Percentage = s.Percentage, + Start = s.Start, + End = s.End, + Status = ComputeStatus(s.Start, s.End, now), + BookETag = ETagHelper.GenerateETag(b.Version) + })) + .OrderByDescending(s => s.Start) + .ToList(); + + var totalItems = allSales.Count; + var skip = (paging.Page!.Value - 1) * paging.PageSize!.Value; + var items = allSales.Skip(skip).Take(paging.PageSize.Value).ToList(); + + return new PagedListDto(items, paging.Page.Value, paging.PageSize.Value, totalItems); + }, + options: new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(2), + LocalCacheExpiration = TimeSpan.FromMinutes(1) + }, + tags: [CacheTags.BookList], + token: cancellationToken); + + return TypedResults.Ok(response); + } + + static string ComputeStatus(DateTimeOffset start, DateTimeOffset end, DateTimeOffset now) => end < now ? "Expired" : start <= now ? "Active" : "Scheduled"; +} diff --git a/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs b/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs index 7a4fb0f7..e74f04e4 100644 --- a/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs +++ b/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs @@ -594,8 +594,11 @@ public async Task SeedSalesAsync(string tenantId) if (aggregate != null) { var saleEvent = aggregate.ScheduleSale(25m, now.AddDays(-1), now.AddDays(7)); - _ = session.Events.Append(books[0].Id, saleEvent.Value); - Log.Seeding.ScheduledSale(logger, 25m, books[0].Id, books[0].Title); + if (saleEvent.IsSuccess) + { + _ = session.Events.Append(books[0].Id, saleEvent.Value); + Log.Seeding.ScheduledSale(logger, 25m, books[0].Id, books[0].Title); + } } } @@ -606,8 +609,11 @@ public async Task SeedSalesAsync(string tenantId) if (aggregate != null) { var saleEvent = aggregate.ScheduleSale(15m, now.AddHours(-12), now.AddDays(5)); - _ = session.Events.Append(books[1].Id, saleEvent.Value); - Log.Seeding.ScheduledSale(logger, 15m, books[1].Id, books[1].Title); + if (saleEvent.IsSuccess) + { + _ = session.Events.Append(books[1].Id, saleEvent.Value); + Log.Seeding.ScheduledSale(logger, 15m, books[1].Id, books[1].Title); + } } } @@ -618,8 +624,11 @@ public async Task SeedSalesAsync(string tenantId) if (aggregate != null) { var saleEvent = aggregate.ScheduleSale(30m, now.AddHours(-1), now.AddDays(3)); - _ = session.Events.Append(books[2].Id, saleEvent.Value); - Log.Seeding.ScheduledSale(logger, 30m, books[2].Id, books[2].Title); + if (saleEvent.IsSuccess) + { + _ = session.Events.Append(books[2].Id, saleEvent.Value); + Log.Seeding.ScheduledSale(logger, 30m, books[2].Id, books[2].Title); + } } } @@ -630,8 +639,11 @@ public async Task SeedSalesAsync(string tenantId) if (aggregate != null) { var saleEvent = aggregate.ScheduleSale(20m, now.AddHours(1), now.AddDays(2)); - _ = session.Events.Append(books[3].Id, saleEvent.Value); - Log.Seeding.ScheduledSale(logger, 20m, books[3].Id, books[3].Title); + if (saleEvent.IsSuccess) + { + _ = session.Events.Append(books[3].Id, saleEvent.Value); + Log.Seeding.ScheduledSale(logger, 20m, books[3].Id, books[3].Title); + } } } @@ -642,8 +654,11 @@ public async Task SeedSalesAsync(string tenantId) if (aggregate != null) { var saleEvent = aggregate.ScheduleSale(10m, now.AddHours(6), now.AddDays(4)); - _ = session.Events.Append(books[4].Id, saleEvent.Value); - Log.Seeding.ScheduledSale(logger, 10m, books[4].Id, books[4].Title); + if (saleEvent.IsSuccess) + { + _ = session.Events.Append(books[4].Id, saleEvent.Value); + Log.Seeding.ScheduledSale(logger, 10m, books[4].Id, books[4].Title); + } } } diff --git a/src/BookStore.ApiService/Infrastructure/Extensions/DatabaseExtensions.cs b/src/BookStore.ApiService/Infrastructure/Extensions/DatabaseExtensions.cs index 773b241f..35e0538b 100644 --- a/src/BookStore.ApiService/Infrastructure/Extensions/DatabaseExtensions.cs +++ b/src/BookStore.ApiService/Infrastructure/Extensions/DatabaseExtensions.cs @@ -4,6 +4,7 @@ using BookStore.ApiService.Projections; using Marten; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -75,6 +76,13 @@ public static void RunDatabaseSeedingAsync(this WebApplication app) // Wait AGAIN for projections await WaitForProjectionsAsync(store, logger, tenantId, expectSales: true); + + // Invalidate stale cache entries now that the projection has sales + var cache = scope.ServiceProvider.GetService(); + if (cache != null) + { + await cache.RemoveByTagAsync([CacheTags.BookList]); + } } } diff --git a/src/BookStore.ApiService/Infrastructure/Extensions/EndpointMappingExtensions.cs b/src/BookStore.ApiService/Infrastructure/Extensions/EndpointMappingExtensions.cs index 8aedb886..32629cda 100644 --- a/src/BookStore.ApiService/Infrastructure/Extensions/EndpointMappingExtensions.cs +++ b/src/BookStore.ApiService/Infrastructure/Extensions/EndpointMappingExtensions.cs @@ -61,6 +61,10 @@ static void MapPublicEndpoints(WebApplication app, Asp.Versioning.Builder.ApiVer .MapPublisherEndpoints() .WithTags("Publishers"); + _ = publicApi.MapGroup("/sales") + .MapSalesEndpoints() + .WithTags("Sales"); + _ = publicApi.MapGroup("/notifications") .MapNotificationEndpoints() .WithTags("Notifications"); diff --git a/src/BookStore.Client/IBooksClient.cs b/src/BookStore.Client/IBooksClient.cs index 3babe87b..930936af 100644 --- a/src/BookStore.Client/IBooksClient.cs +++ b/src/BookStore.Client/IBooksClient.cs @@ -147,13 +147,13 @@ Task> GetFavoriteBooksAsync( /// Cancels a scheduled sale for a book. /// [Delete("/api/books/{id}/sales")] - Task CancelBookSaleAsync(Guid id, [Query] DateTimeOffset saleStart, [Header("If-Match")] string? etag = null, CancellationToken cancellationToken = default); + Task CancelBookSaleAsync(Guid id, [Query(Format = "O")] DateTimeOffset saleStart, [Header("If-Match")] string? etag = null, CancellationToken cancellationToken = default); /// /// Cancels a scheduled sale for a book with full API response. /// [Delete("/api/books/{id}/sales")] - Task CancelBookSaleWithResponseAsync(Guid id, [Query] DateTimeOffset saleStart, [Header("If-Match")] string? etag = null, CancellationToken cancellationToken = default); + Task CancelBookSaleWithResponseAsync(Guid id, [Query(Format = "O")] DateTimeOffset saleStart, [Header("If-Match")] string? etag = null, CancellationToken cancellationToken = default); /// /// Restores a soft-deleted book (Admin only). diff --git a/src/BookStore.Shared/SaleDto.cs b/src/BookStore.Shared/SaleDto.cs index 99e1a263..6832b086 100644 --- a/src/BookStore.Shared/SaleDto.cs +++ b/src/BookStore.Shared/SaleDto.cs @@ -2,10 +2,11 @@ namespace BookStore.Shared.Models; public record SaleDto { - public Guid Id { get; init; } + public Guid BookId { get; init; } public string BookTitle { get; init; } = string.Empty; - public string BuyerName { get; init; } = string.Empty; - public DateTimeOffset Date { get; init; } - public decimal Amount { get; init; } + public decimal Percentage { get; init; } + public DateTimeOffset Start { get; init; } + public DateTimeOffset End { get; init; } public string Status { get; init; } = string.Empty; + public string? BookETag { get; init; } } diff --git a/src/BookStore.Web/Components/Pages/Admin/SalesManagement.razor b/src/BookStore.Web/Components/Pages/Admin/SalesManagement.razor index 60a24cf3..c7975ec3 100644 --- a/src/BookStore.Web/Components/Pages/Admin/SalesManagement.razor +++ b/src/BookStore.Web/Components/Pages/Admin/SalesManagement.razor @@ -1,10 +1,13 @@ @page "/admin/sales" -@using BookStore.Client -@using BookStore.Shared.Models @using Microsoft.AspNetCore.Authorization -@using MudBlazor +@using BookStore.Shared.Notifications +@implements IDisposable @inject ISalesClient SalesClient +@inject IBooksClient BooksClient +@inject IDialogService DialogService @inject ISnackbar Snackbar +@inject BookStoreEventsService EventsService +@inject QueryInvalidationService InvalidationService @attribute [Authorize(Roles = "Admin")] Sales Management - BookStore @@ -12,44 +15,165 @@ Sales Management + + + Schedule Sale + - - - - Book - Buyer - Date - Amount + Discount % + Start + End Status - Actions + Actions - - @context.BookTitle - @context.BuyerName - @context.Date.ToString("g") - @context.Amount.ToString("C") - @context.Status - - + + + @context.BookTitle + + @context.Percentage.ToString("F0")% + @context.Start.ToString("g") + @context.End.ToString("g") + + + @context.Status + + + + + @if (context.Status != "Expired") + { + Cancel Sale + } + + + No sales found. + + + Loading sales... + + + + + + @code { private MudTable _table = null!; - private string? _searchString; + private readonly CancellationTokenSource _cts = new(); + private bool _disposed; + + protected override void OnInitialized() + { + EventsService.StartListening(); + EventsService.OnNotificationReceived += HandleNotification; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _cts.Cancel(); + _cts.Dispose(); + EventsService.OnNotificationReceived -= HandleNotification; + } + + private async void HandleNotification(IDomainEventNotification notification) + { + try + { + if (notification is PingNotification) + { + return; + } + + if (InvalidationService.ShouldInvalidate(notification, ["Books"])) + { + await InvokeAsync(async () => + { + Snackbar.Add("Real-time update received. Refreshing list...", Severity.Info); + await Task.Delay(1500); + await _table.ReloadServerData(); + StateHasChanged(); + }); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Snackbar.Add($"Error processing real-time update: {ex.Message}", Severity.Error); + } + } + + private static Color GetStatusColor(string status) => status switch + { + "Active" => Color.Success, + "Scheduled" => Color.Info, + _ => Color.Default + }; + + private async Task> ReloadData(TableState state, CancellationToken ct) + { + try + { + // MudTable uses zero-based pages and the API uses one-based pages. + var result = await SalesClient.GetSalesAsync(state.Page + 1, state.PageSize, ct); + return new TableData + { + TotalItems = (int)result.TotalItemCount, + Items = result.Items + }; + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load sales: {ex.Message}", Severity.Error); + return new TableData { TotalItems = 0, Items = [] }; + } + } + + private async Task OpenScheduleSaleDialog() + { + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = await DialogService.ShowAsync("Schedule Sale", options); + var result = await dialog.Result; + + if (result is { Canceled: false }) + { + await _table.ReloadServerData(); + } + } - private async Task> ReloadData(TableState state, CancellationToken cancellationToken) + private async Task CancelSale(SaleDto sale) { - // TODO: Implement server data loading - return new TableData { Items = new List(), TotalItems = 0 }; + try + { + await BooksClient.CancelBookSaleAsync(sale.BookId, sale.Start, sale.BookETag); + Snackbar.Add("Sale cancelled successfully.", Severity.Success); + await _table.ReloadServerData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to cancel sale: {ex.Message}", Severity.Error); + } } } diff --git a/src/BookStore.Web/Components/Pages/Admin/ScheduleSaleDialog.razor b/src/BookStore.Web/Components/Pages/Admin/ScheduleSaleDialog.razor new file mode 100644 index 00000000..913d0cde --- /dev/null +++ b/src/BookStore.Web/Components/Pages/Admin/ScheduleSaleDialog.razor @@ -0,0 +1,142 @@ +@inject IBooksClient BooksClient +@inject ISnackbar Snackbar + + + + + + Schedule Sale + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cancel + + Schedule + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + private MudForm _form = null!; + private bool _isValid; + private BookDto? _selectedBook; + private decimal _percentage = 10m; + private DateTime? _startDate = DateTime.UtcNow.Date; + private TimeSpan? _startTime = TimeSpan.Zero; + private DateTime? _endDate = DateTime.UtcNow.Date.AddDays(7); + private TimeSpan? _endTime = TimeSpan.Zero; + + private async Task> SearchBooksAsync(string value, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var result = await BooksClient.GetBooksAsync(new BookSearchRequest { Search = value, PageSize = 10 }, ct); + return result.Items; + } + + private static DateTimeOffset BuildDateTimeOffset(DateTime? date, TimeSpan? time) + { + var selectedDate = date?.Date ?? DateTime.UtcNow.Date; + var selectedTime = time ?? TimeSpan.Zero; + var utcDateTime = DateTime.SpecifyKind(selectedDate.Add(selectedTime), DateTimeKind.Utc); + return new DateTimeOffset(utcDateTime); + } + + private async Task Submit() + { + await _form.Validate(); + + if (!_isValid || _selectedBook is null) + { + return; + } + + var start = BuildDateTimeOffset(_startDate, _startTime); + var end = BuildDateTimeOffset(_endDate, _endTime); + + if (end <= start) + { + Snackbar.Add("End must be after Start.", Severity.Warning); + return; + } + + try + { + var response = await BooksClient.ScheduleBookSaleWithResponseAsync( + _selectedBook.Id, + new ScheduleSaleRequest(_percentage, start, end), + _selectedBook.ETag); + + if (response.IsSuccessStatusCode) + { + MudDialog.Close(DialogResult.Ok(true)); + return; + } + + Snackbar.Add($"Failed to schedule sale: {response.Error?.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Error scheduling sale: {ex.Message}", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj b/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj index 75195b22..2170cce6 100644 --- a/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj +++ b/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj @@ -5,6 +5,7 @@ true true true + $(NoWarn);NU1605 diff --git a/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs index d283e4e8..87636246 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/SseEventHelpers.cs @@ -24,9 +24,11 @@ public static async Task ExecuteAndWaitForEventAsync( Func action, TimeSpan timeout, long minVersion = 0, - DateTimeOffset? minTimestamp = null) + DateTimeOffset? minTimestamp = null, + string tenantId = StorageConstants.DefaultTenantId, + string? accessToken = null) => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventType, action, timeout, minVersion, - minTimestamp)) + minTimestamp, tenantId, accessToken)) .Success; public static async Task ExecuteAndWaitForEventWithVersionAsync( @@ -35,9 +37,11 @@ public static async Task ExecuteAndWaitForEventWithVersionAsync( Func action, TimeSpan timeout, long minVersion = 0, - DateTimeOffset? minTimestamp = null) + DateTimeOffset? minTimestamp = null, + string tenantId = StorageConstants.DefaultTenantId, + string? accessToken = null) => await ExecuteAndWaitForEventWithVersionAsync(entityId, [eventType], action, timeout, minVersion, - minTimestamp); + minTimestamp, tenantId, accessToken); public record EventResult(bool Success, long Version); @@ -47,9 +51,11 @@ public static async Task ExecuteAndWaitForEventAsync( Func action, TimeSpan timeout, long minVersion = 0, - DateTimeOffset? minTimestamp = null) + DateTimeOffset? minTimestamp = null, + string tenantId = StorageConstants.DefaultTenantId, + string? accessToken = null) => (await ExecuteAndWaitForEventWithVersionAsync(entityId, eventTypes, action, timeout, minVersion, - minTimestamp)) + minTimestamp, tenantId, accessToken)) .Success; public static async Task ExecuteAndWaitForEventWithVersionAsync( @@ -58,17 +64,21 @@ public static async Task ExecuteAndWaitForEventWithVersionAsync( Func action, TimeSpan timeout, long minVersion = 0, - DateTimeOffset? minTimestamp = null) + DateTimeOffset? minTimestamp = null, + string tenantId = StorageConstants.DefaultTenantId, + string? accessToken = null) { var matchAnyId = entityId == Guid.Empty; var receivedEvents = new List(); + var bearerToken = accessToken ?? GlobalHooks.AdminAccessToken + ?? throw new InvalidOperationException("Admin access token is not initialized."); var app = GlobalHooks.App!; using var client = app.CreateHttpClient("apiservice"); client.Timeout = TestConstants.DefaultStreamTimeout; // Prevent Aspire default timeout from killing the stream client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", GlobalHooks.AdminAccessToken); - client.DefaultRequestHeaders.Add("X-Tenant-ID", StorageConstants.DefaultTenantId); + new AuthenticationHeaderValue("Bearer", bearerToken); + client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); using var cts = new CancellationTokenSource(timeout); var tcs = new TaskCompletionSource(); diff --git a/tests/BookStore.AppHost.Tests/SalesManagementTests.cs b/tests/BookStore.AppHost.Tests/SalesManagementTests.cs new file mode 100644 index 00000000..34e03db8 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/SalesManagementTests.cs @@ -0,0 +1,358 @@ +using System.Net; +using BookStore.AppHost.Tests.Helpers; +using BookStore.Client; +using BookStore.Shared; +using Refit; +using TUnit; + +namespace BookStore.AppHost.Tests; + +public class SalesManagementTests +{ + const string SecondaryTenantId = "tenant-a"; + + [Before(Class)] + public static Task ClassSetup() => DatabaseHelpers.CreateTenantViaApiAsync(SecondaryTenantId); + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_AfterSchedulingActiveSale_ReturnsSaleWithActiveStatus(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var salesClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var bookResponse = await adminClient.GetBookWithResponseAsync(book.Id); + var currentETag = bookResponse.Headers.ETag?.Tag; + var currentVersion = ParseETag(currentETag); + var saleRequest = new ScheduleSaleRequest(25m, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); + + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + book.Id, + "BookUpdated", + async () => await adminClient.ScheduleBookSaleAsync(book.Id, saleRequest, currentETag), + TimeSpan.FromSeconds(10), + minVersion: currentVersion + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: adminLogin.AccessToken); + + _ = await Assert.That(received).IsTrue(); + + var result = await salesClient.GetSalesAsync(1, 100); + var sale = result.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleRequest.Start); + + _ = await Assert.That(sale).IsNotNull(); + _ = await Assert.That(sale!.Percentage).IsEqualTo(25m); + _ = await Assert.That(sale!.Status).IsEqualTo("Active"); + + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, book.Id, saleRequest.Start); + } + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_FutureSale_StatusIsScheduled(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var salesClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var bookResponse = await adminClient.GetBookWithResponseAsync(book.Id); + var currentETag = bookResponse.Headers.ETag?.Tag; + var currentVersion = ParseETag(currentETag); + var saleRequest = new ScheduleSaleRequest(15m, DateTimeOffset.UtcNow.AddMinutes(30), DateTimeOffset.UtcNow.AddDays(1)); + + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + book.Id, + "BookUpdated", + async () => await adminClient.ScheduleBookSaleAsync(book.Id, saleRequest, currentETag), + TimeSpan.FromSeconds(10), + minVersion: currentVersion + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: adminLogin.AccessToken); + + _ = await Assert.That(received).IsTrue(); + + var result = await salesClient.GetSalesAsync(1, 100); + var sale = result.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleRequest.Start); + + _ = await Assert.That(sale).IsNotNull(); + _ = await Assert.That(sale!.Status).IsEqualTo("Scheduled"); + + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, book.Id, saleRequest.Start); + } + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_ExpiredSale_StatusIsExpired(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var salesClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var bookResponse = await adminClient.GetBookWithResponseAsync(book.Id); + var currentETag = bookResponse.Headers.ETag?.Tag; + var currentVersion = ParseETag(currentETag); + var saleRequest = new ScheduleSaleRequest( + 35m, + DateTimeOffset.UtcNow.AddSeconds(-120), + DateTimeOffset.UtcNow.AddSeconds(-60)); + + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + book.Id, + "BookUpdated", + async () => await adminClient.ScheduleBookSaleAsync(book.Id, saleRequest, currentETag), + TimeSpan.FromSeconds(10), + minVersion: currentVersion + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: adminLogin.AccessToken); + + _ = await Assert.That(received).IsTrue(); + + var result = await salesClient.GetSalesAsync(1, 100); + var sale = result.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleRequest.Start); + + _ = await Assert.That(sale).IsNotNull(); + _ = await Assert.That(sale!.Status).IsEqualTo("Expired"); + + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, book.Id, saleRequest.Start); + } + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_AfterCancelSale_SaleAbsentFromList(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var salesClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var bookResponse = await adminClient.GetBookWithResponseAsync(book.Id); + var scheduleETag = bookResponse.Headers.ETag?.Tag; + var scheduleVersion = ParseETag(scheduleETag); + var saleRequest = new ScheduleSaleRequest(20m, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(2)); + + var scheduleReceived = await SseEventHelpers.ExecuteAndWaitForEventAsync( + book.Id, + "BookUpdated", + async () => await adminClient.ScheduleBookSaleAsync(book.Id, saleRequest, scheduleETag), + TimeSpan.FromSeconds(10), + minVersion: scheduleVersion + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: adminLogin.AccessToken); + + _ = await Assert.That(scheduleReceived).IsTrue(); + + var salesAfterSchedule = await salesClient.GetSalesAsync(1, 100); + var scheduledSale = salesAfterSchedule.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleRequest.Start); + + _ = await Assert.That(scheduledSale).IsNotNull(); + _ = await Assert.That(scheduledSale!.BookETag).IsNotNull().And.IsNotEmpty(); + + var cancelVersion = ParseETag(scheduledSale.BookETag); + var cancelReceived = await SseEventHelpers.ExecuteAndWaitForEventAsync( + book.Id, + "BookUpdated", + async () => await adminClient.CancelBookSaleAsync(book.Id, scheduledSale.Start, scheduledSale.BookETag), + TimeSpan.FromSeconds(10), + minVersion: cancelVersion + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: adminLogin.AccessToken); + + _ = await Assert.That(cancelReceived).IsTrue(); + + var salesAfterCancel = await salesClient.GetSalesAsync(1, 100); + var cancelledSale = salesAfterCancel.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleRequest.Start); + + _ = await Assert.That(cancelledSale).IsNull(); + + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, book.Id, saleRequest.Start); + } + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_WithoutAdminAuth_ReturnsUnauthorizedOrForbidden(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + var book = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var saleStart = DateTimeOffset.UtcNow.AddMinutes(2); + + await ScheduleSaleAsync(adminClient, adminLogin.AccessToken, tenantId, book.Id, 18m, saleStart, saleStart.AddDays(1)); + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, book.Id, saleStart); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + + try + { + _ = await client.GetSalesAsync(1, 10); + Assert.Fail("Should have thrown ApiException"); + } + catch (ApiException ex) + { + var isUnauthorized = ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; + _ = await Assert.That(isUnauthorized).IsTrue(); + } + } + + [Test] + [Category("Integration")] + [Arguments(MultiTenancyConstants.DefaultTenantId)] + [Arguments(SecondaryTenantId)] + public async Task GetSales_Pagination_ReturnsCorrectPage(string tenantId) + { + var otherTenantId = GetOtherTenantId(tenantId); + var adminLogin = await GetAdminLoginAsync(tenantId); + var otherTenantLogin = await GetAdminLoginAsync(otherTenantId); + var adminClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var salesClient = CreateAuthenticatedClient(adminLogin.AccessToken, tenantId); + var otherTenantSalesClient = CreateAuthenticatedClient(otherTenantLogin.AccessToken, otherTenantId); + + var firstBook = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var secondBook = await BookHelpers.CreateBookAsync(adminClient, FakeDataGenerators.GenerateFakeBookRequest()); + var firstSaleStart = DateTimeOffset.UtcNow.AddMinutes(20); + var secondSaleStart = DateTimeOffset.UtcNow.AddMinutes(10); + + await ScheduleSaleAsync(adminClient, adminLogin.AccessToken, tenantId, firstBook.Id, 10m, firstSaleStart, firstSaleStart.AddDays(1)); + await ScheduleSaleAsync(adminClient, adminLogin.AccessToken, tenantId, secondBook.Id, 30m, secondSaleStart, secondSaleStart.AddDays(1)); + + var page = await salesClient.GetSalesAsync(1, 1); + + _ = await Assert.That(page.Items.Count).IsEqualTo(1); + _ = await Assert.That(page.TotalItemCount).IsGreaterThanOrEqualTo(2); + _ = await Assert.That(page.HasNextPage).IsTrue(); + + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, firstBook.Id, firstSaleStart); + await AssertSaleHiddenFromTenantAsync(otherTenantSalesClient, secondBook.Id, secondSaleStart); + } + + [Test] + [Category("Integration")] + public async Task GetSales_SaleCreatedInTenantA_NotVisibleFromTenantB() + { + var tenantB = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantB); + + var tenantABooksClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var tenantASalesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); + + var tenantBLogin = await AuthenticationHelpers.LoginAsAdminAsync(tenantB); + _ = await Assert.That(tenantBLogin).IsNotNull(); + + var tenantBSalesClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tenantBLogin!.AccessToken, tenantB)); + + var book = await BookHelpers.CreateBookAsync(tenantABooksClient, FakeDataGenerators.GenerateFakeBookRequest()); + var saleStart = DateTimeOffset.UtcNow.AddMinutes(5); + await ScheduleSaleAsync(tenantABooksClient, GlobalHooks.AdminAccessToken!, MultiTenancyConstants.DefaultTenantId, + book.Id, 12m, saleStart, saleStart.AddDays(1)); + + var tenantBSales = await tenantBSalesClient.GetSalesAsync(1, 100); + var tenantBVisibleSale = tenantBSales.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleStart); + + _ = await Assert.That(tenantBVisibleSale).IsNull(); + + var tenantASales = await tenantASalesClient.GetSalesAsync(1, 100); + var tenantAVisibleSale = tenantASales.Items.FirstOrDefault(item => item.BookId == book.Id && item.Start == saleStart); + + _ = await Assert.That(tenantAVisibleSale).IsNotNull(); + _ = await Assert.That(tenantAVisibleSale!.BookId).IsEqualTo(book.Id); + } + + static string GetOtherTenantId(string tenantId) + => tenantId.Equals(MultiTenancyConstants.DefaultTenantId, StringComparison.OrdinalIgnoreCase) + ? SecondaryTenantId + : MultiTenancyConstants.DefaultTenantId; + + static async Task GetAdminLoginAsync(string tenantId) + { + var login = await AuthenticationHelpers.LoginAsAdminAsync(tenantId); + if (login is null) + { + throw new InvalidOperationException($"Unable to authenticate admin for tenant '{tenantId}'."); + } + + return login; + } + + static T CreateAuthenticatedClient(string accessToken, string tenantId) + => RestService.For(HttpClientHelpers.GetAuthenticatedClient(accessToken, tenantId)); + + static async Task AssertSaleHiddenFromTenantAsync(ISalesClient salesClient, Guid bookId, DateTimeOffset saleStart) + { + var sales = await salesClient.GetSalesAsync(1, 100); + var hiddenSale = sales.Items.FirstOrDefault(item => item.BookId == bookId && item.Start == saleStart); + + _ = await Assert.That(hiddenSale).IsNull(); + } + + static async Task ScheduleSaleAsync(IBooksClient adminClient, string accessToken, string tenantId, Guid bookId, + decimal percentage, DateTimeOffset start, DateTimeOffset end) + { + var bookResponse = await adminClient.GetBookWithResponseAsync(bookId); + var etag = bookResponse.Headers.ETag?.Tag; + var version = ParseETag(etag); + var saleRequest = new ScheduleSaleRequest(percentage, start, end); + + var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + bookId, + "BookUpdated", + async () => await adminClient.ScheduleBookSaleAsync(bookId, saleRequest, etag), + TimeSpan.FromSeconds(10), + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow, + tenantId: tenantId, + accessToken: accessToken); + + _ = await Assert.That(received).IsTrue(); + } + + static long ParseETag(string? etag) + { + if (string.IsNullOrEmpty(etag)) + { + return 0; + } + + var trimmed = etag.Trim(); + if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[2..]; + } + + trimmed = trimmed.Trim('"'); + return long.TryParse(trimmed, out var version) ? version : 0; + } +} From 54681b7c4198af4a887d8f6ef2df9fb467119712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anta=CC=83o=20Almada?= Date: Thu, 2 Apr 2026 09:02:34 +0100 Subject: [PATCH 2/3] fix: Add newline at end of Directory.Build.targets file --- Directory.Build.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index c867f68f..f5176653 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -17,4 +17,4 @@ - \ No newline at end of file + From d0d5719d08d9c92e6937489c48f38177ef40305d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anta=CC=83o=20Almada?= Date: Fri, 3 Apr 2026 14:00:58 +0100 Subject: [PATCH 3/3] feat: Add comprehensive documentation for .NET scaffolding skills, including build properties, editorconfig, package management, solution formats, and project templates --- .github/skills/dotnet-scaffold/SKILL.md | 90 ++++++ .../dotnet-scaffold/references/build-props.md | 181 ++++++++++++ .../references/editorconfig.md | 259 ++++++++++++++++++ .../references/packages-props.md | 174 ++++++++++++ .../dotnet-scaffold/references/solution.md | 145 ++++++++++ .../dotnet-scaffold/references/templates.md | 158 +++++++++++ 6 files changed, 1007 insertions(+) create mode 100644 .github/skills/dotnet-scaffold/SKILL.md create mode 100644 .github/skills/dotnet-scaffold/references/build-props.md create mode 100644 .github/skills/dotnet-scaffold/references/editorconfig.md create mode 100644 .github/skills/dotnet-scaffold/references/packages-props.md create mode 100644 .github/skills/dotnet-scaffold/references/solution.md create mode 100644 .github/skills/dotnet-scaffold/references/templates.md diff --git a/.github/skills/dotnet-scaffold/SKILL.md b/.github/skills/dotnet-scaffold/SKILL.md new file mode 100644 index 00000000..bb55c55a --- /dev/null +++ b/.github/skills/dotnet-scaffold/SKILL.md @@ -0,0 +1,90 @@ +--- +name: dotnet-scaffold +description: Use this skill when scaffolding new .NET solutions or projects, configuring shared build properties, setting up NuGet Central Package Management, enforcing code style with .editorconfig, or choosing between .sln and .slnx solution formats. Always trigger when users ask about dotnet new templates, multi-project solution layout, Directory.Build.props, Directory.Packages.props, .editorconfig, global.json, TreatWarningsAsErrors, LangVersion, or anything about organizing a .NET repository from scratch—even if they don't mention any of those file names explicitly. +--- + +# .NET Scaffolding Skill + +Use this skill to scaffold modern .NET solutions and projects correctly. The +non-obvious parts are covered in the reference files below — read the relevant +ones before generating or modifying any scaffolding files. + +--- + +## Quick decision tree + +| Question | Go to | +|----------|-------| +| Which `dotnet new` template to use? What options exist? | [references/templates.md](references/templates.md) | +| Create or manage a solution file? sln vs slnx? | [references/solution.md](references/solution.md) | +| Set properties that apply to all projects in the repo? | [references/build-props.md](references/build-props.md) | +| Centralize NuGet versions in one place? | [references/packages-props.md](references/packages-props.md) | +| Enforce coding style and formatting rules? | [references/editorconfig.md](references/editorconfig.md) | + +--- + +## Modern defaults to always apply + +These are the baseline quality settings for every new .NET 10 solution. Do not +omit them — they represent the minimum for professional .NET development today. + +**In `Directory.Build.props`:** +```xml +net10.0 +latest +enable +enable +true +true +true +latest +``` + +Every setting has a reason: +- `LangVersion=latest` — gets C# 13/14 features without waiting for SDK bumps +- `Nullable=enable` — null safety enforced at compile time +- `TreatWarningsAsErrors=true` — stops warnings being silently ignored +- `EnforceCodeStyleInBuild=true` — `.editorconfig` style rules fail the build +- `AnalysisLevel=latest` — enables the newest Roslyn analyzer rules + +**Use `.slnx`** for any new solution (it's the .NET 10 default). See +[references/solution.md](references/solution.md) for details and migration. + +**Enable Central Package Management** for any multi-project solution. See +[references/packages-props.md](references/packages-props.md). + +--- + +## Common project skeleton + +``` +repo/ +├── .editorconfig ← code style rules (root = true) +├── .gitignore ← standard .NET gitignore +├── global.json ← pin the SDK version +├── Directory.Build.props ← shared MSBuild properties (early) +├── Directory.Build.targets ← shared MSBuild targets (late) +├── Directory.Packages.props ← central NuGet versions +├── MySolution.slnx ← solution file (.NET 10 default format) +├── src/ +│ ├── MyApp.Api/ +│ │ └── MyApp.Api.csproj +│ └── MyApp.Core/ +│ └── MyApp.Core.csproj +└── tests/ + └── MyApp.Tests/ + └── MyApp.Tests.csproj +``` + +--- + +## Rules + +``` +✅ .slnx ❌ .sln (for new solutions) +✅ Directory.Build.props ❌ Repeating in every .csproj +✅ Directory.Packages.props ❌ Version attributes on +✅ latest ❌ Pinning to a specific version number +✅ enable ❌ Missing or disabled nullable +✅ file-scoped namespaces ❌ braced namespace blocks +``` diff --git a/.github/skills/dotnet-scaffold/references/build-props.md b/.github/skills/dotnet-scaffold/references/build-props.md new file mode 100644 index 00000000..0d374524 --- /dev/null +++ b/.github/skills/dotnet-scaffold/references/build-props.md @@ -0,0 +1,181 @@ +# Directory.Build.props and Directory.Build.targets + +## What they are + +`Directory.Build.props` and `Directory.Build.targets` are MSBuild files that +are automatically imported into every project in the directory tree beneath them. +The key difference is *when* they're imported: + +| File | Import point | Use for | +|------|-------------|---------| +| `Directory.Build.props` | Early (before SDK defaults) | Properties that configure the SDK | +| `Directory.Build.targets` | Late (after NuGet targets) | Custom build targets, post-build actions | + +Placing them at the repository root means all projects share the same settings +automatically — no property repetition in individual `.csproj` files. + +Create them quickly via the CLI: +```bash +dotnet new buildprops # creates Directory.Build.props +``` + +--- + +## Recommended Directory.Build.props for .NET 10 + +```xml + + + + net10.0 + latest + enable + enable + + + true + true + true + latest + + + true + + + Your Name + Copyright (c) 2025 Your Name + + + + + true + true + true + true + snupkg + + + + + true + + + + + + + +``` + +--- + +## Property explanations + +**`LangVersion=latest`** — automatically uses the latest C# version supported by +the installed SDK (C# 13 in .NET 9, C# 14 in .NET 10). Never pin to a specific +number; you'll miss new features unnecessarily. + +**`Nullable=enable`** — enables nullable reference types. This is one of the +highest-value safety features in modern C#. With it enabled you'll get compile- +time warnings for potential null dereferences. + +**`TreatWarningsAsErrors=true`** — turns all warnings into errors. Without this, +warnings accumulate silently and never get fixed. Pair it with `` for +intentional suppression of specific warnings: +```xml +$(NoWarn);CS1591 +``` + +**`EnforceCodeStyleInBuild=true`** — causes `.editorconfig` style rules to +produce build errors/warnings rather than just IDE squiggles. This makes code +style enforceable in CI. + +**`AnalysisLevel=latest`** — enables the newest generation of Roslyn code +quality rules as soon as they're available in the SDK. + +**`EmitCompilerGeneratedFiles=true`** — writes source-generated files (e.g., +from `System.Text.Json`, `RegexGenerator`, `LoggerMessage`) into `obj/` so you +can inspect them. Harmless and very helpful for debugging generators. + +--- + +## Directory.Build.targets + +Use `.targets` for things that must run *after* the project and NuGet imports, +or that define build targets: + +```xml + + + + true + + + + + + + +``` + +--- + +## Multi-level merging + +By default MSBuild stops scanning upward after finding the first +`Directory.Build.props`. To support per-folder overrides that also inherit +from the root file, add this to the inner file: + +```xml + + + + + + + + true + + +``` + +This is useful for applying different settings to `src/` vs `tests/`. + +--- + +## Overriding in individual projects + +Settings from `Directory.Build.props` are defaults. Any project can override +them by setting the property in its own `.csproj`: + +```xml + + + + net10.0;netstandard2.0 + + + false + + +``` + +--- + +## Troubleshooting + +**Property not taking effect**: Run `dotnet msbuild /pp:preprocessed.xml MyProj.csproj` +to see the full merged file with import order. Look for where your property is set +vs where it's overridden. + +**File silently ignored on Linux/macOS**: The filename must match exactly — +`Directory.Build.props` (capital B and capital P). Linux filesystems are +case-sensitive. + +**Visual Studio not picking up changes**: Close and reopen the solution, or +right-click → Reload Project after editing `.props` or `.targets` files. diff --git a/.github/skills/dotnet-scaffold/references/editorconfig.md b/.github/skills/dotnet-scaffold/references/editorconfig.md new file mode 100644 index 00000000..44966976 --- /dev/null +++ b/.github/skills/dotnet-scaffold/references/editorconfig.md @@ -0,0 +1,259 @@ +# .editorconfig for .NET Projects + +## What it does + +`.editorconfig` configures two distinct categories of rules: + +1. **Editor formatting** — indentation, line endings, charset, trailing + whitespace. These apply in any editor that supports EditorConfig. +2. **.NET / C# code style** — language rules (var vs explicit types, expression + bodies, pattern matching, etc.) and Roslyn naming rules. These are enforced + at build time when `EnforceCodeStyleInBuild=true` is set in + `Directory.Build.props`. + +Create it quickly: +```bash +dotnet new editorconfig +``` + +--- + +## Key settings + +### `root = true` + +Put `root = true` at the top of the file in the repository root. This tells +editors to stop searching parent directories for additional `.editorconfig` +files. Sub-directories can have their own files to override specific rules. + +--- + +## Recommended .editorconfig for modern .NET / C# 14 + +```ini +root = true + +# All files +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +# C# and VB +[*.{cs,vb}] + +#### Naming rules #### + +# Interfaces: IPascalCase +dotnet_naming_rule.interface_should_begin_with_i.symbols = interface +dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i +dotnet_naming_rule.interface_should_begin_with_i.severity = warning + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +# Types: PascalCase +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case +dotnet_naming_rule.types_should_be_pascal_case.severity = warning + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_style.pascal_case.capitalization = pascal_case + +#### Organize usings #### +dotnet_sort_system_directives_first = true:warning +dotnet_separate_import_directive_groups = false:warning + +#### Language rules #### + +# File-scoped namespaces (C# 10+) +csharp_style_namespace_declarations = file_scoped:warning + +# var preferences +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:warning + +# Modern C# features +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_collection_expression = when_types_exactly_match:warning +dotnet_style_prefer_collection_expression = when_types_exactly_match:warning +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_constructors = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_lambdas = true:warning +csharp_style_expression_bodied_local_functions = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_indexers = true:warning + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_prefer_switch_expression = true:warning +csharp_style_prefer_not_pattern = true:warning +csharp_style_prefer_pattern_matching = true:warning +csharp_style_prefer_extended_property_pattern = true:warning + +# Null checking +csharp_style_throw_expression = true:warning +csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_conditional_delegate_call = true:warning + +# Ranges and indices (C# 8+) +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning + +# Readonly structs (C# 8+) +csharp_style_prefer_readonly_struct = true:warning +csharp_style_prefer_readonly_struct_member = true:warning + +# Misc modern preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:error + +# Accessibility modifiers +dotnet_style_require_accessibility_modifiers = omit_if_default:warning + +# Auto-properties +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_simplified_interpolation = true:warning + +# Tuple names +dotnet_style_prefer_inferred_tuple_names = true:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning + +# Parameter hygiene +dotnet_code_quality_unused_parameters = all:warning + +# Namespace matches folder (suggestion to not block builds on new files) +dotnet_style_namespace_match_folder = true:suggestion + +# Blank line hygiene +dotnet_style_allow_multiple_blank_lines_experimental = false:warning +dotnet_style_allow_statement_immediately_after_block_experimental = false:warning + +# Logging — prefer [LoggerMessage] source generator +dotnet_diagnostic.CA1848.severity = warning +``` + +--- + +## Why `csharp_style_unused_value_expression_statement_preference = discard_variable:error` + +This rule (diagnostic [IDE0058](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0058)) is set to **`:error`** — not `:warning` — because silently discarding a return value can cause a runtime bug that compiles cleanly. + +Classic example: changing from `List` to `ImmutableList`: + +```csharp +// List.Add returns void — discarding the call is correct +var list = new List(); +list.Add(42); // fine + +// ImmutableList.Add returns a NEW instance — the original is unchanged! +var immutable = ImmutableList.Create(); +immutable.Add(42); // silent bug: result is thrown away, immutable is still empty +``` + +The refactoring compiles without warning. At runtime, nothing is added. With `IDE0058 = error`, the compiler flags the call immediately. + +When you genuinely intend to ignore a return value (e.g., fluent APIs used for assertions), use an explicit discard to make the intent clear: + +```csharp +// Explicit discard — communicates "yes, I know there's a return value, I don't need it" +_ = result.Must() + .BeEnumerableOf() + .BeEqualTo(expected); +``` + +> Reference: [Defensive Coding in C#: A Closer Look at Unchecked Return Value Discards](https://aalmada.github.io/posts/A-closer-look-at-unchecked-return-value-discards/) + +--- + +## Severity levels + +| Severity | Meaning | +|----------|---------| +| `none` | Rule disabled | +| `silent` / `refactoring` | Available as code fix, no squiggle | +| `suggestion` | Blue squiggle, Quick Fix available | +| `warning` | Yellow squiggle; build warning when `EnforceCodeStyleInBuild=true` | +| `error` | Red squiggle; build error (blocks CI) | + +Use `warning` for style preferences that matter but aren't blocking. Use `error` +only for rules you're absolutely certain you never want to break (e.g., unused +discard variables). + +--- + +## Build-time enforcement + +For `.editorconfig` styles to block a build, two things must both be true: + +1. `true` in + `Directory.Build.props` +2. The rule severity is `warning` or `error` in `.editorconfig` + +Check compliance without building: +```bash +dotnet format --verify-no-changes +``` + +Fix all auto-fixable violations: +```bash +dotnet format +``` + +Fix only whitespace: +```bash +dotnet format whitespace +``` + +Fix only style rules: +```bash +dotnet format style +``` + +--- + +## Sub-directory overrides + +If a sub-project needs a different rule (e.g., a generated-code project that +cannot satisfy certain naming rules), add a nested `.editorconfig` without +`root = true`: + +```ini +# src/Generated/.editorconfig — no "root = true" so it inherits from root +[*.cs] +# Turn off naming rules for generated code +dotnet_naming_rule.interface_should_begin_with_i.severity = none +dotnet_naming_rule.types_should_be_pascal_case.severity = none +``` + +MSBuild merges from root down, with the most specific file winning. + +--- + +## Suppressing specific diagnostic IDs + +When a rule needs to be suppressed project-wide with a rationale comment: + +```ini +# IDE0051: Remove unused private members +# Suppressed because Marten uses reflection to discover Apply() methods +dotnet_diagnostic.IDE0051.severity = none +``` + +This is cleaner than `#pragma warning disable` scattered through source files. diff --git a/.github/skills/dotnet-scaffold/references/packages-props.md b/.github/skills/dotnet-scaffold/references/packages-props.md new file mode 100644 index 00000000..8cf6cb31 --- /dev/null +++ b/.github/skills/dotnet-scaffold/references/packages-props.md @@ -0,0 +1,174 @@ +# NuGet Central Package Management (Directory.Packages.props) + +## Why CPM? + +In a multi-project solution, each project independently specifying +`` leads to +version drift — different projects accidentally pick up different versions. +Central Package Management (CPM) fixes this by declaring every version in one +authoritative file: `Directory.Packages.props`. + +--- + +## Getting started + +```bash +# Create the file via CLI (SDK 7+ required) +dotnet new packagesprops +``` + +This produces a `Directory.Packages.props` at the current directory. + +--- + +## Directory.Packages.props structure + +```xml + + + + true + + + + + + + + +``` + +--- + +## In project files — omit the Version attribute + +```xml + + + + + + + + +``` + +Having `Version` on a `` when CPM is enabled causes **error +NU1008**. Remove the attribute. + +--- + +## Version overrides (use sparingly) + +If one project genuinely needs a different version of a package: + +```xml + + +``` + +To prevent this escape hatch from being misused across the team, disable it: +```xml + + + false + +``` + +--- + +## Global package references + +For packages that every project should receive (e.g., analyzer-only packages, +versioning tools), use `GlobalPackageReference` instead of repeating it in +every project file: + +```xml + + + + + +``` + +This replaces placing `` inside `Directory.Build.props` (which +also works but `GlobalPackageReference` makes the intent clearer). + +--- + +## Transitive pinning + +Prevent "diamond dependency" surprises by explicitly pinning a transitive +dependency to a known-safe version: + +```xml + + + true + + + + + +``` + +--- + +## Opting a single project out of CPM + +```xml + + + false + +``` + +--- + +## Multi-repo / subdirectory overrides + +`Directory.Packages.props` follows the same "walk upward, stop at first match" +rule as `Directory.Build.props`. If a project is in a subdirectory with its own +`Directory.Packages.props`, that file is used instead of the root one. + +To inherit from the root and add overrides: +```xml + + + + + + + + +``` + +Note: use `Update` (not `Include`) when overriding an entry that was already +declared by an imported file. + +--- + +## Keeping versions up to date + +```bash +# List outdated packages (requires dotnet-outdated-tool) +dotnet outdated + +# Or use the NuGet Package Manager in Visual Studio / Rider to see upgrade +# suggestions — they respect the centralised versions file. +``` + +--- + +## Common errors + +| Error | Cause | Fix | +|-------|-------|-----| +| **NU1008** | `` has a `Version` attribute while CPM is on | Remove `Version` from `` | +| **NU1604** | `` entry exists but has no `Version` | Add `Version="..."` to the `` | +| **NU1507** | Multiple package sources defined with CPM | Use package source mapping or single source | diff --git a/.github/skills/dotnet-scaffold/references/solution.md b/.github/skills/dotnet-scaffold/references/solution.md new file mode 100644 index 00000000..4cf8c821 --- /dev/null +++ b/.github/skills/dotnet-scaffold/references/solution.md @@ -0,0 +1,145 @@ +# Solution Files: .sln vs .slnx + +## Why .slnx? + +`.slnx` is the default solution format starting with .NET 10 (`dotnet new sln` +now produces `.slnx`). It replaces the legacy `.sln` format with clean, human- +readable XML. + +| | `.sln` | `.slnx` | +|---|---|---| +| Format | Proprietary text | Standard XML | +| Human-readable | Barely | Completely | +| GUIDs | Required everywhere | None | +| Lines for 3 projects | ~35 | ~7 | +| Git merge conflicts | Frequent, painful | Rare, simple | +| Config platforms | Explicitly listed | Inferred from projects | +| .NET CLI default | .NET 9 and below | .NET 10+ | +| Tooling support | All | VS 2022 17.13+, Rider 2024.3+, VS Code, MSBuild 17.12+, CLI 9.0.200+ | + +**Recommendation:** Use `.slnx` for all new solutions. Only fall back to `.sln` +if a specific unmigrated third-party tool parses `.sln` files directly. + +--- + +## Format examples + +**.sln** — 35 lines for 3 projects, full of GUIDs: +``` +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyApp.Api", "src\MyApp.Api\MyApp.Api.csproj", "{A1B2C3D4-...}" +EndProject +... +GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-...}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + ... +EndGlobalSection +``` + +**.slnx** — 7 lines, pure XML, no GUIDs: +```xml + + + + + + + + + +``` + +Nested solution folders use nested `` elements — no GUIDs, no +`NestedProjects` section. + +--- + +## Creating a solution + +```bash +# .NET 10 — produces .slnx automatically +dotnet new sln -n MySolution + +# Explicit .slnx +dotnet new sln -n MySolution --format slnx + +# Force legacy .sln (avoid unless required) +dotnet new sln -n MySolution --format sln +``` + +--- + +## Managing projects + +```bash +# Add with optional solution folder grouping +dotnet sln MySolution.slnx add src/MyApp.Api/MyApp.Api.csproj --solution-folder src +dotnet sln MySolution.slnx add tests/MyApp.Tests/MyApp.Tests.csproj --solution-folder tests + +# Remove a project +dotnet sln MySolution.slnx remove src/MyApp.Api/MyApp.Api.csproj + +# List projects in the solution +dotnet sln MySolution.slnx list +``` + +--- + +## Migrating from .sln to .slnx + +```bash +# Requires .NET SDK 9.0.200+ +dotnet --version # verify first + +# Migrate (original .sln is preserved; validate before deleting) +dotnet sln MyApp.sln migrate + +# Verify the build still works +dotnet build MyApp.slnx +dotnet test MyApp.slnx + +# Remove the old file once validated +rm MyApp.sln +``` + +**Do not keep both `.sln` and `.slnx` in the same repository** — the CLI will +ask which one to use every time and CI will fail without an explicit path. + +--- + +## CI/CD considerations + +If your pipeline references the solution by name, update it: +```yaml +# Before +- run: dotnet build MyApp.sln + +# After +- run: dotnet build MyApp.slnx +``` + +`dotnet build` / `dotnet test` without a solution name auto-discovers whichever +format is present, so pipelines using bare commands work without changes. + +Docker — update the `COPY` instruction: +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["MyApp.slnx", "."] # was MyApp.sln +``` + +--- + +## .gitignore recommendation + +Once migrated, prevent the old format from accidentally being re-added: +``` +# .gitignore +*.sln +``` + +Or name it explicitly if you still have other repositories using `.sln`: +``` +MyApp.sln +``` diff --git a/.github/skills/dotnet-scaffold/references/templates.md b/.github/skills/dotnet-scaffold/references/templates.md new file mode 100644 index 00000000..be86a372 --- /dev/null +++ b/.github/skills/dotnet-scaffold/references/templates.md @@ -0,0 +1,158 @@ +# .NET Project Templates + +## Creating projects and solutions + +```bash +# New solution (produces .slnx in .NET 10) +dotnet new sln -n MySolution + +# Force legacy .sln format (only if legacy tooling requires it) +dotnet new sln -n MySolution --format sln + +# Common project templates +dotnet new console -n MyApp +dotnet new classlib -n MyCore +dotnet new webapi -n MyApi +dotnet new blazor -n MyWeb +dotnet new aspire-apphost -n MyApp.AppHost +dotnet new nunit -n MyApp.Tests + +# Useful scaffold files +dotnet new gitignore +dotnet new editorconfig +dotnet new buildprops # creates Directory.Build.props +dotnet new packagesprops # creates Directory.Packages.props +dotnet new global.json # pins SDK version + +# Add a project to a solution +dotnet sln MySolution.slnx add src/MyApp.Api/MyApp.Api.csproj + +# List all available templates +dotnet new list + +# Search NuGet for template packs +dotnet new search + +# Install a template pack +dotnet new install +``` + +--- + +## Built-in template reference + +| Short name | Description | +|------------|-------------| +| `console` | Console application | +| `classlib` | Class library | +| `webapi` | ASP.NET Core Web API (Minimal API by default) | +| `web` | Empty ASP.NET Core project | +| `mvc` | ASP.NET Core MVC | +| `blazor` | Blazor Web App (full-stack server/WASM interactive) | +| `blazorwasm` | Blazor WebAssembly standalone | +| `blazorserver` | Blazor Server (legacy; prefer `blazor`) | +| `worker` | Background Worker Service | +| `grpc` | gRPC service | +| `aspire-apphost` | .NET Aspire App Host | +| `aspire-servicedefaults` | .NET Aspire Service Defaults library | +| `aspire-starter` | .NET Aspire starter solution | +| `nunit` | NUnit test project | +| `xunit` | xUnit test project | +| `mstest` | MSTest test project | +| `razorclasslib` | Razor Class Library | +| `globaljson` | `global.json` file | +| `gitignore` | `.gitignore` for .NET | +| `editorconfig` | `.editorconfig` for .NET | +| `buildprops` | `Directory.Build.props` | +| `packagesprops` | `Directory.Packages.props` | +| `sln` | Solution file (`.slnx` in .NET 10) | + +--- + +## Key template options + +### `webapi` +```bash +# Minimal API (default; preferred for new projects) +dotnet new webapi -n MyApi + +# Controller-based (legacy style) +dotnet new webapi -n MyApi --use-controllers + +# Skip HTTPS redirect (handy for container-first apps) +dotnet new webapi -n MyApi --no-https +``` + +### `blazor` +```bash +# Default: Interactive Auto render mode (server + WASM progressive) +dotnet new blazor -n MyWeb + +# Server-only rendering +dotnet new blazor -n MyWeb --interactivity Server + +# WASM-only rendering +dotnet new blazor -n MyWeb --interactivity WebAssembly + +# Static SSR only (no interactivity) +dotnet new blazor -n MyWeb --interactivity None +``` + +### `global.json` +Pin the SDK so every developer and CI agent uses the same version: +```json +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor" + } +} +``` +`rollForward: "latestMinor"` allows patch/minor updates automatically while +locking the major version — a good balance between stability and picking up +security fixes. + +--- + +## Typical multi-project solution workflow + +```bash +# 1. Create the solution +mkdir MyApp && cd MyApp +dotnet new sln -n MyApp + +# 2. Create the scaffold files +dotnet new gitignore +dotnet new editorconfig +dotnet new buildprops +dotnet new packagesprops +dotnet new global.json + +# 3. Create projects +dotnet new webapi -n MyApp.Api --output src/MyApp.Api +dotnet new classlib -n MyApp.Core --output src/MyApp.Core +dotnet new nunit -n MyApp.Tests --output tests/MyApp.Tests + +# 4. Add to solution with folder structure +dotnet sln MyApp.slnx add src/MyApp.Api/MyApp.Api.csproj --solution-folder src +dotnet sln MyApp.slnx add src/MyApp.Core/MyApp.Core.csproj --solution-folder src +dotnet sln MyApp.slnx add tests/MyApp.Tests/MyApp.Tests.csproj --solution-folder tests +``` + +--- + +## Custom templates + +You can create reusable templates for your organisation and install them from a +local path or NuGet: +```bash +# Install from local directory +dotnet new install ./my-template-dir/ + +# Uninstall +dotnet new uninstall ./my-template-dir/ +``` + +A template requires a `.template.config/template.json` descriptor at its root. +See https://learn.microsoft.com/dotnet/core/tools/custom-templates for the full +schema.