DeutschLernen/Tests/Unit/Application/Services/StoryGenerationServiceTests.cs
Mistral Vibe 01f6a1fd30 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 <vibe@mistral.ai>
2026-06-13 14:23:54 +02:00

469 lines
18 KiB
C#

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 StoryGenerationServiceTests
{
private Mock<IMistralService> _mockMistralService;
private Mock<IStoryRepository> _mockStoryRepository;
private Mock<ITtsService> _mockTtsService;
private Mock<ILogger<StoryGenerationService>> _mockLogger;
private StoryGenerationService _service;
[TestInitialize]
public void Setup()
{
_mockMistralService = new Mock<IMistralService>();
_mockStoryRepository = new Mock<IStoryRepository>();
_mockTtsService = new Mock<ITtsService>();
_mockLogger = new Mock<ILogger<StoryGenerationService>>();
_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_WithValidData_ReturnsResponse()
{
// Arrange
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<Lesson> { lesson1, lesson2 };
var expectedStory = "Es war einmal ein Abenteuer...";
_mockMistralService.Setup(m => m.GenerateStoryAsync(
"A1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStory);
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
// Act
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
// Assert
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<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task GenerateStoryAsync_WithNoVocabulary_ThrowsException()
{
// Arrange
var levelId = 1;
var theme = "Abenteuer";
var lessons = new List<Lesson>(); // 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"));
}
}
[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<Lesson> { lesson1 };
_mockMistralService.Setup(m => m.GenerateStoryAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(string.Empty);
// Act & Assert
try
{
await _service.GenerateStoryAsync(levelId, theme, lessons);
Assert.Fail("Expected InvalidOperationException was not thrown");
}
catch (InvalidOperationException ex)
{
Assert.IsTrue(ex.Message.Contains("Empty response"));
}
}
[TestMethod]
public async Task GenerateStoryAsync_WithNullResponse_ThrowsException()
{
// Arrange
var levelId = 1;
var theme = "Abenteuer";
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1);
var lessons = new List<Lesson> { lesson1 };
_mockMistralService.Setup(m => m.GenerateStoryAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string?)null);
// Act & Assert
try
{
await _service.GenerateStoryAsync(levelId, theme, lessons);
Assert.Fail("Expected InvalidOperationException was not thrown");
}
catch (InvalidOperationException ex)
{
Assert.IsTrue(ex.Message.Contains("Empty response"));
}
}
[TestMethod]
public async Task GenerateStoryAsync_WithLevelA2_UsesCorrectLevelCode()
{
// Arrange
var levelId = 2;
var theme = "Abenteuer";
var lesson1 = CreateTestLesson(1, 2, "Lektion 1", "Test", 1);
var lessons = new List<Lesson> { lesson1 };
var expectedStory = "A2 Story";
_mockMistralService.Setup(m => m.GenerateStoryAsync(
"A2", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStory);
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
// Act
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
// Assert
Assert.IsNotNull(result);
_mockMistralService.Verify(m => m.GenerateStoryAsync(
"A2", It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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<Lesson> { lesson1 };
var expectedStory = "B1 Story";
_mockMistralService.Setup(m => m.GenerateStoryAsync(
"B1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStory);
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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<string> { "Haus", "Hund", "Katze" };
var order = 1;
var expectedContent = "Ein kurzer Text...";
_mockMistralService.Setup(m => m.GenerateStoryAsync(
"A1", theme, vocabulary, 100, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedContent);
var createdSegment = CreateTestSegment(100, levelId, lessonId, expectedContent, order,
$"{theme} - Part {order}", theme);
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task GenerateSegmentAsync_WithNoVocabulary_ThrowsException()
{
// Arrange
var levelId = 1;
var lessonId = 1;
var theme = "Abenteuer";
var vocabulary = new List<string>();
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<string> { "Test" };
var order = 1;
_mockMistralService.Setup(m => m.GenerateStoryAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(string.Empty);
// 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("Empty response"));
}
}
#endregion
#region GenerateAudioAsync Tests
[TestMethod]
public async Task GenerateAudioAsync_WithExistingSegmentAndNoAudio_GeneratesAudio()
{
// Arrange
var segmentId = 1;
var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme");
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync(segment);
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("/audio/story/level1-segment1.wav");
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var result = await _service.GenerateAudioAsync(segmentId);
// Assert
Assert.IsNotNull(result);
Assert.AreEqual("/audio/story/level1-segment1.wav", result.AudioUrl);
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task GenerateAudioAsync_WithNonExistingSegment_ReturnsNull()
{
// Arrange
var segmentId = 999;
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync((StorySegment?)null);
// Act
var result = await _service.GenerateAudioAsync(segmentId);
// Assert
Assert.IsNull(result);
}
[TestMethod]
public async Task GenerateAudioAsync_WithExistingAudio_ReturnsExisting()
{
// Arrange
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<CancellationToken>()))
.ReturnsAsync(segment);
// Act
var result = await _service.GenerateAudioAsync(segmentId);
// Assert
Assert.IsNotNull(result);
Assert.AreEqual("/audio/existing.wav", result.AudioUrl);
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[TestMethod]
public async Task GenerateAudioForAllSegmentsAsync_WithLevelFilter_FiltersByLevel()
{
// Arrange
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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var results = await _service.GenerateAudioForAllSegmentsAsync(levelId: 1);
// Assert
Assert.IsNotNull(results);
Assert.AreEqual(1, results.Count);
Assert.AreEqual(1, results[0].LevelId);
}
[TestMethod]
public async Task GenerateAudioForAllSegmentsAsync_WithNoSegments_ReturnsEmpty()
{
// Arrange
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<StorySegment>());
// Act
var results = await _service.GenerateAudioForAllSegmentsAsync();
// Assert
Assert.IsNotNull(results);
Assert.AreEqual(0, results.Count);
}
[TestMethod]
public async Task GenerateAudioForAllSegmentsAsync_WhenOneFails_ContinuesWithOthers()
{
// 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
// First call succeeds, second throws
_mockTtsService.SetupSequence(t => t.GenerateAudioToFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("/audio/test1.wav")
.ThrowsAsync(new Exception("TTS Error"));
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var results = await _service.GenerateAudioForAllSegmentsAsync();
// Assert
Assert.IsNotNull(results);
Assert.AreEqual(1, results.Count); // Only first one succeeded
Assert.AreEqual(1, results[0].Id);
}
#endregion
}