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.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(); await factory.SeedAsync(new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( "/api/learning/answers", new SubmitAnswerDto { QuestionId = questionId, 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(questionId, answer.RootElement.GetProperty("questionId").GetGuid()); Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean()); var wrong = Assert.Single(wrongItems); Assert.Equal(questionId, wrong.GetProperty("questionId").GetGuid()); Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32()); } [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("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()); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync( ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13900000000"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Learning Tenant" }, new User { Id = userId, Phone = phone, Name = "Learning User" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); } 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 JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } }