174 lines
8.6 KiB
C#
174 lines
8.6 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Jobs;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class P0OperationsLifecycleTests
|
|
{
|
|
[Fact]
|
|
public async Task Job_idempotency_cancel_retry_and_tenant_scope_follow_the_reviewed_state_machine()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var tenantA = await SeedTenantAsync(factory);
|
|
var tenantB = await SeedTenantAsync(factory);
|
|
using var scope = factory.CreateSystemScope("Verify P0 job state machine");
|
|
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
|
var command = new CreateBackgroundJobCommand(
|
|
tenantA.TenantId,
|
|
"asset_security_scan",
|
|
JsonSerializer.SerializeToElement(new { assetId = Guid.NewGuid() }),
|
|
IdempotencyKey: "same-request",
|
|
IsSystemJob: true);
|
|
|
|
var first = await jobs.EnqueueAsync(command);
|
|
var duplicate = await jobs.EnqueueAsync(command);
|
|
Assert.Equal(first.Id, duplicate.Id);
|
|
Assert.Null(await jobs.GetAsync(first.Id, tenantB.TenantId));
|
|
|
|
var cancelled = await jobs.RequestCancellationAsync(
|
|
first.Id, tenantA.TenantId, tenantA.UserId, "No longer required");
|
|
Assert.Equal(BackgroundJobStatus.Cancelled, cancelled.Status);
|
|
Assert.NotNull(cancelled.CancellationRequestedAt);
|
|
var retried = await jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId);
|
|
Assert.Equal(BackgroundJobStatus.Pending, retried.Status);
|
|
Assert.Null(retried.CancellationRequestedAt);
|
|
await Assert.ThrowsAsync<BackgroundJobException>(() =>
|
|
jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId));
|
|
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
await dbContext.BackgroundJobs.Where(item => item.Id == first.Id)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.Status, BackgroundJobStatus.Processing)
|
|
.SetProperty(item => item.LockedBy, "active-worker")
|
|
.SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.AddMinutes(5)));
|
|
var cooperative = await jobs.RequestCancellationAsync(
|
|
first.Id, tenantA.TenantId, tenantA.UserId, "Stop at the next cooperative boundary");
|
|
Assert.Equal(BackgroundJobStatus.Processing, cooperative.Status);
|
|
Assert.NotNull(cooperative.CancellationRequestedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_export_archive_restore_and_owner_transfer_preserve_lifecycle_invariants()
|
|
{
|
|
await using var factory = new ApiTestFactory(
|
|
configurationOverrides: new Dictionary<string, string?>
|
|
{
|
|
["Storage:DefaultProvider"] = "local_dev",
|
|
["Storage:DefaultBucket"] = "tenant-assets"
|
|
});
|
|
var seed = await SeedTenantAsync(factory, true);
|
|
Guid exportOperationId;
|
|
using (var scope = factory.CreateSystemScope("Create tenant export operation"))
|
|
{
|
|
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
|
|
var blocked = await lifecycle.PreviewArchiveAsync(seed.TenantId);
|
|
Assert.False(blocked.CanArchive);
|
|
Assert.Contains("recent_successful_export_required", blocked.Blockers);
|
|
exportOperationId = (await lifecycle.CreateExportAsync(seed.TenantId, seed.UserId)).Id;
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Process tenant export operation"))
|
|
{
|
|
var processed = await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
|
.ProcessPendingAsync("tenant-export-test", 10);
|
|
Assert.Equal(1, processed);
|
|
}
|
|
|
|
using (var scope = factory.CreateSystemScope("Archive restore and transfer tenant"))
|
|
{
|
|
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
|
|
var export = await lifecycle.GetOperationAsync(seed.TenantId, exportOperationId);
|
|
var exportJob = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().BackgroundJobs
|
|
.AsNoTracking()
|
|
.SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "tenant_export");
|
|
Assert.True(
|
|
export!.Status == TenantLifecycleOperationStatus.Succeeded,
|
|
$"Export status={export.Status}, operationError={export.LastError}, jobStatus={exportJob.Status}, jobError={exportJob.LastError}");
|
|
Assert.NotNull(export.ExportAssetId);
|
|
Assert.True((await lifecycle.PreviewArchiveAsync(seed.TenantId)).CanArchive);
|
|
await lifecycle.ArchiveAsync(seed.TenantId, seed.UserId, "Contract ended");
|
|
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.Equal(TenantStatus.Archived,
|
|
await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status)
|
|
.SingleAsync());
|
|
Assert.All(
|
|
await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status)
|
|
.ToArrayAsync(),
|
|
status => Assert.Equal(TenantDomainStatus.Disabled, status));
|
|
|
|
await lifecycle.RestoreAsync(seed.TenantId, seed.UserId, "Customer returned");
|
|
Assert.Equal(TenantStatus.Suspended,
|
|
await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status)
|
|
.SingleAsync());
|
|
Assert.All(
|
|
await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status)
|
|
.ToArrayAsync(),
|
|
status => Assert.Equal(TenantDomainStatus.Pending, status));
|
|
|
|
await lifecycle.TransferOwnerAsync(seed.TenantId, seed.UserId, seed.SecondUserId!.Value,
|
|
"Ownership handover");
|
|
Assert.Equal(seed.SecondUserId,
|
|
await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.OwnerUserId)
|
|
.SingleAsync());
|
|
Assert.Equal(TenantRole.TenantAdmin,
|
|
await dbContext.TenantMemberships
|
|
.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId)
|
|
.Select(item => item.Role).SingleAsync());
|
|
Assert.Equal(TenantRole.TenantOwner,
|
|
await dbContext.TenantMemberships
|
|
.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.SecondUserId)
|
|
.Select(item => item.Role).SingleAsync());
|
|
}
|
|
}
|
|
|
|
private static async Task<TenantSeed> SeedTenantAsync(ApiTestFactory factory, bool includeSecondMember = false)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var secondUserId = includeSecondMember ? Guid.NewGuid() : (Guid?)null;
|
|
var entities = new List<object>
|
|
{
|
|
new Tenant
|
|
{
|
|
Id = tenantId, Slug = tenantId.ToString("N"), Name = "Lifecycle tenant", OwnerUserId = userId
|
|
},
|
|
new User { Id = userId, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Owner" }
|
|
.WithTestPassword(),
|
|
new TenantMembership
|
|
{
|
|
TenantId = tenantId, UserId = userId, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active
|
|
},
|
|
new TenantDomain
|
|
{
|
|
TenantId = tenantId, Host = $"{tenantId:N}.example.test", Status = TenantDomainStatus.Active,
|
|
IsPrimary = true
|
|
}
|
|
};
|
|
if (secondUserId.HasValue)
|
|
{
|
|
entities.Add(new User
|
|
{
|
|
Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
|
|
Name = "Next owner"
|
|
}.WithTestPassword());
|
|
entities.Add(new TenantMembership
|
|
{
|
|
TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
}
|
|
|
|
await factory.SeedAsync(entities.ToArray());
|
|
return new TenantSeed(tenantId, userId, secondUserId);
|
|
}
|
|
|
|
private sealed record TenantSeed(Guid TenantId, Guid UserId, Guid? SecondUserId);
|
|
} |