food-market/tests/food-market.UnitTests/HousekeepingJobsTests.cs
nns a74fa114d8 feat(hangfire): dashboard + scheduled cleanup джобы (P1-16)
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>
2026-05-28 10:07:14 +05:00

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);
}
}
}