feat: добавил интеграционные тесты
This commit is contained in:
@@ -144,3 +144,19 @@ LLM-ключ задаётся через `Llm:ApiKey`.
|
|||||||
|
|
||||||
Точные схемы запросов/ответов удобнее смотреть в Swagger.
|
Точные схемы запросов/ответов удобнее смотреть в Swagger.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
|
||||||
|
|
||||||
|
- **xUnit** в качестве основного фреймворка для тестирования.
|
||||||
|
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
|
||||||
|
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
|
||||||
|
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
|
||||||
|
|
||||||
|
Запуск тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using System.Net;
|
||||||
|
using UniVerse.Api.Tests.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Tests.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
|
||||||
|
///
|
||||||
|
/// Каждый тестовый случай представляет собой кортеж:
|
||||||
|
/// (description, method, url, requiredRole, forbiddenRoles[])
|
||||||
|
///
|
||||||
|
/// Три типа сценариев для каждой конечной точки:
|
||||||
|
/// A) Анонимный → 401 Unauthorized
|
||||||
|
/// B) Неправильная роль → 403 Forbidden
|
||||||
|
/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
|
||||||
|
/// </summary>
|
||||||
|
public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Тестовые данные
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конечные точки, требующие аутентификации (не анонимные).
|
||||||
|
/// Формат: (description, method, url, correctRole, forbiddenRoles[])
|
||||||
|
///
|
||||||
|
/// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
|
||||||
|
/// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<object[]> 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"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
|
||||||
|
/// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<object[]> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Вспомогательный метод для компактного создания массивов объектов [MemberData].</summary>
|
||||||
|
private static object[] E(
|
||||||
|
string description,
|
||||||
|
string method,
|
||||||
|
string url,
|
||||||
|
string correctRole,
|
||||||
|
string[]? forbidden = null,
|
||||||
|
string? body = null)
|
||||||
|
=> [description, method, url, correctRole, forbidden ?? [], body];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebApplicationFactory для интеграционных тестов.
|
||||||
|
/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов
|
||||||
|
/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура.
|
||||||
|
/// </summary>
|
||||||
|
public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
// Используем Development, чтобы были включены Swagger и конечная точка DevLogin
|
||||||
|
builder.UseEnvironment("Development");
|
||||||
|
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
// Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory
|
||||||
|
var testSettings = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<DbContextOptions<AppDbContext>>();
|
||||||
|
services.RemoveAll<AppDbContext>();
|
||||||
|
|
||||||
|
// Удаляем все регистрации, связанные с DbContext, которые добавил хост
|
||||||
|
var descriptor = services.SingleOrDefault(
|
||||||
|
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||||
|
if (descriptor != null) services.Remove(descriptor);
|
||||||
|
|
||||||
|
// Находим и удаляем все дескрипторы настроек DbContext
|
||||||
|
var dbContextDescriptors = services
|
||||||
|
.Where(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)
|
||||||
|
|| d.ImplementationType == typeof(AppDbContext))
|
||||||
|
.ToList();
|
||||||
|
foreach (var d in dbContextDescriptors) services.Remove(d);
|
||||||
|
|
||||||
|
services.AddDbContext<AppDbContext>(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<IAuthService>(services, CreateAuthServiceStub());
|
||||||
|
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
|
||||||
|
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
|
||||||
|
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
|
||||||
|
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
|
||||||
|
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
|
||||||
|
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
|
||||||
|
ReplaceWithSubstitute<IAchievementService>(services, CreateAchievementServiceStub());
|
||||||
|
ReplaceWithSubstitute<IGamificationService>(services, CreateGamificationServiceStub());
|
||||||
|
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
||||||
|
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
||||||
|
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReplaceWithSubstitute<TService>(IServiceCollection services, TService instance)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
services.RemoveAll<TService>();
|
||||||
|
services.AddScoped<TService>(_ => instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Фабрики заглушек ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IAuthService CreateAuthServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<IAuthService>();
|
||||||
|
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<string>(), Arg.Any<string?>())
|
||||||
|
.Returns(authResult);
|
||||||
|
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<UserRole>())
|
||||||
|
.Returns(authResult);
|
||||||
|
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||||
|
stub.GetCurrentUserAsync(Arg.Any<int>())
|
||||||
|
.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<IUserService>();
|
||||||
|
var userDto = new UserDto(1, "test@test.com", "Test", null, UserRole.Student, true, 0, 0, 1, DateTime.UtcNow);
|
||||||
|
var pagedUsers = PagedResult<UserDto>.Create([userDto], 1, 1, 20);
|
||||||
|
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
|
||||||
|
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
|
||||||
|
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0));
|
||||||
|
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
||||||
|
stub.SetRoleAsync(Arg.Any<int>(), Arg.Any<UserRole>()).Returns(Task.CompletedTask);
|
||||||
|
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ILectureService CreateLectureServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<ILectureService>();
|
||||||
|
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<LectureDto>.Create([lectureDto], 1, 1, 20);
|
||||||
|
var pagedEnrollments = PagedResult<EnrollmentDto>.Create([], 0, 1, 20);
|
||||||
|
|
||||||
|
stub.GetAllAsync(Arg.Any<LectureFilterRequest>()).Returns(pagedLectures);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
|
||||||
|
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>()).Returns(lectureDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
|
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedEnrollments);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReviewService CreateReviewServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<IReviewService>();
|
||||||
|
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
|
||||||
|
ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
|
||||||
|
null, null, null, null, DateTime.UtcNow);
|
||||||
|
var pagedReviews = PagedResult<ReviewDto>.Create([reviewDto], 1, 1, 20);
|
||||||
|
|
||||||
|
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
|
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||||
|
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||||
|
stub.GetPendingAsync(Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||||
|
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ICourseService CreateCourseServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<ICourseService>();
|
||||||
|
var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow);
|
||||||
|
var paged = PagedResult<CourseDto>.Create([courseDto], 1, 1, 20);
|
||||||
|
|
||||||
|
stub.GetAllAsync(Arg.Any<CourseFilterRequest>()).Returns(paged);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(courseDto);
|
||||||
|
stub.CreateAsync(Arg.Any<CreateCourseRequest>()).Returns(courseDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateCourseRequest>()).Returns(courseDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.AddTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.RemoveTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ITagService CreateTagServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<ITagService>();
|
||||||
|
var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
stub.GetAllAsync(Arg.Any<TagType?>(), Arg.Any<int?>()).Returns([tagDto]);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(tagDto);
|
||||||
|
stub.CreateAsync(Arg.Any<CreateTagRequest>()).Returns(tagDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateTagRequest>()).Returns(tagDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.GetTreeAsync().Returns(new List<TagTreeDto>());
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ILocationService CreateLocationServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<ILocationService>();
|
||||||
|
var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
stub.GetAllAsync().Returns([locationDto]);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(locationDto);
|
||||||
|
stub.CreateAsync(Arg.Any<CreateLocationRequest>()).Returns(locationDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLocationRequest>()).Returns(locationDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IAchievementService CreateAchievementServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<IAchievementService>();
|
||||||
|
var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
stub.GetAllAsync().Returns([achievementDto]);
|
||||||
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(achievementDto);
|
||||||
|
stub.CreateAsync(Arg.Any<CreateAchievementRequest>()).Returns(achievementDto);
|
||||||
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateAchievementRequest>()).Returns(achievementDto);
|
||||||
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IGamificationService CreateGamificationServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<IGamificationService>();
|
||||||
|
var paged = PagedResult<CoinTransactionDto>.Create([], 0, 1, 20);
|
||||||
|
|
||||||
|
stub.GetUserAchievementsAsync(Arg.Any<int>()).Returns(new List<UserAchievementDto>());
|
||||||
|
stub.GetTransactionsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(paged);
|
||||||
|
stub.AwardCoinsAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CoinTransactionType>(),
|
||||||
|
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>()).Returns(Task.CompletedTask);
|
||||||
|
stub.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
stub.CalculateLevel(Arg.Any<int>()).Returns(1);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IScheduleSyncService CreateSyncServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<IScheduleSyncService>();
|
||||||
|
var syncResult = new SyncResultDto(0, 0, 0, null);
|
||||||
|
var syncStatus = new SyncStatusDto(null, "idle", null);
|
||||||
|
|
||||||
|
stub.SyncScheduleAsync(Arg.Any<SyncScheduleRequest>()).Returns(syncResult);
|
||||||
|
stub.SyncRoomsAsync().Returns(syncResult);
|
||||||
|
stub.SearchEmployeesAsync(Arg.Any<string>()).Returns(new List<EmployeeDto>());
|
||||||
|
stub.GetLastSyncStatusAsync().Returns(syncStatus);
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Генерирует подписанные JWT токены для использования в интеграционных тестах.
|
||||||
|
/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Создает значение заголовка Authorization: "Bearer <token>".</summary>
|
||||||
|
public static string BearerHeader(string role, int userId = 1)
|
||||||
|
=> $"Bearer {Generate(role, userId)}";
|
||||||
|
}
|
||||||
@@ -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<ApiWebApplicationFactory>
|
||||||
|
{
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление определениями достижений системы геймификации.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/achievements")]
|
[Route("api/v1/achievements")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class AchievementsController : ControllerBase
|
public class AchievementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAchievementService _achievements;
|
private readonly IAchievementService _achievements;
|
||||||
|
|
||||||
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
|
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
|
||||||
|
|
||||||
|
/// <summary>Получить список всех достижений.</summary>
|
||||||
|
/// <remarks>Возвращает определения достижений (без информации о получении конкретным пользователем).
|
||||||
|
/// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.</remarks>
|
||||||
|
/// <response code="200">Список достижений.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
|
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
|
||||||
|
|
||||||
|
/// <summary>Получить достижение по ID.</summary>
|
||||||
|
/// <param name="id">ID достижения.</param>
|
||||||
|
/// <response code="200">Данные достижения.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Достижение не найдено.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
|
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Создать новое достижение.</summary>
|
||||||
|
/// <remarks>Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.</remarks>
|
||||||
|
/// <param name="req">Название, описание, иконка, награда в XP/монетах и условие получения.</param>
|
||||||
|
/// <response code="201">Достижение создано.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Обновить достижение по ID.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="id">ID достижения.</param>
|
||||||
|
/// <param name="req">Обновляемые поля достижения.</param>
|
||||||
|
/// <response code="200">Обновлённые данные достижения.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Достижение не найдено.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
|
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
|
||||||
Ok(await _achievements.UpdateAsync(id, req));
|
Ok(await _achievements.UpdateAsync(id, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить достижение по ID.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID достижения.</param>
|
||||||
|
/// <response code="204">Достижение удалено.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Достижение не найдено.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _achievements.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ using System.Security.Claims;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Аутентификация и управление сессией пользователя.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/auth")]
|
[Route("api/v1/auth")]
|
||||||
|
[Produces("application/json")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _auth;
|
private readonly IAuthService _auth;
|
||||||
@@ -24,7 +26,18 @@ public class AuthController : ControllerBase
|
|||||||
_config = config;
|
_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
|
||||||
|
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
|
||||||
|
/// refresh token устанавливается в HttpOnly cookie.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
|
||||||
|
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
|
||||||
|
/// <response code="400">Неверный или просроченный authorization code.</response>
|
||||||
[HttpPost("login/microsoft")]
|
[HttpPost("login/microsoft")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
||||||
{
|
{
|
||||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
|
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
|
||||||
@@ -32,10 +45,19 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-driven auth flow: frontend just navigates here; backend builds Microsoft authorize URL.
|
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
|
||||||
// Optional returnUrl is stored in a short-lived cookie and used by callback.
|
/// <remarks>
|
||||||
|
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
|
||||||
|
/// и редиректит пользователя на `login.microsoftonline.com`.
|
||||||
|
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
|
||||||
|
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
|
||||||
|
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
|
||||||
[HttpGet("login/microsoft")]
|
[HttpGet("login/microsoft")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
|
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
|
||||||
{
|
{
|
||||||
var tenantId = _config["AzureAd:TenantId"];
|
var tenantId = _config["AzureAd:TenantId"];
|
||||||
@@ -83,8 +105,26 @@ public class AuthController : ControllerBase
|
|||||||
return Redirect(authorizeUrl);
|
return Redirect(authorizeUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Microsoft редиректит браузер сюда после успешного входа.
|
||||||
|
/// Backend валидирует CSRF state, обменивает code на токены,
|
||||||
|
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="code">Authorization code от Microsoft.</param>
|
||||||
|
/// <param name="state">CSRF state для верификации.</param>
|
||||||
|
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
|
||||||
|
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
|
||||||
|
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
|
||||||
|
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
|
||||||
|
/// <response code="400">Отсутствует authorization code.</response>
|
||||||
|
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
|
||||||
[HttpGet("callback/microsoft")]
|
[HttpGet("callback/microsoft")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<IActionResult> CallbackMicrosoft(
|
public async Task<IActionResult> CallbackMicrosoft(
|
||||||
[FromQuery] string? code = null,
|
[FromQuery] string? code = null,
|
||||||
[FromQuery] string? state = null,
|
[FromQuery] string? state = null,
|
||||||
@@ -129,7 +169,17 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Создаёт или находит пользователя по email без реального OAuth flow.
|
||||||
|
/// Возвращает 404 в Production и Staging.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
|
||||||
|
/// <response code="200">Успешный вход.</response>
|
||||||
|
/// <response code="404">Endpoint недоступен вне Development.</response>
|
||||||
[HttpPost("login/dev")]
|
[HttpPost("login/dev")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
||||||
{
|
{
|
||||||
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
||||||
@@ -139,7 +189,16 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
|
||||||
|
/// Возвращает новую пару токенов и обновляет cookie.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Новая пара токенов.</response>
|
||||||
|
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||||
{
|
{
|
||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
@@ -149,8 +208,17 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Выход из системы — отзыв refresh token.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
|
||||||
|
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="204">Выход выполнен успешно.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpPost("logout")]
|
[HttpPost("logout")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
@@ -160,8 +228,15 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
|
||||||
|
/// <response code="200">Данные текущего пользователя.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
|
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Me()
|
public async Task<ActionResult> Me()
|
||||||
{
|
{
|
||||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
|||||||
@@ -1,46 +1,132 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using UniVerse.Application.DTOs.Common;
|
||||||
using UniVerse.Application.DTOs.Courses;
|
using UniVerse.Application.DTOs.Courses;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/courses")]
|
[Route("api/v1/courses")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class CoursesController : ControllerBase
|
public class CoursesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICourseService _courses;
|
private readonly ICourseService _courses;
|
||||||
|
|
||||||
public CoursesController(ICourseService courses) => _courses = courses;
|
public CoursesController(ICourseService courses) => _courses = courses;
|
||||||
|
|
||||||
|
/// <summary>Получить список курсов с фильтрацией и пагинацией.</summary>
|
||||||
|
/// <param name="filter">Фильтры: tagId, search, isSynced; параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список курсов (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
||||||
Ok(await _courses.GetAllAsync(filter));
|
Ok(await _courses.GetAllAsync(filter));
|
||||||
|
|
||||||
|
/// <summary>Получить курс по ID (включая теги).</summary>
|
||||||
|
/// <param name="id">ID курса.</param>
|
||||||
|
/// <response code="200">Данные курса с тегами.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Курс не найден.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
|
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Создать новый курс.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="req">Название и описание курса.</param>
|
||||||
|
/// <response code="201">Курс создан.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Обновить курс по ID.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="id">ID курса.</param>
|
||||||
|
/// <param name="req">Новое название и/или описание.</param>
|
||||||
|
/// <response code="200">Обновлённые данные курса.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Курс не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
|
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
|
||||||
Ok(await _courses.UpdateAsync(id, req));
|
Ok(await _courses.UpdateAsync(id, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить курс по ID.</summary>
|
||||||
|
/// <remarks>Только Admin. Удаление курса каскадно удаляет связанные лекции.</remarks>
|
||||||
|
/// <param name="id">ID курса.</param>
|
||||||
|
/// <response code="204">Курс удалён.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Курс не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _courses.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Привязать тег к курсу.</summary>
|
||||||
|
/// <remarks>Только Admin. Тег должен существовать в системе.</remarks>
|
||||||
|
/// <param name="id">ID курса.</param>
|
||||||
|
/// <param name="tagId">ID тега.</param>
|
||||||
|
/// <response code="204">Тег привязан.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Курс или тег не найден.</response>
|
||||||
|
/// <response code="409">Тег уже привязан к курсу.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost("{id:int}/tags")]
|
[HttpPost("{id:int}/tags")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
|
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
|
||||||
{ await _courses.AddTagAsync(id, tagId); return NoContent(); }
|
{
|
||||||
|
await _courses.AddTagAsync(id, tagId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отвязать тег от курса.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="id">ID курса.</param>
|
||||||
|
/// <param name="tagId">ID тега.</param>
|
||||||
|
/// <response code="204">Тег отвязан.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Курс или тег не найден, либо связь не существует.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}/tags/{tagId:int}")]
|
[HttpDelete("{id:int}/tags/{tagId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> RemoveTag(int id, int tagId)
|
public async Task<IActionResult> RemoveTag(int id, int tagId)
|
||||||
{ await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
|
{
|
||||||
|
await _courses.RemoveTagAsync(id, tagId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,59 +7,197 @@ using System.Security.Claims;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/lectures")]
|
[Route("api/v1/lectures")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class LecturesController : ControllerBase
|
public class LecturesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILectureService _lectures;
|
private readonly ILectureService _lectures;
|
||||||
private readonly IReviewService _reviews;
|
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");
|
|
||||||
|
|
||||||
|
public LecturesController(ILectureService lectures, IReviewService reviews)
|
||||||
|
{
|
||||||
|
_lectures = lectures;
|
||||||
|
_reviews = reviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CurrentUserId => int.Parse(
|
||||||
|
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||||
|
|
||||||
|
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
|
||||||
|
/// <param name="filter">
|
||||||
|
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
|
||||||
|
/// isOpen, tagId, search; параметры пагинации.
|
||||||
|
/// </param>
|
||||||
|
/// <response code="200">Список лекций (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
|
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
|
||||||
Ok(await _lectures.GetAllAsync(filter));
|
Ok(await _lectures.GetAllAsync(filter));
|
||||||
|
|
||||||
|
/// <summary>Получить детальную карточку лекции по ID.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <response code="200">Детальные данные лекции.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Get(int id) =>
|
public async Task<ActionResult> Get(int id) =>
|
||||||
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
|
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
|
||||||
|
|
||||||
|
/// <summary>Создать новую лекцию.</summary>
|
||||||
|
/// <remarks>Только Admin. Курс задаётся при создании и не может быть изменён.</remarks>
|
||||||
|
/// <param name="req">Данные лекции: курс, преподаватель, локация, время, формат, вместимость.</param>
|
||||||
|
/// <response code="201">Лекция создана.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Обновить лекцию по ID.</summary>
|
||||||
|
/// <remarks>Admin или Teacher. CourseId изменить нельзя.</remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <param name="req">Обновляемые поля: преподаватель, локация, время, формат, описание.</param>
|
||||||
|
/// <response code="200">Обновлённые данные лекции.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
[Authorize(Roles = "Admin,Teacher")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
||||||
Ok(await _lectures.UpdateAsync(id, req));
|
Ok(await _lectures.UpdateAsync(id, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить лекцию по ID.</summary>
|
||||||
|
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <response code="204">Лекция удалена.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _lectures.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Записаться на лекцию.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
|
||||||
|
/// После посещения начисляются монеты через gamification.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <response code="204">Запись выполнена.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Student.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
|
/// <response code="409">Студент уже записан или мест нет.</response>
|
||||||
[Authorize(Roles = "Student")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpPost("{id:int}/enroll")]
|
[HttpPost("{id:int}/enroll")]
|
||||||
public async Task<IActionResult> 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<IActionResult> Enroll(int id)
|
||||||
|
{
|
||||||
|
await _lectures.EnrollAsync(id, CurrentUserId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отменить запись на лекцию.</summary>
|
||||||
|
/// <remarks>Только Student. Отменить можно только свою запись.</remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <response code="204">Запись отменена.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Student.</response>
|
||||||
|
/// <response code="404">Лекция или запись не найдена.</response>
|
||||||
[Authorize(Roles = "Student")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpDelete("{id:int}/enroll")]
|
[HttpDelete("{id:int}/enroll")]
|
||||||
public async Task<IActionResult> 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<IActionResult> Unenroll(int id)
|
||||||
|
{
|
||||||
|
await _lectures.UnenrollAsync(id, CurrentUserId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отметить посещение студента на лекции.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
|
||||||
|
/// через gamification service.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <param name="userId">ID студента.</param>
|
||||||
|
/// <param name="attended">true — посетил, false — не посетил.</param>
|
||||||
|
/// <response code="204">Посещение отмечено.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||||
|
/// <response code="404">Лекция или запись студента не найдена.</response>
|
||||||
[Authorize(Roles = "Admin,Teacher")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpPatch("{id:int}/attendance/{userId:int}")]
|
[HttpPatch("{id:int}/attendance/{userId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
||||||
{ await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
|
{
|
||||||
|
await _lectures.MarkAttendanceAsync(id, userId, attended);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Получить список записавшихся студентов на лекцию.</summary>
|
||||||
|
/// <remarks>Только Admin или Teacher. Включает флаг посещения (`attended`).</remarks>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список записей (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
[Authorize(Roles = "Admin,Teacher")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpGet("{id:int}/enrollments")]
|
[HttpGet("{id:int}/enrollments")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<EnrollmentDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
|
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
|
||||||
|
|
||||||
|
/// <summary>Получить отзывы к лекции.</summary>
|
||||||
|
/// <param name="id">ID лекции.</param>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
[HttpGet("{id:int}/reviews")]
|
[HttpGet("{id:int}/reviews")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _reviews.GetByLectureAsync(id, pagination));
|
Ok(await _reviews.GetByLectureAsync(id, pagination));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/locations")]
|
[Route("api/v1/locations")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class LocationsController : ControllerBase
|
public class LocationsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILocationService _locations;
|
private readonly ILocationService _locations;
|
||||||
|
|
||||||
public LocationsController(ILocationService locations) => _locations = locations;
|
public LocationsController(ILocationService locations) => _locations = locations;
|
||||||
|
|
||||||
|
/// <summary>Получить список всех локаций.</summary>
|
||||||
|
/// <response code="200">Список локаций.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
|
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
|
||||||
|
|
||||||
|
/// <summary>Получить локацию по ID.</summary>
|
||||||
|
/// <param name="id">ID локации.</param>
|
||||||
|
/// <response code="200">Данные локации.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Локация не найдена.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
|
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Создать новую локацию.</summary>
|
||||||
|
/// <remarks>Только Admin. Локации также создаются автоматически при синхронизации с Modeus.</remarks>
|
||||||
|
/// <param name="req">Название, корпус, аудитория и/или адрес.</param>
|
||||||
|
/// <response code="201">Локация создана.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Обновить локацию по ID.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="id">ID локации.</param>
|
||||||
|
/// <param name="req">Обновляемые поля: название, корпус, аудитория, адрес.</param>
|
||||||
|
/// <response code="200">Обновлённые данные локации.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Локация не найдена.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
|
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
|
||||||
Ok(await _locations.UpdateAsync(id, req));
|
Ok(await _locations.UpdateAsync(id, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить локацию по ID.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID локации.</param>
|
||||||
|
/// <response code="204">Локация удалена.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Локация не найдена.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _locations.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,40 +7,123 @@ using System.Security.Claims;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/reviews")]
|
[Route("api/v1/reviews")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class ReviewsController : ControllerBase
|
public class ReviewsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IReviewService _reviews;
|
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");
|
||||||
|
|
||||||
|
/// <summary>Создать отзыв к лекции.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Student. После создания отзыв помещается в очередь LLM-анализа
|
||||||
|
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
||||||
|
/// скрытно от пользователя.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="req">ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.</param>
|
||||||
|
/// <response code="201">Отзыв создан и поставлен в очередь на LLM-анализ.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Student.</response>
|
||||||
|
/// <response code="404">Лекция не найдена.</response>
|
||||||
|
/// <response code="409">Студент уже оставил отзыв к этой лекции.</response>
|
||||||
[Authorize(Roles = "Student")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
||||||
|
|
||||||
|
/// <summary>Получить отзыв по ID.</summary>
|
||||||
|
/// <param name="id">ID отзыва.</param>
|
||||||
|
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Отзыв не найден.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
|
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Обновить отзыв.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
|
||||||
|
/// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID отзыва.</param>
|
||||||
|
/// <param name="req">Новая оценка и/или текст.</param>
|
||||||
|
/// <response code="200">Обновлённые данные отзыва.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Отзыв принадлежит другому пользователю.</response>
|
||||||
|
/// <response code="404">Отзыв не найден.</response>
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
|
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
|
||||||
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
|
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить отзыв.</summary>
|
||||||
|
/// <remarks>Владелец может удалить свой отзыв. Admin может удалить любой.</remarks>
|
||||||
|
/// <param name="id">ID отзыва.</param>
|
||||||
|
/// <response code="204">Отзыв удалён.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Нет прав на удаление (не владелец и не Admin).</response>
|
||||||
|
/// <response code="404">Отзыв не найден.</response>
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получить список отзывов, ожидающих LLM-анализа.</summary>
|
||||||
|
/// <remarks>Только Admin. Используется для мониторинга очереди обработки.</remarks>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список отзывов со статусом Pending (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpGet("pending")]
|
[HttpGet("pending")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _reviews.GetPendingAsync(pagination));
|
Ok(await _reviews.GetPendingAsync(pagination));
|
||||||
|
|
||||||
|
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
|
||||||
|
/// в очередь на повторную обработку фоновым сервисом.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID отзыва.</param>
|
||||||
|
/// <response code="204">Повторный анализ запланирован.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Отзыв не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost("{id:int}/reanalyze")]
|
[HttpPost("{id:int}/reanalyze")]
|
||||||
public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Reanalyze(int id)
|
||||||
|
{
|
||||||
|
await _reviews.ReanalyzeAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,74 @@ using UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/sync")]
|
[Route("api/v1/sync")]
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Produces("application/json")]
|
||||||
public class SyncController : ControllerBase
|
public class SyncController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IScheduleSyncService _sync;
|
private readonly IScheduleSyncService _sync;
|
||||||
|
|
||||||
public SyncController(IScheduleSyncService sync) => _sync = sync;
|
public SyncController(IScheduleSyncService sync) => _sync = sync;
|
||||||
|
|
||||||
|
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
|
||||||
|
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,
|
||||||
|
/// периоду и типу занятий.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="req">Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.</param>
|
||||||
|
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[HttpPost("schedule")]
|
[HttpPost("schedule")]
|
||||||
|
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
||||||
Ok(await _sync.SyncScheduleAsync(req));
|
Ok(await _sync.SyncScheduleAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Получить статус последней синхронизации.</summary>
|
||||||
|
/// <remarks>Только Admin. Возвращает время и результат последней успешной синхронизации.</remarks>
|
||||||
|
/// <response code="200">Статус синхронизации.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
|
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
||||||
Ok(await _sync.GetLastSyncStatusAsync());
|
Ok(await _sync.GetLastSyncStatusAsync());
|
||||||
|
|
||||||
|
/// <summary>Синхронизировать аудитории (локации) из Modeus.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
|
||||||
|
/// соответствующие записи в таблице locations.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Результат синхронизации аудиторий.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[HttpPost("rooms")]
|
[HttpPost("rooms")]
|
||||||
|
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
||||||
Ok(await _sync.SyncRoomsAsync());
|
Ok(await _sync.SyncRoomsAsync());
|
||||||
|
|
||||||
|
/// <summary>Поиск преподавателей в Modeus по ФИО.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Ищет преподавателей через внешнее API и возвращает список
|
||||||
|
/// для ручного импорта. Найденные преподаватели не создаются автоматически.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="fullname">Полное имя или часть имени преподавателя для поиска.</param>
|
||||||
|
/// <response code="200">Список найденных преподавателей.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[HttpPost("employees")]
|
[HttpPost("employees")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
||||||
Ok(await _sync.SearchEmployeesAsync(fullname));
|
Ok(await _sync.SearchEmployeesAsync(fullname));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tags")]
|
[Route("api/v1/tags")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class TagsController : ControllerBase
|
public class TagsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITagService _tags;
|
private readonly ITagService _tags;
|
||||||
|
|
||||||
public TagsController(ITagService tags) => _tags = tags;
|
public TagsController(ITagService tags) => _tags = tags;
|
||||||
|
|
||||||
|
/// <summary>Получить список тегов с опциональной фильтрацией по типу и родителю.</summary>
|
||||||
|
/// <param name="type">Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.</param>
|
||||||
|
/// <param name="parentId">ID родительского тега (фильтрация дочерних).</param>
|
||||||
|
/// <response code="200">Список тегов.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
||||||
Ok(await _tags.GetAllAsync(type, parentId));
|
Ok(await _tags.GetAllAsync(type, parentId));
|
||||||
|
|
||||||
|
/// <summary>Получить тег по ID.</summary>
|
||||||
|
/// <param name="id">ID тега.</param>
|
||||||
|
/// <response code="200">Данные тега.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Тег не найден.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
|
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Получить иерархическое дерево всех тегов.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Возвращает корневые теги с вложенными дочерними тегами.
|
||||||
|
/// Полезно для построения фильтрующих UI-компонентов.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Иерархический список тегов.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet("tree")]
|
[HttpGet("tree")]
|
||||||
|
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
|
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
|
||||||
|
|
||||||
|
/// <summary>Создать новый тег.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="req">Название, тип и опциональный родительский тег.</param>
|
||||||
|
/// <response code="201">Тег создан.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
|
||||||
|
|
||||||
|
/// <summary>Обновить тег по ID.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="id">ID тега.</param>
|
||||||
|
/// <param name="req">Новое название, тип и/или родительский тег.</param>
|
||||||
|
/// <response code="200">Обновлённые данные тега.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Тег не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
|
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
|
||||||
Ok(await _tags.UpdateAsync(id, req));
|
Ok(await _tags.UpdateAsync(id, req));
|
||||||
|
|
||||||
|
/// <summary>Удалить тег по ID.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
|
||||||
|
/// Дочерние теги остаются, но их `parentId` становится null.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">ID тега.</param>
|
||||||
|
/// <response code="204">Тег удалён.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Тег не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
await _tags.DeleteAsync(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,34 +8,76 @@ using System.Security.Claims;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/users")]
|
[Route("api/v1/users")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserService _users;
|
private readonly IUserService _users;
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
|
|
||||||
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
||||||
{
|
{
|
||||||
_users = users; _reviews = reviews; _gamification = gamification;
|
_users = users; _reviews = reviews; _gamification = gamification;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||||
|
|
||||||
|
/// <summary>Получить профиль пользователя по ID.</summary>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <response code="200">Данные пользователя.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Пользователь не найден.</response>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
|
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Обновить профиль пользователя (displayName, avatarUrl).</summary>
|
||||||
|
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="req">Обновляемые поля профиля.</param>
|
||||||
|
/// <response code="200">Обновлённые данные пользователя.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Нет прав — только владелец профиля или Admin.</response>
|
||||||
|
/// <response code="404">Пользователь не найден.</response>
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
|
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
|
||||||
{
|
{
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||||
return Ok(await _users.UpdateProfileAsync(id, req));
|
return Ok(await _users.UpdateProfileAsync(id, req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <response code="200">Статистика пользователя.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Пользователь не найден.</response>
|
||||||
[HttpGet("{id:int}/stats")]
|
[HttpGet("{id:int}/stats")]
|
||||||
|
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||||
|
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список записей (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Нет прав — только владелец или Admin.</response>
|
||||||
[HttpGet("{id:int}/enrollments")]
|
[HttpGet("{id:int}/enrollments")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
|
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
|
||||||
{
|
{
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||||
@@ -43,36 +85,92 @@ public class UsersController : ControllerBase
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получить отзывы пользователя.</summary>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet("{id:int}/reviews")]
|
[HttpGet("{id:int}/reviews")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _reviews.GetByUserAsync(id, pagination));
|
Ok(await _reviews.GetByUserAsync(id, pagination));
|
||||||
|
|
||||||
|
/// <summary>Получить достижения пользователя.</summary>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <response code="200">Список полученных достижений.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet("{id:int}/achievements")]
|
[HttpGet("{id:int}/achievements")]
|
||||||
|
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> Achievements(int id) =>
|
public async Task<ActionResult> Achievements(int id) =>
|
||||||
Ok(await _gamification.GetUserAchievementsAsync(id));
|
Ok(await _gamification.GetUserAchievementsAsync(id));
|
||||||
|
|
||||||
|
/// <summary>Получить историю транзакций монет пользователя.</summary>
|
||||||
|
/// <remarks>Разрешено только самому пользователю или Admin.</remarks>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">История транзакций (пагинированная).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Нет прав — только владелец или Admin.</response>
|
||||||
[HttpGet("{id:int}/transactions")]
|
[HttpGet("{id:int}/transactions")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
|
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
|
||||||
{
|
{
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||||
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получить список всех пользователей с фильтрацией и пагинацией.</summary>
|
||||||
|
/// <remarks>Только Admin.</remarks>
|
||||||
|
/// <param name="filter">Параметры фильтрации (поиск, роль, активность) и пагинации.</param>
|
||||||
|
/// <response code="200">Список пользователей (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
||||||
Ok(await _users.GetAllAsync(filter));
|
Ok(await _users.GetAllAsync(filter));
|
||||||
|
|
||||||
|
/// <summary>Изменить роль пользователя.</summary>
|
||||||
|
/// <remarks>Только Admin. Доступные роли: Student, Teacher, Admin.</remarks>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="role">Новая роль.</param>
|
||||||
|
/// <response code="204">Роль успешно изменена.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Пользователь не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPatch("{id:int}/role")]
|
[HttpPatch("{id:int}/role")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
|
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
|
||||||
{
|
{
|
||||||
await _users.SetRoleAsync(id, role);
|
await _users.SetRoleAsync(id, role);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Активировать или деактивировать аккаунт пользователя.</summary>
|
||||||
|
/// <remarks>Только Admin. Деактивированный пользователь не может войти в систему.</remarks>
|
||||||
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
/// <param name="isActive">true — активировать, false — деактивировать.</param>
|
||||||
|
/// <response code="204">Статус успешно изменён.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
/// <response code="404">Пользователь не найден.</response>
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPatch("{id:int}/active")]
|
[HttpPatch("{id:int}/active")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
|
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
|
||||||
{
|
{
|
||||||
await _users.SetActiveAsync(id, isActive);
|
await _users.SetActiveAsync(id, isActive);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.OpenApi;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<AllowAnonymousAttribute>().Any();
|
||||||
|
if (hasAllowAnonymous)
|
||||||
|
return; // completely public — no lock icon
|
||||||
|
|
||||||
|
var authorizeAttributes = allAttributes.OfType<AuthorizeAttribute>().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] = []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
@@ -121,9 +122,16 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
{
|
{
|
||||||
Title = "UniVerse API",
|
Title = "UniVerse API",
|
||||||
Version = "v1",
|
Version = "v1",
|
||||||
Description = "Universe"
|
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
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
{
|
{
|
||||||
Name = "Authorization",
|
Name = "Authorization",
|
||||||
@@ -131,17 +139,17 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
Scheme = "bearer",
|
Scheme = "bearer",
|
||||||
BearerFormat = "JWT",
|
BearerFormat = "JWT",
|
||||||
In = ParameterLocation.Header,
|
In = ParameterLocation.Header,
|
||||||
Description = "Enter your JWT token"
|
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
|
||||||
});
|
});
|
||||||
|
|
||||||
options.AddSecurityRequirement(doc =>
|
// Include XML doc comments generated from controller /// summaries
|
||||||
{
|
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
|
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||||
return new OpenApiSecurityRequirement
|
if (File.Exists(xmlPath))
|
||||||
{
|
options.IncludeXmlComments(xmlPath);
|
||||||
[bearerSchemeRef] = new List<string>()
|
|
||||||
};
|
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
|
||||||
});
|
options.OperationFilter<AuthorizeOperationFilter>();
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<RootNamespace>UniVerse.Api</RootNamespace>
|
<RootNamespace>UniVerse.Api</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Suppress warnings for public members without XML docs -->
|
||||||
|
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+70
-1
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api", "UniVerse.Api\UniVerse.Api.csproj", "{7D214ABB-8402-4FDD-9B88-D357F2A400C8}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -12,35 +12,104 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.AppHost", "UniVers
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.ServiceDefaults", "UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj", "{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.ServiceDefaults", "UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj", "{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api.Tests", "UniVerse.Api.Tests\UniVerse.Api.Tests.csproj", "{AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Reference in New Issue
Block a user