feat: добавил новые юнит-тесты для сервисов и маппинга
Backend CI / build-and-test (push) Successful in 48s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 24s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 2s

This commit is contained in:
2026-05-28 05:04:43 +03:00
parent cce7bea12f
commit ef2fd39508
6 changed files with 651 additions and 0 deletions
@@ -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);
}
}
@@ -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<ConflictException>(() => 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<NotFoundException>(() => service.AddTagAsync(404, 10));
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(1, 404));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.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
};
}
@@ -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));
}
}
@@ -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<NotFoundException>(() =>
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<AppDbContext>()
.UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities; using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Services;
using Xunit; using Xunit;
@@ -71,6 +74,125 @@ public class UserServiceTests
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots)); 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<ForbiddenException>(() => 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() private static AppDbContext CreateDbContext()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -115,4 +237,31 @@ public class UserServiceTests
IsOpen = true, IsOpen = true,
MaxEnrollments = 30 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()
};
} }
+143
View File
@@ -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
```