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>
This commit is contained in:
Mistral Vibe 2026-06-13 14:23:54 +02:00
parent 6693165f83
commit 01f6a1fd30
9 changed files with 1386 additions and 199 deletions

View file

@ -14,16 +14,16 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
}
// DbSets for domain entities
public DbSet<Level> Levels { get; set; } = null!;
public DbSet<Lesson> Lessons { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
public DbSet<Quiz> Quizzes { get; set; } = null!;
public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public DbSet<QuizOption> QuizOptions { get; set; } = null!;
public DbSet<StorySegment> StorySegments { get; set; } = null!;
public DbSet<StoryProgress> StoryProgress { get; set; } = null!;
public virtual DbSet<Level> Levels { get; set; } = null!;
public virtual DbSet<Lesson> Lessons { get; set; } = null!;
public virtual DbSet<User> Users { get; set; } = null!;
public virtual DbSet<UserProgress> UserProgress { get; set; } = null!;
public virtual DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
public virtual DbSet<Quiz> Quizzes { get; set; } = null!;
public virtual DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public virtual DbSet<QuizOption> QuizOptions { get; set; } = null!;
public virtual DbSet<StorySegment> StorySegments { get; set; } = null!;
public virtual DbSet<StoryProgress> 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.

View file

@ -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<bool> IsSegmentUnlockedAsync(

View file

@ -129,7 +129,7 @@ public class StoryRepository : IStoryRepository
.Select(s => s.Order)
.MaxAsync(cancellationToken);
return maxOrder ?? 0;
return maxOrder;
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)

View file

@ -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<IMistralService> _mockMistralService;
private Mock<IStoryRepository> _mockStoryRepository;
private Mock<ITtsService> _mockTtsService;
private Mock<ILogger<StoryGenerationService>> _mockLogger;
private StoryGenerationService _service;
@ -21,287 +25,445 @@ public class StoryGenerationServiceTests
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_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<string> { "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<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(
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<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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<string> { "Bahn" };
var cancellationToken = CancellationToken.None;
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"));
}
}
_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<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(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<string> { "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<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(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<string> { "Bahn" };
var cancellationToken = CancellationToken.None;
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);
_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<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.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<string> { "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<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.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<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string> { "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<CancellationToken>()))
.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<string, IReadOnlyList<string>>
{
["A1"] = new List<string> { "Apfel", "Banane" },
["A2"] = new List<string> { "Restaurant", "Bestrellung" }
};
var lengthByLevel = new Dictionary<string, int>
{
["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<CancellationToken>()))
.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<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 GenerateStoriesByLevelAsync_WithMissingLength_UsesDefault()
public async Task GenerateAudioForAllSegmentsAsync_WithLevelFilter_FiltersByLevel()
{
// Arrange
var topic = "Food";
var vocabularyByLevel = new Dictionary<string, IReadOnlyList<string>>
{
["A1"] = new List<string> { "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<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 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<string, IReadOnlyList<string>>
{
["A1"] = new List<string> { "Apfel" },
["A2"] = new List<string> { "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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment>());
// 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<IReadOnlyList<string>>(), 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<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 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<IReadOnlyList<string>>(), 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<IReadOnlyList<string>>(), 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
}

View file

@ -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<IStoryRepository> _mockStoryRepository;
private Mock<IStoryProgressRepository> _mockProgressRepository;
private Mock<ILogger<StoryService>> _mockLogger;
private StoryService _service;
[TestInitialize]
public void Setup()
{
_mockStoryRepository = new Mock<IStoryRepository>();
_mockProgressRepository = new Mock<IStoryProgressRepository>();
_mockLogger = new Mock<ILogger<StoryService>>();
_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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment>());
// 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment>()); // No existing segment
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<StorySegment>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { 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<CancellationToken>()))
.ReturnsAsync(existingSegment);
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<StorySegment>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(true);
_mockStoryRepository.Setup(s => s.DeleteAsync(1, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var result = await _service.DeleteAsync(1);
// Assert
Assert.IsTrue(result);
_mockStoryRepository.Verify(s => s.DeleteAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task DeleteAsync_WithNonExistingId_ReturnsFalse()
{
// Arrange
_mockStoryRepository.Setup(s => s.ExistsAsync(999, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(segment);
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(progress);
_mockProgressRepository.Setup(p => p.UpdateAsync(It.IsAny<StoryProgress>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(segment);
_mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockProgressRepository.Setup(p => p.AddAsync(It.IsAny<StoryProgress>(), It.IsAny<CancellationToken>()))
.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<StoryProgress>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(segment);
_mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny<CancellationToken>()))
.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<StoryProgress>(), It.IsAny<CancellationToken>()), Times.Never);
}
[TestMethod]
public async Task UnlockNextSegmentAsync_WithNoSegment_ReturnsNull()
{
// Arrange
_mockStoryRepository.Setup(s => s.GetNextSegmentToUnlockAsync(1, 10, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
_mockProgressRepository.Setup(p => p.GetByUserAndLevelAsync(1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<StoryProgress> { progress1 });
_mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockProgressRepository.Setup(p => p.IsSegmentUnlockedAsync(1, 2, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockProgressRepository.Setup(p => p.IsSegmentCompletedAsync(1, 2, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockProgressRepository.Setup(p => p.GetHighestUnlockedOrderAsync(1, 1, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<StorySegment> { 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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _service.IsSegmentCompletedAsync(1, 999);
// Assert
Assert.IsFalse(result);
}
}

View file

@ -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<StoryService> _mockStoryService;
private Mock<IUserProgressRepository> _mockUserProgressRepository;
private Mock<ILessonRepository> _mockLessonRepository;
private Mock<ILogger<StoryUnlockService>> _mockLogger;
private StoryUnlockService _service;
[TestInitialize]
public void Setup()
{
_mockStoryService = new Mock<StoryService>(
new Mock<IStoryRepository>().Object,
new Mock<IStoryProgressRepository>().Object,
new Mock<ILogger<StoryService>>().Object);
_mockUserProgressRepository = new Mock<IUserProgressRepository>();
_mockLessonRepository = new Mock<ILessonRepository>();
_mockLogger = new Mock<ILogger<StoryUnlockService>>();
_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<CancellationToken>()))
.ReturnsAsync(lesson);
_mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.ReturnsAsync(StorySegmentDto.FromEntity(segment));
_mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockStoryService.Setup(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.ReturnsAsync(StorySegmentDto.FromEntity(segment));
// Act
var result = await _service.HandleLessonCompletionAsync(userId, lessonId);
// Assert
Assert.IsTrue(result);
_mockLessonRepository.Verify(l => l.GetByIdAsync(lessonId, It.IsAny<CancellationToken>()), Times.Once);
_mockUserProgressRepository.Verify(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()), Times.Once);
_mockStoryService.Verify(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task HandleLessonCompletionAsync_WithNonExistingLesson_ReturnsFalse()
{
// Arrange
var userId = 1;
var lessonId = 999;
_mockLessonRepository.Setup(l => l.GetByIdAsync(lessonId, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(lesson);
_mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(lesson);
_mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(lesson);
_mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.ReturnsAsync(StorySegmentDto.FromEntity(segment));
_mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(true); // Already unlocked
// Act
var result = await _service.HandleLessonCompletionAsync(userId, lessonId);
// Assert
Assert.IsFalse(result);
_mockStoryService.Verify(s => s.UnlockNextSegmentAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(lesson);
_mockUserProgressRepository.Setup(u => u.HasUserCompletedLessonAsync(userId, lessonId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockStoryService.Setup(s => s.GetNextSegmentToUnlockAsync(lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.ReturnsAsync(StorySegmentDto.FromEntity(segment));
_mockStoryService.Setup(s => s.IsSegmentUnlockedAsync(userId, segment.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockStoryService.Setup(s => s.UnlockNextSegmentAsync(userId, lesson.LevelId, lesson.Order, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _service.IsSegmentUnlockedAsync(userId, segmentId);
// Assert
Assert.IsTrue(result);
_mockStoryService.Verify(s => s.IsSegmentUnlockedAsync(userId, segmentId, It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task IsSegmentCompletedAsync_DelegatesToStoryService()
{
// Arrange
var userId = 1;
var segmentId = 100;
_mockStoryService.Setup(s => s.IsSegmentCompletedAsync(userId, segmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _service.IsSegmentCompletedAsync(userId, segmentId);
// Assert
Assert.IsTrue(result);
_mockStoryService.Verify(s => s.IsSegmentCompletedAsync(userId, segmentId, It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task MarkSegmentAsCompletedAsync_DelegatesToStoryService()
{
// Arrange
var userId = 1;
var segmentId = 100;
_mockStoryService.Setup(s => s.MarkSegmentAsCompletedAsync(userId, segmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _service.MarkSegmentAsCompletedAsync(userId, segmentId);
// Assert
Assert.IsTrue(result);
_mockStoryService.Verify(s => s.MarkSegmentAsCompletedAsync(userId, segmentId, It.IsAny<CancellationToken>()), 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<StorySegmentProgressDto>());
_mockStoryService.Setup(s => s.GetUserProgressAsync(userId, levelId, It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
#endregion
}

View file

@ -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<DbSet<StoryProgress>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockDbSet.Setup(d => d.AddAsync(progress, It.IsAny<CancellationToken>()))
.Returns(ValueTask.FromResult((EntityEntry<StoryProgress>?)null));
mockContext.Setup(c => c.StoryProgress).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
mockContext.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task UpdateAsync_WithExistingProgress_UpdatesDbSet()
{
// Arrange
var progress = CreateProgress(1, 1, 1, 1);
var mockDbSet = new Mock<DbSet<StoryProgress>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockContext.Setup(c => c.StoryProgress).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
}

View file

@ -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<DbSet<StorySegment>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockDbSet.Setup(d => d.AddAsync(segment, It.IsAny<CancellationToken>()))
.Returns(ValueTask.FromResult((EntityEntry<StorySegment>?)null));
mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
mockContext.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task UpdateAsync_WithExistingSegment_UpdatesDbSet()
{
// Arrange
var segment = CreateSegment(1, 1, 1, 1);
var mockDbSet = new Mock<DbSet<StorySegment>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task DeleteAsync_WithExistingId_RemovesFromDbSet()
{
// Arrange
var segment = CreateSegment(1, 1, 1, 1);
var mockDbSet = new Mock<DbSet<StorySegment>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockDbSet.Setup(d => d.FindAsync(new object[] { 1 }, It.IsAny<CancellationToken>()))
.ReturnsAsync(segment);
mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
[TestMethod]
public async Task DeleteAsync_WithNonExistingId_DoesNotThrow()
{
// Arrange
var mockDbSet = new Mock<DbSet<StorySegment>>();
var options = new DbContextOptions<AppDbContext>();
var mockContext = new Mock<AppDbContext>(options);
mockDbSet.Setup(d => d.FindAsync(new object[] { 999 }, It.IsAny<CancellationToken>()))
.ReturnsAsync((StorySegment?)null);
mockContext.Setup(c => c.StorySegments).Returns(mockDbSet.Object);
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var repository = new StoryRepository(mockContext.Object);
// Act & Assert (should not throw)
await repository.DeleteAsync(999);
mockDbSet.Verify(d => d.Remove(It.IsAny<StorySegment>()), Times.Never);
}
}

View file

@ -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) |
---