feat: добавил интеграционные тесты
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user