using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class LearningEndpointTests { [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/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 questionId = Guid.NewGuid(); var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( "/api/learning/answers", new SubmitAnswerDto { SessionQuestionId = sessionQuestionId, SelectedOptions = ["A"], SelfJudgedCorrect = false }); using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions"); var answer = await ReadJsonAsync(response); var wrongItems = await ReadItemsAsync(wrongResponse); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(sessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid()); Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean()); var wrong = Assert.Single(wrongItems); Assert.Equal(questionId, wrong.GetProperty("locator").GetProperty("questionId").GetGuid()); Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32()); } [Fact] 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" }); 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" }); 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 }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var createResponse = await client.PostAsJsonAsync( "/api/learning/practice-sessions", new CreatePracticeSessionDto { Mode = "chapter", CollectionId = collectionId, QuestionLimit = 2, TotalScore = 100 }); var created = await ReadJsonAsync(createResponse); var practiceSessionId = created.RootElement.GetProperty("id").GetGuid(); var detailResponse = await client.GetAsync($"/api/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(); await client.PostAsJsonAsync( "/api/learning/answers", new SubmitAnswerDto { SessionQuestionId = firstSessionQuestionId, SelectedOptions = ["A"], SelfJudgedCorrect = true }); await client.PostAsJsonAsync( "/api/learning/answers", new SubmitAnswerDto { SessionQuestionId = secondSessionQuestionId, SelectedOptions = ["B"], SelfJudgedCorrect = false }); var submitResponse = await client.PostAsJsonAsync( "/api/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId }); var report = await ReadJsonAsync(submitResponse); var reportResponse = await client.GetAsync($"/api/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); var reportsResponse = await client.GetAsync("/api/learning/practice-reports"); var historyResponse = await client.GetAsync("/api/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.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 questionId = Guid.NewGuid(); await factory.SeedAsync(new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var addResponse = await client.PostAsJsonAsync( "/api/learning/favorites/questions", new QuestionActionDto { QuestionId = questionId }); var listResponse = await client.GetAsync("/api/learning/favorites/questions"); var itemsAfterAdd = await ReadItemsAsync(listResponse); var removeResponse = await client.PostAsJsonAsync( "/api/learning/favorites/questions", new QuestionActionDto { QuestionId = questionId, Favorite = false }); var emptyResponse = await client.GetAsync("/api/learning/favorites/questions"); var itemsAfterRemove = await ReadItemsAsync(emptyResponse); Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal( questionId, Assert.Single(itemsAfterAdd).GetProperty("locator").GetProperty("questionId").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/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, Status = "learning", CorrectDelta = 2 }); using var listResponse = await client.GetAsync("/api/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/learning/vocabulary/favorites", new FavoriteWordDto { WordId = wordId, Note = "重点" }); await client.PostAsJsonAsync( "/api/learning/vocabulary/favorites", new FavoriteWordDto { WordId = otherWordId }); using var response = await client.GetAsync($"/api/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 sessionQuestionId = 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/learning/answers", new SubmitAnswerDto { SessionQuestionId = sessionQuestionId, SelectedOptions = ["A"], SelfJudgedCorrect = false }); await client.PostAsJsonAsync( "/api/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, Status = "learning", WrongDelta = 1, NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); var statsResponse = await client.GetAsync("/api/learning/stats"); var trendResponse = await client.GetAsync("/api/learning/trend?limit=7"); var leaderboardResponse = await client.GetAsync("/api/learning/leaderboard?limit=10"); var wrongPlanResponse = await client.GetAsync("/api/learning/wrong-questions/review-plan"); var wordPlanResponse = await client.GetAsync("/api/learning/vocabulary/review-plan"); var reviewResponse = await client.PostAsJsonAsync( "/api/learning/vocabulary/review", new WordReviewDto { WordId = wordId, Result = "correct" }); var wordStatsResponse = await client.GetAsync("/api/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()); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync( ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13900000000"; 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 }); return (tenantId, userId, phone); } private static async Task SeedAnswerableQuestionAsync( ApiTestFactory factory, (Guid TenantId, Guid UserId, string Phone) seed, Guid questionId) { var versionId = Guid.NewGuid(); await factory.SeedQuestionWithVersionAsync( new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new QuestionVersion { Id = versionId, TenantId = seed.TenantId, QuestionId = questionId, VersionNo = 1, Content = "answerable question" }); 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, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30) }, new PracticeSessionQuestion { Id = sessionQuestionId, TenantId = seed.TenantId, PracticeSessionId = sessionId, QuestionReferenceId = referenceId, QuestionOwnerTenantId = seed.TenantId, QuestionId = questionId, QuestionVersionId = versionId, Position = 0 }); return sessionQuestionId; } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } 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(); } }