Files
tiku-backend.net/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

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