using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Learning; using Tiku.Domain.Common; using Tiku.Domain.Commerce; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class LearningEndpointTests { [Fact] public async Task V2_session_creation_reserves_manifest_quota_atomically() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 1); using var client = factory.CreateClient(); await LoginAsync(client, seed); Task CreateAsync() => client.PostAsJsonAsync( "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { ResourceType = AccessResourceType.ContentRelease, ResourceId = releaseId }); var responses = await Task.WhenAll(CreateAsync(), CreateAsync()); Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK); var denied = Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Forbidden); Assert.Equal("practice_total_quota_exhausted", await ReadProblemCodeAsync(denied)); using var scope = factory.CreateSystemScope("Verify atomic V2 practice quota"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await db.PracticeSessions.CountAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId && item.ResourceId == releaseId)); Assert.Equal(1, await db.StudentEntitlementsV2 .Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId) .Select(item => item.UsedQuestionCount) .SingleAsync()); var usage = await db.PracticeDailyUsages.SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId && item.ScopeType == PracticeUsageScopeType.ProductManifest); Assert.Equal(1, usage.UsedCount); Assert.Equal(1, usage.FreeLimit); } [Fact] public async Task V2_session_delivers_prompt_grades_from_snapshot_and_reveals_solution() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var catalogResponse = await client.GetAsync( $"/api/student/catalog/resources?businessLineId={seed.BusinessLineId}"); var catalog = await ReadJsonAsync(catalogResponse); var catalogResource = Assert.Single(catalog.RootElement.GetProperty("resources").EnumerateArray().ToArray()); Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode); Assert.Equal(releaseId, catalogResource.GetProperty("resourceId").GetGuid()); Assert.Equal("V2 release practice", catalogResource.GetProperty("displayName").GetString()); using var createResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { Mode = "chapter", ResourceType = AccessResourceType.ContentRelease, ResourceId = releaseId }); var created = await ReadJsonAsync(createResponse); var sessionId = created.RootElement.GetProperty("id").GetGuid(); using var detailResponse = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}"); var detail = await ReadJsonAsync(detailResponse); var question = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray()); var sessionQuestionId = question.GetProperty("sessionQuestionId").GetGuid(); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); Assert.Equal("1 + 1 = ?", question.GetProperty("content").GetString()); Assert.NotEqual(Guid.Empty, question.GetProperty("questionAssetId").GetGuid()); Assert.Empty(detail.RootElement.GetProperty("solutionsBySessionQuestion").EnumerateObject()); var answerRequest = new SubmitAnswerDto { SessionQuestionId = sessionQuestionId, ClientSequence = 1, IdempotencyKey = "v2-answer-1", SelectedOptionIndices = [0] }; using var answerResponse = await client.PostAsJsonAsync("/api/student/learning/answers", answerRequest); using var replayResponse = await client.PostAsJsonAsync("/api/student/learning/answers", answerRequest); var answer = await ReadJsonAsync(answerResponse); var replay = await ReadJsonAsync(replayResponse); Assert.Equal(HttpStatusCode.OK, answerResponse.StatusCode); Assert.Equal(answer.RootElement.GetProperty("id").GetGuid(), replay.RootElement.GetProperty("id").GetGuid()); Assert.Equal(0, answer.RootElement.GetProperty("solution").GetProperty("correctOptionIndex").GetInt32()); using var submitResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = sessionId, IdempotencyKey = "v2-submit-1" }); var report = await ReadJsonAsync(submitResponse); Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode); Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32()); Assert.Equal(1m, report.RootElement.GetProperty("score").GetDecimal()); using var scope = factory.CreateSystemScope("Verify V2 immutable answer replay"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await db.AnswerRecords.CountAsync(item => item.PracticeSessionId == sessionId)); var locked = await db.PracticeSessionQuestions.SingleAsync(item => item.PracticeSessionId == sessionId); Assert.Equal(2, locked.SnapshotVersion); Assert.NotEqual(Guid.Empty, locked.QuestionRevisionId); Assert.NotEqual(Guid.Empty, locked.QuestionPlacementId); Assert.NotEqual(Guid.Empty, locked.AssessmentPolicyVersionId); await Assert.ThrowsAnyAsync(() => db.QuestionRevisions .Where(item => item.Id == locked.QuestionRevisionId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Explanation, "tampered"))); await Assert.ThrowsAnyAsync(() => db.ContentReleaseQuestions .Where(item => item.Id == locked.ContentReleaseQuestionId) .ExecuteUpdateAsync(setters => setters.SetProperty( item => item.Status, ContentReleaseQuestionStatus.Retired))); var manifestVersionId = await db.StudentEntitlementsV2 .Where(item => item.UserId == seed.UserId) .Select(item => item.ProductAccessManifestVersionId) .SingleAsync(); db.ChangeTracker.Clear(); db.ProductManifestResources.Add(new ProductManifestResource { TenantId = seed.TenantId, ProductAccessManifestVersionId = manifestVersionId, ResourceType = AccessResourceType.ContentRelease, ResourceId = Guid.NewGuid(), DisplayName = "forbidden late child" }); await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); } [Fact] public async Task V2_class_assignment_is_license_bounded_versioned_and_auditable() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5); var classId = Guid.NewGuid(); await factory.SeedAsync( new TenantClass { Id = classId, TenantId = seed.TenantId, Code = "v2-class", Name = "V2 class" }, new TenantClassMember { TenantId = seed.TenantId, ClassId = classId, UserId = seed.UserId, MemberType = TenantClassMemberType.Student, Status = TenantClassMemberStatus.Active }); Guid manifestVersionId; using (var verification = factory.CreateSystemScope("Resolve V2 manifest fixture")) { var db = verification.ServiceProvider.GetRequiredService(); manifestVersionId = await db.ProductManifestResources.AsNoTracking() .Where(item => item.TenantId == seed.TenantId && item.ResourceType == AccessResourceType.ContentRelease && item.ResourceId == releaseId) .Select(item => item.ProductAccessManifestVersionId) .SingleAsync(); } ClassAssignmentGrantItem created; using (var scope = factory.CreateTenantScope(seed.TenantId, seed.TenantId.ToString("N"))) { var service = scope.ServiceProvider.GetRequiredService(); created = await service.UpsertClassGrantAsync( seed.TenantId, seed.UserId, new UpsertClassAssignmentGrantCommand( null, classId, manifestVersionId, AccessResourceType.ContentRelease, releaseId, DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(7))); } Assert.Equal(ClassAssignmentGrantStatus.Active, created.Status); Assert.Equal(seed.UserId, created.CreatedBy); ClassAssignmentGrantItem revoked; using (var scope = factory.CreateTenantScope(seed.TenantId, seed.TenantId.ToString("N"))) { revoked = await scope.ServiceProvider.GetRequiredService() .RevokeClassGrantAsync( seed.TenantId, seed.UserId, classId, created.Id, "课程调整"); } Assert.Equal(ClassAssignmentGrantStatus.Revoked, revoked.Status); Assert.Equal(seed.UserId, revoked.RevokedBy); Assert.Equal("课程调整", revoked.RevokedReason); using var finalVerification = factory.CreateSystemScope("Verify V2 class grant version bump"); var finalDb = finalVerification.ServiceProvider.GetRequiredService(); var accessVersion = await finalDb.LearningAccessVersions.AsNoTracking().SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId); Assert.True(accessVersion.GrantVersion >= 3); } [Fact] public async Task Learning_endpoints_require_current_tenant_member() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/api/student/learning/favorites/questions"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Submit_answer_records_wrong_question_when_self_judged_wrong() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5); using var client = factory.CreateClient(); await LoginAsync(client, seed); var answerable = await CreateV2SessionAsync(client, releaseId); using var response = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "wrong-answer-1", SelectedOptionIndices = [1] }); using var submitResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, IdempotencyKey = "wrong-submit-1" }); using var beforeProjectionResponse = await client.GetAsync("/api/student/learning/wrong-questions"); Assert.Empty(await ReadItemsAsync(beforeProjectionResponse)); Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "wrong-question-worker")); using var wrongResponse = await client.GetAsync("/api/student/learning/wrong-questions"); var answer = await ReadJsonAsync(response); var wrongItems = await ReadItemsAsync(wrongResponse); Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync()); Assert.Equal(answerable.SessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid()); Assert.False(answer.RootElement.TryGetProperty("isCorrect", out _)); Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode); var wrong = Assert.Single(wrongItems); Assert.Equal(answerable.QuestionAssetId, wrong.GetProperty("questionAssetId").GetGuid()); Assert.NotEqual(Guid.Empty, wrong.GetProperty("lastQuestionRevisionId").GetGuid()); Assert.NotEqual(Guid.Empty, wrong.GetProperty("lastQuestionPlacementId").GetGuid()); Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32()); using (var scope = factory.CreateSystemScope("Replay projected learning event")) { await scope.ServiceProvider.GetRequiredService().LearningOutboxMessages .Where(item => item.TenantId == seed.TenantId && item.EventType == "practice_session_submitted") .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.ProcessedAt, (DateTimeOffset?)null) .SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow)); } Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "wrong-question-replay-worker")); using var replayedWrongResponse = await client.GetAsync("/api/student/learning/wrong-questions"); var replayedWrong = Assert.Single(await ReadItemsAsync(replayedWrongResponse)); Assert.Equal(1, replayedWrong.GetProperty("wrongCount").GetInt32()); } [Fact] public async Task Wrong_question_projection_is_order_independent() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5); using var client = factory.CreateClient(); await LoginAsync(client, seed); var first = await CreateV2SessionAsync(client, releaseId); var second = await CreateV2SessionAsync(client, releaseId); await SubmitWrongSessionAsync(client, first.SessionQuestionId, first.SessionId, "ordered-first"); await SubmitWrongSessionAsync(client, second.SessionQuestionId, second.SessionId, "ordered-second"); DateTimeOffset latestSubmittedAt; using (var scope = factory.CreateSystemScope("Force reverse learning event delivery")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var reports = await dbContext.PracticeSessionReports.AsNoTracking() .Where(item => item.PracticeSessionId == first.SessionId || item.PracticeSessionId == second.SessionId) .ToArrayAsync(); var firstReport = reports.Single(item => item.PracticeSessionId == first.SessionId); latestSubmittedAt = reports.Max(item => item.SubmittedAt); await dbContext.LearningOutboxMessages .Where(item => item.AggregateId == firstReport.Id) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow.AddHours(1))); } Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "reverse-newer-first")); using (var scope = factory.CreateSystemScope("Release older learning event")) { var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.LearningOutboxMessages .Where(item => item.ProcessedAt == null) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow)); } Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "reverse-older-second")); using (var scope = factory.CreateSystemScope("Verify order-independent wrong question projection")) { var wrong = await scope.ServiceProvider.GetRequiredService().WrongQuestions .SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId); Assert.Equal(first.QuestionAssetId, wrong.QuestionAssetId); Assert.Equal(2, wrong.WrongCount); Assert.Equal(latestSubmittedAt, wrong.LastWrongAt); } } [Fact] public async Task Failed_learning_outbox_message_is_released_for_retry() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var messageId = Guid.NewGuid(); await factory.SeedAsync(new LearningOutboxMessage { Id = messageId, TenantId = seed.TenantId, EventType = "unsupported_learning_event", AggregateId = Guid.NewGuid(), OccurredAt = DateTimeOffset.UtcNow, AvailableAt = DateTimeOffset.UtcNow }); Assert.Equal(0, await ProcessLearningOutboxAsync(factory, "poison-learning-event")); using var scope = factory.CreateSystemScope("Verify learning outbox retry state"); var stored = await scope.ServiceProvider.GetRequiredService().LearningOutboxMessages .SingleAsync(item => item.Id == messageId); Assert.Equal(1, stored.AttemptCount); Assert.Null(stored.ProcessedAt); Assert.Null(stored.DeadLetteredAt); Assert.Null(stored.LockedBy); Assert.Null(stored.LockExpiresAt); Assert.NotNull(stored.LastError); Assert.True(stored.AvailableAt > stored.OccurredAt); } [Fact(Skip = "V1 question collections were retired by the Content Domain V2 cutover.")] public async Task Practice_session_can_be_created_detailed_submitted_and_listed() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var collectionId = Guid.NewGuid(); var firstQuestionId = Guid.NewGuid(); var secondQuestionId = Guid.NewGuid(); var firstVersionId = Guid.NewGuid(); var secondVersionId = Guid.NewGuid(); await factory.SeedQuestionWithVersionAsync( new Question { Id = firstQuestionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new QuestionVersion { Id = firstVersionId, TenantId = seed.TenantId, QuestionId = firstQuestionId, VersionNo = 1, Content = "first", CorrectOptionIndex = 0 }); await factory.SeedQuestionWithVersionAsync( new Question { Id = secondQuestionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new QuestionVersion { Id = secondVersionId, TenantId = seed.TenantId, QuestionId = secondQuestionId, VersionNo = 1, Content = "second", CorrectOptionIndex = 0 }); var firstReferenceId = Guid.NewGuid(); var secondReferenceId = Guid.NewGuid(); await factory.SeedAsync( new TenantQuestionReference { Id = firstReferenceId, TenantId = seed.TenantId, QuestionOwnerTenantId = seed.TenantId, QuestionId = firstQuestionId, Source = QuestionSource.Tenant }, new TenantQuestionReference { Id = secondReferenceId, TenantId = seed.TenantId, QuestionOwnerTenantId = seed.TenantId, QuestionId = secondQuestionId, Source = QuestionSource.Tenant }, new QuestionCollection { Id = collectionId, TenantId = seed.TenantId, Name = "基础练习", Status = ContentStatus.Active }, new QuestionCollectionItem { Id = Guid.NewGuid(), TenantId = seed.TenantId, CollectionId = collectionId, QuestionReferenceId = firstReferenceId, QuestionOwnerTenantId = seed.TenantId, QuestionId = firstQuestionId, SortOrder = 1 }, new QuestionCollectionItem { Id = Guid.NewGuid(), TenantId = seed.TenantId, CollectionId = collectionId, QuestionReferenceId = secondReferenceId, QuestionOwnerTenantId = seed.TenantId, QuestionId = secondQuestionId, SortOrder = 2 }); await SeedCollectionAccessAsync(factory, seed, collectionId); using var client = factory.CreateClient(); await LoginAsync(client, seed); var createResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { Mode = "chapter", ResourceType = AccessResourceType.CollectionRelease, ResourceId = collectionId }); var created = await ReadJsonAsync(createResponse); var practiceSessionId = created.RootElement.GetProperty("id").GetGuid(); var detailResponse = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); var detail = await ReadJsonAsync(detailResponse); var detailQuestions = detail.RootElement.GetProperty("questions").EnumerateArray().ToArray(); var firstSessionQuestionId = detailQuestions.Single(item => item.GetProperty("questionId").GetGuid() == firstQuestionId).GetProperty("sessionQuestionId").GetGuid(); var secondSessionQuestionId = detailQuestions.Single(item => item.GetProperty("questionId").GetGuid() == secondQuestionId).GetProperty("sessionQuestionId").GetGuid(); var firstAnswerResponse = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = firstSessionQuestionId, ClientSequence = 1, IdempotencyKey = "practice-first-answer", SelectedOptionIndices = [0] }); var secondAnswerResponse = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = secondSessionQuestionId, ClientSequence = 2, IdempotencyKey = "practice-second-answer", SelectedOptionIndices = [1] }); var submitResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId, IdempotencyKey = "practice-submit" }); var report = await ReadJsonAsync(submitResponse); var reportResponse = await client.GetAsync( $"/api/student/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); var reportsResponse = await client.GetAsync("/api/student/learning/practice-reports"); var historyResponse = await client.GetAsync("/api/student/learning/practice-sessions/history?status=finished"); var reports = await ReadItemsAsync(reportsResponse); var history = await ReadItemsAsync(historyResponse); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); Assert.Equal(2, created.RootElement.GetProperty("questionCount").GetInt32()); Assert.Equal("Active", created.RootElement.GetProperty("status").GetString()); Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); Assert.Equal(2, detail.RootElement.GetProperty("questions").GetArrayLength()); Assert.True(firstAnswerResponse.IsSuccessStatusCode, await firstAnswerResponse.Content.ReadAsStringAsync()); Assert.True(secondAnswerResponse.IsSuccessStatusCode, await secondAnswerResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode); Assert.Equal(2, report.RootElement.GetProperty("totalQuestions").GetInt32()); Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32()); Assert.Equal(1, report.RootElement.GetProperty("wrongCount").GetInt32()); Assert.Equal(50m, report.RootElement.GetProperty("score").GetDecimal()); Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode); Assert.Single(reports); var historyItem = Assert.Single(history); Assert.Equal(practiceSessionId, historyItem.GetProperty("id").GetGuid()); Assert.Equal("finished", historyItem.GetProperty("status").GetString()); } [Fact] public async Task Favorite_question_can_be_added_listed_and_removed() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var createResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { ResourceType = AccessResourceType.ContentRelease, ResourceId = releaseId }); var created = await ReadJsonAsync(createResponse); var sessionId = created.RootElement.GetProperty("id").GetGuid(); using var detailResponse = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}"); var detail = await ReadJsonAsync(detailResponse); var delivered = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray()); var sessionQuestionId = delivered.GetProperty("sessionQuestionId").GetGuid(); var questionAssetId = delivered.GetProperty("questionAssetId").GetGuid(); var addResponse = await client.PostAsJsonAsync( "/api/student/learning/favorites/questions", new QuestionActionDto { SessionQuestionId = sessionQuestionId }); var listResponse = await client.GetAsync("/api/student/learning/favorites/questions"); var itemsAfterAdd = await ReadItemsAsync(listResponse); var removeResponse = await client.PostAsJsonAsync( "/api/student/learning/favorites/questions", new QuestionActionDto { SessionQuestionId = sessionQuestionId, Favorite = false }); var emptyResponse = await client.GetAsync("/api/student/learning/favorites/questions"); var itemsAfterRemove = await ReadItemsAsync(emptyResponse); Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal( questionAssetId, Assert.Single(itemsAfterAdd).GetProperty("questionAssetId").GetGuid()); Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode); Assert.Empty(itemsAfterRemove); } [Fact] public async Task Word_progress_upserts_and_filters_by_status() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var wordId = Guid.NewGuid(); await factory.SeedAsync(new VocabularyWord { Id = wordId, TenantId = seed.TenantId, Word = "composition", IsActive = true }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var updateResponse = await client.PostAsJsonAsync( "/api/student/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, Status = "learning", CorrectDelta = 2 }); using var listResponse = await client.GetAsync("/api/student/learning/vocabulary/progress?status=learning"); var progress = await ReadJsonAsync(updateResponse); var items = await ReadItemsAsync(listResponse); Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); Assert.Equal(2, progress.RootElement.GetProperty("correctCount").GetInt32()); var item = Assert.Single(items); Assert.Equal(wordId, item.GetProperty("wordId").GetGuid()); Assert.Equal("Learning", item.GetProperty("status").GetString()); } [Fact] public async Task Favorite_words_can_be_filtered_by_unit() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var unitId = Guid.NewGuid(); var otherUnitId = Guid.NewGuid(); var wordId = Guid.NewGuid(); var otherWordId = Guid.NewGuid(); await factory.SeedAsync( new VocabularyUnit { Id = unitId, TenantId = seed.TenantId, Name = "Unit 1", IsActive = true }, new VocabularyUnit { Id = otherUnitId, TenantId = seed.TenantId, Name = "Unit 2", IsActive = true }, new VocabularyWord { Id = wordId, TenantId = seed.TenantId, UnitId = unitId, Word = "design", IsActive = true }, new VocabularyWord { Id = otherWordId, TenantId = seed.TenantId, UnitId = otherUnitId, Word = "sketch", IsActive = true }); using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( "/api/student/learning/vocabulary/favorites", new FavoriteWordDto { WordId = wordId, Note = "重点" }); await client.PostAsJsonAsync( "/api/student/learning/vocabulary/favorites", new FavoriteWordDto { WordId = otherWordId }); using var response = await client.GetAsync($"/api/student/learning/vocabulary/favorites?unitId={unitId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var item = Assert.Single(items); Assert.Equal(wordId, item.GetProperty("wordId").GetGuid()); Assert.Equal("重点", item.GetProperty("note").GetString()); } [Fact] public async Task Learning_stats_plans_leaderboard_and_word_review_are_available() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var questionId = Guid.NewGuid(); var wordId = Guid.NewGuid(); var answerable = await SeedAnswerableQuestionAsync(factory, seed, questionId); await factory.SeedAsync( new VocabularyWord { Id = wordId, TenantId = seed.TenantId, Word = "review", IsActive = true }); using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "stats-wrong-answer", SelectedOptionIndices = [1] }); await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, IdempotencyKey = "stats-wrong-submit" }); Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "learning-stats-worker")); await client.PostAsJsonAsync( "/api/student/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, Status = "learning", WrongDelta = 1, NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); var statsResponse = await client.GetAsync("/api/student/learning/stats"); var trendResponse = await client.GetAsync("/api/student/learning/trend?limit=7"); var leaderboardResponse = await client.GetAsync("/api/student/learning/leaderboard?limit=10"); var wrongPlanResponse = await client.GetAsync("/api/student/learning/wrong-questions/review-plan"); var wordPlanResponse = await client.GetAsync("/api/student/learning/vocabulary/review-plan"); var reviewResponse = await client.PostAsJsonAsync( "/api/student/learning/vocabulary/review", new WordReviewDto { WordId = wordId, Result = "correct" }); var wordStatsResponse = await client.GetAsync("/api/student/learning/vocabulary/stats"); var stats = await ReadJsonAsync(statsResponse); var trend = await ReadItemsAsync(trendResponse); var leaderboard = await ReadJsonAsync(leaderboardResponse); var wrongPlan = await ReadJsonAsync(wrongPlanResponse); var wordPlan = await ReadJsonAsync(wordPlanResponse); var review = await ReadJsonAsync(reviewResponse); var wordStats = await ReadJsonAsync(wordStatsResponse); Assert.Equal(HttpStatusCode.OK, statsResponse.StatusCode); Assert.Equal(1, stats.RootElement.GetProperty("answerCount").GetInt32()); Assert.Equal(1, stats.RootElement.GetProperty("wrongCount").GetInt32()); Assert.NotEmpty(trend); Assert.Equal(seed.UserId, Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId") .GetGuid()); Assert.Single(wrongPlan.RootElement.GetProperty("items").EnumerateArray()); Assert.Single(wordPlan.RootElement.GetProperty("items").EnumerateArray()); Assert.Equal(1, review.RootElement.GetProperty("correctCount").GetInt32()); Assert.Equal(1, wordStats.RootElement.GetProperty("total").GetInt32()); } [Fact] public async Task Answers_are_idempotent_versioned_and_keep_revision_history() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid()); using var client = factory.CreateClient(); await LoginAsync(client, seed); var firstRequest = new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "revision-answer-1", SelectedOptionIndices = [1] }; var first = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest); var replay = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = " revision-answer-1 ", SelectedOptionIndices = [1] }); var conflict = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "revision-answer-1", SelectedOptionIndices = [0] }); var revision = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 2, IdempotencyKey = "revision-answer-2", SelectedOptionIndices = [0] }); var stale = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 2, IdempotencyKey = "revision-answer-stale", SelectedOptionIndices = [0] }); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, replay.StatusCode); Assert.Equal(await first.Content.ReadAsStringAsync(), await replay.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict)); Assert.Equal(HttpStatusCode.OK, revision.StatusCode); Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode); Assert.Equal("practice_client_sequence_conflict", await ReadProblemCodeAsync(stale)); using var scope = factory.CreateSystemScope("Verify immutable answer revisions"); var db = scope.ServiceProvider.GetRequiredService(); var records = await db.AnswerRecords .Where(item => item.PracticeSessionId == answerable.SessionId) .OrderBy(item => item.Revision) .ToArrayAsync(); Assert.Equal(2, records.Length); Assert.Equal(2, records.Select(item => item.IdempotencyKey).Distinct().Count()); var current = await db.CurrentAnswers.SingleAsync(item => item.PracticeSessionId == answerable.SessionId && item.SessionQuestionId == answerable.SessionQuestionId); Assert.Equal(records[1].Id, current.AnswerRecordId); Assert.Equal(2, current.Revision); Assert.Equal(AnswerGradingStatus.Correct, records[1].GradingStatus); } [Fact] public async Task Session_detail_uses_locked_immutable_delivery_version() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid()); using var client = factory.CreateClient(); await LoginAsync(client, seed); var before = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); using (var scope = factory.CreateSystemScope("Publish a new question version after session creation")) { var db = scope.ServiceProvider.GetRequiredService(); var version = await db.QuestionVersions.SingleAsync(item => item.Id == answerable.VersionId); var next = new QuestionVersion { TenantId = version.TenantId, QuestionId = version.QuestionId, VersionNo = version.VersionNo + 1, QuestionType = version.QuestionType, Content = "new delivery content", Explanation = "must remain hidden", CorrectOptionIndex = 1 }; db.QuestionVersions.Add(next); var question = await db.Questions.SingleAsync(item => item.TenantId == version.TenantId && item.Id == version.QuestionId); question.CurrentVersionId = next.Id; await db.SaveChangesAsync(); } var after = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); var beforeQuestion = (await ReadJsonAsync(before)).RootElement.GetProperty("questions")[0]; var afterQuestion = (await ReadJsonAsync(after)).RootElement.GetProperty("questions")[0]; Assert.Equal("answerable question", beforeQuestion.GetProperty("content").GetString()); Assert.Equal("answerable question", afterQuestion.GetProperty("content").GetString()); Assert.False(beforeQuestion.TryGetProperty("explanation", out _)); Assert.False(afterQuestion.TryGetProperty("correctOptionIndex", out _)); } [Fact] public async Task Subjective_answer_produces_non_final_pending_review_report() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid(), "short_answer"); using var client = factory.CreateClient(); await LoginAsync(client, seed); var answer = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "subjective-answer", AnswerText = "student response" }); var submit = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, IdempotencyKey = "subjective-submit" }); var answerBody = await ReadJsonAsync(answer); var report = await ReadJsonAsync(submit); var result = report.RootElement.GetProperty("questionResults")[0]; Assert.Equal(HttpStatusCode.OK, answer.StatusCode); Assert.Equal("pending_review", answerBody.RootElement.GetProperty("status").GetString()); Assert.Equal(HttpStatusCode.OK, submit.StatusCode); Assert.Equal("PendingReview", report.RootElement.GetProperty("status").GetString()); Assert.False(report.RootElement.GetProperty("isFinal").GetBoolean()); Assert.Equal(1, report.RootElement.GetProperty("pendingReviewCount").GetInt32()); Assert.Equal(JsonValueKind.Null, result.GetProperty("isCorrect").ValueKind); Assert.Equal(JsonValueKind.Null, result.GetProperty("explanation").ValueKind); } [Fact] public async Task Concurrent_submission_creates_only_one_authoritative_report() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid()); using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "concurrent-answer", SelectedOptionIndices = [0] }); var submissions = await Task.WhenAll( client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, IdempotencyKey = "concurrent-submit-a" }), client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, IdempotencyKey = "concurrent-submit-b" })); Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.OK); Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.Conflict); using var scope = factory.CreateSystemScope("Verify unique authoritative report"); Assert.Equal(1, await scope.ServiceProvider.GetRequiredService().PracticeSessionReports .CountAsync(item => item.PracticeSessionId == answerable.SessionId)); } [Fact] public async Task Concurrent_devices_cannot_silently_overwrite_an_answer() { await using var factory = new ApiTestFactory(); var seed = await SeedLearningUserAsync(factory); var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid()); using var client = factory.CreateClient(); await LoginAsync(client, seed); var responses = await Task.WhenAll( client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 1, IdempotencyKey = "device-a-answer", SelectedOptionIndices = [0] }), client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, ClientSequence = 2, IdempotencyKey = "device-b-answer", SelectedOptionIndices = [1] })); Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK); Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict); using var scope = factory.CreateSystemScope("Verify concurrent answer winner"); var records = await scope.ServiceProvider.GetRequiredService().AnswerRecords .Where(item => item.PracticeSessionId == answerable.SessionId) .ToArrayAsync(); Assert.Single(records); var current = await scope.ServiceProvider.GetRequiredService().CurrentAnswers .SingleAsync(item => item.PracticeSessionId == answerable.SessionId); Assert.Equal(records[0].Id, current.AnswerRecordId); } private static async Task<(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId)> SeedLearningUserAsync( ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var businessLineId = Guid.NewGuid(); var licenseId = Guid.NewGuid(); var phone = $"139{Random.Shared.Next(10_000_000, 99_999_999)}"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Learning Tenant" }, new User { Id = userId, Phone = phone, Name = "Learning User" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new BusinessLine { Id = businessLineId, Code = $"national-{tenantId:N}", Name = "全国业务测试", RegionAccessStrategy = LearningRegionAccessStrategy.NationalOnly }, new TenantLearningLicense { Id = licenseId, TenantId = tenantId, BusinessLineId = businessLineId, IncludesNational = true, StartsAt = DateTimeOffset.UtcNow.AddDays(-1) }, new LearningAccessVersion { TenantId = tenantId, UserId = userId }); return (tenantId, userId, phone, businessLineId); } private static async Task SeedV2ReleaseAccessAsync( ApiTestFactory factory, (Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed, int dailyQuestionLimit) { var now = DateTimeOffset.UtcNow; var licenseId = Guid.NewGuid(); var profileId = Guid.NewGuid(); var profileVersionId = Guid.NewGuid(); var curriculumId = Guid.NewGuid(); var curriculumVersionId = Guid.NewGuid(); var curriculumNodeId = Guid.NewGuid(); var assessmentPolicyId = Guid.NewGuid(); var assessmentPolicyVersionId = Guid.NewGuid(); var questionAssetId = Guid.NewGuid(); var questionRevisionId = Guid.NewGuid(); var placementId = Guid.NewGuid(); var releaseId = Guid.NewGuid(); var segmentId = Guid.NewGuid(); var releaseQuestionId = Guid.NewGuid(); var productId = Guid.NewGuid(); var manifestId = Guid.NewGuid(); var manifestVersionId = Guid.NewGuid(); var entitlementId = Guid.NewGuid(); var fingerprint = new string('a', 64); await factory.SeedAsync( new TenantBusinessLicense { Id = licenseId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, StartsAt = now.AddDays(-1) }, new ExamTargetProfile { Id = profileId, BusinessLineId = seed.BusinessLineId, Code = $"profile-{profileId:N}", Name = "V2 target" }, new ExamTargetProfileVersion { Id = profileVersionId, ExamTargetProfileId = profileId, DisplayName = "V2 target 2027", Status = ContentDefinitionStatus.Published, PublishedAt = now }, new Curriculum { Id = curriculumId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, Code = $"curriculum-{curriculumId:N}", Name = "V2 curriculum" }, new CurriculumVersion { Id = curriculumVersionId, TenantId = seed.TenantId, CurriculumId = curriculumId, Name = "V2 curriculum 2027", Status = ContentDefinitionStatus.Published, PublishedAt = now }, new CurriculumNode { Id = curriculumNodeId, TenantId = seed.TenantId, CurriculumVersionId = curriculumVersionId, Code = "math", Name = "数学", Kind = CurriculumNodeKind.Subject }, new AssessmentPolicy { Id = assessmentPolicyId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, Code = $"policy-{assessmentPolicyId:N}", Name = "Objective" }, new AssessmentPolicyVersion { Id = assessmentPolicyVersionId, TenantId = seed.TenantId, AssessmentPolicyId = assessmentPolicyId, DefaultScore = 1, Status = ContentDefinitionStatus.Published, PublishedAt = now }, new QuestionAsset { Id = questionAssetId, TenantId = seed.TenantId, DeliveryFingerprint = fingerprint, Status = QuestionAssetStatus.Published }, new LearningProduct { Id = productId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, Code = $"product-{productId:N}", Name = "V2 product" }, new ProductAccessManifest { Id = manifestId, TenantId = seed.TenantId, LearningProductId = productId }); await factory.SeedAsync( new QuestionRevision { Id = questionRevisionId, TenantId = seed.TenantId, QuestionAssetId = questionAssetId, NormalizedContent = "one plus one", Content = "1 + 1 = ?", Options = JsonSerializer.SerializeToElement(new[] { "2", "3" }), CorrectOptionIndex = 0, DeliveryFingerprint = fingerprint }, new QuestionPlacement { Id = placementId, TenantId = seed.TenantId, QuestionAssetOwnerTenantId = seed.TenantId, QuestionAssetId = questionAssetId, CurriculumVersionId = curriculumVersionId, CurriculumNodeId = curriculumNodeId, AssessmentPolicyVersionId = assessmentPolicyVersionId, Status = QuestionPlacementStatus.Active }, new ContentRelease { Id = releaseId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, CurriculumVersionId = curriculumVersionId, Name = "V2 release", SourceFingerprint = fingerprint, Status = ContentReleaseStatus.Compiling }, new ProductAccessManifestVersion { Id = manifestVersionId, TenantId = seed.TenantId, ProductAccessManifestId = manifestId, Status = AccessManifestStatus.Draft, MaxActiveTargets = 1, DailyQuestionLimit = dailyQuestionLimit, TotalQuestionLimit = dailyQuestionLimit }); await factory.SeedAsync( new AudienceSegment { Id = segmentId, TenantId = seed.TenantId, ContentReleaseId = releaseId, RuleHash = fingerprint }, new TenantLicensedTarget { TenantId = seed.TenantId, TenantBusinessLicenseId = licenseId, ExamTargetProfileVersionId = profileVersionId, IsBaseTarget = true, StartsAt = now.AddDays(-1) }, new StudentTargetSelectionHistory { TenantId = seed.TenantId, UserId = seed.UserId, BusinessLineId = seed.BusinessLineId, ExamTargetProfileVersionId = profileVersionId, Role = TargetSelectionRole.Primary, EntitlementId = entitlementId, SourceType = "student_entitlement", SourceId = entitlementId, EffectiveAt = now.AddDays(-1) }, new ProductManifestRelease { TenantId = seed.TenantId, ProductAccessManifestVersionId = manifestVersionId, ContentOwnerTenantId = seed.TenantId, ContentReleaseId = releaseId }, new ProductManifestTarget { TenantId = seed.TenantId, ProductAccessManifestVersionId = manifestVersionId, ExamTargetProfileVersionId = profileVersionId, MayBePrimary = true }, new ProductManifestResource { TenantId = seed.TenantId, ProductAccessManifestVersionId = manifestVersionId, ResourceType = AccessResourceType.ContentRelease, ResourceId = releaseId, DisplayName = "V2 release practice", SortOrder = 10 }, new StudentEntitlement { Id = entitlementId, TenantId = seed.TenantId, UserId = seed.UserId, LearningProductId = productId, ProductAccessManifestVersionId = manifestVersionId, StartsAt = now.AddDays(-1), EndsAt = now.AddDays(30), SourceType = "integration_test" }); await factory.SeedAsync( new AudienceSegmentMember { TenantId = seed.TenantId, ContentReleaseId = releaseId, AudienceSegmentId = segmentId, ExamTargetProfileVersionId = profileVersionId }, new ContentReleaseQuestion { Id = releaseQuestionId, TenantId = seed.TenantId, ContentReleaseId = releaseId, AudienceSegmentId = segmentId, CurriculumNodeId = curriculumNodeId, QuestionPlacementId = placementId, QuestionAssetOwnerTenantId = seed.TenantId, QuestionAssetId = questionAssetId, QuestionRevisionId = questionRevisionId, AssessmentPolicyVersionId = assessmentPolicyVersionId, QuestionType = "choice" }); using var scope = factory.CreateSystemScope("Finalize V2 fixture current revisions"); var db = scope.ServiceProvider.GetRequiredService(); await db.QuestionAssets.Where(item => item.Id == questionAssetId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.CurrentRevisionId, questionRevisionId)); await db.ContentReleases.Where(item => item.Id == releaseId) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, ContentReleaseStatus.Published) .SetProperty(item => item.PublishedAt, now)); await db.ProductAccessManifestVersions.Where(item => item.Id == manifestVersionId) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, AccessManifestStatus.Published) .SetProperty(item => item.PublishedAt, now)); return releaseId; } private static async Task<(Guid SessionQuestionId, Guid SessionId, Guid VersionId, Guid ReferenceId)> SeedAnswerableQuestionAsync( ApiTestFactory factory, (Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed, Guid questionId, string questionType = "choice") { var versionId = Guid.NewGuid(); await factory.SeedQuestionWithVersionAsync( new Question { Id = questionId, TenantId = seed.TenantId, Type = questionType, Status = QuestionStatus.Published }, new QuestionVersion { Id = versionId, TenantId = seed.TenantId, QuestionId = questionId, VersionNo = 1, QuestionType = questionType, Content = "answerable question", CorrectOptionIndex = questionType == "choice" ? 0 : null }); var referenceId = Guid.NewGuid(); var sessionId = Guid.NewGuid(); var sessionQuestionId = Guid.NewGuid(); await factory.SeedAsync( new TenantQuestionReference { Id = referenceId, TenantId = seed.TenantId, QuestionOwnerTenantId = seed.TenantId, QuestionId = questionId, Source = QuestionSource.Tenant }, new PracticeSession { Id = sessionId, TenantId = seed.TenantId, UserId = seed.UserId, Mode = "single", QuestionCount = 1, AccessGrantVersion = 1, StrongRevocationVersion = 1, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30) }, new PracticeSessionQuestion { Id = sessionQuestionId, TenantId = seed.TenantId, PracticeSessionId = sessionId, QuestionOwnerTenantId = seed.TenantId, Position = 0, QuestionType = questionType, Score = 1 }); return (sessionQuestionId, sessionId, versionId, referenceId); } private static async Task<(Guid SessionQuestionId, Guid SessionId)> SeedAdditionalAnswerableSessionAsync( ApiTestFactory factory, (Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed, Guid questionId, Guid versionId, Guid referenceId) { var sessionId = Guid.NewGuid(); var sessionQuestionId = Guid.NewGuid(); await factory.SeedAsync( new PracticeSession { Id = sessionId, TenantId = seed.TenantId, UserId = seed.UserId, Mode = "single", QuestionCount = 1, AccessGrantVersion = 1, StrongRevocationVersion = 1, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30) }, new PracticeSessionQuestion { Id = sessionQuestionId, TenantId = seed.TenantId, PracticeSessionId = sessionId, QuestionOwnerTenantId = seed.TenantId, Position = 0, QuestionType = "choice", Score = 1 }); return (sessionQuestionId, sessionId); } private static async Task SubmitWrongSessionAsync( HttpClient client, Guid sessionQuestionId, Guid sessionId, string keyPrefix) { using var answer = await client.PostAsJsonAsync( "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = sessionQuestionId, ClientSequence = 1, IdempotencyKey = $"{keyPrefix}-answer", SelectedOptionIndices = [1] }); Assert.Equal(HttpStatusCode.OK, answer.StatusCode); using var submit = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = sessionId, IdempotencyKey = $"{keyPrefix}-submit" }); Assert.Equal(HttpStatusCode.OK, submit.StatusCode); } private static async Task SeedCollectionAccessAsync( ApiTestFactory factory, (Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed, Guid collectionId) { var productId = Guid.NewGuid(); var sliceId = Guid.NewGuid(); await factory.SeedAsync( new ContentSlice { Id = sliceId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, RegionScope = LearningRegionScopeKind.National, ResourceType = LearningContentResourceType.Collection, ResourceId = collectionId, Status = ContentSliceStatus.Active }, new LearningProduct { Id = productId, TenantId = seed.TenantId, BusinessLineId = seed.BusinessLineId, Code = "full-collection", Name = "完整题集" }, new LearningProductScope { TenantId = seed.TenantId, ProductId = productId, ContentSliceOwnerTenantId = seed.TenantId, ContentSliceId = sliceId }, new Entitlement { TenantId = seed.TenantId, UserId = seed.UserId, LearningProductId = productId, StartsAt = DateTimeOffset.UtcNow.AddDays(-1), ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), Status = EntitlementStatus.Active, SourceType = "integration_test" }); } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task<(Guid SessionId, Guid SessionQuestionId, Guid QuestionAssetId)> CreateV2SessionAsync( HttpClient client, Guid releaseId) { using var createResponse = await client.PostAsJsonAsync( "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { ResourceType = AccessResourceType.ContentRelease, ResourceId = releaseId }); createResponse.EnsureSuccessStatusCode(); using var created = await ReadJsonAsync(createResponse); var sessionId = created.RootElement.GetProperty("id").GetGuid(); using var detailResponse = await client.GetAsync( $"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}"); detailResponse.EnsureSuccessStatusCode(); using var detail = await ReadJsonAsync(detailResponse); var question = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray()); return ( sessionId, question.GetProperty("sessionQuestionId").GetGuid(), question.GetProperty("questionAssetId").GetGuid()); } private static async Task ProcessLearningOutboxAsync(ApiTestFactory factory, string workerId) { using var scope = factory.CreateSystemScope($"Process learning outbox with {workerId}"); return await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync(workerId, 10); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private static async Task ReadItemsAsync(HttpResponseMessage response) { var body = await ReadJsonAsync(response); return body.RootElement .GetProperty("items") .EnumerateArray() .Select(item => item.Clone()) .ToArray(); } private static async Task ReadProblemCodeAsync(HttpResponseMessage response) { using var body = await ReadJsonAsync(response); return body.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; } }