diff --git a/README.md b/README.md
index 4cb1b4f..4df483b 100644
--- a/README.md
+++ b/README.md
@@ -144,3 +144,19 @@ LLM-ключ задаётся через `Llm:ApiKey`.
Точные схемы запросов/ответов удобнее смотреть в Swagger.
+## Тестирование
+
+В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
+
+- **xUnit** в качестве основного фреймворка для тестирования.
+- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
+- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
+- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
+
+Запуск тестов:
+
+```bash
+cd backend
+dotnet test
+```
+
diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs
new file mode 100644
index 0000000..dfedea6
--- /dev/null
+++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs
@@ -0,0 +1,292 @@
+using System.Net;
+using UniVerse.Api.Tests.Helpers;
+using Xunit;
+
+namespace UniVerse.Api.Tests.Authorization;
+
+///
+/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
+///
+/// Каждый тестовый случай представляет собой кортеж:
+/// (description, method, url, requiredRole, forbiddenRoles[])
+///
+/// Три типа сценариев для каждой конечной точки:
+/// A) Анонимный → 401 Unauthorized
+/// B) Неправильная роль → 403 Forbidden
+/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
+///
+public class EndpointAuthorizationTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
+ {
+ _client = factory.CreateClient();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Тестовые данные
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Конечные точки, требующие аутентификации (не анонимные).
+ /// Формат: (description, method, url, correctRole, forbiddenRoles[])
+ ///
+ /// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
+ /// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
+ ///
+ public static IEnumerable AuthenticatedEndpoints()
+ {
+ // ── Auth ─────────────────────────────────────────────────────────────
+ yield return E("auth/logout [AnyAuth]", "POST", "api/v1/auth/logout", "Student");
+ yield return E("auth/me [AnyAuth]", "GET", "api/v1/auth/me", "Student");
+
+ // ── Users — any auth ──────────────────────────────────────────────────
+ yield return E("users/{id} GET [AnyAuth]", "GET", "api/v1/users/1", "Student");
+ yield return E("users/{id} PUT [AnyAuth/self]", "PUT", "api/v1/users/1", "Student",
+ body: """{"displayName":"Test","avatarUrl":null}""");
+ yield return E("users/{id}/stats [AnyAuth]", "GET", "api/v1/users/1/stats", "Student");
+ yield return E("users/{id}/enrollments [AnyAuth]", "GET", "api/v1/users/1/enrollments", "Student");
+ yield return E("users/{id}/reviews [AnyAuth]", "GET", "api/v1/users/1/reviews","Student");
+ yield return E("users/{id}/achievements [AnyAuth]","GET", "api/v1/users/1/achievements","Student");
+ yield return E("users/{id}/transactions [AnyAuth/self]","GET","api/v1/users/1/transactions","Student");
+
+ // ── Users — Admin only ────────────────────────────────────────────────
+ yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
+ yield return E("users/{id}/role PATCH [Admin]", "PATCH", "api/v1/users/1/role", "Admin", forbidden: ["Student", "Teacher"],
+ body: "\"Student\"");
+ yield return E("users/{id}/active PATCH [Admin]", "PATCH", "api/v1/users/1/active", "Admin", forbidden: ["Student", "Teacher"],
+ body: "true");
+
+ // ── Courses — any auth ────────────────────────────────────────────────
+ yield return E("courses GET [AnyAuth]", "GET", "api/v1/courses", "Student");
+ yield return E("courses/{id} GET [AnyAuth]", "GET", "api/v1/courses/1", "Student");
+
+ // ── Courses — Admin only ──────────────────────────────────────────────
+ yield return E("courses POST [Admin]", "POST", "api/v1/courses", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Course","description":null}""");
+ yield return E("courses/{id} PUT [Admin]", "PUT", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Course","description":null}""");
+ yield return E("courses/{id} DELETE [Admin]", "DELETE", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"]);
+ yield return E("courses/{id}/tags POST [Admin]", "POST", "api/v1/courses/1/tags", "Admin", forbidden: ["Student", "Teacher"],
+ body: "1");
+ yield return E("courses/{id}/tags/{tagId} DELETE [Admin]","DELETE","api/v1/courses/1/tags/1","Admin",forbidden: ["Student", "Teacher"]);
+
+ // ── Lectures — any auth ───────────────────────────────────────────────
+ yield return E("lectures GET [AnyAuth]", "GET", "api/v1/lectures", "Student");
+ yield return E("lectures/{id} GET [AnyAuth]", "GET", "api/v1/lectures/1", "Student");
+ yield return E("lectures/{id}/reviews GET [AnyAuth]","GET", "api/v1/lectures/1/reviews","Student");
+
+ // ── Lectures — Admin only ─────────────────────────────────────────────
+ yield return E("lectures POST [Admin]", "POST", "api/v1/lectures", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"courseId":1,"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
+ yield return E("lectures/{id} DELETE [Admin]", "DELETE", "api/v1/lectures/1", "Admin", forbidden: ["Student", "Teacher"]);
+
+ // ── Lectures — Admin OR Teacher ───────────────────────────────────────
+ yield return E("lectures/{id} PUT [Admin]", "PUT", "api/v1/lectures/1", "Admin", forbidden: ["Student"],
+ body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
+ yield return E("lectures/{id} PUT [Teacher]", "PUT", "api/v1/lectures/1", "Teacher", forbidden: ["Student"],
+ body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
+ yield return E("lectures/{id}/attendance PATCH [Admin]", "PATCH","api/v1/lectures/1/attendance/2","Admin", forbidden: ["Student"],
+ body: "true");
+ yield return E("lectures/{id}/attendance PATCH [Teacher]","PATCH","api/v1/lectures/1/attendance/2","Teacher",forbidden: ["Student"],
+ body: "true");
+ yield return E("lectures/{id}/enrollments GET [Admin]", "GET","api/v1/lectures/1/enrollments","Admin", forbidden: ["Student"]);
+ yield return E("lectures/{id}/enrollments GET [Teacher]","GET","api/v1/lectures/1/enrollments","Teacher",forbidden: ["Student"]);
+
+ // ── Lectures — Student only ───────────────────────────────────────────
+ yield return E("lectures/{id}/enroll POST [Student]", "POST", "api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
+ yield return E("lectures/{id}/enroll DELETE [Student]", "DELETE","api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
+
+ // ── Reviews — any auth ────────────────────────────────────────────────
+ yield return E("reviews/{id} GET [AnyAuth]", "GET", "api/v1/reviews/1", "Student");
+ yield return E("reviews/{id} PUT [AnyAuth]", "PUT", "api/v1/reviews/1", "Student",
+ body: """{"rating":"Like","text":"Updated"}""");
+ yield return E("reviews/{id} DELETE [AnyAuth]", "DELETE", "api/v1/reviews/1", "Student");
+
+ // ── Reviews — Student only ────────────────────────────────────────────
+ yield return E("reviews POST [Student]", "POST", "api/v1/reviews", "Student", forbidden: ["Admin", "Teacher"],
+ body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
+
+ // ── Reviews — Admin only ──────────────────────────────────────────────
+ yield return E("reviews/pending GET [Admin]", "GET", "api/v1/reviews/pending","Admin", forbidden: ["Student", "Teacher"]);
+ yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
+
+ // ── Tags — any auth ───────────────────────────────────────────────────
+ yield return E("tags GET [AnyAuth]", "GET", "api/v1/tags", "Student");
+ yield return E("tags/{id} GET [AnyAuth]", "GET", "api/v1/tags/1", "Student");
+ yield return E("tags/tree GET [AnyAuth]", "GET", "api/v1/tags/tree", "Student");
+
+ // ── Tags — Admin only ─────────────────────────────────────────────────
+ yield return E("tags POST [Admin]", "POST", "api/v1/tags", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Tag","type":"Topic","parentId":null}""");
+ yield return E("tags/{id} PUT [Admin]", "PUT", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Tag","type":"Topic","parentId":null}""");
+ yield return E("tags/{id} DELETE [Admin]", "DELETE", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"]);
+
+ // ── Locations — any auth ──────────────────────────────────────────────
+ yield return E("locations GET [AnyAuth]", "GET", "api/v1/locations", "Student");
+ yield return E("locations/{id} GET [AnyAuth]", "GET", "api/v1/locations/1", "Student");
+
+ // ── Locations — Admin only ────────────────────────────────────────────
+ yield return E("locations POST [Admin]", "POST", "api/v1/locations", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
+ yield return E("locations/{id} PUT [Admin]", "PUT", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
+ yield return E("locations/{id} DELETE [Admin]", "DELETE", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"]);
+
+ // ── Achievements — any auth ───────────────────────────────────────────
+ yield return E("achievements GET [AnyAuth]", "GET", "api/v1/achievements", "Student");
+ yield return E("achievements/{id} GET [AnyAuth]", "GET", "api/v1/achievements/1", "Student");
+
+ // ── Achievements — Admin only ─────────────────────────────────────────
+ yield return E("achievements POST [Admin]", "POST", "api/v1/achievements", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
+ yield return E("achievements/{id} PUT [Admin]", "PUT", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
+ yield return E("achievements/{id} DELETE [Admin]", "DELETE", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"]);
+
+ // ── Sync — Admin only ─────────────────────────────────────────────────
+ yield return E("sync/schedule POST [Admin]", "POST", "api/v1/sync/schedule", "Admin", forbidden: ["Student", "Teacher"],
+ body: """{"specialtyCode":null,"timeMin":null,"timeMax":null,"typeId":null}""");
+ yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
+ yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
+ yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
+ }
+
+ ///
+ /// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
+ /// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
+ ///
+ public static IEnumerable AnonymousEndpoints()
+ {
+ // login/microsoft GET перенаправляет на Microsoft — AzureAd настроен в фабрике
+ yield return new object[] { "auth/login/microsoft GET", "GET", "api/v1/auth/login/microsoft" };
+ // callback разрешает анонимный доступ — возвращает 400, если отсутствует параметр code
+ yield return new object[] { "auth/callback/microsoft GET", "GET", "api/v1/auth/callback/microsoft" };
+ // dev login доступен в окружении Development
+ yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
+ """{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
+ // refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
+ // (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
+ // Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Тест: анонимный → 401
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [MemberData(nameof(AuthenticatedEndpoints))]
+ public async Task Endpoint_Anonymous_Returns401(
+ string description, string method, string url,
+ string correctRole, string[] forbiddenRoles, string? body)
+ {
+ // Подготовка — без заголовка аутентификации
+ var request = BuildRequest(method, url, body, authHeader: null);
+
+ // Действие
+ var response = await _client.SendAsync(request);
+
+ // Проверка
+ Assert.True(
+ response.StatusCode == HttpStatusCode.Unauthorized,
+ $"[{description}] Ожидался ответ 401 Unauthorized для анонимного запроса, получено {(int)response.StatusCode} {response.StatusCode}");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Тест: неправильная роль → 403
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [MemberData(nameof(AuthenticatedEndpoints))]
+ public async Task Endpoint_WrongRole_Returns403(
+ string description, string method, string url,
+ string correctRole, string[] forbiddenRoles, string? body)
+ {
+ foreach (var forbidden in forbiddenRoles)
+ {
+ // Подготовка
+ var request = BuildRequest(method, url, body,
+ authHeader: TestJwtFactory.BearerHeader(forbidden));
+
+ // Действие
+ var response = await _client.SendAsync(request);
+
+ // Проверка
+ Assert.True(
+ response.StatusCode == HttpStatusCode.Forbidden,
+ $"[{description}] Ожидался ответ 403 Forbidden для роли '{forbidden}', получено {(int)response.StatusCode} {response.StatusCode}");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Тест: правильная роль → не 401 и не 403
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [MemberData(nameof(AuthenticatedEndpoints))]
+ public async Task Endpoint_CorrectRole_PassesAuthz(
+ string description, string method, string url,
+ string correctRole, string[] forbiddenRoles, string? body)
+ {
+ // Подготовка
+ var request = BuildRequest(method, url, body,
+ authHeader: TestJwtFactory.BearerHeader(correctRole));
+
+ // Действие
+ var response = await _client.SendAsync(request);
+
+ // Проверка — принимается любой ответ, который НЕ 401/403
+ Assert.True(
+ response.StatusCode != HttpStatusCode.Unauthorized &&
+ response.StatusCode != HttpStatusCode.Forbidden,
+ $"[{description}] Роль '{correctRole}' должна успешно пройти авторизацию, получено {(int)response.StatusCode} {response.StatusCode}");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Тест: анонимные конечные точки не должны возвращать 401
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [MemberData(nameof(AnonymousEndpoints))]
+ public async Task AnonymousEndpoint_NoToken_DoesNotReturn401(
+ string description, string method, string url, string? body = null)
+ {
+ var request = BuildRequest(method, url, body, authHeader: null);
+ var response = await _client.SendAsync(request);
+
+ Assert.True(
+ response.StatusCode != HttpStatusCode.Unauthorized,
+ $"[{description}] Анонимная конечная точка не должна возвращать 401, получено {(int)response.StatusCode} {response.StatusCode}");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Вспомогательные методы
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static HttpRequestMessage BuildRequest(
+ string method, string url, string? body, string? authHeader)
+ {
+ var request = new HttpRequestMessage(new HttpMethod(method), url);
+
+ if (authHeader != null)
+ request.Headers.Add("Authorization", authHeader);
+
+ if (body != null)
+ request.Content = new StringContent(body,
+ System.Text.Encoding.UTF8, "application/json");
+
+ return request;
+ }
+
+ /// Вспомогательный метод для компактного создания массивов объектов [MemberData].
+ private static object[] E(
+ string description,
+ string method,
+ string url,
+ string correctRole,
+ string[]? forbidden = null,
+ string? body = null)
+ => [description, method, url, correctRole, forbidden ?? [], body];
+}
diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs
new file mode 100644
index 0000000..e06d5f2
--- /dev/null
+++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs
@@ -0,0 +1,270 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+using UniVerse.Application.DTOs.Achievements;
+using UniVerse.Application.DTOs.Auth;
+using UniVerse.Application.DTOs.Common;
+using UniVerse.Application.DTOs.Courses;
+using UniVerse.Application.DTOs.Gamification;
+using UniVerse.Application.DTOs.Lectures;
+using UniVerse.Application.DTOs.Locations;
+using UniVerse.Application.DTOs.Reviews;
+using UniVerse.Application.DTOs.Sync;
+using UniVerse.Application.DTOs.Tags;
+using UniVerse.Application.DTOs.Users;
+using UniVerse.Application.Interfaces;
+using UniVerse.Domain.Enums;
+using UniVerse.Infrastructure.Data;
+
+namespace UniVerse.Api.Tests.Helpers;
+
+///
+/// WebApplicationFactory для интеграционных тестов.
+/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов
+/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура.
+///
+public class ApiWebApplicationFactory : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // Используем Development, чтобы были включены Swagger и конечная точка DevLogin
+ builder.UseEnvironment("Development");
+
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ // Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory
+ var testSettings = new Dictionary
+ {
+ ["Jwt:Secret"] = TestJwtFactory.Secret,
+ ["Jwt:Issuer"] = TestJwtFactory.Issuer,
+ ["Jwt:Audience"] = TestJwtFactory.Audience,
+ // Отключаем оркестрацию Aspire
+ ["Aspire:Enabled"] = "false",
+ // Фиктивные значения Azure AD (маршруты имеют атрибут [AllowAnonymous] или тестируются отдельно)
+ ["AzureAd:TenantId"] = "test-tenant",
+ ["AzureAd:ClientId"] = "test-client",
+ // Фиктивные значения LLM / Modeus (клиенты заменяются ниже)
+ ["Llm:BaseUrl"] = "http://localhost:9999/",
+ ["ModeusApi:BaseUrl"] = "http://localhost:9998/",
+ };
+ config.AddInMemoryCollection(testSettings);
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ // ── 1. Заменяем Npgsql DbContext на InMemory ──────────────────────────
+ services.RemoveAll>();
+ services.RemoveAll();
+
+ // Удаляем все регистрации, связанные с DbContext, которые добавил хост
+ var descriptor = services.SingleOrDefault(
+ d => d.ServiceType == typeof(DbContextOptions));
+ if (descriptor != null) services.Remove(descriptor);
+
+ // Находим и удаляем все дескрипторы настроек DbContext
+ var dbContextDescriptors = services
+ .Where(d => d.ServiceType == typeof(DbContextOptions)
+ || d.ImplementationType == typeof(AppDbContext))
+ .ToList();
+ foreach (var d in dbContextDescriptors) services.Remove(d);
+
+ services.AddDbContext(options =>
+ options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
+
+ // ── 2. Отключаем фоновые службы ────────────────────────────────────
+ // Удаляем все регистрации IHostedService, чтобы предотвратить запуск фоновой задачи LLM
+ var hostedServices = services
+ .Where(d => d.ServiceType == typeof(IHostedService))
+ .ToList();
+ foreach (var d in hostedServices) services.Remove(d);
+
+ // ── 3. Создаем заглушки для всех интерфейсов Application сервисов ─────────
+ ReplaceWithSubstitute(services, CreateAuthServiceStub());
+ ReplaceWithSubstitute(services, CreateUserServiceStub());
+ ReplaceWithSubstitute(services, CreateLectureServiceStub());
+ ReplaceWithSubstitute(services, CreateReviewServiceStub());
+ ReplaceWithSubstitute(services, CreateCourseServiceStub());
+ ReplaceWithSubstitute(services, CreateTagServiceStub());
+ ReplaceWithSubstitute(services, CreateLocationServiceStub());
+ ReplaceWithSubstitute(services, CreateAchievementServiceStub());
+ ReplaceWithSubstitute(services, CreateGamificationServiceStub());
+ ReplaceWithSubstitute(services, CreateSyncServiceStub());
+ ReplaceWithSubstitute(services, Substitute.For());
+ ReplaceWithSubstitute(services, Substitute.For());
+ });
+ }
+
+ private static void ReplaceWithSubstitute(IServiceCollection services, TService instance)
+ where TService : class
+ {
+ services.RemoveAll();
+ services.AddScoped(_ => instance);
+ }
+
+ // ── Фабрики заглушек ────────────────────────────────────────────────────────────
+
+ private static IAuthService CreateAuthServiceStub()
+ {
+ var stub = Substitute.For();
+ var authResult = new AuthResult(
+ new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
+ new UserAuthDto(1, "test@test.com", "Test User", UserRole.Student)),
+ "refresh_token");
+ stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any())
+ .Returns(authResult);
+ stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(authResult);
+ stub.RefreshTokenAsync(Arg.Any()).Returns(authResult);
+ stub.GetCurrentUserAsync(Arg.Any())
+ .Returns(new UserDto(1, "test@test.com", "Test", null, UserRole.Student, true, 0, 0, 1, DateTime.UtcNow));
+ return stub;
+ }
+
+ private static IUserService CreateUserServiceStub()
+ {
+ var stub = Substitute.For();
+ var userDto = new UserDto(1, "test@test.com", "Test", null, UserRole.Student, true, 0, 0, 1, DateTime.UtcNow);
+ var pagedUsers = PagedResult.Create([userDto], 1, 1, 20);
+
+ stub.GetByIdAsync(Arg.Any()).Returns(userDto);
+ stub.UpdateProfileAsync(Arg.Any(), Arg.Any()).Returns(userDto);
+ stub.GetStatsAsync(Arg.Any()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0));
+ stub.GetAllAsync(Arg.Any()).Returns(pagedUsers);
+ stub.SetRoleAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ return stub;
+ }
+
+ private static ILectureService CreateLectureServiceStub()
+ {
+ var stub = Substitute.For();
+ var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
+ "Title", null, LectureFormat.Offline,
+ DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
+ true, 30, 0, null, DateTime.UtcNow);
+ var detailDto = new LectureDetailDto(1, 1, "Course", null, null, null, null,
+ "Title", null, LectureFormat.Offline,
+ DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
+ true, 30, 0, null, DateTime.UtcNow, false);
+ var pagedLectures = PagedResult.Create([lectureDto], 1, 1, 20);
+ var pagedEnrollments = PagedResult.Create([], 0, 1, 20);
+
+ stub.GetAllAsync(Arg.Any()).Returns(pagedLectures);
+ stub.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(detailDto);
+ stub.CreateAsync(Arg.Any()).Returns(lectureDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(lectureDto);
+ stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask);
+ stub.EnrollAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.UnenrollAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.MarkAttendanceAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any()).Returns(pagedEnrollments);
+ return stub;
+ }
+
+ private static IReviewService CreateReviewServiceStub()
+ {
+ var stub = Substitute.For();
+ var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
+ ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
+ null, null, null, null, DateTime.UtcNow);
+ var pagedReviews = PagedResult.Create([reviewDto], 1, 1, 20);
+
+ stub.CreateAsync(Arg.Any(), Arg.Any()).Returns(reviewDto);
+ stub.GetByIdAsync(Arg.Any()).Returns(reviewDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(reviewDto);
+ stub.DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.GetByLectureAsync(Arg.Any(), Arg.Any()).Returns(pagedReviews);
+ stub.GetByUserAsync(Arg.Any(), Arg.Any()).Returns(pagedReviews);
+ stub.GetPendingAsync(Arg.Any()).Returns(pagedReviews);
+ stub.ReanalyzeAsync(Arg.Any()).Returns(Task.CompletedTask);
+ return stub;
+ }
+
+ private static ICourseService CreateCourseServiceStub()
+ {
+ var stub = Substitute.For();
+ var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow);
+ var paged = PagedResult.Create([courseDto], 1, 1, 20);
+
+ stub.GetAllAsync(Arg.Any()).Returns(paged);
+ stub.GetByIdAsync(Arg.Any()).Returns(courseDto);
+ stub.CreateAsync(Arg.Any()).Returns(courseDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(courseDto);
+ stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask);
+ stub.AddTagAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.RemoveTagAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ return stub;
+ }
+
+ private static ITagService CreateTagServiceStub()
+ {
+ var stub = Substitute.For();
+ var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow);
+
+ stub.GetAllAsync(Arg.Any(), Arg.Any()).Returns([tagDto]);
+ stub.GetByIdAsync(Arg.Any()).Returns(tagDto);
+ stub.CreateAsync(Arg.Any()).Returns(tagDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(tagDto);
+ stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask);
+ stub.GetTreeAsync().Returns(new List());
+ return stub;
+ }
+
+ private static ILocationService CreateLocationServiceStub()
+ {
+ var stub = Substitute.For();
+ var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow);
+
+ stub.GetAllAsync().Returns([locationDto]);
+ stub.GetByIdAsync(Arg.Any()).Returns(locationDto);
+ stub.CreateAsync(Arg.Any()).Returns(locationDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(locationDto);
+ stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask);
+ return stub;
+ }
+
+ private static IAchievementService CreateAchievementServiceStub()
+ {
+ var stub = Substitute.For();
+ var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow);
+
+ stub.GetAllAsync().Returns([achievementDto]);
+ stub.GetByIdAsync(Arg.Any()).Returns(achievementDto);
+ stub.CreateAsync(Arg.Any()).Returns(achievementDto);
+ stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(achievementDto);
+ stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask);
+ return stub;
+ }
+
+ private static IGamificationService CreateGamificationServiceStub()
+ {
+ var stub = Substitute.For();
+ var paged = PagedResult.Create([], 0, 1, 20);
+
+ stub.GetUserAchievementsAsync(Arg.Any()).Returns(new List());
+ stub.GetTransactionsAsync(Arg.Any(), Arg.Any()).Returns(paged);
+ stub.AwardCoinsAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ stub.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask);
+ stub.CalculateLevel(Arg.Any()).Returns(1);
+ return stub;
+ }
+
+ private static IScheduleSyncService CreateSyncServiceStub()
+ {
+ var stub = Substitute.For();
+ var syncResult = new SyncResultDto(0, 0, 0, null);
+ var syncStatus = new SyncStatusDto(null, "idle", null);
+
+ stub.SyncScheduleAsync(Arg.Any()).Returns(syncResult);
+ stub.SyncRoomsAsync().Returns(syncResult);
+ stub.SearchEmployeesAsync(Arg.Any()).Returns(new List());
+ stub.GetLastSyncStatusAsync().Returns(syncStatus);
+ return stub;
+ }
+}
diff --git a/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs b/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs
new file mode 100644
index 0000000..e6b6864
--- /dev/null
+++ b/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs
@@ -0,0 +1,44 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.IdentityModel.Tokens;
+
+namespace UniVerse.Api.Tests.Helpers;
+
+///
+/// Генерирует подписанные JWT токены для использования в интеграционных тестах.
+/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory.
+///
+public static class TestJwtFactory
+{
+ public const string Secret = "test-super-secret-key-32-chars!!";
+ public const string Issuer = "UniVerse-Test";
+ public const string Audience = "UniVerse-Test";
+
+ /// Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя.
+ public static string Generate(string role, int userId = 1)
+ {
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
+ var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
+ new Claim(ClaimTypes.Role, role),
+ new Claim("sub", userId.ToString()),
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: Issuer,
+ audience: Audience,
+ claims: claims,
+ expires: DateTime.UtcNow.AddHours(1),
+ signingCredentials: creds);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ /// Создает значение заголовка Authorization: "Bearer <token>".
+ public static string BearerHeader(string role, int userId = 1)
+ => $"Bearer {Generate(role, userId)}";
+}
diff --git a/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs b/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs
new file mode 100644
index 0000000..aa7cd06
--- /dev/null
+++ b/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs
@@ -0,0 +1,49 @@
+using System.Net;
+using System.Text.Json;
+using UniVerse.Api.Tests.Helpers;
+using Xunit;
+
+namespace UniVerse.Api.Tests.Swagger;
+
+public class SwaggerDocumentTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public SwaggerDocumentTests(ApiWebApplicationFactory factory)
+ {
+ _client = factory.CreateClient();
+ }
+
+ [Fact]
+ public async Task SwaggerJson_IsGenerated()
+ {
+ var response = await _client.GetAsync("api/docs/v1/swagger.json");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var root = document.RootElement;
+
+ Assert.Equal("UniVerse API", root.GetProperty("info").GetProperty("title").GetString());
+ Assert.True(root.GetProperty("components").GetProperty("securitySchemes").TryGetProperty("Bearer", out _));
+ }
+
+ [Fact]
+ public async Task SwaggerJson_DocumentsSecurityOnlyForAuthorizedEndpoints()
+ {
+ using var document = JsonDocument.Parse(await _client.GetStringAsync("api/docs/v1/swagger.json"));
+ var paths = document.RootElement.GetProperty("paths");
+
+ var publicOperation = paths
+ .GetProperty("/api/v1/auth/login/dev")
+ .GetProperty("post");
+ var protectedOperation = paths
+ .GetProperty("/api/v1/users")
+ .GetProperty("get");
+
+ Assert.False(publicOperation.TryGetProperty("security", out _));
+ Assert.True(protectedOperation.TryGetProperty("security", out var security));
+ Assert.Equal("Bearer", security[0].EnumerateObject().Single().Name);
+ Assert.Contains("Required roles:", protectedOperation.GetProperty("description").GetString());
+ }
+}
diff --git a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj
new file mode 100644
index 0000000..c3a346e
--- /dev/null
+++ b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/backend/UniVerse.Api/Controllers/AchievementsController.cs b/backend/UniVerse.Api/Controllers/AchievementsController.cs
index 62cda9c..c4ea491 100644
--- a/backend/UniVerse.Api/Controllers/AchievementsController.cs
+++ b/backend/UniVerse.Api/Controllers/AchievementsController.cs
@@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
+/// Управление определениями достижений системы геймификации.
[ApiController]
[Route("api/v1/achievements")]
[Authorize]
+[Produces("application/json")]
public class AchievementsController : ControllerBase
{
private readonly IAchievementService _achievements;
+
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
+ /// Получить список всех достижений.
+ /// Возвращает определения достижений (без информации о получении конкретным пользователем).
+ /// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.
+ /// Список достижений.
+ /// Требуется аутентификация.
[HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetAll() => Ok(await _achievements.GetAllAsync());
+ /// Получить достижение по ID.
+ /// ID достижения.
+ /// Данные достижения.
+ /// Требуется аутентификация.
+ /// Достижение не найдено.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
+ /// Создать новое достижение.
+ /// Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.
+ /// Название, описание, иконка, награда в XP/монетах и условие получения.
+ /// Достижение создано.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpPost]
+ [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Create([FromBody] CreateAchievementRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
+ /// Обновить достижение по ID.
+ /// Только Admin.
+ /// ID достижения.
+ /// Обновляемые поля достижения.
+ /// Обновлённые данные достижения.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Достижение не найдено.
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateAchievementRequest req) =>
Ok(await _achievements.UpdateAsync(id, req));
+ /// Удалить достижение по ID.
+ ///
+ /// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
+ ///
+ /// ID достижения.
+ /// Достижение удалено.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Достижение не найдено.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
- public async Task Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(int id)
+ {
+ await _achievements.DeleteAsync(id);
+ return NoContent();
+ }
}
diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs
index 38b5303..b850fb4 100644
--- a/backend/UniVerse.Api/Controllers/AuthController.cs
+++ b/backend/UniVerse.Api/Controllers/AuthController.cs
@@ -8,23 +8,36 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
+/// Аутентификация и управление сессией пользователя.
[ApiController]
[Route("api/v1/auth")]
+[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
private readonly IConfiguration _config;
- private const string MicrosoftStateCookieName = "msAuthState";
+ private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
public AuthController(IAuthService auth, IConfiguration config)
{
- _auth = auth;
+ _auth = auth;
_config = config;
}
+ /// Вход через Microsoft Entra ID (SPA/PKCE flow).
+ ///
+ /// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
+ /// полученный authorization code. В ответ возвращается пара JWT-токенов;
+ /// refresh token устанавливается в HttpOnly cookie.
+ ///
+ /// Authorization code и redirect URI из Microsoft OAuth2.
+ /// Успешный вход — возвращает access token и данные пользователя.
+ /// Неверный или просроченный authorization code.
[HttpPost("login/microsoft")]
+ [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
{
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
@@ -32,10 +45,19 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
- // Server-driven auth flow: frontend just navigates here; backend builds Microsoft authorize URL.
- // Optional returnUrl is stored in a short-lived cookie and used by callback.
+ /// Инициация server-driven входа через Microsoft (редирект-flow).
+ ///
+ /// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
+ /// и редиректит пользователя на `login.microsoftonline.com`.
+ /// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
+ ///
+ /// URL для редиректа после успешного входа (опционально).
+ /// Редирект на Microsoft authorize endpoint.
+ /// Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).
[HttpGet("login/microsoft")]
[AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
{
var tenantId = _config["AzureAd:TenantId"];
@@ -51,9 +73,9 @@ public class AuthController : ControllerBase
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
{
HttpOnly = true,
- Secure = Request.IsHttps,
+ Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
- Expires = DateTimeOffset.UtcNow.AddMinutes(10)
+ Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
@@ -61,9 +83,9 @@ public class AuthController : ControllerBase
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
{
HttpOnly = true,
- Secure = Request.IsHttps,
+ Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
- Expires = DateTimeOffset.UtcNow.AddMinutes(10)
+ Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
}
@@ -72,19 +94,37 @@ public class AuthController : ControllerBase
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary
{
- ["client_id"] = clientId,
+ ["client_id"] = clientId,
["response_type"] = "code",
- ["redirect_uri"] = redirectUri,
+ ["redirect_uri"] = redirectUri,
["response_mode"] = "query",
- ["scope"] = scope,
- ["state"] = state
+ ["scope"] = scope,
+ ["state"] = state
});
return Redirect(authorizeUrl);
}
+ /// OAuth2 callback — обмен code на токены (server-driven flow).
+ ///
+ /// Microsoft редиректит браузер сюда после успешного входа.
+ /// Backend валидирует CSRF state, обменивает code на токены,
+ /// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
+ ///
+ /// Authorization code от Microsoft.
+ /// CSRF state для верификации.
+ /// Код ошибки от Microsoft (если вход не удался).
+ /// Описание ошибки от Microsoft.
+ /// Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.
+ /// Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).
+ /// Отсутствует authorization code.
+ /// Ошибка от Microsoft или невалидный CSRF state.
[HttpGet("callback/microsoft")]
[AllowAnonymous]
+ [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task CallbackMicrosoft(
[FromQuery] string? code = null,
[FromQuery] string? state = null,
@@ -129,7 +169,17 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
+ /// Dev-only вход без OAuth (только в Development-окружении).
+ ///
+ /// Создаёт или находит пользователя по email без реального OAuth flow.
+ /// Возвращает 404 в Production и Staging.
+ ///
+ /// Email, отображаемое имя и роль тестового пользователя.
+ /// Успешный вход.
+ /// Endpoint недоступен вне Development.
[HttpPost("login/dev")]
+ [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> DevLogin([FromBody] DevLoginRequest request)
{
if (!HttpContext.RequestServices.GetRequiredService().IsDevelopment())
@@ -139,7 +189,16 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
+ /// Обновление access token по refresh token из HttpOnly cookie.
+ ///
+ /// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
+ /// Возвращает новую пару токенов и обновляет cookie.
+ ///
+ /// Новая пара токенов.
+ /// Refresh token отсутствует, просрочен или отозван.
[HttpPost("refresh")]
+ [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task> Refresh()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -149,8 +208,17 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
+ /// Выход из системы — отзыв refresh token.
+ ///
+ /// Инвалидирует текущий refresh token в БД и удаляет cookie.
+ /// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
+ ///
+ /// Выход выполнен успешно.
+ /// Требуется аутентификация.
[Authorize]
[HttpPost("logout")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task Logout()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -160,8 +228,15 @@ public class AuthController : ControllerBase
return NoContent();
}
+ /// Получение профиля текущего авторизованного пользователя.
+ /// Данные текущего пользователя.
+ /// Требуется аутентификация.
+ /// Пользователь не найден в БД (рассинхронизация токена).
[Authorize]
[HttpGet("me")]
+ [ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Me()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -175,7 +250,7 @@ public class AuthController : ControllerBase
Response.Cookies.Append("refreshToken", token, new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
- Expires = DateTime.UtcNow.AddDays(30)
+ Expires = DateTime.UtcNow.AddDays(30)
});
}
diff --git a/backend/UniVerse.Api/Controllers/CoursesController.cs b/backend/UniVerse.Api/Controllers/CoursesController.cs
index e70369d..904955c 100644
--- a/backend/UniVerse.Api/Controllers/CoursesController.cs
+++ b/backend/UniVerse.Api/Controllers/CoursesController.cs
@@ -1,46 +1,132 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
+/// Управление курсами (дисциплинами) и их тегами.
[ApiController]
[Route("api/v1/courses")]
[Authorize]
+[Produces("application/json")]
public class CoursesController : ControllerBase
{
private readonly ICourseService _courses;
+
public CoursesController(ICourseService courses) => _courses = courses;
+ /// Получить список курсов с фильтрацией и пагинацией.
+ /// Фильтры: tagId, search, isSynced; параметры пагинации.
+ /// Список курсов (пагинированный).
+ /// Требуется аутентификация.
[HttpGet]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetAll([FromQuery] CourseFilterRequest filter) =>
Ok(await _courses.GetAllAsync(filter));
+ /// Получить курс по ID (включая теги).
+ /// ID курса.
+ /// Данные курса с тегами.
+ /// Требуется аутентификация.
+ /// Курс не найден.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _courses.GetByIdAsync(id));
+ /// Создать новый курс.
+ /// Только Admin.
+ /// Название и описание курса.
+ /// Курс создан.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpPost]
+ [ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Create([FromBody] CreateCourseRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
+ /// Обновить курс по ID.
+ /// Только Admin.
+ /// ID курса.
+ /// Новое название и/или описание.
+ /// Обновлённые данные курса.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Курс не найден.
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateCourseRequest req) =>
Ok(await _courses.UpdateAsync(id, req));
+ /// Удалить курс по ID.
+ /// Только Admin. Удаление курса каскадно удаляет связанные лекции.
+ /// ID курса.
+ /// Курс удалён.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Курс не найден.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
- public async Task Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(int id)
+ {
+ await _courses.DeleteAsync(id);
+ return NoContent();
+ }
+ /// Привязать тег к курсу.
+ /// Только Admin. Тег должен существовать в системе.
+ /// ID курса.
+ /// ID тега.
+ /// Тег привязан.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Курс или тег не найден.
+ /// Тег уже привязан к курсу.
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/tags")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task AddTag(int id, [FromBody] int tagId)
- { await _courses.AddTagAsync(id, tagId); return NoContent(); }
+ {
+ await _courses.AddTagAsync(id, tagId);
+ return NoContent();
+ }
+ /// Отвязать тег от курса.
+ /// Только Admin.
+ /// ID курса.
+ /// ID тега.
+ /// Тег отвязан.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Курс или тег не найден, либо связь не существует.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}/tags/{tagId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task RemoveTag(int id, int tagId)
- { await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
+ {
+ await _courses.RemoveTagAsync(id, tagId);
+ return NoContent();
+ }
}
diff --git a/backend/UniVerse.Api/Controllers/LecturesController.cs b/backend/UniVerse.Api/Controllers/LecturesController.cs
index cd201ff..dc4da78 100644
--- a/backend/UniVerse.Api/Controllers/LecturesController.cs
+++ b/backend/UniVerse.Api/Controllers/LecturesController.cs
@@ -7,59 +7,197 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
+/// Каталог лекций — просмотр, управление, запись и отзывы.
[ApiController]
[Route("api/v1/lectures")]
[Authorize]
+[Produces("application/json")]
public class LecturesController : ControllerBase
{
private readonly ILectureService _lectures;
- private readonly IReviewService _reviews;
- public LecturesController(ILectureService lectures, IReviewService reviews)
- { _lectures = lectures; _reviews = reviews; }
- private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
+ private readonly IReviewService _reviews;
+ public LecturesController(ILectureService lectures, IReviewService reviews)
+ {
+ _lectures = lectures;
+ _reviews = reviews;
+ }
+
+ private int CurrentUserId => int.Parse(
+ User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
+
+ /// Получить каталог лекций с фильтрацией и пагинацией.
+ ///
+ /// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
+ /// isOpen, tagId, search; параметры пагинации.
+ ///
+ /// Список лекций (пагинированный).
+ /// Требуется аутентификация.
[HttpGet]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetAll([FromQuery] LectureFilterRequest filter) =>
Ok(await _lectures.GetAllAsync(filter));
+ /// Получить детальную карточку лекции по ID.
+ ///
+ /// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
+ ///
+ /// ID лекции.
+ /// Детальные данные лекции.
+ /// Требуется аутентификация.
+ /// Лекция не найдена.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Get(int id) =>
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
+ /// Создать новую лекцию.
+ /// Только Admin. Курс задаётся при создании и не может быть изменён.
+ /// Данные лекции: курс, преподаватель, локация, время, формат, вместимость.
+ /// Лекция создана.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpPost]
+ [ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Create([FromBody] CreateLectureRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
+ /// Обновить лекцию по ID.
+ /// Admin или Teacher. CourseId изменить нельзя.
+ /// ID лекции.
+ /// Обновляемые поля: преподаватель, локация, время, формат, описание.
+ /// Обновлённые данные лекции.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin или Teacher.
+ /// Лекция не найдена.
[Authorize(Roles = "Admin,Teacher")]
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateLectureRequest req) =>
Ok(await _lectures.UpdateAsync(id, req));
+ /// Удалить лекцию по ID.
+ /// Только Admin. Каскадно удаляет записи и отзывы.
+ /// ID лекции.
+ /// Лекция удалена.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Лекция не найдена.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
- public async Task Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(int id)
+ {
+ await _lectures.DeleteAsync(id);
+ return NoContent();
+ }
+ /// Записаться на лекцию.
+ ///
+ /// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
+ /// После посещения начисляются монеты через gamification.
+ ///
+ /// ID лекции.
+ /// Запись выполнена.
+ /// Требуется аутентификация.
+ /// Требуется роль Student.
+ /// Лекция не найдена.
+ /// Студент уже записан или мест нет.
[Authorize(Roles = "Student")]
[HttpPost("{id:int}/enroll")]
- public async Task Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Enroll(int id)
+ {
+ await _lectures.EnrollAsync(id, CurrentUserId);
+ return NoContent();
+ }
+ /// Отменить запись на лекцию.
+ /// Только Student. Отменить можно только свою запись.
+ /// ID лекции.
+ /// Запись отменена.
+ /// Требуется аутентификация.
+ /// Требуется роль Student.
+ /// Лекция или запись не найдена.
[Authorize(Roles = "Student")]
[HttpDelete("{id:int}/enroll")]
- public async Task Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Unenroll(int id)
+ {
+ await _lectures.UnenrollAsync(id, CurrentUserId);
+ return NoContent();
+ }
+ /// Отметить посещение студента на лекции.
+ ///
+ /// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
+ /// через gamification service.
+ ///
+ /// ID лекции.
+ /// ID студента.
+ /// true — посетил, false — не посетил.
+ /// Посещение отмечено.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin или Teacher.
+ /// Лекция или запись студента не найдена.
[Authorize(Roles = "Admin,Teacher")]
[HttpPatch("{id:int}/attendance/{userId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Attendance(int id, int userId, [FromBody] bool attended)
- { await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
+ {
+ await _lectures.MarkAttendanceAsync(id, userId, attended);
+ return NoContent();
+ }
+ /// Получить список записавшихся студентов на лекцию.
+ /// Только Admin или Teacher. Включает флаг посещения (`attended`).
+ /// ID лекции.
+ /// Параметры пагинации.
+ /// Список записей (пагинированный).
+ /// Требуется аутентификация.
+ /// Требуется роль Admin или Teacher.
+ /// Лекция не найдена.
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}/enrollments")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
+ /// Получить отзывы к лекции.
+ /// ID лекции.
+ /// Параметры пагинации.
+ /// Список отзывов (пагинированный).
+ /// Требуется аутентификация.
+ /// Лекция не найдена.
[HttpGet("{id:int}/reviews")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByLectureAsync(id, pagination));
-
}
diff --git a/backend/UniVerse.Api/Controllers/LocationsController.cs b/backend/UniVerse.Api/Controllers/LocationsController.cs
index 7b1e616..a8c3e13 100644
--- a/backend/UniVerse.Api/Controllers/LocationsController.cs
+++ b/backend/UniVerse.Api/Controllers/LocationsController.cs
@@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
+/// Управление локациями проведения лекций (аудитории, онлайн-площадки).
[ApiController]
[Route("api/v1/locations")]
[Authorize]
+[Produces("application/json")]
public class LocationsController : ControllerBase
{
private readonly ILocationService _locations;
+
public LocationsController(ILocationService locations) => _locations = locations;
+ /// Получить список всех локаций.
+ /// Список локаций.
+ /// Требуется аутентификация.
[HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetAll() => Ok(await _locations.GetAllAsync());
+ /// Получить локацию по ID.
+ /// ID локации.
+ /// Данные локации.
+ /// Требуется аутентификация.
+ /// Локация не найдена.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _locations.GetByIdAsync(id));
+ /// Создать новую локацию.
+ /// Только Admin. Локации также создаются автоматически при синхронизации с Modeus.
+ /// Название, корпус, аудитория и/или адрес.
+ /// Локация создана.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpPost]
+ [ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Create([FromBody] CreateLocationRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
+ /// Обновить локацию по ID.
+ /// Только Admin.
+ /// ID локации.
+ /// Обновляемые поля: название, корпус, аудитория, адрес.
+ /// Обновлённые данные локации.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Локация не найдена.
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateLocationRequest req) =>
Ok(await _locations.UpdateAsync(id, req));
+ /// Удалить локацию по ID.
+ ///
+ /// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
+ ///
+ /// ID локации.
+ /// Локация удалена.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Локация не найдена.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
- public async Task Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(int id)
+ {
+ await _locations.DeleteAsync(id);
+ return NoContent();
+ }
}
diff --git a/backend/UniVerse.Api/Controllers/ReviewsController.cs b/backend/UniVerse.Api/Controllers/ReviewsController.cs
index 1f59f1c..781c89d 100644
--- a/backend/UniVerse.Api/Controllers/ReviewsController.cs
+++ b/backend/UniVerse.Api/Controllers/ReviewsController.cs
@@ -7,40 +7,123 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
+/// Отзывы студентов на лекции с LLM-анализом и модерацией.
[ApiController]
[Route("api/v1/reviews")]
[Authorize]
+[Produces("application/json")]
public class ReviewsController : ControllerBase
{
private readonly IReviewService _reviews;
- public ReviewsController(IReviewService reviews) => _reviews = reviews;
- private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
+ public ReviewsController(IReviewService reviews) => _reviews = reviews;
+
+ private int CurrentUserId => int.Parse(
+ User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
+
+ /// Создать отзыв к лекции.
+ ///
+ /// Только Student. После создания отзыв помещается в очередь LLM-анализа
+ /// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
+ /// скрытно от пользователя.
+ ///
+ /// ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.
+ /// Отзыв создан и поставлен в очередь на LLM-анализ.
+ /// Требуется аутентификация.
+ /// Требуется роль Student.
+ /// Лекция не найдена.
+ /// Студент уже оставил отзыв к этой лекции.
[Authorize(Roles = "Student")]
[HttpPost]
+ [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> Create([FromBody] CreateReviewRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
+ /// Получить отзыв по ID.
+ /// ID отзыва.
+ /// Данные отзыва (включая LLM-статус и сентимент).
+ /// Требуется аутентификация.
+ /// Отзыв не найден.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
+ /// Обновить отзыв.
+ ///
+ /// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
+ /// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
+ ///
+ /// ID отзыва.
+ /// Новая оценка и/или текст.
+ /// Обновлённые данные отзыва.
+ /// Требуется аутентификация.
+ /// Отзыв принадлежит другому пользователю.
+ /// Отзыв не найден.
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateReviewRequest req) =>
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
+ /// Удалить отзыв.
+ /// Владелец может удалить свой отзыв. Admin может удалить любой.
+ /// ID отзыва.
+ /// Отзыв удалён.
+ /// Требуется аутентификация.
+ /// Нет прав на удаление (не владелец и не Admin).
+ /// Отзыв не найден.
[HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task Delete(int id)
{
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
return NoContent();
}
+ /// Получить список отзывов, ожидающих LLM-анализа.
+ /// Только Admin. Используется для мониторинга очереди обработки.
+ /// Параметры пагинации.
+ /// Список отзывов со статусом Pending (пагинированный).
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpGet("pending")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task Pending([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetPendingAsync(pagination));
+ /// Запустить повторный LLM-анализ отзыва.
+ ///
+ /// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
+ /// в очередь на повторную обработку фоновым сервисом.
+ ///
+ /// ID отзыва.
+ /// Повторный анализ запланирован.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Отзыв не найден.
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/reanalyze")]
- public async Task Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Reanalyze(int id)
+ {
+ await _reviews.ReanalyzeAsync(id);
+ return NoContent();
+ }
}
diff --git a/backend/UniVerse.Api/Controllers/SyncController.cs b/backend/UniVerse.Api/Controllers/SyncController.cs
index 51754a0..141cb2c 100644
--- a/backend/UniVerse.Api/Controllers/SyncController.cs
+++ b/backend/UniVerse.Api/Controllers/SyncController.cs
@@ -5,28 +5,74 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
+/// Синхронизация данных из внешней системы расписания Modeus (только Admin).
[ApiController]
[Route("api/v1/sync")]
[Authorize(Roles = "Admin")]
+[Produces("application/json")]
public class SyncController : ControllerBase
{
private readonly IScheduleSyncService _sync;
+
public SyncController(IScheduleSyncService sync) => _sync = sync;
+ /// Запустить синхронизацию расписания лекций из Modeus.
+ ///
+ /// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
+ /// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,
+ /// периоду и типу занятий.
+ ///
+ /// Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.
+ /// Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[HttpPost("schedule")]
+ [ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> SyncSchedule([FromBody] SyncScheduleRequest req) =>
Ok(await _sync.SyncScheduleAsync(req));
+ /// Получить статус последней синхронизации.
+ /// Только Admin. Возвращает время и результат последней успешной синхронизации.
+ /// Статус синхронизации.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[HttpGet("status")]
+ [ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Status() =>
Ok(await _sync.GetLastSyncStatusAsync());
+ /// Синхронизировать аудитории (локации) из Modeus.
+ ///
+ /// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
+ /// соответствующие записи в таблице locations.
+ ///
+ /// Результат синхронизации аудиторий.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[HttpPost("rooms")]
+ [ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> SyncRooms() =>
Ok(await _sync.SyncRoomsAsync());
+ /// Поиск преподавателей в Modeus по ФИО.
+ ///
+ /// Только Admin. Ищет преподавателей через внешнее API и возвращает список
+ /// для ручного импорта. Найденные преподаватели не создаются автоматически.
+ ///
+ /// Полное имя или часть имени преподавателя для поиска.
+ /// Список найденных преподавателей.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[HttpPost("employees")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task SearchEmployees([FromQuery] string fullname) =>
Ok(await _sync.SearchEmployeesAsync(fullname));
-
}
diff --git a/backend/UniVerse.Api/Controllers/TagsController.cs b/backend/UniVerse.Api/Controllers/TagsController.cs
index 27a6bd7..c9021a7 100644
--- a/backend/UniVerse.Api/Controllers/TagsController.cs
+++ b/backend/UniVerse.Api/Controllers/TagsController.cs
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
namespace UniVerse.Api.Controllers;
+/// Управление тегами для категоризации курсов (институты, факультеты, темы и др.).
[ApiController]
[Route("api/v1/tags")]
[Authorize]
+[Produces("application/json")]
public class TagsController : ControllerBase
{
private readonly ITagService _tags;
+
public TagsController(ITagService tags) => _tags = tags;
+ /// Получить список тегов с опциональной фильтрацией по типу и родителю.
+ /// Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.
+ /// ID родительского тега (фильтрация дочерних).
+ /// Список тегов.
+ /// Требуется аутентификация.
[HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
Ok(await _tags.GetAllAsync(type, parentId));
+ /// Получить тег по ID.
+ /// ID тега.
+ /// Данные тега.
+ /// Требуется аутентификация.
+ /// Тег не найден.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _tags.GetByIdAsync(id));
+ /// Получить иерархическое дерево всех тегов.
+ ///
+ /// Возвращает корневые теги с вложенными дочерними тегами.
+ /// Полезно для построения фильтрующих UI-компонентов.
+ ///
+ /// Иерархический список тегов.
+ /// Требуется аутентификация.
[HttpGet("tree")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task GetTree() => Ok(await _tags.GetTreeAsync());
+ /// Создать новый тег.
+ /// Только Admin.
+ /// Название, тип и опциональный родительский тег.
+ /// Тег создан.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpPost]
+ [ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task> Create([FromBody] CreateTagRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
+ /// Обновить тег по ID.
+ /// Только Admin.
+ /// ID тега.
+ /// Новое название, тип и/или родительский тег.
+ /// Обновлённые данные тега.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Тег не найден.
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateTagRequest req) =>
Ok(await _tags.UpdateAsync(id, req));
+ /// Удалить тег по ID.
+ ///
+ /// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
+ /// Дочерние теги остаются, но их `parentId` становится null.
+ ///
+ /// ID тега.
+ /// Тег удалён.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Тег не найден.
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
- public async Task Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(int id)
+ {
+ await _tags.DeleteAsync(id);
+ return NoContent();
+ }
}
diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs
index 703ce99..a635e9e 100644
--- a/backend/UniVerse.Api/Controllers/UsersController.cs
+++ b/backend/UniVerse.Api/Controllers/UsersController.cs
@@ -8,34 +8,76 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
+/// Управление пользователями, профилями и геймификацией.
[ApiController]
[Route("api/v1/users")]
[Authorize]
+[Produces("application/json")]
public class UsersController : ControllerBase
{
- private readonly IUserService _users;
- private readonly IReviewService _reviews;
+ private readonly IUserService _users;
+ private readonly IReviewService _reviews;
private readonly IGamificationService _gamification;
+
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
{
_users = users; _reviews = reviews; _gamification = gamification;
}
+
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
+ /// Получить профиль пользователя по ID.
+ /// ID пользователя.
+ /// Данные пользователя.
+ /// Требуется аутентификация.
+ /// Пользователь не найден.
[HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(int id) => Ok(await _users.GetByIdAsync(id));
+ /// Обновить профиль пользователя (displayName, avatarUrl).
+ /// Разрешено только самому пользователю или Admin.
+ /// ID пользователя.
+ /// Обновляемые поля профиля.
+ /// Обновлённые данные пользователя.
+ /// Требуется аутентификация.
+ /// Нет прав — только владелец профиля или Admin.
+ /// Пользователь не найден.
[HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Update(int id, [FromBody] UpdateUserRequest req)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _users.UpdateProfileAsync(id, req));
}
+ /// Получить статистику пользователя (XP, монеты, уровень, посещения).
+ /// ID пользователя.
+ /// Статистика пользователя.
+ /// Требуется аутентификация.
+ /// Пользователь не найден.
[HttpGet("{id:int}/stats")]
+ [ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Stats(int id) => Ok(await _users.GetStatsAsync(id));
+ /// Получить список записей пользователя на лекции.
+ /// Разрешено только самому пользователю или Admin.
+ /// ID пользователя.
+ /// Параметры пагинации.
+ /// Список записей (пагинированный).
+ /// Требуется аутентификация.
+ /// Нет прав — только владелец или Admin.
[HttpGet("{id:int}/enrollments")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
@@ -43,36 +85,92 @@ public class UsersController : ControllerBase
return Ok();
}
+ /// Получить отзывы пользователя.
+ /// ID пользователя.
+ /// Параметры пагинации.
+ /// Список отзывов (пагинированный).
+ /// Требуется аутентификация.
[HttpGet("{id:int}/reviews")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(id, pagination));
+ /// Получить достижения пользователя.
+ /// ID пользователя.
+ /// Список полученных достижений.
+ /// Требуется аутентификация.
[HttpGet("{id:int}/achievements")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task Achievements(int id) =>
Ok(await _gamification.GetUserAchievementsAsync(id));
+ /// Получить историю транзакций монет пользователя.
+ /// Разрешено только самому пользователю или Admin.
+ /// ID пользователя.
+ /// Параметры пагинации.
+ /// История транзакций (пагинированная).
+ /// Требуется аутентификация.
+ /// Нет прав — только владелец или Admin.
[HttpGet("{id:int}/transactions")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task Transactions(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
}
+ /// Получить список всех пользователей с фильтрацией и пагинацией.
+ /// Только Admin.
+ /// Параметры фильтрации (поиск, роль, активность) и пагинации.
+ /// Список пользователей (пагинированный).
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
[Authorize(Roles = "Admin")]
[HttpGet]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task GetAll([FromQuery] UserFilterRequest filter) =>
Ok(await _users.GetAllAsync(filter));
+ /// Изменить роль пользователя.
+ /// Только Admin. Доступные роли: Student, Teacher, Admin.
+ /// ID пользователя.
+ /// Новая роль.
+ /// Роль успешно изменена.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Пользователь не найден.
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/role")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task SetRole(int id, [FromBody] UserRole role)
{
await _users.SetRoleAsync(id, role);
return NoContent();
}
+ /// Активировать или деактивировать аккаунт пользователя.
+ /// Только Admin. Деактивированный пользователь не может войти в систему.
+ /// ID пользователя.
+ /// true — активировать, false — деактивировать.
+ /// Статус успешно изменён.
+ /// Требуется аутентификация.
+ /// Требуется роль Admin.
+ /// Пользователь не найден.
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/active")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task SetActive(int id, [FromBody] bool isActive)
{
await _users.SetActiveAsync(id, isActive);
diff --git a/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs b/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs
new file mode 100644
index 0000000..e7d7a65
--- /dev/null
+++ b/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs
@@ -0,0 +1,81 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.OpenApi;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace UniVerse.Api.Filters;
+
+///
+/// Swagger operation filter that:
+/// 1. Adds Bearer security requirement only to endpoints that actually require authentication.
+/// 2. Appends a "Required roles: ..." remark to the operation description when role restrictions exist.
+///
+/// This replaces the global AddSecurityRequirement approach so anonymous endpoints
+/// (auth/login, auth/refresh, auth/callback) don't show the lock icon in Swagger UI.
+///
+public class AuthorizeOperationFilter : IOperationFilter
+{
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+ // Collect [Authorize] and [AllowAnonymous] from both the controller and the action.
+ var actionAttributes = context.MethodInfo.GetCustomAttributes(inherit: true);
+ var controllerAttributes = context.MethodInfo.DeclaringType?
+ .GetCustomAttributes(inherit: true) ?? [];
+
+ var allAttributes = actionAttributes.Concat(controllerAttributes).ToList();
+
+ var hasAllowAnonymous = allAttributes.OfType().Any();
+ if (hasAllowAnonymous)
+ return; // completely public — no lock icon
+
+ var authorizeAttributes = allAttributes.OfType().ToList();
+ if (authorizeAttributes.Count == 0)
+ return; // no [Authorize] at all — also public
+
+ // Collect all distinct roles across all [Authorize(Roles = "...")] attributes.
+ var roles = authorizeAttributes
+ .Where(a => !string.IsNullOrWhiteSpace(a.Roles))
+ .SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(r => r)
+ .ToList();
+
+ // Append role information to the operation description.
+ var roleInfo = roles.Count > 0
+ ? $"**Required roles:** {string.Join(", ", roles)}"
+ : "**Required:** any authenticated user";
+
+ operation.Description = string.IsNullOrWhiteSpace(operation.Description)
+ ? roleInfo
+ : $"{operation.Description}\n\n{roleInfo}";
+
+ operation.Responses ??= new OpenApiResponses();
+
+ // Add 401 / 403 responses if not already declared.
+ if (!operation.Responses.ContainsKey("401"))
+ {
+ operation.Responses.Add("401", new OpenApiResponse
+ {
+ Description = "Unauthorized — JWT token missing or invalid"
+ });
+ }
+
+ if (roles.Count > 0 && !operation.Responses.ContainsKey("403"))
+ {
+ operation.Responses.Add("403", new OpenApiResponse
+ {
+ Description = $"Forbidden — requires role: {string.Join(" or ", roles)}"
+ });
+ }
+
+ // Add Bearer security requirement to this specific operation.
+ // OpenAPI v2 (Microsoft.OpenApi 2.x) uses OpenApiSecuritySchemeReference
+ // instead of OpenApiSecurityScheme with a Reference property.
+ var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", context.Document);
+
+ operation.Security ??= [];
+ operation.Security.Add(new OpenApiSecurityRequirement
+ {
+ [bearerSchemeRef] = []
+ });
+ }
+}
diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs
index 6204f5d..d4e88aa 100644
--- a/backend/UniVerse.Api/Program.cs
+++ b/backend/UniVerse.Api/Program.cs
@@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Serilog;
using UniVerse.Api.BackgroundServices;
+using UniVerse.Api.Filters;
using UniVerse.Api.Middleware;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Services;
@@ -119,29 +120,36 @@ builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
- Title = "UniVerse API",
- Version = "v1",
- Description = "Universe"
+ Title = "UniVerse API",
+ Version = "v1",
+ Description =
+ "REST API веб-платформы UniVerse.\n\n" +
+ "Аутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).",
+ Contact = new OpenApiContact
+ {
+ Name = "UniVerse Dev"
+ }
});
+ // Bearer security scheme definition (used per-endpoint by AuthorizeOperationFilter)
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
- Name = "Authorization",
- Type = SecuritySchemeType.Http,
- Scheme = "bearer",
+ Name = "Authorization",
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
BearerFormat = "JWT",
- In = ParameterLocation.Header,
- Description = "Enter your JWT token"
+ In = ParameterLocation.Header,
+ Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
});
- options.AddSecurityRequirement(doc =>
- {
- var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
- return new OpenApiSecurityRequirement
- {
- [bearerSchemeRef] = new List()
- };
- });
+ // Include XML doc comments generated from controller /// summaries
+ var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
+ if (File.Exists(xmlPath))
+ options.IncludeXmlComments(xmlPath);
+
+ // Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
+ options.OperationFilter();
});
var app = builder.Build();
diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj
index 498c052..29343ee 100644
--- a/backend/UniVerse.Api/UniVerse.Api.csproj
+++ b/backend/UniVerse.Api/UniVerse.Api.csproj
@@ -7,6 +7,9 @@
UniVerse.Api
Linux
true
+ true
+
+ $(NoWarn);1591
diff --git a/backend/UniVerse.sln b/backend/UniVerse.sln
index ad0e781..1844b37 100644
--- a/backend/UniVerse.sln
+++ b/backend/UniVerse.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api", "UniVerse.Api\UniVerse.Api.csproj", "{7D214ABB-8402-4FDD-9B88-D357F2A400C8}"
EndProject
@@ -12,35 +12,104 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.AppHost", "UniVers
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.ServiceDefaults", "UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj", "{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api.Tests", "UniVerse.Api.Tests\UniVerse.Api.Tests.csproj", "{AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x64.Build.0 = Debug|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x86.Build.0 = Debug|Any CPU
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x64.ActiveCfg = Release|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x64.Build.0 = Release|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x86.ActiveCfg = Release|Any CPU
+ {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455557777}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-444455558888}.Release|x86.Build.0 = Release|Any CPU
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x64.Build.0 = Debug|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x86.Build.0 = Debug|Any CPU
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x64.ActiveCfg = Release|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x64.Build.0 = Release|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x86.ActiveCfg = Release|Any CPU
+ {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x86.Build.0 = Release|Any CPU
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x64.Build.0 = Debug|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x86.Build.0 = Debug|Any CPU
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x64.ActiveCfg = Release|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x64.Build.0 = Release|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x86.ActiveCfg = Release|Any CPU
+ {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x86.Build.0 = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x64.Build.0 = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x86.Build.0 = Debug|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x64.ActiveCfg = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x64.Build.0 = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x86.ActiveCfg = Release|Any CPU
+ {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
EndGlobalSection
EndGlobal