Раньше прогресс фоновых импортов жил в 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>
115 lines
4.8 KiB
C#
115 lines
4.8 KiB
C#
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()!);
|
||
}
|
||
}
|