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