Hangfire.PostgreSql storage (тот же ConnectionString:Default). Сервер стартует только когда Hangfire:Enabled (true по умолчанию) — в интеграционных тестах выключаем через env Hangfire__Enabled=false, чтобы тесты не плодили служебные таблицы в одноразовом контейнере. Dashboard на /hangfire с авторизационным фильтром SuperAdminHangfireFilter — требует роли SuperAdmin (стандартный OpenIddict-токен валидируется аутентификационным middleware'ом перед этим). Recurring jobs (HangfireJobsConfigurator): • prune-stock-movements — ежедневно 03:30 UTC, удаляет StockMovement старше 730 дней (Hangfire:Retention:StockMovementDays). За 30 минут до бэкапа, чтобы pg_dump не цеплял временные блокировки. • prune-audit-log — ежедневно 03:45 UTC, удаляет super_admin_audit_log старше 90 дней (Hangfire:Retention:AuditLogDays). Логика очистки в HousekeepingJobs (scoped, использует AppDbContext с IgnoreQueryFilters — это межтенантная задача). Тесты: 1 unit (PruneStockMovements удаляет только старые), 1 интеграционный (dashboard не отвечает без Hangfire-сервера). Полный прогон: 24 unit + 32 integration = 56 зелёных. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
66 lines
2.7 KiB
C#
66 lines
2.7 KiB
C#
using FluentAssertions;
|
|
using foodmarket.Api.Background;
|
|
using foodmarket.Domain.Inventory;
|
|
using foodmarket.Domain.Platform;
|
|
using foodmarket.Infrastructure.Persistence;
|
|
using foodmarket.UnitTests.Support;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace foodmarket.UnitTests;
|
|
|
|
/// <summary>Юнит-тест: HousekeepingJobs.PruneStockMovements удаляет только
|
|
/// движения старше N дней (по умолчанию 730). Stock-инвариант не проверяем —
|
|
/// это межтенантная задача очистки далёкой истории, документы за этот горизонт
|
|
/// в норме закрыты.</summary>
|
|
public class HousekeepingJobsTests
|
|
{
|
|
private static IConfiguration CfgWith(int stockDays = 730, int auditDays = 90) =>
|
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Hangfire:Retention:StockMovementDays"] = stockDays.ToString(),
|
|
["Hangfire:Retention:AuditLogDays"] = auditDays.ToString(),
|
|
}).Build();
|
|
|
|
[Fact]
|
|
public async Task PruneStockMovements_deletes_only_old_rows()
|
|
{
|
|
using var sqlite = new SqliteDb(foreignKeys: false);
|
|
var orgId = Guid.NewGuid();
|
|
var tenant = new FakeTenantContext { OrganizationId = orgId };
|
|
|
|
using (var db = sqlite.Create(tenant))
|
|
{
|
|
db.StockMovements.AddRange(
|
|
new StockMovement
|
|
{
|
|
OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(),
|
|
Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply",
|
|
OccurredAt = DateTime.UtcNow.AddYears(-3), // старая
|
|
},
|
|
new StockMovement
|
|
{
|
|
OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(),
|
|
Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply",
|
|
OccurredAt = DateTime.UtcNow.AddDays(-30), // свежая
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
int deleted;
|
|
using (var db = sqlite.Create(tenant))
|
|
{
|
|
var jobs = new HousekeepingJobs(db, CfgWith(stockDays: 730), NullLogger<HousekeepingJobs>.Instance);
|
|
deleted = await jobs.PruneStockMovementsAsync();
|
|
}
|
|
|
|
deleted.Should().Be(1);
|
|
using (var db = sqlite.Create(tenant))
|
|
{
|
|
(await db.StockMovements.IgnoreQueryFilters().CountAsync()).Should().Be(1);
|
|
}
|
|
}
|
|
}
|