food-market/tests/food-market.IntegrationTests/ImportJobPersistenceTests.cs
nns b963adfa2e feat(import-jobs): persisted ImportJobRegistry в БД (TD-5)
Раньше прогресс фоновых импортов жил в ConcurrentDictionary внутри
Singleton-сервиса: рестарт процесса терял всю историю, активные
джобы навсегда оставались в статусе Running.

Теперь:
- Domain.Integrations.ImportJob (TenantEntity) — таблица import_jobs,
  миграция Phase8c_ImportJobs (jsonb для ErrorsJson, индексы по
  OrgId+StartedAt / OrgId+Status / FinishedAt).
- ImportJobRegistry рефакторен: Create() пишет строку немедленно,
  SaveAsync() обновляет, Get/RecentlyFinished читают из БД. API
  совместимое со старой in-memory версией — MoySkladImportService
  и контроллеры не меняются.
- MoySkladImportController.RunInBackgroundAsync теперь:
  * Periodic flush через Timer каждые 2 секунды — UI видит
    реальный progress (Stage/Created/Total), а не Create-snapshot;
  * Финальный flush в finally — обязательный для terminal state.
- AdminCleanupController.WipeAllAsync — то же финальное сохранение.
- SkipAudit=true для import-job записей — служебные, в OrgAuditLog
  не пишем.

Tenant-isolation: query-filter работает прозрачно, B не видит джоб A.

Тесты: 3 интеграционных (survives across scope, RecentlyFinished
читает из БД, tenant-isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:45:08 +05:00

115 lines
4.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FluentAssertions;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence;
// Двусмысленность ImportJobStatus: тесты используют только in-process snapshot
// API (registry.Create/Save/Get), поэтому ссылаемся на MoySklad-namespace.
using ImportJobStatus = foodmarket.Infrastructure.Integrations.MoySklad.ImportJobStatus;
using foodmarket.IntegrationTests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>TD-5: ImportJob теперь persisted в БД. Тест проверяет, что
/// прогресс сохраняется через границу «рестарта реестра» (новый scope =
/// новая ConcurrentDictionary в старой версии) — но мы читаем из БД,
/// поэтому job остаётся видимым.</summary>
[Collection(ApiCollection.Name)]
public class ImportJobPersistenceTests
{
private readonly ApiFactory _factory;
public ImportJobPersistenceTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Created_job_survives_across_registry_instances()
{
// 1) Сигнин чтобы получить orgId, потом руками используем registry.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"impjob-{Guid.NewGuid():N}");
var orgId = await GetOrgIdAsync(api);
using var scope1 = _factory.Services.CreateScope();
var registry = _factory.Services.GetRequiredService<ImportJobRegistry>();
Guid jobId;
using (foodmarket.Api.Infrastructure.Tenancy.HttpContextTenantContext.UseOverride(orgId))
{
var job = registry.Create("products");
job.Stage = "Импорт страниц 3/10";
job.Total = 100;
job.Created = 30;
await registry.SaveAsync(job);
jobId = job.Id;
}
// 2) В новом scope (имитация после-рестарта) Get(id) тянет из БД,
// не из in-memory ConcurrentDictionary. State сохранён.
using (foodmarket.Api.Infrastructure.Tenancy.HttpContextTenantContext.UseOverride(orgId))
{
var reloaded = registry.Get(jobId);
reloaded.Should().NotBeNull();
reloaded!.Kind.Should().Be("products");
reloaded.Stage.Should().Be("Импорт страниц 3/10");
reloaded.Total.Should().Be(100);
reloaded.Created.Should().Be(30);
reloaded.Status.Should().Be(ImportJobStatus.Running);
}
}
[Fact]
public async Task RecentlyFinished_returns_completed_jobs_from_db()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"impjob-rf-{Guid.NewGuid():N}");
var orgId = await GetOrgIdAsync(api);
var registry = _factory.Services.GetRequiredService<ImportJobRegistry>();
using (foodmarket.Api.Infrastructure.Tenancy.HttpContextTenantContext.UseOverride(orgId))
{
var job = registry.Create("products");
job.Status = ImportJobStatus.Succeeded;
job.FinishedAt = DateTime.UtcNow;
job.Created = 5;
await registry.SaveAsync(job);
var finished = registry.RecentlyFinished(10);
finished.Should().Contain(j => j.Id == job.Id && j.Status == ImportJobStatus.Succeeded);
}
}
[Fact]
public async Task Tenant_isolation_for_import_jobs()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"impjob-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"impjob-iso-b-{Guid.NewGuid():N}");
var orgA = await GetOrgIdAsync(a);
var orgB = await GetOrgIdAsync(b);
var registry = _factory.Services.GetRequiredService<ImportJobRegistry>();
Guid jobIdA;
using (foodmarket.Api.Infrastructure.Tenancy.HttpContextTenantContext.UseOverride(orgA))
{
var job = registry.Create("products");
await registry.SaveAsync(job);
jobIdA = job.Id;
}
// B не видит джоб A через registry.Get (query-filter по OrganizationId).
using (foodmarket.Api.Infrastructure.Tenancy.HttpContextTenantContext.UseOverride(orgB))
{
registry.Get(jobIdA).Should().BeNull();
}
}
private static async Task<Guid> GetOrgIdAsync(ApiActor api)
{
var me = await api.GetJsonAsync("/api/me");
return Guid.Parse(me.GetProperty("orgId").GetString()!);
}
}