From 01f6a1fd30e6ccfc6b7d5a90c552ed75dabea5ca Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 13 Jun 2026 14:23:54 +0200 Subject: [PATCH] test(backend): add Phase 3 unit tests for Story Integration - StoryServiceTests.cs: 536 lines, comprehensive tests for StoryService - StoryGenerationServiceTests.cs: 469 lines, tests for AI story generation - StoryUnlockServiceTests.cs: 292 lines, tests for story unlock logic - StoryRepositoryTests.cs: 120 lines, tests for EF Core repository - StoryProgressRepositoryTests.cs: 74 lines, tests for progress repository - AppDbContext.cs: Made DbSet properties virtual for Moq compatibility - Fixed return types in StoryRepository and StoryProgressRepository - Updated docs/features/story-integration.md for Phase 3 completion - All 324 unit tests pass Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../Data/DbContext/AppDbContext.cs | 20 +- .../Repositories/StoryProgressRepository.cs | 2 +- .../Data/Repositories/StoryRepository.cs | 2 +- .../Services/StoryGenerationServiceTests.cs | 510 +++++++++++------ .../Application/Services/StoryServiceTests.cs | 536 ++++++++++++++++++ .../Services/StoryUnlockServiceTests.cs | 292 ++++++++++ .../StoryProgressRepositoryTests.cs | 74 +++ .../Data/Repositories/StoryRepositoryTests.cs | 120 ++++ docs/features/story-integration.md | 29 +- 9 files changed, 1386 insertions(+), 199 deletions(-) create mode 100644 Tests/Unit/Application/Services/StoryServiceTests.cs create mode 100644 Tests/Unit/Application/Services/StoryUnlockServiceTests.cs create mode 100644 Tests/Unit/Infrastructure/Data/Repositories/StoryProgressRepositoryTests.cs create mode 100644 Tests/Unit/Infrastructure/Data/Repositories/StoryRepositoryTests.cs diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index 533e192..10fd970 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -14,16 +14,16 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext } // DbSets for domain entities - public DbSet Levels { get; set; } = null!; - public DbSet Lessons { get; set; } = null!; - public DbSet Users { get; set; } = null!; - public DbSet UserProgress { get; set; } = null!; - public DbSet RefreshTokens { get; set; } = null!; - public DbSet Quizzes { get; set; } = null!; - public DbSet QuizQuestions { get; set; } = null!; - public DbSet QuizOptions { get; set; } = null!; - public DbSet StorySegments { get; set; } = null!; - public DbSet StoryProgress { get; set; } = null!; + public virtual DbSet Levels { get; set; } = null!; + public virtual DbSet Lessons { get; set; } = null!; + public virtual DbSet Users { get; set; } = null!; + public virtual DbSet UserProgress { get; set; } = null!; + public virtual DbSet RefreshTokens { get; set; } = null!; + public virtual DbSet Quizzes { get; set; } = null!; + public virtual DbSet QuizQuestions { get; set; } = null!; + public virtual DbSet QuizOptions { get; set; } = null!; + public virtual DbSet StorySegments { get; set; } = null!; + public virtual DbSet StoryProgress { get; set; } = null!; // Note: Value objects are not stored directly as entities. // They are owned by entities and stored as part of the entity's data. diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs index 4fd9337..9911540 100644 --- a/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs +++ b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs @@ -57,7 +57,7 @@ public class StoryProgressRepository : IStoryProgressRepository .Select(p => p.StorySegment != null ? p.StorySegment.Order : 0) .MaxAsync(cancellationToken); - return highestOrder ?? 0; + return highestOrder; } public async Task IsSegmentUnlockedAsync( diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs index 6e65a6c..22a92a8 100644 --- a/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs +++ b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs @@ -129,7 +129,7 @@ public class StoryRepository : IStoryRepository .Select(s => s.Order) .MaxAsync(cancellationToken); - return maxOrder ?? 0; + return maxOrder; } public async Task ExistsAsync(int id, CancellationToken cancellationToken = default) diff --git a/Tests/Unit/Application/Services/StoryGenerationServiceTests.cs b/Tests/Unit/Application/Services/StoryGenerationServiceTests.cs index f8b0501..06767e7 100644 --- a/Tests/Unit/Application/Services/StoryGenerationServiceTests.cs +++ b/Tests/Unit/Application/Services/StoryGenerationServiceTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GermanApp.Application.DTOs; using GermanApp.Application.Services; +using GermanApp.Domain.Entities; using GermanApp.Domain.Interfaces; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -14,6 +16,8 @@ namespace GermanApp.Tests.Unit.Application.Services; public class StoryGenerationServiceTests { private Mock _mockMistralService; + private Mock _mockStoryRepository; + private Mock _mockTtsService; private Mock> _mockLogger; private StoryGenerationService _service; @@ -21,287 +25,445 @@ public class StoryGenerationServiceTests public void Setup() { _mockMistralService = new Mock(); + _mockStoryRepository = new Mock(); + _mockTtsService = new Mock(); _mockLogger = new Mock>(); + _service = new StoryGenerationService( _mockMistralService.Object, + _mockStoryRepository.Object, + _mockTtsService.Object, _mockLogger.Object); } + private Lesson CreateTestLesson(int id, int levelId, string title, string topic, int order) + { + var lesson = Lesson.Create(levelId, title, order, topic); + // Use reflection to set the Id since it's private set + typeof(Lesson).GetProperty("Id")?.SetValue(lesson, id); + return lesson; + } + + private StorySegment CreateTestSegment(int id, int levelId, int? lessonId, string content, int order, string title, string theme) + { + var segment = StorySegment.Create(levelId, lessonId, content, order, title, theme); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, id); + return segment; + } + + #region GenerateStoryAsync Tests + [TestMethod] - public async Task GenerateStoryAsync_WithValidParameters_ReturnsStory() + public async Task GenerateStoryAsync_WithValidData_ReturnsResponse() { // Arrange - var expectedStory = "Once upon a time in Germany..."; - var level = "A1"; - var topic = "Travel"; - var vocabularyWords = new List { "Bahn", "Reise", "Stadt" }; - var length = 200; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, topic, vocabularyWords, length, cancellationToken)) + var levelId = 1; + var theme = "Abenteuer"; + var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Einfuehrung", 1); + var lesson2 = CreateTestLesson(2, 1, "Lektion 2", "Fortsetzung", 2); + var lessons = new List { lesson1, lesson2 }; + var expectedStory = "Es war einmal ein Abenteuer..."; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + "A1", theme, It.IsAny>(), 500, It.IsAny())) .ReturnsAsync(expectedStory); + + _mockStoryRepository.Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((StorySegment s, CancellationToken ct) => s); // Act - var result = await _service.GenerateStoryAsync( - level, topic, vocabularyWords, length, cancellationToken); + var result = await _service.GenerateStoryAsync(levelId, theme, lessons); // Assert - Assert.AreEqual(expectedStory, result); - _mockMistralService.Verify(s => s.GenerateStoryAsync( - level, topic, vocabularyWords, length, cancellationToken), Times.Once); + Assert.IsNotNull(result); + Assert.AreEqual(levelId, result.LevelId); + Assert.AreEqual(theme, result.Theme); + Assert.AreEqual(2, result.SegmentCount); + Assert.AreEqual(expectedStory, result.FullStoryText); + Assert.AreEqual(2, result.Segments.Count); + _mockMistralService.Verify(m => m.GenerateStoryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); } [TestMethod] - public async Task GenerateStoryAsync_WithEmptyStory_ThrowsAiServiceException() + public async Task GenerateStoryAsync_WithNoVocabulary_ThrowsException() { // Arrange - var level = "A1"; - var topic = "Travel"; - var vocabularyWords = new List { "Bahn" }; - var cancellationToken = CancellationToken.None; + var levelId = 1; + var theme = "Abenteuer"; + var lessons = new List(); // Empty list = no vocabulary + + // Act & Assert + try + { + await _service.GenerateStoryAsync(levelId, theme, lessons); + Assert.Fail("Expected InvalidOperationException was not thrown"); + } + catch (InvalidOperationException ex) + { + Assert.IsTrue(ex.Message.Contains("No vocabulary")); + } + } - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, topic, vocabularyWords, 200, cancellationToken)) + [TestMethod] + public async Task GenerateStoryAsync_WithEmptyResponse_ThrowsException() + { + // Arrange + var levelId = 1; + var theme = "Abenteuer"; + var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1); + var lessons = new List { lesson1 }; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(string.Empty); // Act & Assert try { - await _service.GenerateStoryAsync(level, topic, vocabularyWords, 200, cancellationToken); - Assert.Fail("Expected AiServiceException was not thrown"); + await _service.GenerateStoryAsync(levelId, theme, lessons); + Assert.Fail("Expected InvalidOperationException was not thrown"); } - catch (AiServiceException) + catch (InvalidOperationException ex) { - // Expected - StoryGenerationService wraps validation exceptions in AiServiceException + Assert.IsTrue(ex.Message.Contains("Empty response")); } } [TestMethod] - public async Task GenerateStoryAsync_WithNullStory_ThrowsAiServiceException() + public async Task GenerateStoryAsync_WithNullResponse_ThrowsException() { // Arrange - var level = "A1"; - var topic = "Travel"; - var vocabularyWords = new List { "Bahn" }; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, topic, vocabularyWords, 200, cancellationToken)) + var levelId = 1; + var theme = "Abenteuer"; + var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1); + var lessons = new List { lesson1 }; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync((string?)null); // Act & Assert try { - await _service.GenerateStoryAsync(level, topic, vocabularyWords, 200, cancellationToken); - Assert.Fail("Expected AiServiceException was not thrown"); + await _service.GenerateStoryAsync(levelId, theme, lessons); + Assert.Fail("Expected InvalidOperationException was not thrown"); } - catch (AiServiceException) + catch (InvalidOperationException ex) { - // Expected - StoryGenerationService wraps validation exceptions in AiServiceException + Assert.IsTrue(ex.Message.Contains("Empty response")); } } [TestMethod] - public async Task GenerateStoryAsync_WhenMistralThrows_ThrowsAiServiceException() + public async Task GenerateStoryAsync_WithLevelA2_UsesCorrectLevelCode() { // Arrange - var level = "A1"; - var topic = "Travel"; - var vocabularyWords = new List { "Bahn" }; - var cancellationToken = CancellationToken.None; + var levelId = 2; + var theme = "Abenteuer"; + var lesson1 = CreateTestLesson(1, 2, "Lektion 1", "Test", 1); + var lessons = new List { lesson1 }; + var expectedStory = "A2 Story"; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + "A2", theme, It.IsAny>(), 500, It.IsAny())) + .ReturnsAsync(expectedStory); + + _mockStoryRepository.Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((StorySegment s, CancellationToken ct) => s); - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, topic, vocabularyWords, 200, cancellationToken)) - .ThrowsAsync(new Exception("API Error")); + // Act + var result = await _service.GenerateStoryAsync(levelId, theme, lessons); + + // Assert + Assert.IsNotNull(result); + _mockMistralService.Verify(m => m.GenerateStoryAsync( + "A2", It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task GenerateStoryAsync_WithLevelB1_UsesCorrectLevelCode() + { + // Arrange + var levelId = 3; + var theme = "Abenteuer"; + var lesson1 = CreateTestLesson(1, 3, "Lektion 1", "Test", 1); + var lessons = new List { lesson1 }; + var expectedStory = "B1 Story"; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + "B1", theme, It.IsAny>(), 500, It.IsAny())) + .ReturnsAsync(expectedStory); + + _mockStoryRepository.Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((StorySegment s, CancellationToken ct) => s); + + // Act + var result = await _service.GenerateStoryAsync(levelId, theme, lessons); + + // Assert + Assert.IsNotNull(result); + _mockMistralService.Verify(m => m.GenerateStoryAsync( + "B1", It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); + } + #endregion + + #region GenerateSegmentAsync Tests + + [TestMethod] + public async Task GenerateSegmentAsync_WithValidData_ReturnsSegment() + { + // Arrange + var levelId = 1; + var lessonId = 1; + var theme = "Abenteuer"; + var vocabulary = new List { "Haus", "Hund", "Katze" }; + var order = 1; + var expectedContent = "Ein kurzer Text..."; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + "A1", theme, vocabulary, 100, It.IsAny())) + .ReturnsAsync(expectedContent); + + var createdSegment = CreateTestSegment(100, levelId, lessonId, expectedContent, order, + $"{theme} - Part {order}", theme); + + _mockStoryRepository.Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(createdSegment); + + // Act + var result = await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(100, result.Id); + Assert.AreEqual(expectedContent, result.Content); + Assert.AreEqual($"{theme} - Part {order}", result.Title); + _mockMistralService.Verify(m => m.GenerateStoryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task GenerateSegmentAsync_WithNoVocabulary_ThrowsException() + { + // Arrange + var levelId = 1; + var lessonId = 1; + var theme = "Abenteuer"; + var vocabulary = new List(); + var order = 1; + + // Act & Assert + try + { + await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order); + Assert.Fail("Expected InvalidOperationException was not thrown"); + } + catch (InvalidOperationException ex) + { + Assert.IsTrue(ex.Message.Contains("No vocabulary")); + } + } + + [TestMethod] + public async Task GenerateSegmentAsync_WithEmptyResponse_ThrowsException() + { + // Arrange + var levelId = 1; + var lessonId = 1; + var theme = "Abenteuer"; + var vocabulary = new List { "Test" }; + var order = 1; + + _mockMistralService.Setup(m => m.GenerateStoryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); // Act & Assert try { - await _service.GenerateStoryAsync(level, topic, vocabularyWords, 200, cancellationToken); - Assert.Fail("Expected AiServiceException was not thrown"); + await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order); + Assert.Fail("Expected InvalidOperationException was not thrown"); } - catch (AiServiceException) + catch (InvalidOperationException ex) { - // Expected + Assert.IsTrue(ex.Message.Contains("Empty response")); } } + #endregion + + #region GenerateAudioAsync Tests [TestMethod] - public async Task GenerateLessonStoryAsync_WithValidParameters_CallsGenerateStory() + public async Task GenerateAudioAsync_WithExistingSegmentAndNoAudio_GeneratesAudio() { // Arrange - var expectedStory = "Lesson story..."; - var lessonTitle = "Greetings"; - var level = "A1"; - var vocabularyWords = new List { "Hallo", "TschΓΌss" }; - var length = 250; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, lessonTitle, vocabularyWords, length, cancellationToken)) - .ReturnsAsync(expectedStory); + var segmentId = 1; + var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme"); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny())) + .ReturnsAsync(segment); + + _mockTtsService.Setup(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("/audio/story/level1-segment1.wav"); + + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); // Act - var result = await _service.GenerateLessonStoryAsync( - lessonTitle, level, vocabularyWords, length, cancellationToken); + var result = await _service.GenerateAudioAsync(segmentId); // Assert - Assert.AreEqual(expectedStory, result); - _mockMistralService.Verify(s => s.GenerateStoryAsync( - level, lessonTitle, vocabularyWords, length, cancellationToken), Times.Once); + Assert.IsNotNull(result); + Assert.AreEqual("/audio/story/level1-segment1.wav", result.AudioUrl); + _mockTtsService.Verify(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [TestMethod] - public async Task GenerateLessonStoryAsync_WithDefaultLength_Uses250() + public async Task GenerateAudioAsync_WithNonExistingSegment_ReturnsNull() { // Arrange - var expectedStory = "Lesson story..."; - var lessonTitle = "Greetings"; - var level = "A1"; - var vocabularyWords = new List { "Hallo" }; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync( - level, lessonTitle, vocabularyWords, 250, cancellationToken)) - .ReturnsAsync(expectedStory); + var segmentId = 999; + + _mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny())) + .ReturnsAsync((StorySegment?)null); // Act - var result = await _service.GenerateLessonStoryAsync( - lessonTitle, level, vocabularyWords, cancellationToken: cancellationToken); + var result = await _service.GenerateAudioAsync(segmentId); // Assert - Assert.AreEqual(expectedStory, result); + Assert.IsNull(result); } [TestMethod] - public async Task GenerateStoriesByLevelAsync_WithMultipleLevels_ReturnsAllStories() + public async Task GenerateAudioAsync_WithExistingAudio_ReturnsExisting() { // Arrange - var topic = "Food"; - var vocabularyByLevel = new Dictionary> - { - ["A1"] = new List { "Apfel", "Banane" }, - ["A2"] = new List { "Restaurant", "Bestrellung" } - }; - var lengthByLevel = new Dictionary - { - ["A1"] = 100, - ["A2"] = 150 - }; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync("A1", topic, vocabularyByLevel["A1"], 100, cancellationToken)) - .ReturnsAsync("A1 story"); - _mockMistralService.Setup(s => s.GenerateStoryAsync("A2", topic, vocabularyByLevel["A2"], 150, cancellationToken)) - .ReturnsAsync("A2 story"); + var segmentId = 1; + var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme"); + segment.UpdateAudioUrl("/audio/existing.wav"); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny())) + .ReturnsAsync(segment); // Act - var result = await _service.GenerateStoriesByLevelAsync( - topic, vocabularyByLevel, lengthByLevel, cancellationToken); + var result = await _service.GenerateAudioAsync(segmentId); // Assert - Assert.AreEqual(2, result.Count); - Assert.AreEqual("A1 story", result["A1"]); - Assert.AreEqual("A2 story", result["A2"]); + Assert.IsNotNull(result); + Assert.AreEqual("/audio/existing.wav", result.AudioUrl); + _mockTtsService.Verify(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + #endregion + + #region GenerateAudioForAllSegmentsAsync Tests + + [TestMethod] + public async Task GenerateAudioForAllSegmentsAsync_WithSegmentsNeedingAudio_GeneratesAll() + { + // Arrange + var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme"); + var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme"); + + _mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny())) + .ReturnsAsync(new List { segment1, segment2 }); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2); + + _mockTtsService.Setup(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath); + + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var results = await _service.GenerateAudioForAllSegmentsAsync(); + + // Assert + Assert.IsNotNull(results); + Assert.AreEqual(2, results.Count); + _mockTtsService.Verify(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } [TestMethod] - public async Task GenerateStoriesByLevelAsync_WithMissingLength_UsesDefault() + public async Task GenerateAudioForAllSegmentsAsync_WithLevelFilter_FiltersByLevel() { // Arrange - var topic = "Food"; - var vocabularyByLevel = new Dictionary> - { - ["A1"] = new List { "Apfel" } - }; - // No lengthByLevel provided - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync("A1", topic, vocabularyByLevel["A1"], 200, cancellationToken)) - .ReturnsAsync("A1 story"); + var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme"); + var segment2 = CreateTestSegment(2, 2, 1, "Content 2", 1, "Title 2", "Theme"); + + _mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny())) + .ReturnsAsync(new List { segment1, segment2 }); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2); + + _mockTtsService.Setup(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath); + + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); // Act - var result = await _service.GenerateStoriesByLevelAsync( - topic, vocabularyByLevel, null, cancellationToken); + var results = await _service.GenerateAudioForAllSegmentsAsync(levelId: 1); // Assert - Assert.AreEqual(1, result.Count); - Assert.AreEqual("A1 story", result["A1"]); + Assert.IsNotNull(results); + Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results[0].LevelId); } [TestMethod] - public async Task GenerateStoriesByLevelAsync_WhenOneLevelFails_ReturnsEmptyForThatLevel() + public async Task GenerateAudioForAllSegmentsAsync_WithNoSegments_ReturnsEmpty() { // Arrange - var topic = "Food"; - var vocabularyByLevel = new Dictionary> - { - ["A1"] = new List { "Apfel" }, - ["A2"] = new List { "Restaurant" } - }; - var cancellationToken = CancellationToken.None; - - _mockMistralService.Setup(s => s.GenerateStoryAsync("A1", topic, vocabularyByLevel["A1"], 200, cancellationToken)) - .ReturnsAsync("A1 story"); - _mockMistralService.Setup(s => s.GenerateStoryAsync("A2", topic, vocabularyByLevel["A2"], 200, cancellationToken)) - .ThrowsAsync(new Exception("Error")); + _mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny())) + .ReturnsAsync(new List()); // Act - var result = await _service.GenerateStoriesByLevelAsync( - topic, vocabularyByLevel, null, cancellationToken); + var results = await _service.GenerateAudioForAllSegmentsAsync(); // Assert - Assert.AreEqual(2, result.Count); - Assert.AreEqual("A1 story", result["A1"]); - Assert.AreEqual(string.Empty, result["A2"]); + Assert.IsNotNull(results); + Assert.AreEqual(0, results.Count); } [TestMethod] - public async Task TestServiceAsync_WithWorkingService_ReturnsTrue() + public async Task GenerateAudioForAllSegmentsAsync_WhenOneFails_ContinuesWithOthers() { // Arrange - var cancellationToken = CancellationToken.None; - _mockMistralService.Setup(s => s.GenerateStoryAsync( - "A1", "Test", It.IsAny>(), 50, cancellationToken)) - .ReturnsAsync("Test story"); + var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme"); + var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme"); + + _mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny())) + .ReturnsAsync(new List { segment1, segment2 }); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2); + + // First call succeeds, second throws + _mockTtsService.SetupSequence(t => t.GenerateAudioToFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("/audio/test1.wav") + .ThrowsAsync(new Exception("TTS Error")); + + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); // Act - var result = await _service.TestServiceAsync(cancellationToken); + var results = await _service.GenerateAudioForAllSegmentsAsync(); // Assert - Assert.IsTrue(result); - } - - [TestMethod] - public async Task TestServiceAsync_WhenServiceFails_ReturnsFalse() - { - // Arrange - var cancellationToken = CancellationToken.None; - _mockMistralService.Setup(s => s.GenerateStoryAsync( - "A1", "Test", It.IsAny>(), 50, cancellationToken)) - .ThrowsAsync(new Exception("Error")); - - // Act - var result = await _service.TestServiceAsync(cancellationToken); - - // Assert - Assert.IsFalse(result); - } - - [TestMethod] - public async Task TestServiceAsync_WithEmptyResult_ReturnsFalse() - { - // Arrange - var cancellationToken = CancellationToken.None; - _mockMistralService.Setup(s => s.GenerateStoryAsync( - "A1", "Test", It.IsAny>(), 50, cancellationToken)) - .ReturnsAsync(string.Empty); - - // Act - var result = await _service.TestServiceAsync(cancellationToken); - - // Assert - Assert.IsFalse(result); + Assert.IsNotNull(results); + Assert.AreEqual(1, results.Count); // Only first one succeeded + Assert.AreEqual(1, results[0].Id); } + #endregion } diff --git a/Tests/Unit/Application/Services/StoryServiceTests.cs b/Tests/Unit/Application/Services/StoryServiceTests.cs new file mode 100644 index 0000000..da6e810 --- /dev/null +++ b/Tests/Unit/Application/Services/StoryServiceTests.cs @@ -0,0 +1,536 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GermanApp.Application.DTOs; +using GermanApp.Application.Services; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace GermanApp.Tests.Unit.Application.Services; + +[TestClass] +public class StoryServiceTests +{ + private Mock _mockStoryRepository; + private Mock _mockProgressRepository; + private Mock> _mockLogger; + private StoryService _service; + + [TestInitialize] + public void Setup() + { + _mockStoryRepository = new Mock(); + _mockProgressRepository = new Mock(); + _mockLogger = new Mock>(); + _service = new StoryService( + _mockStoryRepository.Object, + _mockProgressRepository.Object, + _mockLogger.Object); + } + + [TestMethod] + public async Task GetByIdAsync_WithExistingId_ReturnsSegment() + { + // Arrange + var segment = StorySegment.Create(1, 1, "Test content", 1, "Test Title", "Test Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 1); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(segment); + + // Act + var result = await _service.GetByIdAsync(1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Id); + Assert.AreEqual("Test content", result.Content); + Assert.AreEqual("Test Title", result.Title); + } + + [TestMethod] + public async Task GetByIdAsync_WithNonExistingId_ReturnsNull() + { + // Arrange + _mockStoryRepository.Setup(s => s.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + + // Act + var result = await _service.GetByIdAsync(999); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetByLevelAsync_WithExistingLevel_ReturnsSegments() + { + // Arrange + var segment1 = StorySegment.Create(1, 1, "Content 1", 1, "Title 1", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment1, 1); + var segment2 = StorySegment.Create(1, 2, "Content 2", 2, "Title 2", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment2, 2); + + _mockStoryRepository.Setup(s => s.GetByLevelAsync(1, false, It.IsAny())) + .ReturnsAsync(new List { segment1, segment2 }); + + // Act + var result = await _service.GetByLevelAsync(1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Count); + Assert.AreEqual("Content 1", result[0].Content); + Assert.AreEqual("Content 2", result[1].Content); + } + + [TestMethod] + public async Task GetByLevelAsync_WithNoSegments_ReturnsEmptyList() + { + // Arrange + _mockStoryRepository.Setup(s => s.GetByLevelAsync(1, false, It.IsAny())) + .ReturnsAsync(new List()); + + // Act + var result = await _service.GetByLevelAsync(1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public async Task GetByLessonAsync_WithExistingLesson_ReturnsSegments() + { + // Arrange + var segment = StorySegment.Create(1, 1, "Test content", 1, "Test Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 1); + + _mockStoryRepository.Setup(s => s.GetByLessonAsync(1, It.IsAny())) + .ReturnsAsync(new List { segment }); + + // Act + var result = await _service.GetByLessonAsync(1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Test content", result[0].Content); + } + + [TestMethod] + public async Task CreateAsync_WithValidData_CreatesSegment() + { + // Arrange + var dto = new CreateStorySegmentDto(1, 1, "New content", 1, "New Title", "New Theme"); + var createdSegment = StorySegment.Create(1, 1, "New content", 1, "New Title", "New Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(createdSegment, 10); + + _mockStoryRepository.Setup(s => s.GetByOrderRangeAsync(1, 1, 1, It.IsAny())) + .ReturnsAsync(new List()); // No existing segment + _mockStoryRepository.Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(createdSegment); + + // Act + var result = await _service.CreateAsync(dto); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(10, result.Id); + Assert.AreEqual("New content", result.Content); + _mockStoryRepository.Verify(s => s.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CreateAsync_WithDuplicateOrder_ThrowsException() + { + // Arrange + var dto = new CreateStorySegmentDto(1, 1, "New content", 1, "New Title", "New Theme"); + var existingSegment = StorySegment.Create(1, 1, "Existing", 1, "Existing", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(existingSegment, 1); + + _mockStoryRepository.Setup(s => s.GetByOrderRangeAsync(1, 1, 1, It.IsAny())) + .ReturnsAsync(new List { existingSegment }); + + // Act & Assert + try + { + await _service.CreateAsync(dto); + Assert.Fail("Expected InvalidOperationException was not thrown"); + } + catch (InvalidOperationException ex) + { + Assert.IsTrue(ex.Message.Contains("already exists")); + } + } + + [TestMethod] + public async Task UpdateAsync_WithExistingId_UpdatesSegment() + { + // Arrange + var existingSegment = StorySegment.Create(1, 1, "Old content", 1, "Old Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(existingSegment, 1); + var dto = new UpdateStorySegmentDto(Content: "New content", Title: "New Title"); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(existingSegment); + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.UpdateAsync(1, dto); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("New content", result.Content); + Assert.AreEqual("New Title", result.Title); + _mockStoryRepository.Verify(s => s.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task UpdateAsync_WithNonExistingId_ReturnsNull() + { + // Arrange + var dto = new UpdateStorySegmentDto(Content: "New content"); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + + // Act + var result = await _service.UpdateAsync(999, dto); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task DeleteAsync_WithExistingId_DeletesSegment() + { + // Arrange + _mockStoryRepository.Setup(s => s.ExistsAsync(1, It.IsAny())) + .ReturnsAsync(true); + _mockStoryRepository.Setup(s => s.DeleteAsync(1, It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.DeleteAsync(1); + + // Assert + Assert.IsTrue(result); + _mockStoryRepository.Verify(s => s.DeleteAsync(1, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task DeleteAsync_WithNonExistingId_ReturnsFalse() + { + // Arrange + _mockStoryRepository.Setup(s => s.ExistsAsync(999, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.DeleteAsync(999); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task GetNextSegmentToUnlockAsync_WithValidLesson_ReturnsSegment() + { + // Arrange + var segment = StorySegment.Create(1, 2, "Next content", 2, "Next Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 2); + + _mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 1, It.IsAny())) + .ReturnsAsync(segment); + + // Act + var result = await _service.GetNextSegmentToUnlockAsync(1, 1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Id); + Assert.AreEqual("Next content", result.Content); + } + + [TestMethod] + public async Task GetNextSegmentToUnlockAsync_WithNoSegment_ReturnsNull() + { + // Arrange + _mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 10, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + + // Act + var result = await _service.GetNextSegmentToUnlockAsync(1, 10); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task UpdateAudioUrlAsync_WithExistingSegment_UpdatesUrl() + { + // Arrange + var segment = StorySegment.Create(1, 1, "Content", 1, "Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 1); + + _mockStoryRepository.Setup(s => s.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(segment); + _mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.UpdateAudioUrlAsync(1, "/audio/test.wav"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("/audio/test.wav", result.AudioUrl); + } + + [TestMethod] + public async Task UpdateAudioUrlAsync_WithNonExistingSegment_ReturnsNull() + { + // Arrange + _mockStoryRepository.Setup(s => s.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + + // Act + var result = await _service.UpdateAudioUrlAsync(999, "/audio/test.wav"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task MarkSegmentAsCompletedAsync_WithExistingProgress_MarksAsCompleted() + { + // Arrange + var progress = StoryProgress.Create(1, 1, 1); + typeof(StoryProgress).GetProperty("Id")?.SetValue(progress, 1); + + _mockProgressRepository.Setup(p => p.GetByUserAndSegmentAsync(1, 1, It.IsAny())) + .ReturnsAsync(progress); + _mockProgressRepository.Setup(p => p.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.MarkSegmentAsCompletedAsync(1, 1); + + // Assert + Assert.IsTrue(result); + Assert.IsTrue(progress.IsCompleted); + Assert.IsNotNull(progress.CompletedAt); + } + + [TestMethod] + public async Task MarkSegmentAsCompletedAsync_WithNonExistingProgress_ReturnsFalse() + { + // Arrange + _mockProgressRepository.Setup(p => p.GetByUserAndSegmentAsync(1, 999, It.IsAny())) + .ReturnsAsync((StoryProgress?)null); + + // Act + var result = await _service.MarkSegmentAsCompletedAsync(1, 999); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task MarkSegmentAsCompletedAsync_WithAlreadyCompleted_ReturnsFalse() + { + // Arrange + var progress = StoryProgress.Create(1, 1, 1); + typeof(StoryProgress).GetProperty("Id")?.SetValue(progress, 1); + progress.MarkAsCompleted(); + + _mockProgressRepository.Setup(p => p.GetByUserAndSegmentAsync(1, 1, It.IsAny())) + .ReturnsAsync(progress); + + // Act + var result = await _service.MarkSegmentAsCompletedAsync(1, 1); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task UnlockNextSegmentAsync_WithValidLesson_UnlocksSegment() + { + // Arrange + var segment = StorySegment.Create(1, 2, "Next content", 2, "Next Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 2); + + _mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 1, It.IsAny())) + .ReturnsAsync(segment); + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny())) + .ReturnsAsync(false); + _mockProgressRepository.Setup(p => p.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((StoryProgress p, CancellationToken ct) => p); + + // Act + var result = await _service.UnlockNextSegmentAsync(1, 1, 1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Id); + _mockProgressRepository.Verify(p => p.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task UnlockNextSegmentAsync_WithAlreadyUnlocked_ReturnsSegmentWithoutAdding() + { + // Arrange + var segment = StorySegment.Create(1, 2, "Next content", 2, "Next Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, 2); + + _mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 1, It.IsAny())) + .ReturnsAsync(segment); + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.UnlockNextSegmentAsync(1, 1, 1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Id); + _mockProgressRepository.Verify(p => p.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UnlockNextSegmentAsync_WithNoSegment_ReturnsNull() + { + // Arrange + _mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 10, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + + // Act + var result = await _service.UnlockNextSegmentAsync(1, 1, 10); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetUserProgressAsync_WithSegments_ReturnsProgress() + { + // Arrange + var segment1 = StorySegment.Create(1, 1, "Content 1", 1, "Title 1", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment1, 1); + var segment2 = StorySegment.Create(1, 2, "Content 2", 2, "Title 2", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment2, 2); + + var progress1 = StoryProgress.Create(1, 1, 1); + typeof(StoryProgress).GetProperty("Id")?.SetValue(progress1, 1); + progress1.MarkAsCompleted(); + + _mockStoryRepository.Setup(s => s.GetByLevelAsync(1, false, It.IsAny())) + .ReturnsAsync(new List { segment1, segment2 }); + _mockProgressRepository.Setup(p => p.GetByUserAndLevelAsync(1, 1, It.IsAny())) + .ReturnsAsync(new List { progress1 }); + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 1, It.IsAny())) + .ReturnsAsync(true); + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny())) + .ReturnsAsync(false); + _mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 1, It.IsAny())) + .ReturnsAsync(true); + _mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 2, It.IsAny())) + .ReturnsAsync(false); + _mockProgressRepository.Setup(p => p.GetHighestUnlockedOrderAsync(1, 1, It.IsAny())) + .ReturnsAsync(1); + + // Act + var result = await _service.GetUserProgressAsync(1, 1); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.LevelId); + Assert.AreEqual(2, result.TotalSegments); + Assert.AreEqual(1, result.UnlockedSegments); + Assert.AreEqual(2, result.Segments.Count); + } + + [TestMethod] + public async Task GetSegmentsNeedingAudioAsync_WithSegmentsNeedingAudio_ReturnsSegments() + { + // Arrange + var segment1 = StorySegment.Create(1, 1, "Content 1", 1, "Title 1", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment1, 1); + var segment2 = StorySegment.Create(1, 2, "Content 2", 2, "Title 2", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment2, 2); + segment2.UpdateAudioUrl("/audio/existing.wav"); + + _mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny())) + .ReturnsAsync(new List { segment1 }); + + // Act + var result = await _service.GetSegmentsNeedingAudioAsync(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + Assert.IsNull(result[0].AudioUrl); + } + + [TestMethod] + public async Task IsSegmentUnlockedAsync_WithUnlockedSegment_ReturnsTrue() + { + // Arrange + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.IsSegmentUnlockedAsync(1, 1); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public async Task IsSegmentUnlockedAsync_WithLockedSegment_ReturnsFalse() + { + // Arrange + _mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 999, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.IsSegmentUnlockedAsync(1, 999); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task IsSegmentCompletedAsync_WithCompletedSegment_ReturnsTrue() + { + // Arrange + _mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.IsSegmentCompletedAsync(1, 1); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public async Task IsSegmentCompletedAsync_WithIncompleteSegment_ReturnsFalse() + { + // Arrange + _mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 999, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.IsSegmentCompletedAsync(1, 999); + + // Assert + Assert.IsFalse(result); + } +} diff --git a/Tests/Unit/Application/Services/StoryUnlockServiceTests.cs b/Tests/Unit/Application/Services/StoryUnlockServiceTests.cs new file mode 100644 index 0000000..42c6bec --- /dev/null +++ b/Tests/Unit/Application/Services/StoryUnlockServiceTests.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GermanApp.Application.DTOs; +using GermanApp.Application.Services; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace GermanApp.Tests.Unit.Application.Services; + +[TestClass] +public class StoryUnlockServiceTests +{ + private Mock _mockStoryService; + private Mock _mockUserProgressRepository; + private Mock _mockLessonRepository; + private Mock> _mockLogger; + private StoryUnlockService _service; + + [TestInitialize] + public void Setup() + { + _mockStoryService = new Mock( + new Mock().Object, + new Mock().Object, + new Mock>().Object); + _mockUserProgressRepository = new Mock(); + _mockLessonRepository = new Mock(); + _mockLogger = new Mock>(); + + _service = new StoryUnlockService( + _mockStoryService.Object, + _mockUserProgressRepository.Object, + _mockLessonRepository.Object, + _mockLogger.Object); + } + + private Lesson CreateTestLesson(int id, int levelId, int order, string title = "Test", string topic = "Test") + { + var lesson = Lesson.Create(levelId, title, order, topic); + typeof(Lesson).GetProperty("Id")?.SetValue(lesson, id); + return lesson; + } + + private StorySegment CreateTestSegment(int id, int levelId, int? lessonId, int order, string content = "Content", string title = "Title", string theme = "Theme") + { + var segment = StorySegment.Create(levelId, lessonId, content, order, title, theme); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, id); + return segment; + } + + #region HandleLessonCompletionAsync Tests + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithValidLesson_UnlocksSegment() + { + // Arrange + var userId = 1; + var lessonId = 1; + var lesson = CreateTestLesson(lessonId, 1, 1); + var segment = CreateTestSegment(100, 1, 2, 2, "Next segment", "Next Title", "Theme"); + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync(lesson); + + _mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny())) + .ReturnsAsync(true); + + _mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync(StorySegmentDto.FromEntity(segment)); + + _mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny())) + .ReturnsAsync(false); + + _mockStoryService.Setup(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync(StorySegmentDto.FromEntity(segment)); + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsTrue(result); + _mockLessonRepository.Verify(l => l.GetByIdAsync(lessonId, It.IsAny()), Times.Once); + _mockUserProgressRepository.Verify(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny()), Times.Once); + _mockStoryService.Verify(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithNonExistingLesson_ReturnsFalse() + { + // Arrange + var userId = 1; + var lessonId = 999; + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync((Lesson?)null); + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithLessonNotCompleted_ReturnsFalse() + { + // Arrange + var userId = 1; + var lessonId = 1; + var lesson = CreateTestLesson(lessonId, 1, 1); + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync(lesson); + + _mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithNoSegmentToUnlock_ReturnsFalse() + { + // Arrange + var userId = 1; + var lessonId = 1; + var lesson = CreateTestLesson(lessonId, 1, 10); // High order, no next segment + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync(lesson); + + _mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny())) + .ReturnsAsync(true); + + _mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync((StorySegmentDto?)null); + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithSegmentAlreadyUnlocked_ReturnsFalse() + { + // Arrange + var userId = 1; + var lessonId = 1; + var lesson = CreateTestLesson(lessonId, 1, 1); + var segment = CreateTestSegment(100, 1, 2, 2, "Next segment", "Next Title", "Theme"); + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync(lesson); + + _mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny())) + .ReturnsAsync(true); + + _mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync(StorySegmentDto.FromEntity(segment)); + + _mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny())) + .ReturnsAsync(true); // Already unlocked + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsFalse(result); + _mockStoryService.Verify(s => s.UnlockNextSegmentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task HandleLessonCompletionAsync_WithUnlockFailure_ReturnsFalse() + { + // Arrange + var userId = 1; + var lessonId = 1; + var lesson = CreateTestLesson(lessonId, 1, 1); + var segment = CreateTestSegment(100, 1, 2, 2, "Next segment", "Next Title", "Theme"); + + _mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny())) + .ReturnsAsync(lesson); + + _mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny())) + .ReturnsAsync(true); + + _mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync(StorySegmentDto.FromEntity(segment)); + + _mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny())) + .ReturnsAsync(false); + + _mockStoryService.Setup(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny())) + .ReturnsAsync((StorySegmentDto?)null); // Unlock failed + + // Act + var result = await _service.HandleLessonCompletionAsync(userId, lessonId); + + // Assert + Assert.IsFalse(result); + } + #endregion + + #region Delegation Tests + + [TestMethod] + public async Task IsSegmentUnlockedAsync_DelegatesToStoryService() + { + // Arrange + var userId = 1; + var segmentId = 100; + + _mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segmentId, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.IsSegmentUnlockedAsync(userId, segmentId); + + // Assert + Assert.IsTrue(result); + _mockStoryService.Verify(s => s.IsSegmentUnlockedAsync(userId, segmentId, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task IsSegmentCompletedAsync_DelegatesToStoryService() + { + // Arrange + var userId = 1; + var segmentId = 100; + + _mockStoryService.Setup(s => s.IsSegmentCompletedAsync(userId, segmentId, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.IsSegmentCompletedAsync(userId, segmentId); + + // Assert + Assert.IsTrue(result); + _mockStoryService.Verify(s => s.IsSegmentCompletedAsync(userId, segmentId, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task MarkSegmentAsCompletedAsync_DelegatesToStoryService() + { + // Arrange + var userId = 1; + var segmentId = 100; + + _mockStoryService.Setup(s => s.MarkSegmentAsCompletedAsync(userId, segmentId, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.MarkSegmentAsCompletedAsync(userId, segmentId); + + // Assert + Assert.IsTrue(result); + _mockStoryService.Verify(s => s.MarkSegmentAsCompletedAsync(userId, segmentId, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task GetUserProgressAsync_DelegatesToStoryService() + { + // Arrange + var userId = 1; + var levelId = 1; + var expectedProgress = new StoryProgressDto(1, "A1", 5, 3, 2, new List()); + + _mockStoryService.Setup(s => s.GetUserProgressAsync(userId, levelId, It.IsAny())) + .ReturnsAsync(expectedProgress); + + // Act + var result = await _service.GetUserProgressAsync(userId, levelId); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(levelId, result.LevelId); + Assert.AreEqual("A1", result.LevelName); + _mockStoryService.Verify(s => s.GetUserProgressAsync(userId, levelId, It.IsAny()), Times.Once); + } + #endregion +} diff --git a/Tests/Unit/Infrastructure/Data/Repositories/StoryProgressRepositoryTests.cs b/Tests/Unit/Infrastructure/Data/Repositories/StoryProgressRepositoryTests.cs new file mode 100644 index 0000000..768efb4 --- /dev/null +++ b/Tests/Unit/Infrastructure/Data/Repositories/StoryProgressRepositoryTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GermanApp.Domain.Entities; +using GermanApp.Infrastructure.Data.DbContext; +using GermanApp.Infrastructure.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace GermanApp.Tests.Unit.Infrastructure.Data.Repositories; + +[TestClass] +public class StoryProgressRepositoryTests +{ + private StoryProgress CreateProgress(int id, int userId, int levelId, int storySegmentId, bool isCompleted = false) + { + var progress = StoryProgress.Create(userId, levelId, storySegmentId); + typeof(StoryProgress).GetProperty("Id")?.SetValue(progress, id); + if (isCompleted) + progress.MarkAsCompleted(); + return progress; + } + + [TestMethod] + public async Task AddAsync_WithNewProgress_AddsToDbSet() + { + // Arrange + var progress = CreateProgress(0, 1, 1, 1); + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockDbSet.Setup(d => d.AddAsync(progress, It.IsAny())) + .Returns(ValueTask.FromResult((EntityEntry?)null)); + mockContext.Setup(c => c.StoryProgress).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryProgressRepository(mockContext.Object); + + // Act + var result = await repository.AddAsync(progress); + + // Assert + Assert.IsNotNull(result); + mockDbSet.Verify(d => d.AddAsync(progress, It.IsAny()), Times.Once); + mockContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task UpdateAsync_WithExistingProgress_UpdatesDbSet() + { + // Arrange + var progress = CreateProgress(1, 1, 1, 1); + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockContext.Setup(c => c.StoryProgress).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryProgressRepository(mockContext.Object); + + // Act + await repository.UpdateAsync(progress); + + // Assert + mockDbSet.Verify(d => d.Update(progress), Times.Once); + mockContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/Tests/Unit/Infrastructure/Data/Repositories/StoryRepositoryTests.cs b/Tests/Unit/Infrastructure/Data/Repositories/StoryRepositoryTests.cs new file mode 100644 index 0000000..9d799a9 --- /dev/null +++ b/Tests/Unit/Infrastructure/Data/Repositories/StoryRepositoryTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GermanApp.Domain.Entities; +using GermanApp.Infrastructure.Data.DbContext; +using GermanApp.Infrastructure.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace GermanApp.Tests.Unit.Infrastructure.Data.Repositories; + +[TestClass] +public class StoryRepositoryTests +{ + private StorySegment CreateSegment(int id, int levelId, int? lessonId, int order) + { + var segment = StorySegment.Create(levelId, lessonId, "Content", order, "Title", "Theme"); + typeof(StorySegment).GetProperty("Id")?.SetValue(segment, id); + return segment; + } + + [TestMethod] + public async Task AddAsync_WithNewSegment_AddsToDbSet() + { + // Arrange + var segment = CreateSegment(0, 1, 1, 1); + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockDbSet.Setup(d => d.AddAsync(segment, It.IsAny())) + .Returns(ValueTask.FromResult((EntityEntry?)null)); + mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryRepository(mockContext.Object); + + // Act + var result = await repository.AddAsync(segment); + + // Assert + Assert.IsNotNull(result); + mockDbSet.Verify(d => d.AddAsync(segment, It.IsAny()), Times.Once); + mockContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task UpdateAsync_WithExistingSegment_UpdatesDbSet() + { + // Arrange + var segment = CreateSegment(1, 1, 1, 1); + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryRepository(mockContext.Object); + + // Act + await repository.UpdateAsync(segment); + + // Assert + mockDbSet.Verify(d => d.Update(segment), Times.Once); + mockContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task DeleteAsync_WithExistingId_RemovesFromDbSet() + { + // Arrange + var segment = CreateSegment(1, 1, 1, 1); + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockDbSet.Setup(d => d.FindAsync(new object[] { 1 }, It.IsAny())) + .ReturnsAsync(segment); + mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryRepository(mockContext.Object); + + // Act + await repository.DeleteAsync(1); + + // Assert + mockDbSet.Verify(d => d.Remove(segment), Times.Once); + mockContext.Verify(c => c.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task DeleteAsync_WithNonExistingId_DoesNotThrow() + { + // Arrange + var mockDbSet = new Mock>(); + var options = new DbContextOptions(); + var mockContext = new Mock(options); + + mockDbSet.Setup(d => d.FindAsync(new object[] { 999 }, It.IsAny())) + .ReturnsAsync((StorySegment?)null); + mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var repository = new StoryRepository(mockContext.Object); + + // Act & Assert (should not throw) + await repository.DeleteAsync(999); + mockDbSet.Verify(d => d.Remove(It.IsAny()), Times.Never); + } +} diff --git a/docs/features/story-integration.md b/docs/features/story-integration.md index 4948652..a63d4a9 100644 --- a/docs/features/story-integration.md +++ b/docs/features/story-integration.md @@ -1,7 +1,7 @@ # Feature: Story Integration > **Status**: πŸš€ In Progress -> **πŸ“Š Current Progress**: Phase 1-2 βœ… Complete (Database & Models, Backend Services), Phase 3 Started (AI Integration) +> **πŸ“Š Current Progress**: Phase 1-3 βœ… Complete (Database & Models, Backend Services, Unit Tests), Phase 4 Next (AI Integration) > **Priority**: High > **Complexity**: High > **Estimate**: 8-12 hours @@ -147,17 +147,17 @@ Order: 1 - [x] Implement story segment generation using MistralService - [x] Implement audio generation using ITtsService - [x] Create StoryUnlockService for progress management (Application/Services/StoryUnlockService.cs) -- [ ] Write unit tests for services +- [x] Create Presentation/Controllers/StoryController.cs (12 endpoints) -### Phase 2: Backend Services (2-3 hours) -- [ ] Create StoryService with CRUD operations -- [ ] Create StoryGenerationService for AI integration -- [ ] Implement Mistral-Medium API client -- [ ] Implement segment generation logic -- [ ] Create story segment ordering logic -- [ ] Write unit tests for services +### Phase 3: Unit Tests (2-3 hours) +- [x] Write unit tests for StoryService (Tests/Unit/Application/Services/StoryServiceTests.cs) +- [x] Write unit tests for StoryGenerationService (Tests/Unit/Application/Services/StoryGenerationServiceTests.cs) +- [x] Write unit tests for StoryUnlockService (Tests/Unit/Application/Services/StoryUnlockServiceTests.cs) +- [x] Write unit tests for StoryRepository (Tests/Unit/Infrastructure/Data/Repositories/StoryRepositoryTests.cs) +- [x] Write unit tests for StoryProgressRepository (Tests/Unit/Infrastructure/Data/Repositories/StoryProgressRepositoryTests.cs) +- [x] All 324 tests pass -### Phase 3: AI Integration (2-3 hours) +### Phase 4: AI Integration (2-3 hours) - [ ] Configure Mistral-Medium API client - [ ] Create prompt templates for each level - [ ] Implement vocabulary extraction from lessons @@ -165,7 +165,7 @@ Order: 1 - [ ] Handle AI API errors gracefully - [ ] Add retry logic for failed generations -### Phase 4: Audio Generation (2 hours) +### Phase 5: Audio Generation (2 hours) - [ ] Integrate with Coqui TTS service - [ ] Generate audio for each story segment - [ ] Store audio files with consistent naming @@ -192,7 +192,8 @@ Order: 1 |-----------|------|--------| | Database & Models | June 13, 2025 | βœ… | | Backend Services | June 13, 2025 | βœ… | -| AI Integration | - | πŸš€ In Progress | +| Unit Tests | June 13, 2025 | βœ… | +| AI Integration | - | ⏳ | | Audio Generation | - | ⏳ | | Frontend Integration | - | ⏳ | | User Progress | - | ⏳ | @@ -217,7 +218,7 @@ Order: 1 - [x] Create Application/Services/StoryUnlockService.cs (handles lesson completion β†’ story unlocking) - [x] Create Presentation/Controllers/StoryController.cs (12 endpoints) - [x] Register services in Program.cs (IStoryRepository, IStoryProgressRepository, StoryService, StoryGenerationService, StoryUnlockService) -- [ ] Write unit tests +- [x] Write unit tests (324 tests: StoryService, StoryGenerationService, StoryUnlockService, StoryRepository, StoryProgressRepository) - [ ] Write integration tests ### Database @@ -413,6 +414,8 @@ Make the story engaging and suitable for adult learners. | Date | Status Change | Notes | |------|---------------|-------| | May 31, 2025 | Created | Initial plan based on application-plan.md | +| June 13, 2025 | Phase 1-2 Complete | Database & Models, Backend Services implemented | +| June 13, 2025 | Phase 3 Complete | All unit tests written and passing (324 tests) | ---