diff --git a/backend/DramaLing.Api.Tests/DramaLing.Api.Tests.csproj b/backend/DramaLing.Api.Tests/DramaLing.Api.Tests.csproj index 3caabcc..abf2a62 100644 --- a/backend/DramaLing.Api.Tests/DramaLing.Api.Tests.csproj +++ b/backend/DramaLing.Api.Tests/DramaLing.Api.Tests.csproj @@ -19,6 +19,8 @@ + + diff --git a/backend/DramaLing.Api.Tests/Integration/Controllers/AIControllerTests.cs b/backend/DramaLing.Api.Tests/Integration/Controllers/AIControllerTests.cs new file mode 100644 index 0000000..33ba0a0 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Controllers/AIControllerTests.cs @@ -0,0 +1,139 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; + +namespace DramaLing.Api.Tests.Integration.Controllers; + +/// +/// AIController 整合測試 +/// 測試 AI 分析相關的 API 端點功能 +/// +public class AIControllerTests : IntegrationTestBase +{ + public AIControllerTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task AnalyzeSentence_WithValidAuth_ShouldReturnAnalysis() + { + // Arrange + var client = CreateTestUser1Client(); + var analysisData = new + { + text = "Hello, this is a beautiful day for learning English.", + targetLevel = "A2", + includeGrammar = true, + includeVocabulary = true + }; + + // Act + var response = await client.PostAsJsonAsync("/api/ai/analyze-sentence", analysisData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task AnalyzeSentence_WithoutAuth_ShouldReturn401() + { + // Arrange + var analysisData = new + { + text = "Hello, this is a test sentence.", + targetLevel = "A2" + }; + + // Act + var response = await HttpClient.PostAsJsonAsync("/api/ai/analyze-sentence", analysisData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task AnalyzeSentence_WithEmptyText_ShouldReturn400() + { + // Arrange + var client = CreateTestUser1Client(); + var analysisData = new + { + text = "", // 空文本 + targetLevel = "A2" + }; + + // Act + var response = await client.PostAsJsonAsync("/api/ai/analyze-sentence", analysisData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetHealth_ShouldReturnHealthStatus() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/ai/health"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task GetStats_WithValidAuth_ShouldReturnStats() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/ai/stats"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task GetStats_WithoutAuth_ShouldReturn401() + { + // Arrange & Act + var response = await HttpClient.GetAsync("/api/ai/stats"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task MockGeminiService_ShouldWorkCorrectly() + { + // Arrange + var client = CreateTestUser1Client(); + var testSentence = new + { + text = "The sophisticated algorithm analyzed the beautiful sentence.", + targetLevel = "B2", + includeGrammar = true, + includeVocabulary = true + }; + + // Act + var response = await client.PostAsJsonAsync("/api/ai/analyze-sentence", testSentence); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + + // 驗證 Mock 服務返回預期的回應格式 + content.Should().Contain("success"); + // Mock 服務應該能夠處理這個請求而不需要真實的 Gemini API + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Controllers/AuthControllerTests.cs b/backend/DramaLing.Api.Tests/Integration/Controllers/AuthControllerTests.cs new file mode 100644 index 0000000..2138983 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Controllers/AuthControllerTests.cs @@ -0,0 +1,215 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DramaLing.Api.Tests.Integration.Controllers; + +/// +/// AuthController 整合測試 +/// 測試用戶認證相關的 API 端點功能 +/// +public class AuthControllerTests : IntegrationTestBase +{ + public AuthControllerTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task Register_WithValidData_ShouldCreateUser() + { + // Arrange + var registerData = new + { + username = "newuser", + email = "newuser@example.com", + password = "password123", + displayName = "New Test User" + }; + + // Act + var response = await HttpClient.PostAsJsonAsync("/api/auth/register", registerData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + content.Should().Contain("token"); + } + + [Fact] + public async Task Register_WithDuplicateEmail_ShouldReturn400() + { + // Arrange - 使用已存在的測試用戶 email + var registerData = new + { + username = "duplicateuser", + email = "test1@example.com", // 已存在的 email + password = "password123", + displayName = "Duplicate User" + }; + + // Act + var response = await HttpClient.PostAsJsonAsync("/api/auth/register", registerData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("error"); + } + + [Fact] + public async Task Login_WithValidCredentials_ShouldReturnToken() + { + // Arrange + var loginData = new + { + email = "test1@example.com", + password = "password123" // 對應 TestDataSeeder 中的密碼 + }; + + // Act + var response = await HttpClient.PostAsJsonAsync("/api/auth/login", loginData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + content.Should().Contain("token"); + + // 驗證 JWT Token 格式 + var jsonResponse = JsonSerializer.Deserialize(content); + if (jsonResponse.TryGetProperty("data", out var data) && + data.TryGetProperty("token", out var tokenElement)) + { + var token = tokenElement.GetString(); + token.Should().NotBeNullOrEmpty(); + token.Should().StartWith("eyJ"); // JWT Token 格式 + } + } + + [Fact] + public async Task Login_WithInvalidCredentials_ShouldReturn401() + { + // Arrange + var loginData = new + { + email = "test1@example.com", + password = "wrongpassword" + }; + + // Act + var response = await HttpClient.PostAsJsonAsync("/api/auth/login", loginData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("error"); + } + + [Fact] + public async Task GetProfile_WithValidAuth_ShouldReturnUserProfile() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/auth/profile"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Test User 1"); + content.Should().Contain("testuser1"); + content.Should().Contain("test1@example.com"); + } + + [Fact] + public async Task GetProfile_WithoutAuth_ShouldReturn401() + { + // Arrange + var client = HttpClient; // 未認證 + + // Act + var response = await client.GetAsync("/api/auth/profile"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task UpdateProfile_WithValidAuth_ShouldUpdateSuccessfully() + { + // Arrange + var client = CreateTestUser1Client(); + var updateData = new + { + displayName = "Updated Display Name", + bio = "Updated bio information" + }; + + // Act + var response = await client.PutAsJsonAsync("/api/auth/profile", updateData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + + // 驗證更新是否生效 + var profileResponse = await client.GetAsync("/api/auth/profile"); + var profileContent = await profileResponse.Content.ReadAsStringAsync(); + profileContent.Should().Contain("Updated Display Name"); + } + + [Fact] + public async Task GetSettings_WithValidAuth_ShouldReturnSettings() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/auth/settings"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task UpdateSettings_WithValidAuth_ShouldUpdateSuccessfully() + { + // Arrange + var client = CreateTestUser1Client(); + var settingsData = new + { + language = "zh-TW", + theme = "dark", + notifications = true + }; + + // Act + var response = await client.PutAsJsonAsync("/api/auth/settings", settingsData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task GetStatus_ShouldReturnUserStatus() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/auth/status"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Controllers/FlashcardsControllerTests.cs b/backend/DramaLing.Api.Tests/Integration/Controllers/FlashcardsControllerTests.cs new file mode 100644 index 0000000..db5a363 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Controllers/FlashcardsControllerTests.cs @@ -0,0 +1,140 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; + +namespace DramaLing.Api.Tests.Integration.Controllers; + +/// +/// FlashcardsController 整合測試 +/// 測試詞卡相關的 API 端點功能 +/// +public class FlashcardsControllerTests : IntegrationTestBase +{ + public FlashcardsControllerTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetDueFlashcards_WithValidUser_ShouldReturnFlashcards() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/flashcards/due"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().NotBeNullOrEmpty(); + content.Should().Contain("flashcards"); + } + + [Fact] + public async Task GetDueFlashcards_WithoutAuth_ShouldReturn401() + { + // Arrange + var client = HttpClient; // 未認證的 client + + // Act + var response = await client.GetAsync("/api/flashcards/due"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetAllFlashcards_WithValidUser_ShouldReturnUserFlashcards() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/flashcards"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetFlashcardById_WithValidUserAndId_ShouldReturnFlashcard() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // Act + var response = await client.GetAsync($"/api/flashcards/{flashcardId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("hello"); // 測試資料中的詞彙 + } + + [Fact] + public async Task GetFlashcardById_WithDifferentUser_ShouldReturn404() + { + // Arrange - TestUser2 嘗試存取 TestUser1 的詞卡 + var client = CreateTestUser2Client(); + var user1FlashcardId = TestDataSeeder.TestFlashcard1Id; // 屬於 TestUser1 + + // Act + var response = await client.GetAsync($"/api/flashcards/{user1FlashcardId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task MarkWordMastered_WithValidFlashcard_ShouldUpdateReview() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // Act + var response = await client.PostAsync($"/api/flashcards/{flashcardId}/mastered", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + + // 驗證資料庫中的複習記錄是否更新 + using var context = GetDbContext(); + var review = context.FlashcardReviews + .First(r => r.FlashcardId == flashcardId && r.UserId == TestDataSeeder.TestUser1Id); + review.SuccessCount.Should().BeGreaterThan(0); + } + + [Fact] + public async Task UserDataIsolation_ShouldBeEnforced() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Act - 兩個用戶分別取得詞卡 + var user1Response = await user1Client.GetAsync("/api/flashcards"); + var user2Response = await user2Client.GetAsync("/api/flashcards"); + + // Assert + user1Response.StatusCode.Should().Be(HttpStatusCode.OK); + user2Response.StatusCode.Should().Be(HttpStatusCode.OK); + + var user1Content = await user1Response.Content.ReadAsStringAsync(); + var user2Content = await user2Response.Content.ReadAsStringAsync(); + + // TestUser1 有 2 張詞卡,TestUser2 有 1 張詞卡 + user1Content.Should().Contain("hello"); + user1Content.Should().Contain("beautiful"); + user2Content.Should().Contain("sophisticated"); + + // 確保用戶間資料隔離 + user1Content.Should().NotContain("sophisticated"); + user2Content.Should().NotContain("hello"); + user2Content.Should().NotContain("beautiful"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Controllers/ImageGenerationControllerTests.cs b/backend/DramaLing.Api.Tests/Integration/Controllers/ImageGenerationControllerTests.cs new file mode 100644 index 0000000..22ad9b7 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Controllers/ImageGenerationControllerTests.cs @@ -0,0 +1,129 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; + +namespace DramaLing.Api.Tests.Integration.Controllers; + +/// +/// ImageGenerationController 整合測試 +/// 測試圖片生成相關的 API 端點功能 +/// +public class ImageGenerationControllerTests : IntegrationTestBase +{ + public ImageGenerationControllerTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GenerateImage_WithValidFlashcard_ShouldReturnRequestId() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + var generationData = new + { + style = "realistic", + description = "A person saying hello in a friendly manner" + }; + + // Act + var response = await client.PostAsJsonAsync($"/api/image-generation/flashcards/{flashcardId}/generate", generationData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task GenerateImage_WithOtherUserFlashcard_ShouldReturn404() + { + // Arrange - TestUser1 嘗試為 TestUser2 的詞卡生成圖片 + var client = CreateTestUser1Client(); + var otherUserFlashcardId = TestDataSeeder.TestFlashcard3Id; // 屬於 TestUser2 + + var generationData = new + { + style = "realistic", + description = "Test description" + }; + + // Act + var response = await client.PostAsJsonAsync($"/api/image-generation/flashcards/{otherUserFlashcardId}/generate", generationData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GenerateImage_WithoutAuth_ShouldReturn401() + { + // Arrange + var flashcardId = TestDataSeeder.TestFlashcard1Id; + var generationData = new + { + style = "realistic", + description = "Test description" + }; + + // Act + var response = await HttpClient.PostAsJsonAsync($"/api/image-generation/flashcards/{flashcardId}/generate", generationData); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetRequestStatus_WithValidRequest_ShouldReturnStatus() + { + // Arrange + var client = CreateTestUser1Client(); + var requestId = Guid.NewGuid(); // 模擬的請求 ID + + // Act + var response = await client.GetAsync($"/api/image-generation/requests/{requestId}/status"); + + // Assert + // 即使請求不存在,API 也應該正常回應而不是崩潰 + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound); + } + + [Fact] + public async Task CancelRequest_WithValidRequest_ShouldCancelSuccessfully() + { + // Arrange + var client = CreateTestUser1Client(); + var requestId = Guid.NewGuid(); + + // Act + var response = await client.PostAsync($"/api/image-generation/requests/{requestId}/cancel", null); + + // Assert + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetHistory_WithValidAuth_ShouldReturnHistory() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/image-generation/history"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetHistory_WithoutAuth_ShouldReturn401() + { + // Act + var response = await HttpClient.GetAsync("/api/image-generation/history"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Controllers/OptionsVocabularyTestControllerTests.cs b/backend/DramaLing.Api.Tests/Integration/Controllers/OptionsVocabularyTestControllerTests.cs new file mode 100644 index 0000000..4d008c3 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Controllers/OptionsVocabularyTestControllerTests.cs @@ -0,0 +1,131 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; + +namespace DramaLing.Api.Tests.Integration.Controllers; + +/// +/// OptionsVocabularyTestController 整合測試 +/// 測試詞彙選項生成相關的 API 端點功能 +/// +public class OptionsVocabularyTestControllerTests : IntegrationTestBase +{ + public OptionsVocabularyTestControllerTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GenerateDistractors_WithValidParameters_ShouldReturnOptions() + { + // Arrange + var client = CreateTestUser1Client(); + var queryParams = "?word=hello&level=A1&partOfSpeech=interjection&count=3"; + + // Act + var response = await client.GetAsync($"/api/options-vocabulary-test/generate-distractors{queryParams}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GenerateDistractors_WithMissingParameters_ShouldReturn400() + { + // Arrange + var client = CreateTestUser1Client(); + var queryParams = "?word=hello"; // 缺少必要參數 + + // Act + var response = await client.GetAsync($"/api/options-vocabulary-test/generate-distractors{queryParams}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GenerateDistractors_WithoutAuth_ShouldReturn401() + { + // Arrange + var queryParams = "?word=hello&level=A1&partOfSpeech=noun&count=3"; + + // Act + var response = await HttpClient.GetAsync($"/api/options-vocabulary-test/generate-distractors{queryParams}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CheckSufficiency_WithValidData_ShouldReturnStatus() + { + // Arrange + var client = CreateTestUser1Client(); + var queryParams = "?level=A1&partOfSpeech=noun"; + + // Act + var response = await client.GetAsync($"/api/options-vocabulary-test/check-sufficiency{queryParams}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task GenerateDistractorsDetailed_WithValidData_ShouldReturnDetailedOptions() + { + // Arrange + var client = CreateTestUser1Client(); + var queryParams = "?word=beautiful&level=A2&partOfSpeech=adjective&count=4"; + + // Act + var response = await client.GetAsync($"/api/options-vocabulary-test/generate-distractors-detailed{queryParams}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CoverageTest_ShouldReturnCoverageInfo() + { + // Arrange + var client = CreateTestUser1Client(); + + // Act + var response = await client.GetAsync("/api/options-vocabulary-test/coverage-test"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("success"); + } + + [Fact] + public async Task VocabularyOptionsGeneration_ShouldBeConsistent() + { + // Arrange + var client = CreateTestUser1Client(); + var word = "sophisticated"; + var queryParams = $"?word={word}&level=C1&partOfSpeech=adjective&count=3"; + + // Act - 多次調用同一個端點 + var response1 = await client.GetAsync($"/api/options-vocabulary-test/generate-distractors{queryParams}"); + var response2 = await client.GetAsync($"/api/options-vocabulary-test/generate-distractors{queryParams}"); + + // Assert + response1.StatusCode.Should().Be(HttpStatusCode.OK); + response2.StatusCode.Should().Be(HttpStatusCode.OK); + + var content1 = await response1.Content.ReadAsStringAsync(); + var content2 = await response2.Content.ReadAsStringAsync(); + + // Mock 服務應該返回一致的格式(雖然內容可能不同) + content1.Should().Contain("success"); + content2.Should().Contain("success"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/DramaLingWebApplicationFactory.cs b/backend/DramaLing.Api.Tests/Integration/DramaLingWebApplicationFactory.cs new file mode 100644 index 0000000..999ce6d --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/DramaLingWebApplicationFactory.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using DramaLing.Api.Data; +using DramaLing.Api.Tests.Integration.Fixtures; +using DramaLing.Api.Tests.Integration.Mocks; +using DramaLing.Api.Services.AI.Gemini; +using DramaLing.Api.Models.Configuration; + +namespace DramaLing.Api.Tests.Integration; + +/// +/// API 整合測試的 WebApplicationFactory +/// 提供完整的測試環境設定,包含 InMemory 資料庫和測試配置 +/// +public class DramaLingWebApplicationFactory : WebApplicationFactory +{ + private readonly string _databaseName; + + public DramaLingWebApplicationFactory() + { + _databaseName = $"TestDb_{Guid.NewGuid()}"; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // 移除原有的資料庫配置 + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + { + services.Remove(descriptor); + } + + // 使用 InMemory 資料庫 + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_databaseName); + options.EnableSensitiveDataLogging(); + }); + + // 替換 Gemini Client 為 Mock + var geminiDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IGeminiClient)); + if (geminiDescriptor != null) + { + services.Remove(geminiDescriptor); + } + services.AddScoped(); + + // 設定測試用的 Gemini 配置 + services.Configure(options => + { + options.ApiKey = "AIza-test-key-for-integration-testing-purposes-only"; + options.BaseUrl = "https://test.googleapis.com"; + options.TimeoutSeconds = 10; + options.MaxRetries = 1; + options.Temperature = 0.5; + }); + + // 建立資料庫並種子資料 + var serviceProvider = services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + context.Database.EnsureCreated(); + TestDataSeeder.SeedTestData(context); + }); + + builder.UseEnvironment("Testing"); + + // 設定測試用環境變數 + Environment.SetEnvironmentVariable("USE_INMEMORY_DB", "true"); + Environment.SetEnvironmentVariable("DRAMALING_SUPABASE_JWT_SECRET", "test-secret-minimum-32-characters-long-for-jwt-signing-in-test-mode-only"); + Environment.SetEnvironmentVariable("DRAMALING_SUPABASE_URL", "https://test.supabase.co"); + Environment.SetEnvironmentVariable("DRAMALING_GEMINI_API_KEY", "AIza-test-key-for-integration-testing-purposes-only"); + + // 設定測試專用的配置 + builder.ConfigureAppConfiguration((context, config) => + { + // 添加測試用的記憶體配置 + var testConfig = new Dictionary + { + ["Supabase:JwtSecret"] = "test-secret-minimum-32-characters-long-for-jwt-signing-in-test-mode-only", + ["Supabase:Url"] = "https://test.supabase.co", + ["Gemini:ApiKey"] = "AIza-test-key-for-integration-testing-purposes-only" + }; + + config.AddInMemoryCollection(testConfig); + }); + + // 設定 Logging 層級 + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Warning); + }); + } + + /// + /// 取得測試用的 HttpClient,並設定預設的 JWT Token + /// + public HttpClient CreateClientWithAuth(string? token = null) + { + var client = CreateClient(); + + if (!string.IsNullOrEmpty(token)) + { + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + + return client; + } + + /// + /// 重置資料庫資料 - 用於測試間的隔離 + /// + public void ResetDatabase() + { + using var scope = Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // 清除所有資料 + context.FlashcardReviews.RemoveRange(context.FlashcardReviews); + context.Flashcards.RemoveRange(context.Flashcards); + context.Users.RemoveRange(context.Users); + context.OptionsVocabularies.RemoveRange(context.OptionsVocabularies); + context.SaveChanges(); + + // 重新種子測試資料 + TestDataSeeder.SeedTestData(context); + } + + /// + /// 取得測試資料庫上下文 + /// + public DramaLingDbContext GetDbContext() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/EndToEnd/AIVocabularyWorkflowTests.cs b/backend/DramaLing.Api.Tests/Integration/EndToEnd/AIVocabularyWorkflowTests.cs new file mode 100644 index 0000000..25edd7b --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/EndToEnd/AIVocabularyWorkflowTests.cs @@ -0,0 +1,240 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DramaLing.Api.Tests.Integration.EndToEnd; + +/// +/// AI 詞彙生成到儲存完整流程測試 +/// 驗證從 AI 分析句子、生成詞彙、同義詞到儲存的完整業務流程 +/// +public class AIVocabularyWorkflowTests : IntegrationTestBase +{ + public AIVocabularyWorkflowTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task CompleteAIVocabularyWorkflow_ShouldGenerateAndStoreFlashcard() + { + // Arrange + var client = CreateTestUser1Client(); + + // Step 1: AI 分析句子生成詞彙 + var analysisRequest = new + { + text = "The magnificent sunset painted the sky with brilliant colors.", + targetLevel = "B2", + includeGrammar = true, + includeVocabulary = true + }; + + var analysisResponse = await client.PostAsJsonAsync("/api/ai/analyze-sentence", analysisRequest); + analysisResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var analysisContent = await analysisResponse.Content.ReadAsStringAsync(); + var analysisJson = JsonSerializer.Deserialize(analysisContent); + + // 驗證 AI 分析結果包含詞彙資訊 + analysisJson.GetProperty("success").GetBoolean().Should().BeTrue(); + + // Step 2: 模擬從 AI 分析結果中選擇詞彙並建立詞卡 + // 假設 AI 分析返回了 "magnificent" 這個詞 + var newFlashcard = new + { + word = "magnificent", + translation = "宏偉的,壯麗的", + definition = "Very beautiful and impressive", + partOfSpeech = "adjective", + pronunciation = "/mæɡˈnɪf.ɪ.sənt/", + example = "The magnificent sunset painted the sky.", + exampleTranslation = "壯麗的夕陽將天空染色。", + difficultyLevelNumeric = 4, // B2 + synonyms = "[\"splendid\", \"impressive\", \"gorgeous\"]" // AI 生成的同義詞 + }; + + var createResponse = await client.PostAsJsonAsync("/api/flashcards", newFlashcard); + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var createContent = await createResponse.Content.ReadAsStringAsync(); + var createJson = JsonSerializer.Deserialize(createContent); + + // Step 3: 驗證詞卡已正確儲存 + var createdFlashcardId = createJson.GetProperty("data").GetProperty("id").GetString(); + createdFlashcardId.Should().NotBeNullOrEmpty(); + + // Step 4: 取得儲存的詞卡並驗證同義詞 + var getResponse = await client.GetAsync($"/api/flashcards/{createdFlashcardId}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getContent = await getResponse.Content.ReadAsStringAsync(); + var getJson = JsonSerializer.Deserialize(getContent); + + var flashcard = getJson.GetProperty("data"); + flashcard.GetProperty("word").GetString().Should().Be("magnificent"); + flashcard.GetProperty("synonyms").EnumerateArray().Should().HaveCountGreaterThan(0, "應該有同義詞"); + } + + [Fact] + public async Task SynonymsGeneration_ShouldBeStoredAndDisplayedCorrectly() + { + // Arrange + var client = CreateTestUser1Client(); + + // Step 1: 建立包含同義詞的詞卡 + var flashcardWithSynonyms = new + { + word = "brilliant", + translation = "聰明的,傑出的", + definition = "Exceptionally clever or talented", + partOfSpeech = "adjective", + pronunciation = "/ˈbrɪl.jənt/", + example = "She has a brilliant mind.", + exampleTranslation = "她有聰明的頭腦。", + difficultyLevelNumeric = 3, // B1 + synonyms = "[\"intelligent\", \"smart\", \"clever\", \"outstanding\"]" // JSON 格式的同義詞 + }; + + var createResponse = await client.PostAsJsonAsync("/api/flashcards", flashcardWithSynonyms); + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var createContent = await createResponse.Content.ReadAsStringAsync(); + var createJson = JsonSerializer.Deserialize(createContent); + var flashcardId = createJson.GetProperty("data").GetProperty("id").GetString(); + + // Step 2: 取得詞卡並驗證同義詞正確解析 + var getResponse = await client.GetAsync($"/api/flashcards/{flashcardId}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getContent = await getResponse.Content.ReadAsStringAsync(); + var getJson = JsonSerializer.Deserialize(getContent); + + var retrievedFlashcard = getJson.GetProperty("data"); + + // Step 3: 驗證同義詞格式和內容 + var synonymsArray = retrievedFlashcard.GetProperty("synonyms"); + var synonymsList = synonymsArray.EnumerateArray().Select(s => s.GetString()).ToList(); + + synonymsList.Should().Contain("intelligent"); + synonymsList.Should().Contain("smart"); + synonymsList.Should().Contain("clever"); + synonymsList.Should().Contain("outstanding"); + synonymsList.Should().HaveCount(4, "應該有4個同義詞"); + + // Step 4: 驗證同義詞在複習時正確顯示 + var dueResponse = await client.GetAsync("/api/flashcards/due"); + var dueContent = await dueResponse.Content.ReadAsStringAsync(); + var dueJson = JsonSerializer.Deserialize(dueContent); + + var flashcards = dueJson.GetProperty("data").GetProperty("flashcards"); + var targetFlashcard = flashcards.EnumerateArray() + .FirstOrDefault(f => f.GetProperty("id").GetString() == flashcardId); + + if (!targetFlashcard.Equals(default(JsonElement))) + { + var synonymsInDue = targetFlashcard.GetProperty("synonyms"); + synonymsInDue.GetArrayLength().Should().BeGreaterThan(0, "複習時應該顯示同義詞"); + } + } + + [Fact] + public async Task OptionsGeneration_ShouldProvideValidDistractors() + { + // Arrange + var client = CreateTestUser1Client(); + + // Step 1: 建立詞卡 + var flashcard = new + { + word = "extraordinary", + translation = "非凡的", + definition = "Very unusual or remarkable", + partOfSpeech = "adjective", + difficultyLevelNumeric = 4 // B2 + }; + + var createResponse = await client.PostAsJsonAsync("/api/flashcards", flashcard); + var createContent = await createResponse.Content.ReadAsStringAsync(); + var createJson = JsonSerializer.Deserialize(createContent); + var flashcardId = createJson.GetProperty("data").GetProperty("id").GetString(); + + // Step 2: 取得詞卡的待複習狀態 (應該包含 AI 生成的選項) + var dueResponse = await client.GetAsync("/api/flashcards/due"); + dueResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var dueContent = await dueResponse.Content.ReadAsStringAsync(); + var dueJson = JsonSerializer.Deserialize(dueContent); + + var flashcards = dueJson.GetProperty("data").GetProperty("flashcards"); + var targetFlashcard = flashcards.EnumerateArray() + .FirstOrDefault(f => f.GetProperty("id").GetString() == flashcardId); + + if (!targetFlashcard.Equals(default(JsonElement))) + { + // Step 3: 驗證 AI 生成的測驗選項 + var quizOptions = targetFlashcard.GetProperty("quizOptions"); + quizOptions.GetArrayLength().Should().BeGreaterThan(0, "應該有 AI 生成的測驗選項"); + + // 驗證選項不包含正確答案 (混淆選項) + var optionsList = quizOptions.EnumerateArray().Select(o => o.GetString()).ToList(); + optionsList.Should().NotContain("非凡的", "混淆選項不應該包含正確翻譯"); + } + } + + [Fact] + public async Task VocabularyGenerationToReview_EndToEndFlow_ShouldWork() + { + // Arrange + var client = CreateTestUser1Client(); + + // Step 1: 從AI分析開始 → Step 2: 生成詞卡 → Step 3: 複習詞卡 + var analysisRequest = new + { + text = "The sophisticated algorithm processes complex data efficiently.", + targetLevel = "C1" + }; + + await client.PostAsJsonAsync("/api/ai/analyze-sentence", analysisRequest); + + // Step 2: 建立從分析中得出的詞彙 (模擬用戶選擇 "algorithm") + var newFlashcard = new + { + word = "algorithm", + translation = "演算法", + definition = "A process or set of rules for calculations", + partOfSpeech = "noun", + difficultyLevelNumeric = 5, // C1 + synonyms = "[\"procedure\", \"method\", \"process\"]" + }; + + var createResponse = await client.PostAsJsonAsync("/api/flashcards", newFlashcard); + var createContent = await createResponse.Content.ReadAsStringAsync(); + var createJson = JsonSerializer.Deserialize(createContent); + var newFlashcardId = createJson.GetProperty("data").GetProperty("id").GetString(); + + // Step 3: 立即複習新詞卡 + var reviewRequest = new + { + confidence = 1, // 中等信心度 + wasSkipped = false, + responseTime = 4000 + }; + + var reviewResponse = await client.PostAsJsonAsync($"/api/flashcards/{newFlashcardId}/review", reviewRequest); + + // Assert: 驗證完整流程 + reviewResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var reviewContent = await reviewResponse.Content.ReadAsStringAsync(); + var reviewJson = JsonSerializer.Deserialize(reviewContent); + + var reviewResult = reviewJson.GetProperty("data"); + reviewResult.GetProperty("newSuccessCount").GetInt32().Should().Be(1, "新詞卡第一次答對應該成功次數為1"); + + // 驗證下次複習間隔 + var nextReviewDate = DateTime.Parse(reviewResult.GetProperty("nextReviewDate").GetString()!); + var intervalHours = (nextReviewDate - DateTime.UtcNow).TotalHours; + intervalHours.Should().BeInRange(40, 56, "第一次答對應該約2天後再複習 (2^1 = 2天)"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/EndToEnd/DataIsolationTests.cs b/backend/DramaLing.Api.Tests/Integration/EndToEnd/DataIsolationTests.cs new file mode 100644 index 0000000..dba0aef --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/EndToEnd/DataIsolationTests.cs @@ -0,0 +1,182 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DramaLing.Api.Tests.Integration.EndToEnd; + +/// +/// 使用者資料隔離測試 +/// 驗證多用戶環境下的資料安全和隔離機制 +/// +public class DataIsolationTests : IntegrationTestBase +{ + public DataIsolationTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task UserFlashcards_ShouldBeCompletelyIsolated() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Act: 兩個用戶分別取得詞卡列表 + var user1Response = await user1Client.GetAsync("/api/flashcards"); + var user2Response = await user2Client.GetAsync("/api/flashcards"); + + // Assert + user1Response.StatusCode.Should().Be(HttpStatusCode.OK); + user2Response.StatusCode.Should().Be(HttpStatusCode.OK); + + var user1Content = await user1Response.Content.ReadAsStringAsync(); + var user2Content = await user2Response.Content.ReadAsStringAsync(); + + var user1Json = JsonSerializer.Deserialize(user1Content); + var user2Json = JsonSerializer.Deserialize(user2Content); + + var user1Flashcards = user1Json.GetProperty("data").EnumerateArray().ToList(); + var user2Flashcards = user2Json.GetProperty("data").EnumerateArray().ToList(); + + // 驗證 User1 只能看到自己的詞卡 (hello, beautiful) + user1Flashcards.Should().HaveCount(2); + user1Flashcards.Should().Contain(f => f.GetProperty("word").GetString() == "hello"); + user1Flashcards.Should().Contain(f => f.GetProperty("word").GetString() == "beautiful"); + + // 驗證 User2 只能看到自己的詞卡 (sophisticated) + user2Flashcards.Should().HaveCount(1); + user2Flashcards.Should().Contain(f => f.GetProperty("word").GetString() == "sophisticated"); + + // 交叉驗證:確保絕對隔離 + user1Flashcards.Should().NotContain(f => f.GetProperty("word").GetString() == "sophisticated"); + user2Flashcards.Should().NotContain(f => f.GetProperty("word").GetString() == "hello"); + user2Flashcards.Should().NotContain(f => f.GetProperty("word").GetString() == "beautiful"); + } + + [Fact] + public async Task ReviewData_ShouldBeIsolatedBetweenUsers() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Step 1: 用戶1進行複習 + var user1FlashcardId = TestDataSeeder.TestFlashcard1Id; + await user1Client.PostAsJsonAsync($"/api/flashcards/{user1FlashcardId}/review", new + { + confidence = 2, + wasSkipped = false, + responseTime = 2000 + }); + + // Step 2: 檢查複習記錄隔離 + using var context = GetDbContext(); + var user1Reviews = context.FlashcardReviews + .Where(r => r.UserId == TestDataSeeder.TestUser1Id) + .ToList(); + + var user2Reviews = context.FlashcardReviews + .Where(r => r.UserId == TestDataSeeder.TestUser2Id) + .ToList(); + + // Assert: 驗證複習記錄隔離 + user1Reviews.Should().HaveCountGreaterThan(0, "用戶1應該有複習記錄"); + user2Reviews.Should().HaveCount(0, "用戶2不應該有複習記錄(在測試資料中)"); + + // 驗證 User1 的複習不會影響 User2 的資料 + user1Reviews.Should().OnlyContain(r => r.UserId == TestDataSeeder.TestUser1Id); + } + + [Fact] + public async Task CreateFlashcard_ShouldOnlyBeAccessibleByOwner() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Step 1: User1 建立新詞卡 + var newFlashcard = new + { + word = "isolation-test", + translation = "隔離測試", + definition = "A test for data isolation", + partOfSpeech = "noun" + }; + + var createResponse = await user1Client.PostAsJsonAsync("/api/flashcards", newFlashcard); + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var createContent = await createResponse.Content.ReadAsStringAsync(); + var createJson = JsonSerializer.Deserialize(createContent); + var newFlashcardId = createJson.GetProperty("data").GetProperty("id").GetString(); + + // Step 2: User2 嘗試存取 User1 的詞卡 + var accessResponse = await user2Client.GetAsync($"/api/flashcards/{newFlashcardId}"); + + // Assert: User2 應該無法存取 User1 的詞卡 + accessResponse.StatusCode.Should().Be(HttpStatusCode.NotFound, "用戶不應該能存取其他用戶的詞卡"); + + // Step 3: User1 應該能正常存取自己的詞卡 + var ownerAccessResponse = await user1Client.GetAsync($"/api/flashcards/{newFlashcardId}"); + ownerAccessResponse.StatusCode.Should().Be(HttpStatusCode.OK, "用戶應該能存取自己的詞卡"); + } + + [Fact] + public async Task ReviewStats_ShouldBeUserSpecific() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Act: 獲取各用戶的複習統計 + var user1StatsResponse = await user1Client.GetAsync("/api/flashcards/review-stats"); + var user2StatsResponse = await user2Client.GetAsync("/api/flashcards/review-stats"); + + // Assert + user1StatsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + user2StatsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var user1StatsContent = await user1StatsResponse.Content.ReadAsStringAsync(); + var user2StatsContent = await user2StatsResponse.Content.ReadAsStringAsync(); + + // 統計資料應該不同 (因為用戶有不同的詞卡和複習歷史) + user1StatsContent.Should().NotBe(user2StatsContent, "不同用戶的統計資料應該不同"); + + // 解析並驗證統計資料結構 + var user1Stats = JsonSerializer.Deserialize(user1StatsContent); + var user2Stats = JsonSerializer.Deserialize(user2StatsContent); + + user1Stats.GetProperty("success").GetBoolean().Should().BeTrue(); + user2Stats.GetProperty("success").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task MasteredFlashcards_ShouldOnlyAffectOwner() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + var user1FlashcardId = TestDataSeeder.TestFlashcard1Id; + + // Step 1: User1 標記詞卡為已掌握 + var masteredResponse = await user1Client.PostAsync($"/api/flashcards/{user1FlashcardId}/mastered", null); + masteredResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Step 2: 驗證只影響 User1 的複習間隔 + using var context = GetDbContext(); + var user1Review = context.FlashcardReviews + .FirstOrDefault(r => r.FlashcardId == user1FlashcardId && r.UserId == TestDataSeeder.TestUser1Id); + + var user2Reviews = context.FlashcardReviews + .Where(r => r.UserId == TestDataSeeder.TestUser2Id) + .ToList(); + + // Assert + user1Review.Should().NotBeNull("User1 應該有複習記錄"); + user1Review!.SuccessCount.Should().BeGreaterThan(0, "User1 的成功次數應該增加"); + + // User2 的複習記錄不應受影響 + user2Reviews.Should().NotContain(r => r.FlashcardId == user1FlashcardId, "User2 不應該有 User1 詞卡的複習記錄"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/EndToEnd/ReviewWorkflowTests.cs b/backend/DramaLing.Api.Tests/Integration/EndToEnd/ReviewWorkflowTests.cs new file mode 100644 index 0000000..45f17c3 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/EndToEnd/ReviewWorkflowTests.cs @@ -0,0 +1,277 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DramaLing.Api.Tests.Integration.EndToEnd; + +/// +/// 完整複習流程端對端測試 +/// 驗證從取得詞卡到提交答案再到更新間隔的完整業務流程 +/// +public class ReviewWorkflowTests : IntegrationTestBase +{ + public ReviewWorkflowTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task CompleteReviewWorkflow_ShouldUpdateReviewIntervalCorrectly() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // Step 1: 取得待複習的詞卡 + var dueCardsResponse = await client.GetAsync("/api/flashcards/due"); + dueCardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var dueCardsContent = await dueCardsResponse.Content.ReadAsStringAsync(); + var dueCardsJson = JsonSerializer.Deserialize(dueCardsContent); + + // 驗證詞卡包含在待複習列表中 + var flashcards = dueCardsJson.GetProperty("data").GetProperty("flashcards"); + var targetFlashcard = flashcards.EnumerateArray() + .FirstOrDefault(f => f.GetProperty("id").GetString() == flashcardId.ToString()); + + // Step 2: 提交複習答案 (答對,高信心度) + var reviewRequest = new + { + confidence = 2, // 高信心度 (答對) + wasSkipped = false, + responseTime = 3500 + }; + + var submitResponse = await client.PostAsJsonAsync($"/api/flashcards/{flashcardId}/review", reviewRequest); + submitResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var submitContent = await submitResponse.Content.ReadAsStringAsync(); + var submitJson = JsonSerializer.Deserialize(submitContent); + + // Step 3: 驗證複習結果 + var reviewResult = submitJson.GetProperty("data"); + reviewResult.GetProperty("newSuccessCount").GetInt32().Should().BeGreaterThan(0); + + var nextReviewDate = DateTime.Parse(reviewResult.GetProperty("nextReviewDate").GetString()!); + nextReviewDate.Should().BeAfter(DateTime.UtcNow.AddHours(12)); // 至少12小時後 + + // Step 4: 驗證詞卡不會立即出現在待複習列表 + var newDueCardsResponse = await client.GetAsync("/api/flashcards/due"); + var newDueCardsContent = await newDueCardsResponse.Content.ReadAsStringAsync(); + var newDueCardsJson = JsonSerializer.Deserialize(newDueCardsContent); + + var newFlashcards = newDueCardsJson.GetProperty("data").GetProperty("flashcards"); + var isStillDue = newFlashcards.EnumerateArray() + .Any(f => f.GetProperty("id").GetString() == flashcardId.ToString()); + + isStillDue.Should().BeFalse("詞卡答對後應該不會立即出現在待複習列表"); + } + + [Fact] + public async Task ReviewWorkflow_AnswerWrong_ShouldResetInterval() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard2Id; // 使用另一張詞卡 + + // Act: 提交錯誤答案 (信心度 0) + var reviewRequest = new + { + confidence = 0, // 不熟悉 (答錯) + wasSkipped = false, + responseTime = 8000 + }; + + var response = await client.PostAsJsonAsync($"/api/flashcards/{flashcardId}/review", reviewRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + var jsonResponse = JsonSerializer.Deserialize(content); + + var reviewResult = jsonResponse.GetProperty("data"); + reviewResult.GetProperty("newSuccessCount").GetInt32().Should().Be(0, "答錯時成功次數應該重置為0"); + + var nextReviewDate = DateTime.Parse(reviewResult.GetProperty("nextReviewDate").GetString()!); + var hoursUntilNextReview = (nextReviewDate - DateTime.UtcNow).TotalHours; + hoursUntilNextReview.Should().BeLessThan(25, "答錯時應該在24小時內再次複習"); + } + + [Fact] + public async Task ReviewWorkflow_Skip_ShouldScheduleForTomorrow() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // Act: 跳過詞卡 + var reviewRequest = new + { + confidence = 0, + wasSkipped = true, + responseTime = 500 + }; + + var response = await client.PostAsJsonAsync($"/api/flashcards/{flashcardId}/review", reviewRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + var jsonResponse = JsonSerializer.Deserialize(content); + + var reviewResult = jsonResponse.GetProperty("data"); + var nextReviewDate = DateTime.Parse(reviewResult.GetProperty("nextReviewDate").GetString()!); + var hoursUntilNextReview = (nextReviewDate - DateTime.UtcNow).TotalHours; + + hoursUntilNextReview.Should().BeInRange(20, 26, "跳過的詞卡應該明天複習"); + } + + [Fact] + public async Task MarkWordMastered_ShouldUpdateIntervalExponentially() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // 先取得當前的成功次數 + using var beforeContext = GetDbContext(); + var beforeReview = beforeContext.FlashcardReviews + .FirstOrDefault(r => r.FlashcardId == flashcardId && r.UserId == TestDataSeeder.TestUser1Id); + var beforeSuccessCount = beforeReview?.SuccessCount ?? 0; + + // Act: 標記為已掌握 + var response = await client.PostAsync($"/api/flashcards/{flashcardId}/mastered", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + var jsonResponse = JsonSerializer.Deserialize(content); + + var result = jsonResponse.GetProperty("data"); + var newSuccessCount = result.GetProperty("successCount").GetInt32(); + var intervalDays = result.GetProperty("intervalDays").GetInt32(); + + newSuccessCount.Should().Be(beforeSuccessCount + 1, "成功次數應該增加1"); + + // 驗證指數增長算法: 間隔 = 2^成功次數 天 + var expectedInterval = (int)Math.Pow(2, newSuccessCount); + var maxInterval = 180; // 最大間隔 + var expectedFinalInterval = Math.Min(expectedInterval, maxInterval); + + intervalDays.Should().Be(expectedFinalInterval, $"間隔應該遵循 2^{newSuccessCount} = {expectedInterval} 天的公式"); + } + + [Fact] + public async Task ReviewStats_ShouldReflectReviewActivity() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcardId = TestDataSeeder.TestFlashcard1Id; + + // Step 1: 取得複習前的統計 + var beforeStatsResponse = await client.GetAsync("/api/flashcards/review-stats"); + beforeStatsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var beforeStatsContent = await beforeStatsResponse.Content.ReadAsStringAsync(); + var beforeStats = JsonSerializer.Deserialize(beforeStatsContent); + + var beforeTotalReviews = beforeStats.GetProperty("data").GetProperty("totalReviews").GetInt32(); + + // Step 2: 進行複習 + var reviewRequest = new + { + confidence = 2, + wasSkipped = false, + responseTime = 2000 + }; + + await client.PostAsJsonAsync($"/api/flashcards/{flashcardId}/review", reviewRequest); + + // Step 3: 驗證統計數據更新 + var afterStatsResponse = await client.GetAsync("/api/flashcards/review-stats"); + var afterStatsContent = await afterStatsResponse.Content.ReadAsStringAsync(); + var afterStats = JsonSerializer.Deserialize(afterStatsContent); + + // 注意:根據實際的統計實作,這個檢驗可能需要調整 + // 目前的實作可能沒有立即更新 todayReviewed 等統計 + afterStats.GetProperty("data").Should().NotBeNull("統計資料應該存在"); + } + + [Fact] + public async Task MultipleReviews_ShouldMaintainCorrectState() + { + // Arrange + var client = CreateTestUser1Client(); + var flashcard1Id = TestDataSeeder.TestFlashcard1Id; + var flashcard2Id = TestDataSeeder.TestFlashcard2Id; + + // Act: 對多張詞卡進行不同類型的複習 + + // 詞卡1: 答對 + await client.PostAsJsonAsync($"/api/flashcards/{flashcard1Id}/review", new + { + confidence = 2, + wasSkipped = false, + responseTime = 2000 + }); + + // 詞卡2: 答錯 + await client.PostAsJsonAsync($"/api/flashcards/{flashcard2Id}/review", new + { + confidence = 0, + wasSkipped = false, + responseTime = 5000 + }); + + // Assert: 驗證複習記錄的狀態 + using var context = GetDbContext(); + var reviews = context.FlashcardReviews + .Where(r => r.UserId == TestDataSeeder.TestUser1Id) + .ToList(); + + var review1 = reviews.First(r => r.FlashcardId == flashcard1Id); + var review2 = reviews.First(r => r.FlashcardId == flashcard2Id); + + // 詞卡1 (答對): 成功次數應該增加 + review1.SuccessCount.Should().BeGreaterThan(0); + review1.NextReviewDate.Should().BeAfter(DateTime.UtcNow.AddHours(12)); + + // 詞卡2 (答錯): 成功次數應該重置為0 + review2.SuccessCount.Should().Be(0); + review2.NextReviewDate.Should().BeBefore(DateTime.UtcNow.AddHours(25)); + } + + [Fact] + public async Task ReviewWorkflow_ShouldHandleUserDataIsolation() + { + // Arrange + var user1Client = CreateTestUser1Client(); + var user2Client = CreateTestUser2Client(); + + // Act: 兩個用戶分別取得待複習詞卡 + var user1DueResponse = await user1Client.GetAsync("/api/flashcards/due"); + var user2DueResponse = await user2Client.GetAsync("/api/flashcards/due"); + + // Assert + user1DueResponse.StatusCode.Should().Be(HttpStatusCode.OK); + user2DueResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var user1Content = await user1DueResponse.Content.ReadAsStringAsync(); + var user2Content = await user2DueResponse.Content.ReadAsStringAsync(); + + var user1Json = JsonSerializer.Deserialize(user1Content); + var user2Json = JsonSerializer.Deserialize(user2Content); + + var user1Flashcards = user1Json.GetProperty("data").GetProperty("flashcards"); + var user2Flashcards = user2Json.GetProperty("data").GetProperty("flashcards"); + + // 驗證用戶資料隔離 + var user1HasUser2Cards = user1Flashcards.EnumerateArray() + .Any(f => f.GetProperty("id").GetString() == TestDataSeeder.TestFlashcard3Id.ToString()); + + var user2HasUser1Cards = user2Flashcards.EnumerateArray() + .Any(f => f.GetProperty("id").GetString() == TestDataSeeder.TestFlashcard1Id.ToString()); + + user1HasUser2Cards.Should().BeFalse("用戶1不應該看到用戶2的詞卡"); + user2HasUser1Cards.Should().BeFalse("用戶2不應該看到用戶1的詞卡"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Fixtures/JwtTestHelper.cs b/backend/DramaLing.Api.Tests/Integration/Fixtures/JwtTestHelper.cs new file mode 100644 index 0000000..6e3f82a --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Fixtures/JwtTestHelper.cs @@ -0,0 +1,167 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace DramaLing.Api.Tests.Integration.Fixtures; + +/// +/// JWT 測試助手類別 +/// 提供測試用的 JWT Token 生成功能 +/// +public static class JwtTestHelper +{ + private const string TestSecretKey = "test-secret-minimum-32-characters-long-for-jwt-signing-in-test-mode-only"; + private const string TestIssuer = "https://test.supabase.co"; + private const string TestAudience = "authenticated"; + + /// + /// 為指定使用者生成測試用 JWT Token + /// + public static string GenerateJwtToken(Guid userId, string? email = null, string? username = null) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(TestSecretKey); + + var claims = new List + { + new("sub", userId.ToString()), + new("aud", TestAudience), + new("iss", TestIssuer), + new("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), + new("exp", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) + }; + + // 添加可選的 claims + if (!string.IsNullOrEmpty(email)) + claims.Add(new Claim("email", email)); + + if (!string.IsNullOrEmpty(username)) + claims.Add(new Claim("preferred_username", username)); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = TestIssuer, + Audience = TestAudience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// 為 TestUser1 生成 JWT Token + /// + public static string GenerateTestUser1Token() + { + return GenerateJwtToken( + TestDataSeeder.TestUser1Id, + "test1@example.com", + "testuser1" + ); + } + + /// + /// 為 TestUser2 生成 JWT Token + /// + public static string GenerateTestUser2Token() + { + return GenerateJwtToken( + TestDataSeeder.TestUser2Id, + "test2@example.com", + "testuser2" + ); + } + + /// + /// 生成已過期的 JWT Token (用於測試無效 token) + /// + public static string GenerateExpiredJwtToken(Guid userId) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(TestSecretKey); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim("sub", userId.ToString()), + new Claim("aud", TestAudience) + }), + Expires = DateTime.UtcNow.AddHours(-1), // 1 小時前過期 + IssuedAt = DateTime.UtcNow.AddHours(-2), // 2 小時前簽發 + // 不設置 NotBefore,讓它使用預設值 + Issuer = TestIssuer, + Audience = TestAudience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// 生成無效簽章的 JWT Token (用於測試無效 token) + /// + public static string GenerateInvalidSignatureToken(Guid userId) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var wrongKey = Encoding.UTF8.GetBytes("wrong-secret-key-for-invalid-signature-test-purposes-only"); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim("sub", userId.ToString()), + new Claim("aud", TestAudience) + }), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = TestIssuer, + Audience = TestAudience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(wrongKey), // 使用錯誤的 key + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + /// + /// 驗證 JWT Token 是否有效 (用於測試驗證) + /// + public static ClaimsPrincipal? ValidateToken(string token) + { + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(TestSecretKey); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidIssuer = TestIssuer, + ValidateAudience = true, + ValidAudience = TestAudience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + var principal = tokenHandler.ValidateToken(token, validationParameters, out _); + return principal; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Fixtures/TestDataSeeder.cs b/backend/DramaLing.Api.Tests/Integration/Fixtures/TestDataSeeder.cs new file mode 100644 index 0000000..5c89756 --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Fixtures/TestDataSeeder.cs @@ -0,0 +1,176 @@ +using DramaLing.Api.Data; +using DramaLing.Api.Models.Entities; + +namespace DramaLing.Api.Tests.Integration.Fixtures; + +/// +/// 測試資料種子類別 +/// 提供一致的測試資料給所有整合測試使用 +/// +public static class TestDataSeeder +{ + // 測試使用者 IDs + public static readonly Guid TestUser1Id = new("11111111-1111-1111-1111-111111111111"); + public static readonly Guid TestUser2Id = new("22222222-2222-2222-2222-222222222222"); + + // 測試詞卡 IDs + public static readonly Guid TestFlashcard1Id = new("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"); + public static readonly Guid TestFlashcard2Id = new("BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"); + public static readonly Guid TestFlashcard3Id = new("CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"); + + /// + /// 種子測試資料 + /// + public static void SeedTestData(DramaLingDbContext context) + { + // 如果已有資料則跳過 + if (context.Users.Any()) return; + + SeedUsers(context); + SeedFlashcards(context); + SeedFlashcardReviews(context); + + context.SaveChanges(); + } + + private static void SeedUsers(DramaLingDbContext context) + { + var users = new[] + { + new User + { + Id = TestUser1Id, + Username = "testuser1", + Email = "test1@example.com", + PasswordHash = "$2a$11$TestHashForUser1Password123", // bcrypt hash for "password123" + DisplayName = "Test User 1", + CreatedAt = DateTime.UtcNow.AddDays(-30), + UpdatedAt = DateTime.UtcNow + }, + new User + { + Id = TestUser2Id, + Username = "testuser2", + Email = "test2@example.com", + PasswordHash = "$2a$11$TestHashForUser2Password456", // bcrypt hash for "password456" + DisplayName = "Test User 2", + CreatedAt = DateTime.UtcNow.AddDays(-15), + UpdatedAt = DateTime.UtcNow + } + }; + + context.Users.AddRange(users); + } + + private static void SeedFlashcards(DramaLingDbContext context) + { + var flashcards = new[] + { + new Flashcard + { + Id = TestFlashcard1Id, + UserId = TestUser1Id, + Word = "hello", + Translation = "你好", + Definition = "A greeting used when meeting someone", + PartOfSpeech = "interjection", + Pronunciation = "/həˈloʊ/", + Example = "Hello, how are you today?", + ExampleTranslation = "你好,你今天好嗎?", + DifficultyLevelNumeric = 1, // A1 + IsFavorite = false, + Synonyms = "[\"hi\", \"greetings\", \"salutations\"]", + CreatedAt = DateTime.UtcNow.AddDays(-10), + UpdatedAt = DateTime.UtcNow.AddDays(-5) + }, + new Flashcard + { + Id = TestFlashcard2Id, + UserId = TestUser1Id, + Word = "beautiful", + Translation = "美麗的", + Definition = "Having qualities that give great pleasure to see or hear", + PartOfSpeech = "adjective", + Pronunciation = "/ˈbjuː.tɪ.fəl/", + Example = "The sunset was absolutely beautiful.", + ExampleTranslation = "夕陽非常美麗。", + DifficultyLevelNumeric = 2, // A2 + IsFavorite = true, + Synonyms = "[\"gorgeous\", \"stunning\", \"lovely\"]", + CreatedAt = DateTime.UtcNow.AddDays(-8), + UpdatedAt = DateTime.UtcNow.AddDays(-3) + }, + new Flashcard + { + Id = TestFlashcard3Id, + UserId = TestUser2Id, + Word = "sophisticated", + Translation = "精緻的,複雜的", + Definition = "Having a refined knowledge of the ways of the world", + PartOfSpeech = "adjective", + Pronunciation = "/səˈfɪs.tɪ.keɪ.tɪd/", + Example = "She has very sophisticated taste in art.", + ExampleTranslation = "她對藝術有非常精緻的品味。", + DifficultyLevelNumeric = 5, // C1 + IsFavorite = false, + Synonyms = "[\"refined\", \"elegant\", \"cultured\"]", + CreatedAt = DateTime.UtcNow.AddDays(-5), + UpdatedAt = DateTime.UtcNow.AddDays(-1) + } + }; + + context.Flashcards.AddRange(flashcards); + } + + private static void SeedFlashcardReviews(DramaLingDbContext context) + { + var reviews = new[] + { + new FlashcardReview + { + Id = Guid.NewGuid(), + UserId = TestUser1Id, + FlashcardId = TestFlashcard1Id, + SuccessCount = 3, + TotalCorrectCount = 5, + TotalWrongCount = 2, + TotalSkipCount = 1, + LastReviewDate = DateTime.UtcNow.AddDays(-2), + LastSuccessDate = DateTime.UtcNow.AddDays(-2), + NextReviewDate = DateTime.UtcNow.AddDays(4), // 2^3 = 8 天後 (但已過 4 天) + CreatedAt = DateTime.UtcNow.AddDays(-10), + UpdatedAt = DateTime.UtcNow.AddDays(-2) + }, + new FlashcardReview + { + Id = Guid.NewGuid(), + UserId = TestUser1Id, + FlashcardId = TestFlashcard2Id, + SuccessCount = 1, + TotalCorrectCount = 2, + TotalWrongCount = 3, + TotalSkipCount = 0, + LastReviewDate = DateTime.UtcNow.AddDays(-3), + LastSuccessDate = DateTime.UtcNow.AddDays(-3), + NextReviewDate = DateTime.UtcNow.AddDays(-1), // 應該要複習了 + CreatedAt = DateTime.UtcNow.AddDays(-8), + UpdatedAt = DateTime.UtcNow.AddDays(-3) + } + // TestFlashcard3 沒有複習記錄 (新詞卡) + }; + + context.FlashcardReviews.AddRange(reviews); + } + + /// + /// 清除所有測試資料 + /// + public static void ClearTestData(DramaLingDbContext context) + { + context.FlashcardReviews.RemoveRange(context.FlashcardReviews); + context.Flashcards.RemoveRange(context.Flashcards); + context.Users.RemoveRange(context.Users); + context.OptionsVocabularies.RemoveRange(context.OptionsVocabularies); + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/FrameworkTests.cs b/backend/DramaLing.Api.Tests/Integration/FrameworkTests.cs new file mode 100644 index 0000000..f99f22e --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/FrameworkTests.cs @@ -0,0 +1,144 @@ +using DramaLing.Api.Tests.Integration.Fixtures; +using System.Net; + +namespace DramaLing.Api.Tests.Integration; + +/// +/// 測試框架驗證測試 +/// 確保整合測試基礎設施正常工作 +/// +public class FrameworkTests : IntegrationTestBase +{ + public FrameworkTests(DramaLingWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task WebApplicationFactory_ShouldStartSuccessfully() + { + // Arrange & Act + var response = await HttpClient.GetAsync("/"); + + // Assert + // 不期望特定狀態碼,只要應用程式能啟動即可 + response.Should().NotBeNull(); + } + + [Fact] + public void TestDataSeeder_ShouldCreateConsistentTestData() + { + // Arrange & Act + using var context = GetDbContext(); + + // Assert + var users = context.Users.ToList(); + users.Should().HaveCount(2); + users.Should().Contain(u => u.Id == TestDataSeeder.TestUser1Id); + users.Should().Contain(u => u.Id == TestDataSeeder.TestUser2Id); + + var flashcards = context.Flashcards.ToList(); + flashcards.Should().HaveCount(3); + flashcards.Should().Contain(f => f.Id == TestDataSeeder.TestFlashcard1Id); + + var reviews = context.FlashcardReviews.ToList(); + reviews.Should().HaveCount(2); + reviews.Should().OnlyContain(r => r.UserId == TestDataSeeder.TestUser1Id); + } + + [Fact] + public void JwtTestHelper_ShouldGenerateValidTokens() + { + // Arrange + var userId = TestDataSeeder.TestUser1Id; + + // Act + var token = JwtTestHelper.GenerateJwtToken(userId, "test@example.com", "testuser"); + + // Assert + token.Should().NotBeNullOrEmpty(); + + var principal = JwtTestHelper.ValidateToken(token); + principal.Should().NotBeNull(); + principal!.FindFirst("sub")?.Value.Should().Be(userId.ToString()); + } + + [Fact] + public void JwtTestHelper_ShouldGenerateTokensWithCorrectClaims() + { + // Arrange + var userId = TestDataSeeder.TestUser1Id; + + // Act + var token = JwtTestHelper.GenerateJwtToken(userId, "test@example.com", "testuser"); + + // Assert + token.Should().NotBeNullOrEmpty(); + + var principal = JwtTestHelper.ValidateToken(token); + principal.Should().NotBeNull(); + principal!.FindFirst("sub")?.Value.Should().Be(userId.ToString()); + principal!.FindFirst("email")?.Value.Should().Be("test@example.com"); + principal!.FindFirst("preferred_username")?.Value.Should().Be("testuser"); + } + + [Fact] + public void JwtTestHelper_ShouldDetectInvalidSignature() + { + // Arrange + var userId = TestDataSeeder.TestUser1Id; + + // Act + var invalidToken = JwtTestHelper.GenerateInvalidSignatureToken(userId); + + // Assert + invalidToken.Should().NotBeNullOrEmpty(); + + var principal = JwtTestHelper.ValidateToken(invalidToken); + principal.Should().BeNull("因為簽章無效"); + } + + [Fact] + public async Task CreateAuthenticatedClient_ShouldWorkCorrectly() + { + // Arrange + var userId = TestDataSeeder.TestUser1Id; + var authenticatedClient = CreateAuthenticatedClient(userId); + + // Act + var response = await authenticatedClient.GetAsync("/"); + + // Assert + response.Should().NotBeNull(); + authenticatedClient.DefaultRequestHeaders.Authorization.Should().NotBeNull(); + authenticatedClient.DefaultRequestHeaders.Authorization!.Scheme.Should().Be("Bearer"); + } + + [Fact] + public void DatabaseReset_ShouldWorkBetweenTests() + { + // Arrange + using var context = GetDbContext(); + var initialUserCount = context.Users.Count(); + + // Act + ResetDatabase(); + + // Assert + using var newContext = GetDbContext(); + var afterResetUserCount = newContext.Users.Count(); + afterResetUserCount.Should().Be(initialUserCount, "資料庫重置後應該還原到初始狀態"); + } + + [Fact] + public async Task SendRequestExpectingError_ShouldHandleErrorResponsesCorrectly() + { + // Arrange + var nonExistentEndpoint = "/api/nonexistent"; + + // Act + var response = await SendRequestExpectingError(HttpMethod.Get, nonExistentEndpoint); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/IntegrationTestBase.cs b/backend/DramaLing.Api.Tests/Integration/IntegrationTestBase.cs new file mode 100644 index 0000000..8eff99d --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/IntegrationTestBase.cs @@ -0,0 +1,213 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Json; +using System.Text.Json; +using DramaLing.Api.Data; +using DramaLing.Api.Tests.Integration.Fixtures; + +namespace DramaLing.Api.Tests.Integration; + +/// +/// 整合測試基底類別 +/// 提供所有整合測試的共用功能和設定 +/// +public abstract class IntegrationTestBase : IClassFixture, IDisposable +{ + protected readonly DramaLingWebApplicationFactory Factory; + protected readonly HttpClient HttpClient; + protected readonly JsonSerializerOptions JsonOptions; + + protected IntegrationTestBase(DramaLingWebApplicationFactory factory) + { + Factory = factory; + HttpClient = factory.CreateClient(); + + // 設定 JSON 序列化選項 + JsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + // 每個測試開始前重置資料庫 + ResetDatabase(); + } + + /// + /// 重置測試資料庫 + /// + protected void ResetDatabase() + { + Factory.ResetDatabase(); + } + + /// + /// 取得測試資料庫上下文 + /// + protected DramaLingDbContext GetDbContext() + { + return Factory.GetDbContext(); + } + + /// + /// 建立帶有認證的 HttpClient + /// + protected HttpClient CreateAuthenticatedClient(Guid userId) + { + var token = JwtTestHelper.GenerateJwtToken(userId); + return Factory.CreateClientWithAuth(token); + } + + /// + /// 建立 TestUser1 的認證 HttpClient + /// + protected HttpClient CreateTestUser1Client() + { + var token = JwtTestHelper.GenerateTestUser1Token(); + return Factory.CreateClientWithAuth(token); + } + + /// + /// 建立 TestUser2 的認證 HttpClient + /// + protected HttpClient CreateTestUser2Client() + { + var token = JwtTestHelper.GenerateTestUser2Token(); + return Factory.CreateClientWithAuth(token); + } + + /// + /// 發送 GET 請求並反序列化回應 + /// + protected async Task GetAsync(string endpoint, HttpClient? client = null) + { + client ??= HttpClient; + var response = await client.GetAsync(endpoint); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"GET {endpoint} failed with status {response.StatusCode}: {content}"); + } + + return JsonSerializer.Deserialize(content, JsonOptions); + } + + /// + /// 發送 POST 請求並反序列化回應 + /// + protected async Task PostAsync(string endpoint, object? data = null, HttpClient? client = null) + { + client ??= HttpClient; + var response = await client.PostAsJsonAsync(endpoint, data, JsonOptions); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"POST {endpoint} failed with status {response.StatusCode}: {content}"); + } + + return JsonSerializer.Deserialize(content, JsonOptions); + } + + /// + /// 發送 PUT 請求並反序列化回應 + /// + protected async Task PutAsync(string endpoint, object data, HttpClient? client = null) + { + client ??= HttpClient; + var response = await client.PutAsJsonAsync(endpoint, data, JsonOptions); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"PUT {endpoint} failed with status {response.StatusCode}: {content}"); + } + + return JsonSerializer.Deserialize(content, JsonOptions); + } + + /// + /// 發送 DELETE 請求 + /// + protected async Task DeleteAsync(string endpoint, HttpClient? client = null) + { + client ??= HttpClient; + var response = await client.DeleteAsync(endpoint); + + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException( + $"DELETE {endpoint} failed with status {response.StatusCode}: {content}"); + } + } + + /// + /// 發送不期望成功的請求,並返回 HttpResponseMessage + /// + protected async Task SendRequestExpectingError( + HttpMethod method, string endpoint, object? data = null, HttpClient? client = null) + { + client ??= HttpClient; + + var request = new HttpRequestMessage(method, endpoint); + if (data != null) + { + request.Content = JsonContent.Create(data, options: JsonOptions); + } + + return await client.SendAsync(request); + } + + /// + /// 等待異步操作完成 (用於測試背景任務) + /// + protected async Task WaitForAsync(Func> condition, TimeSpan timeout = default) + { + if (timeout == default) + timeout = TimeSpan.FromSeconds(30); + + var start = DateTime.UtcNow; + while (DateTime.UtcNow - start < timeout) + { + if (await condition()) + return; + + await Task.Delay(100); + } + + throw new TimeoutException($"Condition was not met within {timeout}"); + } + + /// + /// 驗證 API 回應格式 + /// + protected void AssertApiResponse(object response, bool expectedSuccess = true) + { + response.Should().NotBeNull(); + + // 可以根據你的 ApiResponse 格式調整 + var responseType = response.GetType(); + + if (responseType.GetProperty("Success") != null) + { + var success = (bool)responseType.GetProperty("Success")!.GetValue(response)!; + success.Should().Be(expectedSuccess); + } + + if (expectedSuccess && responseType.GetProperty("Data") != null) + { + var data = responseType.GetProperty("Data")!.GetValue(response); + data.Should().NotBeNull(); + } + } + + public virtual void Dispose() + { + HttpClient?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Integration/Mocks/MockGeminiClient.cs b/backend/DramaLing.Api.Tests/Integration/Mocks/MockGeminiClient.cs new file mode 100644 index 0000000..648b39c --- /dev/null +++ b/backend/DramaLing.Api.Tests/Integration/Mocks/MockGeminiClient.cs @@ -0,0 +1,145 @@ +using DramaLing.Api.Services.AI.Gemini; +using System.Text.Json; + +namespace DramaLing.Api.Tests.Integration.Mocks; + +/// +/// 測試用的 Mock Gemini Client +/// 提供穩定可預測的 AI 服務回應,不依賴外部 API +/// +public class MockGeminiClient : IGeminiClient +{ + /// + /// 模擬 Gemini API 調用 + /// 根據 prompt 內容返回預定義的測試回應 + /// + public async Task CallGeminiAPIAsync(string prompt) + { + await Task.Delay(50); // 模擬 API 延遲 + + // 根據 prompt 類型返回不同的 mock 回應 + if (prompt.Contains("generate distractors") || prompt.Contains("混淆選項")) + { + return GenerateDistractorsMockResponse(prompt); + } + + if (prompt.Contains("analyze sentence") || prompt.Contains("句子分析")) + { + return GenerateSentenceAnalysisMockResponse(prompt); + } + + if (prompt.Contains("synonyms") || prompt.Contains("同義詞")) + { + return GenerateSynonymsMockResponse(prompt); + } + + // 預設回應 + return JsonSerializer.Serialize(new + { + response = "Mock response from Gemini API", + timestamp = DateTime.UtcNow, + prompt_length = prompt.Length + }); + } + + /// + /// 測試連線 - 在測試環境中永遠回傳成功 + /// + public async Task TestConnectionAsync() + { + await Task.Delay(10); + return true; + } + + private string GenerateDistractorsMockResponse(string prompt) + { + // 從 prompt 中提取目標詞彙 (簡化邏輯) + var targetWord = ExtractTargetWord(prompt); + + var distractors = targetWord.ToLower() switch + { + "hello" => new[] { "goodbye", "welcome", "thanks" }, + "beautiful" => new[] { "ugly", "plain", "ordinary" }, + "sophisticated" => new[] { "simple", "basic", "crude" }, + _ => new[] { "option1", "option2", "option3" } + }; + + return JsonSerializer.Serialize(new + { + distractors = distractors, + target_word = targetWord, + generated_at = DateTime.UtcNow + }); + } + + private string GenerateSentenceAnalysisMockResponse(string prompt) + { + return JsonSerializer.Serialize(new + { + analysis = new + { + difficulty = "A2", + grammar_points = new[] { "present simple", "adjectives" }, + vocabulary = new[] { "basic", "intermediate" }, + suggestions = new[] { "Good sentence structure", "Clear meaning" } + }, + words = new[] + { + new + { + word = "example", + translation = "範例", + part_of_speech = "noun", + difficulty = "A2", + synonyms = new[] { "sample", "instance" } + } + }, + generated_at = DateTime.UtcNow + }); + } + + private string GenerateSynonymsMockResponse(string prompt) + { + var targetWord = ExtractTargetWord(prompt); + + var synonyms = targetWord.ToLower() switch + { + "hello" => new[] { "hi", "greetings", "salutations" }, + "beautiful" => new[] { "gorgeous", "stunning", "lovely" }, + "sophisticated" => new[] { "refined", "elegant", "cultured" }, + _ => new[] { "synonym1", "synonym2", "synonym3" } + }; + + return JsonSerializer.Serialize(synonyms); + } + + private string ExtractTargetWord(string prompt) + { + // 簡化的詞彙提取邏輯 + // 實際實作中可能會更複雜 + var words = prompt.Split(' '); + + // 尋找可能的目標詞彙 + foreach (var word in words) + { + var cleanWord = word.Trim('"', '\'', ',', '.', '!', '?').ToLower(); + if (cleanWord.Length > 2 && !IsCommonWord(cleanWord)) + { + return cleanWord; + } + } + + return "unknown"; + } + + private bool IsCommonWord(string word) + { + var commonWords = new HashSet + { + "the", "and", "or", "but", "for", "with", "from", "to", "of", "in", "on", "at", + "generate", "create", "make", "find", "get", "give", "word", "words", "options" + }; + + return commonWords.Contains(word); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Services/OptionsVocabularyServiceTests.cs b/backend/DramaLing.Api.Tests/Services/OptionsVocabularyServiceTests.cs deleted file mode 100644 index 08aaf9f..0000000 --- a/backend/DramaLing.Api.Tests/Services/OptionsVocabularyServiceTests.cs +++ /dev/null @@ -1,300 +0,0 @@ -using DramaLing.Api.Models.Entities; -using DramaLing.Api.Services; -using Microsoft.Extensions.Logging; -using Moq; - -namespace DramaLing.Api.Tests.Services; - -/// -/// OptionsVocabularyService 單元測試 -/// -public class OptionsVocabularyServiceTests : TestBase -{ - private readonly OptionsVocabularyService _service; - private readonly Mock> _mockLogger; - - public OptionsVocabularyServiceTests() - { - _mockLogger = CreateMockLogger(); - _service = new OptionsVocabularyService(DbContext, MemoryCache, _mockLogger.Object); - } - - #region GenerateDistractorsAsync Tests - - [Fact] - public async Task GenerateDistractorsAsync_WithValidParameters_ShouldReturnDistractors() - { - // Arrange - await SeedTestVocabularyData(); - - // Act - var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCountLessOrEqualTo(3); - result.Should().NotContain("target"); // 不應包含目標詞彙 - } - - [Fact] - public async Task GenerateDistractorsAsync_WithNoAvailableVocabulary_ShouldReturnEmptyList() - { - // Arrange - 空資料庫 - - // Act - var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public async Task GenerateDistractorsAsync_WithInsufficientVocabulary_ShouldReturnAvailableWords() - { - // Arrange - await SeedLimitedVocabularyData(); // 只有2個詞彙 - - // Act - var result = await _service.GenerateDistractorsAsync("target", "A1", "noun", 5); - - // Assert - result.Should().HaveCount(2); // 只能返回2個 - } - - [Fact] - public async Task GenerateDistractorsAsync_ShouldExcludeTargetWord() - { - // Arrange - await SeedTestVocabularyData(); - - // Act - var result = await _service.GenerateDistractorsAsync("cat", "A1", "noun", 3); - - // Assert - result.Should().NotContain("cat"); - } - - [Theory] - [InlineData("", "B1", "noun", 3)] - [InlineData("word", "", "noun", 3)] - [InlineData("word", "B1", "", 3)] - [InlineData("word", "B1", "noun", 0)] - public async Task GenerateDistractorsAsync_WithInvalidParameters_ShouldHandleGracefully( - string targetWord, string cefrLevel, string partOfSpeech, int count) - { - // Act & Assert - var result = await _service.GenerateDistractorsAsync(targetWord, cefrLevel, partOfSpeech, count); - result.Should().NotBeNull(); - } - - #endregion - - #region GenerateDistractorsWithDetailsAsync Tests - - [Fact] - public async Task GenerateDistractorsWithDetailsAsync_ShouldReturnDetailedVocabulary() - { - // Arrange - await SeedTestVocabularyData(); - - // Act - var result = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 2); - - // Assert - result.Should().NotBeNull(); - result.Should().HaveCountLessOrEqualTo(2); - result.Should().OnlyContain(v => v.CEFRLevel != null && v.PartOfSpeech != null); - } - - [Fact] - public async Task GenerateDistractorsWithDetailsAsync_ShouldMatchWordLengthRange() - { - // Arrange - await SeedTestVocabularyData(); - const string targetWord = "hello"; // 5個字元 - - // Act - var result = await _service.GenerateDistractorsWithDetailsAsync(targetWord, "B1", "noun", 5); - - // Assert - result.Should().OnlyContain(v => v.WordLength >= 3 && v.WordLength <= 7); // targetLength ± 2 - } - - #endregion - - #region HasSufficientVocabularyAsync Tests - - [Fact] - public async Task HasSufficientVocabularyAsync_WithSufficientVocabulary_ShouldReturnTrue() - { - // Arrange - await SeedTestVocabularyData(); - - // Act - var result = await _service.HasSufficientVocabularyAsync("A1", "noun"); - - // Assert - result.Should().BeTrue(); - } - - [Fact] - public async Task HasSufficientVocabularyAsync_WithInsufficientVocabulary_ShouldReturnFalse() - { - // Arrange - await SeedLimitedVocabularyData(); - - // Act - var result = await _service.HasSufficientVocabularyAsync("C2", "adverb"); - - // Assert - result.Should().BeFalse(); - } - - [Fact] - public async Task HasSufficientVocabularyAsync_WithEmptyDatabase_ShouldReturnFalse() - { - // Act - var result = await _service.HasSufficientVocabularyAsync("B1", "noun"); - - // Assert - result.Should().BeFalse(); - } - - #endregion - - #region Caching Tests - - [Fact] - public async Task GenerateDistractorsWithDetailsAsync_ShouldUseCaching() - { - // Arrange - await SeedTestVocabularyData(); - - // Act - 第一次呼叫 - var result1 = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); - - // Act - 第二次呼叫(應該使用快取) - var result2 = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); - - // Assert - result1.Should().NotBeNull(); - result2.Should().NotBeNull(); - // 注意:由於有隨機性,我們主要測試快取功能不會拋出異常 - } - - [Fact] - public async Task CacheInvalidation_ShouldWorkCorrectly() - { - // Arrange - await SeedTestVocabularyData(); - await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); - - // Act - ClearCache(); - var resultAfterClearCache = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); - - // Assert - resultAfterClearCache.Should().NotBeNull(); - } - - #endregion - - #region CEFR Level Tests - - [Fact] - public async Task GenerateDistractorsAsync_ShouldIncludeAdjacentCEFRLevels() - { - // Arrange - await SeedVocabularyWithDifferentLevels(); - - // Act - var result = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 10); - - // Assert - result.Should().Contain(v => v.CEFRLevel == "A2" || v.CEFRLevel == "B1" || v.CEFRLevel == "B2"); - } - - #endregion - - #region Error Handling Tests - - [Fact] - public async Task GenerateDistractorsAsync_WithDatabaseError_ShouldReturnEmptyListAndLog() - { - // Arrange - await DbContext.Database.EnsureDeletedAsync(); // 破壞資料庫連接 - - // Act - var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); - - // Assert - result.Should().BeEmpty(); - - // 驗證日誌記錄 - _mockLogger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error generating distractors")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - #endregion - - #region Test Data Setup - - private async Task SeedTestVocabularyData() - { - var vocabularies = new[] - { - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "cat", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "dog", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "house", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "beautiful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "quickly", CEFRLevel = "B1", PartOfSpeech = "adverb", WordLength = 7, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "table", CEFRLevel = "A2", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "computer", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "wonderful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true } - }; - - DbContext.OptionsVocabularies.AddRange(vocabularies); - await DbContext.SaveChangesAsync(); - } - - private async Task SeedLimitedVocabularyData() - { - var vocabularies = new[] - { - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "cat", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "dog", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true } - }; - - DbContext.OptionsVocabularies.AddRange(vocabularies); - await DbContext.SaveChangesAsync(); - } - - private async Task SeedVocabularyWithDifferentLevels() - { - var vocabularies = new[] - { - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "cat", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "dog", CEFRLevel = "A2", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "book", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 4, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "table", CEFRLevel = "B2", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "chair", CEFRLevel = "C1", PartOfSpeech = "noun", WordLength = 5, IsActive = true } - }; - - DbContext.OptionsVocabularies.AddRange(vocabularies); - await DbContext.SaveChangesAsync(); - } - - #endregion - - protected override void Dispose() - { - ClearDatabase(); - base.Dispose(); - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api.Tests/Services/QuestionGeneratorServiceTests.cs b/backend/DramaLing.Api.Tests/Services/QuestionGeneratorServiceTests.cs deleted file mode 100644 index fd87f75..0000000 --- a/backend/DramaLing.Api.Tests/Services/QuestionGeneratorServiceTests.cs +++ /dev/null @@ -1,336 +0,0 @@ -using DramaLing.Api.Models.Entities; -using DramaLing.Api.Services; -using Microsoft.Extensions.Logging; -using Moq; - -namespace DramaLing.Api.Tests.Services; - -/// -/// QuestionGeneratorService 整合測試 -/// -public class QuestionGeneratorServiceTests : TestBase -{ - private readonly QuestionGeneratorService _questionService; - private readonly OptionsVocabularyService _optionsService; - private readonly Mock> _mockQuestionLogger; - private readonly Mock> _mockOptionsLogger; - - public QuestionGeneratorServiceTests() - { - _mockQuestionLogger = CreateMockLogger(); - _mockOptionsLogger = CreateMockLogger(); - - _optionsService = new OptionsVocabularyService(DbContext, MemoryCache, _mockOptionsLogger.Object); - _questionService = new QuestionGeneratorService(DbContext, _optionsService, _mockQuestionLogger.Object); - } - - #region Vocab Choice Tests - - [Fact] - public async Task GenerateQuestionAsync_VocabChoice_WithSufficientVocabulary_ShouldUseSmartOptions() - { - // Arrange - await SeedTestData(); - var flashcard = CreateTestFlashcard("beautiful", "B1", "adjective"); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("vocab-choice"); - result.Options.Should().HaveCount(4); // 正確答案 + 3個干擾選項 - result.Options.Should().Contain("beautiful"); - result.CorrectAnswer.Should().Be("beautiful"); - - // 驗證干擾選項來自智能詞彙庫 - var distractors = result.Options!.Where(o => o != "beautiful").ToList(); - distractors.Should().HaveCount(3); - } - - [Fact] - public async Task GenerateQuestionAsync_VocabChoice_WithInsufficientVocabulary_ShouldUseFallback() - { - // Arrange - await SeedLimitedData(); - var flashcard = CreateTestFlashcard("target", "C2", "idiom"); // 使用較少見的組合 - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("vocab-choice"); - result.Options.Should().HaveCount(4); // 仍應該有4個選項(使用回退機制) - result.CorrectAnswer.Should().Be("target"); - } - - [Fact] - public async Task GenerateQuestionAsync_VocabChoice_ShouldUseFlashcardProperties() - { - // Arrange - await SeedTestData(); - var flashcard = CreateTestFlashcard("computer", "B2", "noun"); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice"); - - // Assert - result.Should().NotBeNull(); - result.CorrectAnswer.Should().Be("computer"); - - // 驗證使用了 Flashcard 的 DifficultyLevel 和 PartOfSpeech - // 這可以通過檢查生成的干擾選項是否符合相應的 CEFR 等級和詞性來間接驗證 - } - - [Fact] - public async Task GenerateQuestionAsync_VocabChoice_WithNullFlashcardProperties_ShouldUseDefaults() - { - // Arrange - await SeedTestData(); - var flashcard = CreateTestFlashcard("test", null, null); // 空的屬性 - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("vocab-choice"); - result.CorrectAnswer.Should().Be("test"); - // 應該使用預設值 B1 和 noun - } - - #endregion - - #region Fill Blank Tests - - [Fact] - public async Task GenerateQuestionAsync_FillBlank_WithValidExample_ShouldCreateBlankedSentence() - { - // Arrange - var flashcard = CreateTestFlashcard("beautiful", "B1", "adjective", "This is a beautiful flower."); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-fill"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("sentence-fill"); - result.BlankedSentence.Should().Contain("______"); - result.BlankedSentence.Should().NotContain("beautiful"); - result.CorrectAnswer.Should().Be("beautiful"); - result.Sentence.Should().Be("This is a beautiful flower."); - } - - [Fact] - public async Task GenerateQuestionAsync_FillBlank_WithoutExample_ShouldThrowException() - { - // Arrange - var flashcard = CreateTestFlashcard("test", "B1", "noun", null); - - // Act & Assert - await Assert.ThrowsAsync( - () => _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-fill")); - } - - #endregion - - #region Sentence Reorder Tests - - [Fact] - public async Task GenerateQuestionAsync_SentenceReorder_ShouldScrambleWords() - { - // Arrange - var flashcard = CreateTestFlashcard("test", "B1", "noun", "This is a simple test sentence."); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-reorder"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("sentence-reorder"); - result.ScrambledWords.Should().NotBeNull(); - result.ScrambledWords!.Length.Should().BeGreaterThan(0); - result.CorrectAnswer.Should().Be("This is a simple test sentence."); - - // 驗證包含所有單字(忽略順序) - var originalWords = "This is a simple test sentence." - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(w => w.Trim('.', ',', '!', '?', ';', ':')); - - foreach (var word in originalWords) - { - result.ScrambledWords.Should().Contain(word); - } - } - - #endregion - - #region Sentence Listening Tests - - [Fact] - public async Task GenerateQuestionAsync_SentenceListening_ShouldProvideOptions() - { - // Arrange - await SeedFlashcardsWithExamples(); - var flashcard = CreateTestFlashcard("test", "B1", "noun", "This is a test sentence."); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-listening"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("sentence-listening"); - result.Options.Should().HaveCount(4); - result.Options.Should().Contain("This is a test sentence."); - result.CorrectAnswer.Should().Be("This is a test sentence."); - result.AudioUrl.Should().Contain(flashcard.Id.ToString()); - } - - #endregion - - #region Error Handling Tests - - [Fact] - public async Task GenerateQuestionAsync_WithNonExistentFlashcard_ShouldThrowException() - { - // Act & Assert - await Assert.ThrowsAsync( - () => _questionService.GenerateQuestionAsync(Guid.NewGuid(), "vocab-choice")); - } - - [Fact] - public async Task GenerateQuestionAsync_WithUnsupportedQuestionType_ShouldReturnBasicQuestion() - { - // Arrange - var flashcard = CreateTestFlashcard("test", "B1", "noun"); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "unsupported-type"); - - // Assert - result.Should().NotBeNull(); - result.QuestionType.Should().Be("unsupported-type"); - result.CorrectAnswer.Should().Be("test"); - } - - #endregion - - #region Performance Tests - - [Fact] - public async Task GenerateQuestionAsync_ShouldCompleteWithinReasonableTime() - { - // Arrange - await SeedTestData(); - var flashcard = CreateTestFlashcard("performance", "B1", "noun"); - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - // Act - var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice"); - - // Assert - stopwatch.Stop(); - result.Should().NotBeNull(); - stopwatch.ElapsedMilliseconds.Should().BeLessThan(500); // 應在500ms內完成 - } - - #endregion - - #region Helper Methods - - private async Task SeedTestData() - { - var vocabularies = new[] - { - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "wonderful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "amazing", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 7, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "fantastic", CEFRLevel = "B2", PartOfSpeech = "adjective", WordLength = 9, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "excellent", CEFRLevel = "A2", PartOfSpeech = "adjective", WordLength = 9, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "computer", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "keyboard", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "monitor", CEFRLevel = "B2", PartOfSpeech = "noun", WordLength = 7, IsActive = true }, - }; - - DbContext.OptionsVocabularies.AddRange(vocabularies); - await DbContext.SaveChangesAsync(); - } - - private async Task SeedLimitedData() - { - var vocabularies = new[] - { - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "cat", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, - new OptionsVocabulary { Id = Guid.NewGuid(), Word = "dog", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true } - }; - - DbContext.OptionsVocabularies.AddRange(vocabularies); - await DbContext.SaveChangesAsync(); - } - - private async Task SeedFlashcardsWithExamples() - { - var user = new User { Id = Guid.NewGuid(), Email = "test@example.com", Username = "testuser" }; - DbContext.Users.Add(user); - - var flashcards = new[] - { - new Flashcard - { - Id = Guid.NewGuid(), - UserId = user.Id, - Word = "example1", - Translation = "範例1", - Definition = "第一個範例", - Example = "This is the first example sentence.", - DifficultyLevel = "B1", - PartOfSpeech = "noun" - }, - new Flashcard - { - Id = Guid.NewGuid(), - UserId = user.Id, - Word = "example2", - Translation = "範例2", - Definition = "第二個範例", - Example = "This is the second example sentence.", - DifficultyLevel = "B1", - PartOfSpeech = "noun" - } - }; - - DbContext.Flashcards.AddRange(flashcards); - await DbContext.SaveChangesAsync(); - } - - private Flashcard CreateTestFlashcard(string word, string? difficultyLevel, string? partOfSpeech, string? example = null) - { - var user = new User { Id = Guid.NewGuid(), Email = "test@example.com", Username = "testuser" }; - DbContext.Users.Add(user); - - var flashcard = new Flashcard - { - Id = Guid.NewGuid(), - UserId = user.Id, - Word = word, - Translation = $"{word} 的翻譯", - Definition = $"{word} 的定義", - Example = example, - DifficultyLevel = difficultyLevel, - PartOfSpeech = partOfSpeech - }; - - DbContext.Flashcards.Add(flashcard); - DbContext.SaveChanges(); - - return flashcard; - } - - #endregion - - protected override void Dispose() - { - ClearDatabase(); - base.Dispose(); - } -} \ No newline at end of file