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(); 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(() => jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId)); var dbContext = scope.ServiceProvider.GetRequiredService(); 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 { ["Storage:DefaultProvider"] = "local_dev", ["Storage:DefaultBucket"] = "tenant-assets" }); var seed = await SeedTenantAsync(factory, includeSecondMember: true); Guid exportOperationId; using (var scope = factory.CreateSystemScope("Create tenant export operation")) { var lifecycle = scope.ServiceProvider.GetRequiredService(); 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() .ProcessPendingAsync("tenant-export-test", 10); Assert.Equal(1, processed); } using (var scope = factory.CreateSystemScope("Archive restore and transfer tenant")) { var lifecycle = scope.ServiceProvider.GetRequiredService(); var export = await lifecycle.GetOperationAsync(seed.TenantId, exportOperationId); var exportJob = await scope.ServiceProvider.GetRequiredService().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(); 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 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 { 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); }