feat: добавил интеграционные тесты

This commit is contained in:
2026-05-11 03:42:47 +03:00
parent fc380c7c51
commit f168050637
19 changed files with 1616 additions and 51 deletions
@@ -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 &lt;token&gt;".</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>