using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Content; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.IntegrationTests.Api; namespace Tiku.IntegrationTests.ContentV2; public sealed class ContentReleaseCompilerTests { [Fact] public async Task Publishing_new_revision_creates_new_release_without_mutating_old_delivery() { await using var factory = new ApiTestFactory(); var fixture = await SeedAsync(factory); using var scope = factory.CreateSystemScope("Compile immutable V2 releases"); var compiler = scope.ServiceProvider.GetRequiredService(); var authoring = scope.ServiceProvider.GetRequiredService(); var first = await compiler.PublishAsync(new PublishContentReleaseCommand( fixture.TenantId, fixture.ActorUserId, fixture.CurriculumVersionId, "Release 1")); var revised = await authoring.CreateRevisionAsync( new ContentAuthorActor(fixture.TenantId, fixture.ActorUserId), fixture.QuestionAssetId, Draft("2 + 2 = ?", 1), true); var second = await compiler.PublishAsync(new PublishContentReleaseCommand( fixture.TenantId, fixture.ActorUserId, fixture.CurriculumVersionId, "Release 2")); var db = scope.ServiceProvider.GetRequiredService(); var firstQuestion = await db.ContentReleaseQuestions.AsNoTracking() .SingleAsync(item => item.ContentReleaseId == first.ContentReleaseId); var secondQuestion = await db.ContentReleaseQuestions.AsNoTracking() .SingleAsync(item => item.ContentReleaseId == second.ContentReleaseId); Assert.Equal(1, first.ReleaseNo); Assert.Equal(2, second.ReleaseNo); Assert.Equal(fixture.QuestionRevisionId, firstQuestion.QuestionRevisionId); Assert.Equal(revised.QuestionRevisionId, secondQuestion.QuestionRevisionId); Assert.NotEqual(firstQuestion.QuestionRevisionId, secondQuestion.QuestionRevisionId); Assert.Equal(2, await db.LearningOutboxMessages.CountAsync(item => item.EventType == "content_release_published")); Assert.All(await db.ContentReleases.AsNoTracking().ToArrayAsync(), release => Assert.Equal(ContentReleaseStatus.Published, release.Status)); } private static async Task SeedAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var platformOwnerId = Guid.NewGuid(); var actorUserId = Guid.NewGuid(); var businessLineId = Guid.NewGuid(); var profileId = Guid.NewGuid(); var curriculumId = Guid.NewGuid(); var curriculumVersionId = Guid.NewGuid(); var curriculumNodeId = Guid.NewGuid(); var policyId = Guid.NewGuid(); var policyVersionId = Guid.NewGuid(); var questionAssetId = Guid.NewGuid(); var questionRevisionId = Guid.NewGuid(); var placementId = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; var draft = Draft("1 + 1 = ?", 0); var fingerprint = QuestionDeliveryFingerprint.Compute(ToFingerprintInput(draft)); await factory.SeedAsync( new Tenant { Id = platformOwnerId, Slug = $"platform-{platformOwnerId:N}", Name = "Platform owner", Mode = TenantMode.PlatformOwned }, new Tenant { Id = tenantId, Slug = $"tenant-{tenantId:N}", Name = "V2 compiler tenant" }, new BusinessLine { Id = businessLineId, Code = $"business-{businessLineId:N}", Name = "Extensible exam business" }, new ExamTargetProfile { Id = profileId, BusinessLineId = businessLineId, Code = $"profile-{profileId:N}", Name = "Profile" }, new Curriculum { Id = curriculumId, TenantId = tenantId, BusinessLineId = businessLineId, Code = $"curriculum-{curriculumId:N}", Name = "Curriculum" }, new AssessmentPolicy { Id = policyId, TenantId = tenantId, BusinessLineId = businessLineId, Code = $"policy-{policyId:N}", Name = "Policy" }, new QuestionAsset { Id = questionAssetId, TenantId = tenantId, DeliveryFingerprint = fingerprint, Status = QuestionAssetStatus.Published, CreatedBy = actorUserId, UpdatedBy = actorUserId }); await factory.SeedAsync( new ExamTargetProfileVersion { ExamTargetProfileId = profileId, DisplayName = "Profile 2027", Status = ContentDefinitionStatus.Published, PublishedAt = now }, new CurriculumVersion { Id = curriculumVersionId, TenantId = tenantId, CurriculumId = curriculumId, Name = "Curriculum 2027", Status = ContentDefinitionStatus.Published, PublishedAt = now }, new AssessmentPolicyVersion { Id = policyVersionId, TenantId = tenantId, AssessmentPolicyId = policyId, DefaultScore = 1, Status = ContentDefinitionStatus.Published, PublishedAt = now }, new QuestionRevision { Id = questionRevisionId, TenantId = tenantId, QuestionAssetId = questionAssetId, QuestionType = draft.QuestionType, NormalizedContent = QuestionDeliveryFingerprint.NormalizePrompt(draft.Content), Content = draft.Content, Options = draft.Options, CorrectOptionIndex = draft.CorrectOptionIndex, DeliveryFingerprint = fingerprint, CreatedBy = actorUserId }); await factory.SeedAsync( new CurriculumNode { Id = curriculumNodeId, TenantId = tenantId, CurriculumVersionId = curriculumVersionId, Code = "math", Name = "Math", Kind = CurriculumNodeKind.Subject }); await factory.SeedAsync( new QuestionPlacement { Id = placementId, TenantId = tenantId, QuestionAssetOwnerTenantId = tenantId, QuestionAssetId = questionAssetId, CurriculumVersionId = curriculumVersionId, CurriculumNodeId = curriculumNodeId, AssessmentPolicyVersionId = policyVersionId, Status = QuestionPlacementStatus.Active, CreatedBy = actorUserId, UpdatedBy = actorUserId }); using (var scope = factory.CreateSystemScope("Set current immutable revision")) { var db = scope.ServiceProvider.GetRequiredService(); await db.QuestionAssets.Where(item => item.Id == questionAssetId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.CurrentRevisionId, questionRevisionId)); } return new CompilerFixture( tenantId, actorUserId, curriculumVersionId, questionAssetId, questionRevisionId); } private static QuestionRevisionDraft Draft(string content, int correctOptionIndex) => new( "choice", null, 2, content, JsonSerializer.SerializeToElement(new[] { "2", "4" }), correctOptionIndex, JsonSerializer.SerializeToElement(Array.Empty()), null, "Explanation", JsonSerializer.SerializeToElement(Array.Empty()), null, null); private static QuestionDeliveryFingerprintInput ToFingerprintInput(QuestionRevisionDraft draft) => new( draft.QuestionType, draft.Content, draft.Options, draft.CorrectOptionIndex, draft.CorrectOptionIndices, draft.AnswerText, draft.SubQuestions, draft.CodeLang, draft.CodeTemplate); private sealed record CompilerFixture( Guid TenantId, Guid ActorUserId, Guid CurriculumVersionId, Guid QuestionAssetId, Guid QuestionRevisionId); }