From ef2fd39508ae98c7e05d3c3c4370cb9f06c00405 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 05:04:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=8E=D0=BD=D0=B8?= =?UTF-8?q?=D1=82-=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/MappingExtensionsTests.cs | 123 +++++++++++++++ .../Courses/CourseServiceTests.cs | 121 ++++++++++++++ .../Domain/EnrollmentSlotPolicyTests.cs | 29 ++++ .../Tags/TagServiceTests.cs | 86 ++++++++++ .../Users/UserServiceTests.cs | 149 ++++++++++++++++++ docs/backend-unit-tests.md | 143 +++++++++++++++++ 6 files changed, 651 insertions(+) create mode 100644 backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs create mode 100644 backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs create mode 100644 backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs create mode 100644 backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs create mode 100644 docs/backend-unit-tests.md diff --git a/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs new file mode 100644 index 0000000..138b38c --- /dev/null +++ b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs @@ -0,0 +1,123 @@ +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using Xunit; + +namespace UniVerse.Api.Tests.Application; + +public class MappingExtensionsTests +{ + [Fact] + public void UserMappings_OrderRolesConsistently() + { + var user = new User + { + Id = 1, + Email = "user@test.local", + DisplayName = "User", + AvatarUrl = "avatar.png", + IsActive = true, + Xp = 120, + Coins = 30, + CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Roles = + [ + new UserRoleAssignment { Role = UserRole.Teacher }, + new UserRoleAssignment { Role = UserRole.Student }, + new UserRoleAssignment { Role = UserRole.Admin } + ] + }; + + var dto = user.ToDto(level: 2); + var currentUser = user.ToCurrentUserDto(level: 2); + var auth = user.ToAuthDto(); + + Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles); + Assert.Equal(dto.Roles, currentUser.Roles); + Assert.Equal(dto.Roles, auth.Roles); + Assert.Equal(2, dto.Level); + } + + [Fact] + public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount() + { + var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc); + var lecture = new Lecture + { + Id = 10, + CourseId = 5, + Title = "Offline lecture", + Description = "Description", + Format = LectureFormat.Offline, + StartsAt = startsAt, + EndsAt = startsAt.AddHours(2), + IsOpen = true, + MaxEnrollments = 25, + Enrollments = + [ + new LectureEnrollment { UserId = 1 }, + new LectureEnrollment { UserId = 2 } + ] + }; + + var dto = lecture.ToDto(isEnrolled: true); + var detail = lecture.ToDetailDto(isEnrolled: false); + + Assert.Equal("", dto.CourseName); + Assert.Null(dto.TeacherName); + Assert.Null(dto.LocationName); + Assert.Equal(2, dto.EnrollmentsCount); + Assert.True(dto.IsEnrolled); + Assert.False(detail.IsEnrolled); + } + + [Fact] + public void ReviewMapping_CopiesAnalysisFields() + { + var review = new Review + { + Id = 7, + LectureId = 3, + UserId = 4, + Rating = ReviewRating.Like, + Text = "Clear and useful", + LlmStatus = ReviewLlmStatus.Analyzed, + Sentiment = ReviewSentiment.Positive, + QualityScore = 0.95, + IsInformative = true, + LlmTags = ["clear", "useful"], + LlmRawOutput = "{\"quality_score\":0.95}", + CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc), + Lecture = new Lecture { Title = "Lecture title" }, + User = new User { DisplayName = "Student" } + }; + + var dto = review.ToDto(); + + Assert.Equal("Lecture title", dto.LectureTitle); + Assert.Equal("Student", dto.UserName); + Assert.Equal(ReviewSentiment.Positive, dto.Sentiment); + Assert.Equal(0.95, dto.QualityScore); + Assert.True(dto.IsInformative); + Assert.NotNull(dto.LlmTags); + Assert.Equal(["clear", "useful"], dto.LlmTags); + Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput); + } + + [Fact] + public void TagTreeMapping_MapsChildrenRecursively() + { + var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic }; + var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 }; + var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 }; + root.Children.Add(child); + child.Children.Add(grandchild); + + var dto = root.ToTreeDto(); + + Assert.Equal("Root", dto.Name); + var childDto = Assert.Single(dto.Children); + Assert.Equal("Child", childDto.Name); + Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name); + } +} diff --git a/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs new file mode 100644 index 0000000..cc1c50e --- /dev/null +++ b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Courses; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Courses; + +public class CourseServiceTests +{ + [Fact] + public async Task GetAllAsync_AppliesSearchSyncedTagFiltersAndPagination() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Backend", Type = TagType.Subject }, + new Tag { Id = 2, Name = "Frontend", Type = TagType.Subject }); + db.Courses.AddRange( + Course(1, "ASP.NET Core", true, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)), + Course(2, "Vue Basics", true, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)), + Course(3, "Advanced ASP.NET", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + db.CourseTags.AddRange( + new CourseTag { CourseId = 1, TagId = 1 }, + new CourseTag { CourseId = 2, TagId = 2 }, + new CourseTag { CourseId = 3, TagId = 1 }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + var result = await service.GetAllAsync(new CourseFilterRequest( + TagId: 1, + Search: "asp", + IsSynced: true, + Page: 1, + PageSize: 10)); + + var item = Assert.Single(result.Items); + Assert.Equal(1, item.Id); + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.TotalPages); + Assert.Equal("Backend", Assert.Single(item.Tags).Name); + } + + [Fact] + public async Task GetAllAsync_ReturnsRequestedPageMetadata() + { + await using var db = CreateDbContext(); + db.Courses.AddRange( + Course(1, "Old", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)), + Course(2, "Middle", false, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)), + Course(3, "Newest", false, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc))); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + var result = await service.GetAllAsync(new CourseFilterRequest(null, null, null, Page: 2, PageSize: 1)); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Page); + Assert.Equal(1, result.PageSize); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, Assert.Single(result.Items).Id); + } + + [Fact] + public async Task AddTagAsync_LinksExistingCourseAndTag() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await service.AddTagAsync(1, 10); + + Assert.True(await db.CourseTags.AnyAsync(ct => ct.CourseId == 1 && ct.TagId == 10)); + } + + [Fact] + public async Task AddTagAsync_ThrowsWhenTagAlreadyLinked() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + db.CourseTags.Add(new CourseTag { CourseId = 1, TagId = 10 }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await Assert.ThrowsAsync(() => service.AddTagAsync(1, 10)); + } + + [Fact] + public async Task AddTagAsync_ThrowsWhenCourseOrTagMissing() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await Assert.ThrowsAsync(() => service.AddTagAsync(404, 10)); + await Assert.ThrowsAsync(() => service.AddTagAsync(1, 404)); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"CourseServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static Course Course(int id, string name, bool isSynced, DateTime createdAt) => new() + { + Id = id, + Name = name, + IsSynced = isSynced, + CreatedAt = createdAt + }; +} diff --git a/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs new file mode 100644 index 0000000..741a447 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs @@ -0,0 +1,29 @@ +using UniVerse.Domain.Services; +using Xunit; + +namespace UniVerse.Api.Tests.DomainServices; + +public class EnrollmentSlotPolicyTests +{ + [Theory] + [InlineData(-1, 3)] + [InlineData(0, 3)] + [InlineData(1, 3)] + [InlineData(2, 3)] + [InlineData(3, 5)] + [InlineData(4, 7)] + [InlineData(10, 7)] + public void GetLimitForLevel_UsesHighestMatchingRuleOrDefault(int level, int expectedSlots) + { + var slots = EnrollmentSlotPolicy.GetLimitForLevel(level); + + Assert.Equal(expectedSlots, slots); + } + + [Fact] + public void Rules_ExposeConfiguredThresholdsInAscendingOrder() + { + Assert.Equal(new[] { 1, 3, 4 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Level)); + Assert.Equal(new[] { 3, 5, 7 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Slots)); + } +} diff --git a/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs new file mode 100644 index 0000000..fa9c6bd --- /dev/null +++ b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Tags; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Tags; + +public class TagServiceTests +{ + [Fact] + public async Task GetAllAsync_FiltersByTypeAndParentAndOrdersByName() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Root", Type = TagType.Topic }, + new Tag { Id = 2, Name = "Zeta", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 3, Name = "Alpha", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 4, Name = "Other parent", Type = TagType.Subject, ParentId = 99 }, + new Tag { Id = 5, Name = "Other type", Type = TagType.Topic, ParentId = 1 }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var result = await service.GetAllAsync(TagType.Subject, parentId: 1); + + Assert.Equal(new[] { "Alpha", "Zeta" }, result.Select(tag => tag.Name)); + } + + [Fact] + public async Task CreateAsync_ThrowsWhenParentMissing() + { + await using var db = CreateDbContext(); + var service = new TagService(db); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 404))); + } + + [Fact] + public async Task CreateAsync_CreatesChildWhenParentExists() + { + await using var db = CreateDbContext(); + db.Tags.Add(new Tag { Id = 1, Name = "Parent", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var created = await service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 1)); + + Assert.Equal("Child", created.Name); + Assert.Equal(1, created.ParentId); + Assert.True(await db.Tags.AnyAsync(tag => tag.Name == "Child" && tag.ParentId == 1)); + } + + [Fact] + public async Task GetTreeAsync_ReturnsNestedRootTrees() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Root A", Type = TagType.Topic }, + new Tag { Id = 2, Name = "Child A", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 3, Name = "Grandchild A", Type = TagType.Other, ParentId = 2 }, + new Tag { Id = 4, Name = "Root B", Type = TagType.Organization }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var tree = await service.GetTreeAsync(); + + Assert.Equal(new[] { "Root A", "Root B" }, tree.Select(tag => tag.Name)); + var rootA = tree.Single(tag => tag.Name == "Root A"); + var child = Assert.Single(rootA.Children); + Assert.Equal("Child A", child.Name); + Assert.Equal("Grandchild A", Assert.Single(child.Children).Name); + Assert.Empty(tree.Single(tag => tag.Name == "Root B").Children); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs index 7e13394..0559834 100644 --- a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; @@ -71,6 +74,125 @@ public class UserServiceTests Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots)); } + [Fact] + public async Task SetRolesAsync_DeduplicatesRolesAndCreatesProfiles() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await service.SetRolesAsync(1, [UserRole.Teacher, UserRole.Teacher, UserRole.Student]); + + var user = await db.Users + .Include(u => u.Roles) + .FirstAsync(u => u.Id == 1); + Assert.Equal(new[] { UserRole.Student, UserRole.Teacher }, user.Roles.Select(role => role.Role).OrderBy(role => role)); + Assert.Equal(2, user.Roles.Count); + Assert.True(await db.StudentProfiles.AnyAsync(profile => profile.UserId == 1)); + Assert.True(await db.TeacherProfiles.AnyAsync(profile => profile.UserId == 1)); + } + + [Fact] + public async Task SetRolesAsync_RejectsEmptyRoleSet() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await Assert.ThrowsAsync(() => service.SetRolesAsync(1, [])); + } + + [Fact] + public async Task SetRolesAsync_PreservesExistingProfiles() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + db.StudentProfiles.Add(new StudentProfile + { + Id = 10, + UserId = 1, + StudentId = "S-1" + }); + db.TeacherProfiles.Add(new TeacherProfile + { + Id = 20, + UserId = 1, + Department = "Math" + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await service.SetRolesAsync(1, [UserRole.Teacher]); + + Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId); + Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department); + } + + [Fact] + public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "anna@test.local", "Anna", true, 120, UserRole.Student), + User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher), + User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin), + User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest( + Search: "anna", + Role: UserRole.Student, + IsActive: true, + Page: 1, + PageSize: 10)); + + var user = Assert.Single(result.Items); + Assert.Equal(1, user.Id); + Assert.Equal(2, user.Level); + Assert.Equal(1, result.TotalCount); + } + + [Fact] + public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1)); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Page); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, Assert.Single(result.Items).Id); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -115,4 +237,31 @@ public class UserServiceTests IsOpen = true, MaxEnrollments = 30 }; + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + params UserRole[] roles) => + User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles); + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + DateTime createdAt, + params UserRole[] roles) => new() + { + Id = id, + Email = email, + DisplayName = displayName, + IsActive = isActive, + Xp = xp, + CreatedAt = createdAt, + Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList() + }; } diff --git a/docs/backend-unit-tests.md b/docs/backend-unit-tests.md new file mode 100644 index 0000000..9d91d19 --- /dev/null +++ b/docs/backend-unit-tests.md @@ -0,0 +1,143 @@ +# Backend unit-тесты + +## Назначение + +Unit- и service-тесты backend проверяют бизнес-логику без запуска HTTP API: + +- доменные правила записи на лекции; +- преобразование сущностей в DTO; +- фильтрацию, пагинацию и связи курсов; +- дерево тегов; +- управление ролями и профилями пользователей. + +Security-тесты авторизации находятся в том же тестовом проекте, но это отдельный интеграционный набор: они запускают API через `WebApplicationFactory` и проверяют HTTP-доступ к endpoint-ам. + +## Где лежат файлы + +- [EnrollmentSlotPolicyTests.cs](../backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs) - правила лимита активных записей по уровню. +- [MappingExtensionsTests.cs](../backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs) - маппинг доменных сущностей в DTO. +- [CourseServiceTests.cs](../backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs) - фильтры, пагинация и теги курсов. +- [TagServiceTests.cs](../backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs) - фильтры тегов и построение дерева. +- [UserServiceTests.cs](../backend/UniVerse.Api.Tests/Users/UserServiceTests.cs) - статистика, роли, профили и список пользователей. +- [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs) - security-тесты ролевого доступа к API. +- [ApiWebApplicationFactory.cs](../backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs) - тестовый запуск API. +- [TestJwtFactory.cs](../backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs) - генерация JWT для ролей в security-тестах. + +Тестовый проект: [UniVerse.Api.Tests.csproj](../backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj). + +## Тестовый стек + +- `xUnit` - test runner и assertions. +- `NSubstitute` - mock-объекты для сервисных зависимостей. +- `Microsoft.EntityFrameworkCore.InMemory` - изолированная InMemory БД для service-тестов. + +Каждый service-тест создает отдельный `AppDbContext` с уникальным именем базы через `Guid.NewGuid()`, чтобы данные разных тестов не пересекались. + +## Что покрыто + +### EnrollmentSlotPolicy + +Проверяется, что `GetLimitForLevel` выбирает последний подходящий threshold: + +- уровни ниже первого правила получают базовый лимит; +- уровни между threshold используют предыдущий лимит; +- уровни выше последнего threshold используют максимальный лимит; +- публичный список `Rules` остается в ожидаемом порядке. + +### MappingExtensions + +Проверяется стабильность DTO-маппинга: + +- роли пользователя сортируются одинаково в `UserDto`, `CurrentUserDto` и `UserAuthDto`; +- лекции корректно считают записи и используют fallback для отсутствующих navigation properties; +- отзывы переносят поля LLM-анализа; +- дерево тегов маппится рекурсивно. + +### CourseService + +Проверяется поведение без HTTP-слоя: + +- совместная работа фильтров `Search`, `IsSynced`, `TagId`; +- корректные `TotalCount`, `Page`, `PageSize`, `TotalPages`; +- добавление связи курс-тег; +- ошибки при повторной связи или отсутствующем курсе/теге. + +### TagService + +Проверяется: + +- фильтрация по `TagType` и `ParentId`; +- сортировка по имени; +- запрет создания дочернего тега без существующего родителя; +- построение вложенного дерева тегов. + +### UserService + +Проверяется: + +- статистика пользователя, прогресс уровня и лимиты записей; +- `SetRolesAsync` удаляет дубли ролей; +- пустой набор ролей отклоняется; +- профили студента и преподавателя создаются и не дублируются; +- `GetAllAsync` фильтрует по поиску, активности и одиночной роли; +- пагинация пользователей идет в порядке `CreatedAt` по убыванию. + +## Security-тесты авторизации + +Security-тесты находятся в [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs). Это интеграционные тесты, которые отправляют реальные HTTP-запросы в тестовый API через `ApiWebApplicationFactory`. + +Они проверяют не бизнес-результат endpoint-а, а сам факт прохождения или блокировки авторизации: + +- анонимный запрос к защищенному endpoint-у получает `401 Unauthorized`; +- запрос с неподходящей ролью получает `403 Forbidden`; +- запрос с подходящей ролью не получает `401` или `403`; +- публичные endpoint-ы из `AnonymousEndpoints` доступны без JWT и не возвращают `401` от middleware авторизации. + +Таблица защищенных endpoint-ов задается в методе `AuthenticatedEndpoints`. Каждый кейс описывает: + +- человекочитаемое имя сценария; +- HTTP-метод; +- URL; +- роль, которая должна пройти авторизацию; +- роли, которые должны получить `403`; +- опциональное JSON-тело запроса. + +Для endpoint-ов, доступных любой авторизованной роли, используется обычная тестовая роль, чаще `Student`, и пустой список запрещенных ролей. Для endpoint-ов с несколькими разрешенными ролями добавляется отдельный кейс на каждую разрешенную роль, например `Admin` и `Teacher`. + +JWT для ролей создаются через `TestJwtFactory.BearerHeader(role)`. Это позволяет проверять backend-авторизацию без Microsoft OAuth flow и без реального входа пользователя. + +## Как обновлять security-тесты + +При добавлении или изменении API endpoint-а нужно обновить `EndpointAuthorizationTests`: + +1. Если endpoint требует авторизации, добавьте его в `AuthenticatedEndpoints`. +2. Укажите правильную роль или отдельные кейсы для нескольких ролей. +3. Для role-specific endpoint-а заполните `forbidden` ролями, которые должны получать `403`. +4. Если endpoint публичный, добавьте его в `AnonymousEndpoints`. +5. Для `POST`, `PUT`, `PATCH` endpoint-ов добавьте минимальное валидное тело запроса, чтобы тест дошел до авторизации и не падал на model binding раньше времени. + +Security-тест считается успешным для правильной роли, если ответ не `401` и не `403`. Это намеренно: после авторизации endpoint может вернуть `404`, `400`, `409` или другой доменный ответ из-за тестовых данных, и это не является ошибкой проверки доступа. + +## Как запускать + +Из корня репозитория: + +```bash +dotnet test backend/UniVerse.sln --no-restore +``` + +## Как добавлять новые unit/service-тесты + +1. Размещайте тесты рядом с проверяемой областью внутри `backend/UniVerse.Api.Tests`. +2. Для сервисов с EF используйте InMemory `AppDbContext` с уникальным именем базы. +3. Мокайте только внешние зависимости и соседние сервисы через `NSubstitute`. +4. Не запускайте `WebApplicationFactory`, если проверяется не HTTP/auth behavior. +5. Покрывайте не только успешный сценарий, но и доменные ошибки: `NotFoundException`, `ConflictException`, `ForbiddenException`. + +## Текущий baseline + +После добавления unit/service-тестов и с учетом существующих security-тестов полный backend test suite проходит: + +```text +Passed: 303, Failed: 0, Skipped: 0 +```