backend phase 1: config, reference & platform signals
Lay the cross-cutting platform backbone every later phase reads from. Adds the first marketplace EF migration baseline (new `ops` schema) and the mechanisms b2..b15 reuse: typed runtime config, an append-only audit trail, an analytics event log, the holiday/bank-closure calendar, in-app notifications, and the internal support-alert worklist. Schema & migration - New `ops` schema + migration InitialMarketplaceBaseline with 6 tables: PlatformConfigs (IAuditable), AuditLogs (append-only), SystemEvents, IranianHolidays, Notifications, SupportAlerts — with indexes/uniques and FKs to usr.Users. Seeded 12 config keys + 7 sample holidays via HasData. Domain / Application - IAuditable marker + [AuditRedacted] attribute; entities + string-code constant holders (config data_type, holiday type, alert type/severity/status). - Facade contracts: IPlatformConfig, IHolidayCalendar, IAnalyticsSink, IAuditLogger, INotificationService, ISupportAlertService; DTOs + PagedResult<T>; evolved the INotificationDispatcher.Notification record to carry Type + DataJson; Pagination helper. - 14 CQRS commands/queries (+ validators) wiring the endpoints to the facades. Infrastructure - DB-backed facade implementations in Persistence/Services/; real in-app INotificationDispatcher (removes the b0 log stub); notification-retention hosted service (purge is_read=1 AND age>90d). - Extended AuditFieldInterceptor to also append an old/new-diff audit_logs row for every IAuditable change in the same transaction (PII redacted). - Registered all facades + hosted service in AddPersistenceServices; removed the dispatcher registration from AddCrossCuttingSeams. API - 5 controllers: admin PlatformConfig/Holidays/Audit/SupportAlerts ([Authorize(DynamicPermission)]) + current-user Notifications ([Authorize]), all tenant-scoped and paginated. 16 Swagger paths total. Money-correctness & safety rules honoured - Config read at compute time (cached, parsed by data_type), never hardcoded; every config change is audited in the same transaction; audit_logs is append-only (no update/delete path); support alerts are admin-only; notifications are tenant-scoped; analytics is fire-and-forget. Tests & docs - 18 new foundation tests over in-memory SQLite (config typing + audit, holidays, notifications + tenancy + retention, support alerts, analytics); build clean (0 new code warnings), 22 tests green; migration applied to the dev DB and swagger.v1.json refreshed. - Updated server Project map + CONVENTIONS, product data-model doc 12 (seeded config defaults), config-reference contract, mock registry, backend handoff/ STATUS/report. Follow-ups: add FK constraints for SupportAlerts.BookingId (b9) and ReviewId (b14) when those tables land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
using Baya.Domain.Entities.Analytics;
|
||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
public sealed class AnalyticsSinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Emit_InsertsSystemEventRow()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
host.CurrentUser.UserId = await host.AddUserAsync("actor");
|
||||
var sink = new AnalyticsSink(host.Db, host.CurrentUser, host.Clock, NullLogger<AnalyticsSink>.Instance);
|
||||
|
||||
await sink.EmitAsync("nurse_search_performed", new { query = "cardiac" });
|
||||
|
||||
var evt = await host.Db.Set<SystemEvent>().SingleAsync();
|
||||
Assert.Equal("nurse_search_performed", evt.Name);
|
||||
Assert.Equal(host.CurrentUser.UserId, evt.UserId);
|
||||
Assert.Contains("cardiac", evt.PropsJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Baya.Domain.Entities.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
public sealed class HolidayCalendarServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IsBankClosed_SeededBankClosedDate_True()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var calendar = new HolidayCalendarService(host.Db, host.Cache);
|
||||
|
||||
Assert.True(await calendar.IsBankClosed(new DateOnly(2026, 3, 21)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHoliday_NonHoliday_False()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var calendar = new HolidayCalendarService(host.Db, host.Cache);
|
||||
|
||||
Assert.False(await calendar.IsHoliday(new DateOnly(2026, 5, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextBusinessDay_FromHoliday_ReturnsOpenBankDay()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var calendar = new HolidayCalendarService(host.Db, host.Cache);
|
||||
|
||||
// The Nowruz block 21–24 March is bank-closed; the answer is the first later open, non-Friday day.
|
||||
var next = await calendar.NextBusinessDay(new DateOnly(2026, 3, 21));
|
||||
|
||||
Assert.True(next > new DateOnly(2026, 3, 24));
|
||||
Assert.NotEqual(DayOfWeek.Friday, next.DayOfWeek);
|
||||
Assert.False(await calendar.IsBankClosed(next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ThenDelete_RoundTrips()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
host.CurrentUser.UserId = await host.AddUserAsync("admin");
|
||||
var calendar = new HolidayCalendarService(host.Db, host.Cache);
|
||||
var date = new DateOnly(2026, 9, 1);
|
||||
|
||||
await calendar.UpsertAsync(date, "روز آزمایشی", HolidayType.Official, true);
|
||||
Assert.True(await calendar.IsHoliday(date));
|
||||
Assert.True(await calendar.IsBankClosed(date));
|
||||
|
||||
Assert.True(await calendar.DeleteAsync(date));
|
||||
Assert.False(await calendar.IsHoliday(date));
|
||||
Assert.False(await calendar.DeleteAsync(date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
public sealed class NotificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Dispatch_ThenList_UnreadFirst_Count_MarkRead()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var userId = await host.AddUserAsync("family");
|
||||
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
|
||||
var service = new NotificationService(host.Db, host.Clock);
|
||||
|
||||
await dispatcher.DispatchAsync(new Notification(userId, "booking_confirmed", "Booking confirmed", DataJson: "{\"booking_id\":1}"));
|
||||
|
||||
var page = await service.ListMineAsync(userId, 1, 20);
|
||||
var notification = Assert.Single(page.Items);
|
||||
Assert.False(notification.IsRead);
|
||||
Assert.Equal("booking_confirmed", notification.Type);
|
||||
Assert.Equal(1, await service.GetUnreadCountAsync(userId));
|
||||
|
||||
Assert.True(await service.MarkReadAsync(userId, notification.Id));
|
||||
Assert.Equal(0, await service.GetUnreadCountAsync(userId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_AreTenantScoped()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var owner = await host.AddUserAsync("owner");
|
||||
var other = await host.AddUserAsync("other");
|
||||
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
|
||||
var service = new NotificationService(host.Db, host.Clock);
|
||||
|
||||
await dispatcher.DispatchAsync(new Notification(owner, "booking_confirmed", "For owner"));
|
||||
|
||||
Assert.Empty((await service.ListMineAsync(other, 1, 20)).Items);
|
||||
Assert.Equal(0, await service.GetUnreadCountAsync(other));
|
||||
|
||||
// A different user cannot mark another user's notification read.
|
||||
var ownerNotificationId = (await service.ListMineAsync(owner, 1, 20)).Items[0].Id;
|
||||
Assert.False(await service.MarkReadAsync(other, ownerNotificationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeOldRead_RemovesOnlyReadOlderThanWindow()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var userId = await host.AddUserAsync("family");
|
||||
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
|
||||
var service = new NotificationService(host.Db, host.Clock);
|
||||
|
||||
var reference = host.Clock.UtcNow;
|
||||
|
||||
// Old + read → should be purged.
|
||||
host.Clock.UtcNow = reference.AddDays(-100);
|
||||
await dispatcher.DispatchAsync(new Notification(userId, "t", "old read"));
|
||||
var oldReadId = (await service.ListMineAsync(userId, 1, 20)).Items[0].Id;
|
||||
await service.MarkReadAsync(userId, oldReadId);
|
||||
|
||||
// Old + unread → must survive.
|
||||
await dispatcher.DispatchAsync(new Notification(userId, "t", "old unread"));
|
||||
|
||||
// Recent + read → must survive.
|
||||
host.Clock.UtcNow = reference;
|
||||
await dispatcher.DispatchAsync(new Notification(userId, "t", "recent read"));
|
||||
var recentReadId = (await service.ListMineAsync(userId, 1, 20)).Items.First(n => n.Title == "recent read").Id;
|
||||
await service.MarkReadAsync(userId, recentReadId);
|
||||
|
||||
var removed = await service.PurgeOldReadAsync(90);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
var remaining = (await service.ListMineAsync(userId, 1, 20)).Items;
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.DoesNotContain(remaining, n => n.Title == "old read");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up a real <see cref="ApplicationDbContext"/> over an isolated in-memory SQLite database with the
|
||||
/// audit interceptor wired and the marketplace seed applied (via <c>EnsureCreated</c>). Gives the
|
||||
/// platform-signal services something faithful to run against, with a controllable clock and caller.
|
||||
/// </summary>
|
||||
internal sealed class OpsTestHost : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public ApplicationDbContext Db { get; }
|
||||
public TestClock Clock { get; } = new();
|
||||
public TestCurrentUser CurrentUser { get; } = new();
|
||||
public ICacheService Cache { get; } = new MemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
public OpsTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
Clock.UtcNow = new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var interceptor = new AuditFieldInterceptor(CurrentUser, Clock);
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
|
||||
Db = new ApplicationDbContext(options);
|
||||
Db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// <summary>Adds a real user row so FK-bound rows (notifications, audit actor) satisfy the constraint.</summary>
|
||||
public async Task<int> AddUserAsync(string userName)
|
||||
{
|
||||
var user = new User { UserName = userName };
|
||||
Db.Set<User>().Add(user);
|
||||
await Db.SaveChangesAsync();
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestClock : IDateTimeProvider
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TestCurrentUser : ICurrentUser
|
||||
{
|
||||
public int? UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
public IReadOnlyList<string> Roles { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
public sealed class PlatformConfigServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetConfig_ParsesValueByDataType()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var config = new PlatformConfigService(host.Db, host.Cache);
|
||||
|
||||
Assert.Equal(0.10m, await config.GetConfig<decimal>("vat_rate"));
|
||||
Assert.Equal(72, await config.GetConfig<int>("dispute_window_hours"));
|
||||
Assert.Equal(30, await config.GetConfig<int>("booking_payment_deadline_minutes"));
|
||||
Assert.Equal("platform", await config.GetConfig<string>("bnpl_merchant_of_record"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfig_UpdatesValue_WritesAuditRow_AndEvictsCache()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
host.CurrentUser.UserId = await host.AddUserAsync("admin");
|
||||
var config = new PlatformConfigService(host.Db, host.Cache);
|
||||
|
||||
// Prime the cache with the seeded value.
|
||||
Assert.Equal(0.15m, await config.GetConfig<decimal>("platform_fee_rate"));
|
||||
|
||||
var updated = await config.SetConfig("platform_fee_rate", "0.18");
|
||||
Assert.True(updated);
|
||||
|
||||
// Cache was evicted → the next read returns the new value.
|
||||
Assert.Equal(0.18m, await config.GetConfig<decimal>("platform_fee_rate"));
|
||||
|
||||
var history = await config.GetConfigChangeHistory("platform_fee_rate", 1, 20);
|
||||
Assert.Equal(1, history.Total);
|
||||
var change = Assert.Single(history.Items);
|
||||
Assert.Equal("updated", change.Action);
|
||||
Assert.Equal(host.CurrentUser.UserId, change.ActorUserId);
|
||||
Assert.Contains("0.15", change.ChangedFieldsJson);
|
||||
Assert.Contains("0.18", change.ChangedFieldsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConfig_MissingKey_ReturnsFalse()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var config = new PlatformConfigService(host.Db, host.Cache);
|
||||
|
||||
Assert.False(await config.SetConfig("does_not_exist", "x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
|
||||
|
||||
namespace Baya.Test.Foundation.Marketplace;
|
||||
|
||||
public sealed class SupportAlertServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Raise_List_Assign_Resolve_Lifecycle()
|
||||
{
|
||||
using var host = new OpsTestHost();
|
||||
var admin = await host.AddUserAsync("admin");
|
||||
var service = new SupportAlertService(host.Db, host.Clock);
|
||||
|
||||
var alertId = await service.RaiseAsync(
|
||||
SupportAlertType.LowRating, "review", "42", SupportAlertSeverity.High, reviewId: 42);
|
||||
|
||||
var open = await service.ListAsync(null, SupportAlertStatus.Open, null, 1, 20);
|
||||
var raised = Assert.Single(open.Items);
|
||||
Assert.Equal(alertId, raised.Id);
|
||||
Assert.Equal(42, raised.ReviewId);
|
||||
Assert.Equal(SupportAlertStatus.Open, raised.Status);
|
||||
|
||||
Assert.True(await service.AssignAsync(alertId, admin));
|
||||
var assigned = (await service.ListAsync(null, SupportAlertStatus.Assigned, null, 1, 20)).Items.Single();
|
||||
Assert.Equal(admin, assigned.OwnerUserId);
|
||||
|
||||
Assert.True(await service.ResolveAsync(alertId, "Handled — nurse contacted."));
|
||||
var resolved = (await service.ListAsync(null, SupportAlertStatus.Resolved, null, 1, 20)).Items.Single();
|
||||
Assert.Equal("Handled — nurse contacted.", resolved.ResolutionNote);
|
||||
Assert.NotNull(resolved.ResolvedAt);
|
||||
|
||||
// Forward-only: a resolved alert cannot be resolved again.
|
||||
Assert.False(await service.ResolveAsync(alertId, "again"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user