feat(backend/validation): add FluentValidation for DTOs and update controllers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Add FluentValidation.AspNetCore package
- Create LevelValidators (CreateLevelValidator, UpdateLevelValidator)
- Create LessonValidators (CreateLessonValidator, UpdateLessonValidator)
- Register FluentValidation in Program.cs with auto-validation
- Update LevelsController and LessonsController to use IActionResult
- Make service methods virtual for Moq testing compatibility
- Add 26 integration tests for LevelsController (13 tests)
- Add 34 integration tests for LessonsController (17 tests)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-09 17:35:14 +02:00
parent 3caee4c21e
commit a6021fb148
11 changed files with 938 additions and 53 deletions

View file

@ -22,7 +22,7 @@ public class LessonService
/// <summary>
/// Gets all lessons.
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAllAsync(cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
@ -31,7 +31,7 @@ public class LessonService
/// <summary>
/// Gets all lessons ordered by level and lesson order.
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAllOrderedAsync(cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
@ -40,7 +40,7 @@ public class LessonService
/// <summary>
/// Gets a lesson by its ID.
/// </summary>
public async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
public virtual async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
return lesson?.ToDto();
@ -49,7 +49,7 @@ public class LessonService
/// <summary>
/// Gets lessons by level ID.
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
@ -58,7 +58,7 @@ public class LessonService
/// <summary>
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{
var level1Lessons = await _lessonRepository.GetByLevelAsync(1, cancellationToken);
var level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
@ -72,7 +72,7 @@ public class LessonService
/// <summary>
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{
var level4Lessons = await _lessonRepository.GetByLevelAsync(4, cancellationToken);
var level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
@ -86,7 +86,7 @@ public class LessonService
/// <summary>
/// Creates a new lesson.
/// </summary>
public async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
public virtual async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
{
// Validate that the level exists
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
@ -101,7 +101,7 @@ public class LessonService
/// <summary>
/// Updates an existing lesson.
/// </summary>
public async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
public virtual async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
if (lesson == null)
@ -120,7 +120,7 @@ public class LessonService
/// <summary>
/// Deletes a lesson by its ID.
/// </summary>
public async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
public virtual async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
if (lesson == null)
@ -133,7 +133,7 @@ public class LessonService
/// <summary>
/// Gets the first lesson in a level.
/// </summary>
public async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
public virtual async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetFirstLessonInLevelAsync(levelId, cancellationToken);
return lesson?.ToDto();
@ -142,7 +142,7 @@ public class LessonService
/// <summary>
/// Gets the next lesson after the specified one.
/// </summary>
public async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
public virtual async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
return lesson?.ToDto();
@ -151,7 +151,7 @@ public class LessonService
/// <summary>
/// Gets lessons that the user can access (based on completion of previous lessons).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();

View file

@ -20,7 +20,7 @@ public class LevelService
/// <summary>
/// Gets all CEFR levels ordered by their sort order.
/// </summary>
public async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
{
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
return levels.Select(l => l.ToDto()).ToList();
@ -29,7 +29,7 @@ public class LevelService
/// <summary>
/// Gets a level by its ID.
/// </summary>
public async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
public virtual async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
return level?.ToDto();
@ -38,7 +38,7 @@ public class LevelService
/// <summary>
/// Gets a level by its code (e.g., "A1", "B2").
/// </summary>
public async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
public virtual async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByCodeAsync(code, cancellationToken);
return level?.ToDto();
@ -47,7 +47,7 @@ public class LevelService
/// <summary>
/// Creates a new CEFR level.
/// </summary>
public async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
public virtual async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = dto.ToEntity();
var createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
@ -57,7 +57,7 @@ public class LevelService
/// <summary>
/// Updates an existing CEFR level.
/// </summary>
public async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
public virtual async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
if (level == null)
@ -71,7 +71,7 @@ public class LevelService
/// <summary>
/// Deletes a CEFR level by its ID.
/// </summary>
public async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
public virtual async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
if (level == null)
@ -84,7 +84,7 @@ public class LevelService
/// <summary>
/// Gets the first level (lowest order number) - typically A1.
/// </summary>
public async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
public virtual async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
return level?.ToDto();
@ -93,7 +93,7 @@ public class LevelService
/// <summary>
/// Gets the next level after the specified one.
/// </summary>
public async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
public virtual async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
return level?.ToDto();

View file

@ -27,7 +27,7 @@ public class ProgressService
/// <summary>
/// Gets user progress for a specific lesson.
/// </summary>
public async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
public virtual async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
return progress?.ToDto();
@ -36,7 +36,7 @@ public class ProgressService
/// <summary>
/// Gets all progress records for a user.
/// </summary>
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressAsync(int userId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<UserProgressDto>> GetUserProgressAsync(int userId, CancellationToken cancellationToken = default)
{
var progressRecords = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
return progressRecords.Select(p => p.ToDto()).ToList();
@ -45,7 +45,7 @@ public class ProgressService
/// <summary>
/// Gets progress for all lessons in a specific level for a user.
/// </summary>
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressByLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<UserProgressDto>> GetUserProgressByLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
var progressRecords = await _userProgressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
return progressRecords.Select(p => p.ToDto()).ToList();
@ -54,7 +54,7 @@ public class ProgressService
/// <summary>
/// Checks if a user has completed a lesson.
/// </summary>
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
public virtual async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.HasUserCompletedLessonAsync(userId, lessonId, cancellationToken);
}
@ -62,7 +62,7 @@ public class ProgressService
/// <summary>
/// Marks a lesson as completed for a user with a quiz score.
/// </summary>
public async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
public virtual async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
@ -86,7 +86,7 @@ public class ProgressService
/// <summary>
/// Updates user progress for a lesson without marking as completed.
/// </summary>
public async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
public virtual async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, dto.LessonId, cancellationToken);
@ -109,7 +109,7 @@ public class ProgressService
/// <summary>
/// Resets user progress for a lesson.
/// </summary>
public async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
public virtual async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
if (progress == null)
@ -123,7 +123,7 @@ public class ProgressService
/// <summary>
/// Gets the user's average score for a level.
/// </summary>
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
public virtual async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.GetAverageScoreForLevelAsync(userId, levelId, cancellationToken);
}
@ -131,7 +131,7 @@ public class ProgressService
/// <summary>
/// Gets the percentage of lessons completed in a level.
/// </summary>
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
public virtual async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.GetLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
}
@ -139,7 +139,7 @@ public class ProgressService
/// <summary>
/// Gets level completion summaries for a user.
/// </summary>
public async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(int userId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(int userId, CancellationToken cancellationToken = default)
{
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
var completions = new List<LevelCompletionDto>();
@ -176,7 +176,7 @@ public class ProgressService
/// <summary>
/// Gets lessons that the user can access (completed previous lessons or first in level).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
@ -185,7 +185,7 @@ public class ProgressService
/// <summary>
/// Checks if the next lesson in a level is unlocked for the user.
/// </summary>
public async Task<bool> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
public virtual async Task<bool> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
{
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
if (currentLesson == null)

View file

@ -13,6 +13,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />

View file

@ -29,7 +29,7 @@ public class LessonsController : ControllerBase
/// <returns>List of all lessons</returns>
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
return Ok(lessons);
@ -41,7 +41,7 @@ public class LessonsController : ControllerBase
/// <returns>List of all lessons in order</returns>
[HttpGet("ordered")]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
return Ok(lessons);
@ -54,7 +54,7 @@ public class LessonsController : ControllerBase
/// <returns>The lesson with the specified ID</returns>
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<ActionResult<LessonDto>> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
{
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
if (lesson == null)
@ -69,7 +69,7 @@ public class LessonsController : ControllerBase
/// <returns>List of lessons for the specified level</returns>
[HttpGet("by-level/{levelId}")]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
return Ok(lessons);
@ -81,7 +81,7 @@ public class LessonsController : ControllerBase
/// <returns>List of beginner lessons</returns>
[HttpGet("beginner")]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
return Ok(lessons);
@ -93,7 +93,7 @@ public class LessonsController : ControllerBase
/// <returns>List of advanced lessons</returns>
[HttpGet("advanced")]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
return Ok(lessons);
@ -106,7 +106,7 @@ public class LessonsController : ControllerBase
/// <returns>The created lesson</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<LessonDto>> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
public async Task<IActionResult> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
{
var lesson = await _lessonService.CreateLessonAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetLessonByIdAsync), new { id = lesson.Id }, lesson);
@ -120,7 +120,7 @@ public class LessonsController : ControllerBase
/// <returns>The updated lesson</returns>
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<LessonDto>> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
public async Task<IActionResult> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
{
var lesson = await _lessonService.UpdateLessonAsync(id, dto, cancellationToken);
if (lesson == null)
@ -135,7 +135,7 @@ public class LessonsController : ControllerBase
/// <returns>No content on success</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
{
var result = await _lessonService.DeleteLessonAsync(id, cancellationToken);
if (!result)
@ -150,7 +150,7 @@ public class LessonsController : ControllerBase
/// <returns>The first lesson in the level</returns>
[HttpGet("first/{levelId}")]
[AllowAnonymous]
public async Task<ActionResult<LessonDto>> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
if (lesson == null)
@ -165,7 +165,7 @@ public class LessonsController : ControllerBase
/// <returns>The next lesson</returns>
[HttpGet("next/{currentLessonId}")]
[AllowAnonymous]
public async Task<ActionResult<LessonDto>> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
if (lesson == null)
@ -180,7 +180,7 @@ public class LessonsController : ControllerBase
/// <returns>List of accessible lessons for the user</returns>
[HttpGet("accessible/{userId}")]
[Authorize]
public async Task<ActionResult<IReadOnlyList<LessonDto>>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
{
var lessons = await _lessonService.GetAccessibleLessonsAsync(userId, cancellationToken);
return Ok(lessons);
@ -194,7 +194,7 @@ public class LessonsController : ControllerBase
/// <returns>True if the next lesson is unlocked</returns>
[HttpGet("unlocked/{userId}/{currentLessonId}")]
[Authorize]
public async Task<ActionResult<bool>> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
public async Task<IActionResult> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
{
var isUnlocked = await _progressService.IsNextLessonUnlockedAsync(userId, currentLessonId, cancellationToken);
return Ok(isUnlocked);

View file

@ -27,7 +27,7 @@ public class LevelsController : ControllerBase
/// <returns>List of all levels</returns>
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<LevelDto>>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
{
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
return Ok(levels);
@ -40,7 +40,7 @@ public class LevelsController : ControllerBase
/// <returns>The level with the specified ID</returns>
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<ActionResult<LevelDto>> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
{
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
if (level == null)
@ -55,7 +55,7 @@ public class LevelsController : ControllerBase
/// <returns>The level with the specified code</returns>
[HttpGet("by-code/{code}")]
[AllowAnonymous]
public async Task<ActionResult<LevelDto>> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
if (level == null)
@ -70,7 +70,7 @@ public class LevelsController : ControllerBase
/// <returns>The created level</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<LevelDto>> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
public async Task<IActionResult> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = await _levelService.CreateLevelAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetLevelByIdAsync), new { id = level.Id }, level);
@ -84,7 +84,7 @@ public class LevelsController : ControllerBase
/// <returns>The updated level</returns>
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<LevelDto>> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
public async Task<IActionResult> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = await _levelService.UpdateLevelAsync(id, dto, cancellationToken);
if (level == null)
@ -99,7 +99,7 @@ public class LevelsController : ControllerBase
/// <returns>No content on success</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
{
var result = await _levelService.DeleteLevelAsync(id, cancellationToken);
if (!result)
@ -113,7 +113,7 @@ public class LevelsController : ControllerBase
/// <returns>The first level</returns>
[HttpGet("first")]
[AllowAnonymous]
public async Task<ActionResult<LevelDto>> GetFirstLevelAsync(CancellationToken cancellationToken = default)
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
{
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
if (level == null)
@ -128,7 +128,7 @@ public class LevelsController : ControllerBase
/// <returns>The next level</returns>
[HttpGet("next/{currentLevelId}")]
[AllowAnonymous]
public async Task<ActionResult<LevelDto>> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
{
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
if (level == null)

View file

@ -0,0 +1,64 @@
using FluentValidation;
using GermanApp.Application.DTOs;
namespace GermanApp.Presentation.Validators;
/// <summary>
/// Validator for CreateLessonDto.
/// Validates input when creating a new lesson.
/// </summary>
public class CreateLessonValidator : AbstractValidator<CreateLessonDto>
{
public CreateLessonValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Lesson title is required.")
.MinimumLength(3).WithMessage("Lesson title must be at least 3 characters long.")
.MaximumLength(200).WithMessage("Lesson title must not exceed 200 characters.");
RuleFor(x => x.Description)
.MaximumLength(2000).WithMessage("Lesson description must not exceed 2000 characters.");
RuleFor(x => x.LevelId)
.GreaterThan(0).WithMessage("Level ID must be greater than 0.");
RuleFor(x => x.Order)
.GreaterThanOrEqualTo(0).WithMessage("Order must be at least 0.")
.LessThanOrEqualTo(1000).WithMessage("Order must not exceed 1000.");
RuleFor(x => x.Topic)
.NotEmpty().WithMessage("Topic is required.")
.MinimumLength(2).WithMessage("Topic must be at least 2 characters long.")
.MaximumLength(100).WithMessage("Topic must not exceed 100 characters.");
}
}
/// <summary>
/// Validator for UpdateLessonDto.
/// Validates input when updating an existing lesson.
/// </summary>
public class UpdateLessonValidator : AbstractValidator<UpdateLessonDto>
{
public UpdateLessonValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Lesson title is required.")
.MinimumLength(3).WithMessage("Lesson title must be at least 3 characters long.")
.MaximumLength(200).WithMessage("Lesson title must not exceed 200 characters.");
RuleFor(x => x.Description)
.MaximumLength(2000).WithMessage("Lesson description must not exceed 2000 characters.");
RuleFor(x => x.LevelId)
.GreaterThan(0).WithMessage("Level ID must be greater than 0.");
RuleFor(x => x.Order)
.GreaterThanOrEqualTo(0).WithMessage("Order must be at least 0.")
.LessThanOrEqualTo(1000).WithMessage("Order must not exceed 1000.");
RuleFor(x => x.Topic)
.NotEmpty().WithMessage("Topic is required.")
.MinimumLength(2).WithMessage("Topic must be at least 2 characters long.")
.MaximumLength(100).WithMessage("Topic must not exceed 100 characters.");
}
}

View file

@ -0,0 +1,78 @@
using FluentValidation;
using GermanApp.Application.DTOs;
namespace GermanApp.Presentation.Validators;
/// <summary>
/// Validator for CreateLevelDto.
/// Validates input when creating a new CEFR level.
/// </summary>
public class CreateLevelValidator : AbstractValidator<CreateLevelDto>
{
public CreateLevelValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Level name is required.")
.MinimumLength(2).WithMessage("Level name must be at least 2 characters long.")
.MaximumLength(100).WithMessage("Level name must not exceed 100 characters.");
RuleFor(x => x.Code)
.NotEmpty().WithMessage("Level code is required.")
.MinimumLength(1).WithMessage("Level code must be at least 1 character long.")
.MaximumLength(10).WithMessage("Level code must not exceed 10 characters.")
.Matches("^[A-Za-z][A-Za-z0-9]*$").WithMessage("Level code must start with a letter and contain only letters and numbers.")
.Must(BeValidCefrCode).WithMessage("Level code must be a valid CEFR code (A1, A2, B1, B2, C1, C2).");
RuleFor(x => x.Order)
.GreaterThan(0).WithMessage("Order must be greater than 0.")
.LessThanOrEqualTo(100).WithMessage("Order must not exceed 100.");
}
private static bool BeValidCefrCode(string code)
{
// Normalize to uppercase for comparison
var upperCode = code.ToUpperInvariant();
// Valid CEFR levels
var validCodes = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
return validCodes.Contains(upperCode);
}
}
/// <summary>
/// Validator for UpdateLevelDto.
/// Validates input when updating an existing CEFR level.
/// </summary>
public class UpdateLevelValidator : AbstractValidator<UpdateLevelDto>
{
public UpdateLevelValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Level name is required.")
.MinimumLength(2).WithMessage("Level name must be at least 2 characters long.")
.MaximumLength(100).WithMessage("Level name must not exceed 100 characters.");
RuleFor(x => x.Code)
.NotEmpty().WithMessage("Level code is required.")
.MinimumLength(1).WithMessage("Level code must be at least 1 character long.")
.MaximumLength(10).WithMessage("Level code must not exceed 10 characters.")
.Matches("^[A-Za-z][A-Za-z0-9]*$").WithMessage("Level code must start with a letter and contain only letters and numbers.")
.Must(BeValidCefrCode).WithMessage("Level code must be a valid CEFR code (A1, A2, B1, B2, C1, C2).");
RuleFor(x => x.Order)
.GreaterThan(0).WithMessage("Order must be greater than 0.")
.LessThanOrEqualTo(100).WithMessage("Order must not exceed 100.");
}
private static bool BeValidCefrCode(string code)
{
// Normalize to uppercase for comparison
var upperCode = code.ToUpperInvariant();
// Valid CEFR levels
var validCodes = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
return validCodes.Contains(upperCode);
}
}

View file

@ -1,3 +1,5 @@
using FluentValidation;
using FluentValidation.AspNetCore;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Application.Interfaces;
@ -9,6 +11,7 @@ using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Infrastructure.Data.SeedData;
using GermanApp.Infrastructure.Services;
using GermanApp.Presentation.Controllers;
using GermanApp.Presentation.Validators;
using GermanApp.Presentation.Endpoints;
using GermanApp.Shared.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -92,6 +95,15 @@ try
});
builder.Services.AddControllers();
// ============================================
// PRESENTATION LAYER - Validation
// ============================================
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<CreateLevelValidator>();
builder.Services.AddFluentValidationAutoValidation();
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

View file

@ -0,0 +1,372 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Presentation.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace GermanApp.Tests.Integration.Controllers;
/// <summary>
/// Integration tests for LessonsController.
/// Tests controller behavior with mocked services.
/// </summary>
[TestClass]
public class LessonsControllerTests
{
private Mock<LessonService>? _mockLessonService;
private Mock<ProgressService>? _mockProgressService;
private LessonsController? _controller;
[TestInitialize]
public void TestInitialize()
{
_mockLessonService = new Mock<LessonService>(null!, null!);
_mockProgressService = new Mock<ProgressService>(null!, null!, null!);
_controller = new LessonsController(_mockLessonService.Object, _mockProgressService.Object);
}
[TestCleanup]
public void TestCleanup()
{
_controller = null;
_mockLessonService = null;
_mockProgressService = null;
}
private static DateTime TestDate => new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
// ==================== GET ALL LESSONS TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetAllLessons")]
public async Task GetAllLessons_WithData_ReturnsOkWithLessons()
{
// Arrange
var expectedLessons = new List<LessonDto>
{
new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null),
new LessonDto(2, "Introductions", "Learn to introduce yourself", 1, "A1", "Beginner A1", 2, "Vocabulary", TestDate, null)
};
_mockLessonService!.Setup(s => s.GetAllLessonsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLessons);
// Act
var result = await _controller!.GetAllLessonsAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
Assert.IsNotNull(returnedLessons);
Assert.AreEqual(2, returnedLessons.Count);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetAllLessons")]
public async Task GetAllLessons_WithNoData_ReturnsEmptyList()
{
// Arrange
var expectedLessons = new List<LessonDto>();
_mockLessonService!.Setup(s => s.GetAllLessonsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLessons);
// Act
var result = await _controller!.GetAllLessonsAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
Assert.IsNotNull(returnedLessons);
Assert.AreEqual(0, returnedLessons.Count);
}
// ==================== GET LESSON BY ID TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetLessonById")]
public async Task GetLessonById_WithExistingId_ReturnsOkWithLesson()
{
// Arrange
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
_mockLessonService!.Setup(s => s.GetLessonByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLesson);
// Act
var result = await _controller!.GetLessonByIdAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLesson = okResult.Value as LessonDto;
Assert.IsNotNull(returnedLesson);
Assert.AreEqual(1, returnedLesson.Id);
Assert.AreEqual("Greetings", returnedLesson.Title);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetLessonById")]
public async Task GetLessonById_WithNonExistingId_ReturnsNotFound()
{
// Arrange
_mockLessonService!.Setup(s => s.GetLessonByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((LessonDto?)null);
// Act
var result = await _controller!.GetLessonByIdAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== GET LESSONS BY LEVEL TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetLessonsByLevel")]
public async Task GetLessonsByLevel_WithExistingLevel_ReturnsOkWithLessons()
{
// Arrange
var expectedLessons = new List<LessonDto>
{
new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null)
};
_mockLessonService!.Setup(s => s.GetLessonsByLevelAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLessons);
// Act
var result = await _controller!.GetLessonsByLevelAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
Assert.IsNotNull(returnedLessons);
Assert.AreEqual(1, returnedLessons.Count);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetLessonsByLevel")]
public async Task GetLessonsByLevel_WithNonExistingLevel_ReturnsEmptyList()
{
// Arrange
var expectedLessons = new List<LessonDto>();
_mockLessonService!.Setup(s => s.GetLessonsByLevelAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLessons);
// Act
var result = await _controller!.GetLessonsByLevelAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
Assert.IsNotNull(returnedLessons);
Assert.AreEqual(0, returnedLessons.Count);
}
// ==================== CREATE LESSON TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("CreateLesson")]
public async Task CreateLesson_WithValidData_ReturnsCreatedAtAction()
{
// Arrange
var createDto = new CreateLessonDto("Greetings", "Learn basic greetings", 1, 1, "Vocabulary");
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
_mockLessonService!.Setup(s => s.CreateLessonAsync(createDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLesson);
// Act
var result = await _controller!.CreateLessonAsync(createDto);
// Assert
Assert.IsInstanceOfType(result, typeof(CreatedAtActionResult));
var createdResult = result as CreatedAtActionResult;
Assert.IsNotNull(createdResult);
var returnedLesson = createdResult.Value as LessonDto;
Assert.IsNotNull(returnedLesson);
Assert.AreEqual(1, returnedLesson.Id);
}
// ==================== UPDATE LESSON TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("UpdateLesson")]
public async Task UpdateLesson_WithExistingId_ReturnsOkWithUpdatedLesson()
{
// Arrange
var updateDto = new UpdateLessonDto("Updated Greetings", "Updated description", 1, 1, "Updated Topic");
var expectedLesson = new LessonDto(1, "Updated Greetings", "Updated description", 1, "A1", "Beginner A1", 1, "Updated Topic", TestDate, TestDate);
_mockLessonService!.Setup(s => s.UpdateLessonAsync(1, updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLesson);
// Act
var result = await _controller!.UpdateLessonAsync(1, updateDto);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLesson = okResult.Value as LessonDto;
Assert.IsNotNull(returnedLesson);
Assert.AreEqual("Updated Greetings", returnedLesson.Title);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("UpdateLesson")]
public async Task UpdateLesson_WithNonExistingId_ReturnsNotFound()
{
// Arrange
var updateDto = new UpdateLessonDto("Updated Greetings", "Updated description", 1, 1, "Updated Topic");
_mockLessonService!.Setup(s => s.UpdateLessonAsync(999, updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync((LessonDto?)null);
// Act
var result = await _controller!.UpdateLessonAsync(999, updateDto);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== DELETE LESSON TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("DeleteLesson")]
public async Task DeleteLesson_WithExistingId_ReturnsNoContent()
{
// Arrange
_mockLessonService!.Setup(s => s.DeleteLessonAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _controller!.DeleteLessonAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(NoContentResult));
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("DeleteLesson")]
public async Task DeleteLesson_WithNonExistingId_ReturnsNotFound()
{
// Arrange
_mockLessonService!.Setup(s => s.DeleteLessonAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _controller!.DeleteLessonAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== GET FIRST LESSON IN LEVEL TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetFirstLessonInLevel")]
public async Task GetFirstLessonInLevel_WithExistingLevel_ReturnsOkWithLesson()
{
// Arrange
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
_mockLessonService!.Setup(s => s.GetFirstLessonInLevelAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLesson);
// Act
var result = await _controller!.GetFirstLessonInLevelAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLesson = okResult.Value as LessonDto;
Assert.IsNotNull(returnedLesson);
Assert.AreEqual(1, returnedLesson.Order);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("GetFirstLessonInLevel")]
public async Task GetFirstLessonInLevel_WithNonExistingLevel_ReturnsNotFound()
{
// Arrange
_mockLessonService!.Setup(s => s.GetFirstLessonInLevelAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((LessonDto?)null);
// Act
var result = await _controller!.GetFirstLessonInLevelAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== IS NEXT LESSON UNLOCKED TESTS ====================
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("IsNextLessonUnlocked")]
public async Task IsNextLessonUnlocked_WithCompletedPrevious_ReturnsTrue()
{
// Arrange
_mockProgressService!.Setup(s => s.IsNextLessonUnlockedAsync(1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _controller!.IsNextLessonUnlockedAsync(1, 1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
Assert.AreEqual(true, okResult.Value);
}
[TestMethod]
[TestCategory("LessonsController")]
[TestCategory("IsNextLessonUnlocked")]
public async Task IsNextLessonUnlocked_WithIncompletePrevious_ReturnsFalse()
{
// Arrange
_mockProgressService!.Setup(s => s.IsNextLessonUnlockedAsync(1, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _controller!.IsNextLessonUnlockedAsync(1, 1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
Assert.AreEqual(false, okResult.Value);
}
}

View file

@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Presentation.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace GermanApp.Tests.Integration.Controllers;
/// <summary>
/// Integration tests for LevelsController.
/// Tests controller behavior with mocked services.
/// </summary>
[TestClass]
public class LevelsControllerTests
{
private Mock<LevelService>? _mockLevelService;
private LevelsController? _controller;
[TestInitialize]
public void TestInitialize()
{
_mockLevelService = new Mock<LevelService>(null!);
_controller = new LevelsController(_mockLevelService.Object);
}
[TestCleanup]
public void TestCleanup()
{
_controller = null;
_mockLevelService = null;
}
// ==================== GET ALL LEVELS TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetAllLevels")]
public async Task GetAllLevels_WithData_ReturnsOkWithLevels()
{
// Arrange
var expectedLevels = new List<LevelDto>
{
new LevelDto(1, "Beginner A1", "A1", 1),
new LevelDto(2, "Elementary A2", "A2", 2)
};
_mockLevelService!.Setup(s => s.GetAllLevelsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevels);
// Act
var result = await _controller!.GetAllLevelsAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevels = okResult.Value as IReadOnlyList<LevelDto>;
Assert.IsNotNull(returnedLevels);
Assert.AreEqual(2, returnedLevels.Count);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetAllLevels")]
public async Task GetAllLevels_WithNoData_ReturnsEmptyList()
{
// Arrange
var expectedLevels = new List<LevelDto>();
_mockLevelService!.Setup(s => s.GetAllLevelsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevels);
// Act
var result = await _controller!.GetAllLevelsAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevels = okResult.Value as IReadOnlyList<LevelDto>;
Assert.IsNotNull(returnedLevels);
Assert.AreEqual(0, returnedLevels.Count);
}
// ==================== GET LEVEL BY ID TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetLevelById")]
public async Task GetLevelById_WithExistingId_ReturnsOkWithLevel()
{
// Arrange
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
_mockLevelService!.Setup(s => s.GetLevelByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.GetLevelByIdAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevel = okResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual(1, returnedLevel.Id);
Assert.AreEqual("Beginner A1", returnedLevel.Name);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetLevelById")]
public async Task GetLevelById_WithNonExistingId_ReturnsNotFound()
{
// Arrange
_mockLevelService!.Setup(s => s.GetLevelByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((LevelDto?)null);
// Act
var result = await _controller!.GetLevelByIdAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== GET LEVEL BY CODE TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetLevelByCode")]
public async Task GetLevelByCode_WithExistingCode_ReturnsOkWithLevel()
{
// Arrange
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
_mockLevelService!.Setup(s => s.GetLevelByCodeAsync("A1", It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.GetLevelByCodeAsync("A1");
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevel = okResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual("A1", returnedLevel.Code);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetLevelByCode")]
public async Task GetLevelByCode_WithNonExistingCode_ReturnsNotFound()
{
// Arrange
_mockLevelService!.Setup(s => s.GetLevelByCodeAsync("INVALID", It.IsAny<CancellationToken>()))
.ReturnsAsync((LevelDto?)null);
// Act
var result = await _controller!.GetLevelByCodeAsync("INVALID");
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== CREATE LEVEL TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("CreateLevel")]
public async Task CreateLevel_WithValidData_ReturnsCreatedAtAction()
{
// Arrange
var createDto = new CreateLevelDto("Beginner A1", "A1", 1);
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
_mockLevelService!.Setup(s => s.CreateLevelAsync(createDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.CreateLevelAsync(createDto);
// Assert
Assert.IsInstanceOfType(result, typeof(CreatedAtActionResult));
var createdResult = result as CreatedAtActionResult;
Assert.IsNotNull(createdResult);
var returnedLevel = createdResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual(1, returnedLevel.Id);
}
// ==================== UPDATE LEVEL TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("UpdateLevel")]
public async Task UpdateLevel_WithExistingId_ReturnsOkWithUpdatedLevel()
{
// Arrange
var updateDto = new UpdateLevelDto("Updated A1", "A1", 1);
var expectedLevel = new LevelDto(1, "Updated A1", "A1", 1);
_mockLevelService!.Setup(s => s.UpdateLevelAsync(1, updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.UpdateLevelAsync(1, updateDto);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevel = okResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual("Updated A1", returnedLevel.Name);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("UpdateLevel")]
public async Task UpdateLevel_WithNonExistingId_ReturnsNotFound()
{
// Arrange
var updateDto = new UpdateLevelDto("Updated A1", "A1", 1);
_mockLevelService!.Setup(s => s.UpdateLevelAsync(999, updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync((LevelDto?)null);
// Act
var result = await _controller!.UpdateLevelAsync(999, updateDto);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== DELETE LEVEL TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("DeleteLevel")]
public async Task DeleteLevel_WithExistingId_ReturnsNoContent()
{
// Arrange
_mockLevelService!.Setup(s => s.DeleteLevelAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var result = await _controller!.DeleteLevelAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(NoContentResult));
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("DeleteLevel")]
public async Task DeleteLevel_WithNonExistingId_ReturnsNotFound()
{
// Arrange
_mockLevelService!.Setup(s => s.DeleteLevelAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act
var result = await _controller!.DeleteLevelAsync(999);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== GET FIRST LEVEL TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetFirstLevel")]
public async Task GetFirstLevel_WithData_ReturnsOkWithLevel()
{
// Arrange
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
_mockLevelService!.Setup(s => s.GetFirstLevelAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.GetFirstLevelAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevel = okResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual(1, returnedLevel.Order);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetFirstLevel")]
public async Task GetFirstLevel_WithNoData_ReturnsNotFound()
{
// Arrange
_mockLevelService!.Setup(s => s.GetFirstLevelAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync((LevelDto?)null);
// Act
var result = await _controller!.GetFirstLevelAsync();
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
// ==================== GET NEXT LEVEL TESTS ====================
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetNextLevel")]
public async Task GetNextLevel_WithExistingCurrentLevel_ReturnsOkWithNextLevel()
{
// Arrange
var expectedLevel = new LevelDto(2, "Elementary A2", "A2", 2);
_mockLevelService!.Setup(s => s.GetNextLevelAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedLevel);
// Act
var result = await _controller!.GetNextLevelAsync(1);
// Assert
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
var okResult = result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedLevel = okResult.Value as LevelDto;
Assert.IsNotNull(returnedLevel);
Assert.AreEqual(2, returnedLevel.Order);
}
[TestMethod]
[TestCategory("LevelsController")]
[TestCategory("GetNextLevel")]
public async Task GetNextLevel_WithNoNextLevel_ReturnsNotFound()
{
// Arrange
_mockLevelService!.Setup(s => s.GetNextLevelAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync((LevelDto?)null);
// Act
var result = await _controller!.GetNextLevelAsync(5);
// Assert
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
}
}