1 Commits

Author SHA1 Message Date
Renovate Bot 9e5a72c53a chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8 2026-05-25 00:06:35 +00:00
267 changed files with 344 additions and 34306 deletions
-40
View File
@@ -1,40 +0,0 @@
# Postgre
POSTGRES_USER=universe
POSTGRES_PASSWORD=
POSTGRES_DATABASE=universe
# Azure AD
AzureAd_Instance=https://login.microsoftonline.com/
AzureAd_TenantId=sfedu.ru
AzureAd_ClientId=
AzureAd_ClientSecret=
AzureAd_Domain=sfedu.onmicrosoft.com
AzureAd_CallbackPath=/signin-oidc
# JWT
JWT_SECRET=
JWT_ISSUER=UniVerse
JWT_AUDIENCE=UniVerse
JWT_ACCESS_TOKEN_EXPIRATION_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRATION_DAYS=30
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:3000
# LLM
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=
# Modeus API
MODEUS_API_BASE_URL=
MODEUS_API_KEY=
# Email SMTP
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_ENABLE_SSL=true
EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local
EMAIL_SMTP_FROM_NAME=UniVerse
-33
View File
@@ -1,33 +0,0 @@
name: Backend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore backend/UniVerse.sln
- name: Build
run: dotnet build backend/UniVerse.sln --no-restore --configuration Release
- name: Test
run: dotnet test backend/UniVerse.sln --no-build --configuration Release --verbosity normal
-61
View File
@@ -1,61 +0,0 @@
name: Frontend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
jobs:
build-and-check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Audit dependencies
if: always()
run: pnpm audit --audit-level moderate
- name: Check formatting
if: always()
run: pnpm exec prettier --check src/
- name: Lint with oxlint
if: always()
run: pnpm exec oxlint .
- name: Lint with ESLint
if: always()
run: pnpm exec eslint . --max-warnings=0
- name: Type check
if: always()
run: pnpm run type-check
- name: Build
if: always()
run: pnpm run build-only
-40
View File
@@ -1,40 +0,0 @@
name: Frontend Playwright
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build app
run: pnpm build-only
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
run: pnpm test:e2e
+13 -96
View File
@@ -1,48 +1,17 @@
name: 🚀 Create and publish a Docker image name: Create and publish a Docker image
on: on:
push: push:
branches: ['main', 'dev'] branches: ['main', 'staging']
env: env:
BACKEND_PATH: backend CONTEXT: ./backend
FRONTEND_PATH: frontend
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
jobs: jobs:
detect-changes: build-and-push-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Detect changes in backend and frontend name: Publish image
container: catthehacker/ubuntu:act-latest container: catthehacker/ubuntu:act-latest
outputs:
backend_changed: ${{ steps.backend-changed.outputs.backend }}
frontend_changed: ${{ steps.frontend-changed.outputs.frontend }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Check for backend changes
id: backend-changed
uses: dorny/paths-filter@v2
with:
filters: |
backend:
- '${{ env.BACKEND_PATH }}/**'
- name: Check for frontend changes
id: frontend-changed
uses: dorny/paths-filter@v2
with:
filters: |
frontend:
- '${{ env.FRONTEND_PATH }}/**'
backend:
runs-on: ubuntu-latest
name: Build & publish backend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.backend_changed == 'true' }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -55,69 +24,17 @@ jobs:
id: meta id: meta
uses: https://github.com/docker/metadata-action@v4 uses: https://github.com/docker/metadata-action@v4
with: with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/backend images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
- name: Build an image from Dockerfile
run: |
cd ${{ env.CONTEXT }} &&
docker build -f UniVerse.Api/Dockerfile -t ${{ steps.meta.outputs.tags }} .
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with: with:
registry: ${{ vars.SERVER_DOMAIN }} registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }} password: ${{ secrets.TOKEN }}
- name: Push
- name: Build and push Docker image run: |
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 docker push '${{ steps.meta.outputs.tags }}'
with:
context: ./${{ env.BACKEND_PATH }}
file: ./${{ env.BACKEND_PATH }}/UniVerse.Api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
frontend:
runs-on: ubuntu-latest
name: Build & publish frontend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.frontend_changed == 'true' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/frontend
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./${{ env.FRONTEND_PATH }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy:
runs-on: ubuntu-latest
needs: [frontend, backend]
# always() - костыль для того, чтобы деплой выполнялся даже если один из билдов пропущен
if: github.ref == 'refs/heads/dev' && always() && (needs.backend.result == 'success' || needs.frontend.result == 'success')
name: Update stack on Portainer
steps:
- name: Deploy Stage
uses: fjogeleit/http-request-action@v1
with:
url: ${{ secrets.PORTAINER_WEBHOOK_URL }}
method: 'POST'
ignoreSsl: true
timeout: 60000
-2
View File
@@ -141,7 +141,6 @@ $RECYCLE.BIN/
# Icon must end with two \r # Icon must end with two \r
Icon Icon
# Thumbnails # Thumbnails
._* ._*
@@ -161,4 +160,3 @@ Network Trash Folder
Network Trash Folder Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
backend/UniVerse.Api/appsettings.Development.json
+1 -24
View File
@@ -2,9 +2,6 @@
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации. UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
[Документация API](backend/UniVerse.Api/openapi.json)
[Документация бекнда](docs/backend.md)
## Что внутри ## Что внутри
- Расписание/события и сущности: курсы, лекции, аудитории (locations) - Расписание/события и сущности: курсы, лекции, аудитории (locations)
@@ -104,13 +101,9 @@ docker run --rm -p 8080:8080 \
## Аутентификация ## Аутентификация
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки. - `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
- `GET /api/v1/auth/login/microsoft`старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft). - `POST /api/v1/auth/login/microsoft`заготовка под Microsoft Entra ID (сейчас не реализовано).
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me` - `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
Большинство методов API защищены `[Authorize]`. Большинство методов API защищены `[Authorize]`.
## Фоновый LLM-анализ отзывов ## Фоновый LLM-анализ отзывов
@@ -147,19 +140,3 @@ 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
```
-1
View File
@@ -1 +0,0 @@
UniVerse
+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="UserContentModel"> <component name="UserContentModel">
<attachedFolders> <attachedFolders />
<Path>../frontend</Path>
</attachedFolders>
<explicitIncludes /> <explicitIncludes />
<explicitExcludes /> <explicitExcludes />
</component> </component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<Project>
<PropertyGroup>
<BuildInParallel>false</BuildInParallel>
<RestoreUseStaticGraphEvaluation>true</RestoreUseStaticGraphEvaluation>
</PropertyGroup>
</Project>
@@ -1,123 +0,0 @@
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using Xunit;
namespace UniVerse.Api.Tests.Application;
public class MappingExtensionsTests
{
[Fact]
public void UserMappings_OrderRolesConsistently()
{
var user = new User
{
Id = 1,
Email = "user@test.local",
DisplayName = "User",
AvatarUrl = "avatar.png",
IsActive = true,
Xp = 120,
Coins = 30,
CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
Roles =
[
new UserRoleAssignment { Role = UserRole.Teacher },
new UserRoleAssignment { Role = UserRole.Student },
new UserRoleAssignment { Role = UserRole.Admin }
]
};
var dto = user.ToDto(level: 2);
var currentUser = user.ToCurrentUserDto(level: 2);
var auth = user.ToAuthDto();
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles);
Assert.Equal(dto.Roles, currentUser.Roles);
Assert.Equal(dto.Roles, auth.Roles);
Assert.Equal(2, dto.Level);
}
[Fact]
public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount()
{
var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc);
var lecture = new Lecture
{
Id = 10,
CourseId = 5,
Title = "Offline lecture",
Description = "Description",
Format = LectureFormat.Offline,
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 25,
Enrollments =
[
new LectureEnrollment { UserId = 1 },
new LectureEnrollment { UserId = 2 }
]
};
var dto = lecture.ToDto(isEnrolled: true);
var detail = lecture.ToDetailDto(isEnrolled: false);
Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName);
Assert.Equal(2, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled);
}
[Fact]
public void ReviewMapping_CopiesAnalysisFields()
{
var review = new Review
{
Id = 7,
LectureId = 3,
UserId = 4,
Rating = ReviewRating.Like,
Text = "Clear and useful",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.95,
IsInformative = true,
LlmTags = ["clear", "useful"],
LlmRawOutput = "{\"quality_score\":0.95}",
CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc),
Lecture = new Lecture { Title = "Lecture title" },
User = new User { DisplayName = "Student" }
};
var dto = review.ToDto();
Assert.Equal("Lecture title", dto.LectureTitle);
Assert.Equal("Student", dto.UserName);
Assert.Equal(ReviewSentiment.Positive, dto.Sentiment);
Assert.Equal(0.95, dto.QualityScore);
Assert.True(dto.IsInformative);
Assert.NotNull(dto.LlmTags);
Assert.Equal(["clear", "useful"], dto.LlmTags);
Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput);
}
[Fact]
public void TagTreeMapping_MapsChildrenRecursively()
{
var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic };
var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 };
var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 };
root.Children.Add(child);
child.Children.Add(grandchild);
var dto = root.ToTreeDto();
Assert.Equal("Root", dto.Name);
var childDto = Assert.Single(dto.Children);
Assert.Equal("Child", childDto.Name);
Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name);
}
}
@@ -1,138 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Auth;
public class AuthServiceTests
{
[Fact]
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.RefreshTokens.Add(new RefreshToken
{
Id = 1,
UserId = 1,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
Assert.NotNull(token.RevokedAt);
}
[Fact]
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
}
[Fact]
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 10,
Email = "modeus-person-1@modeus.local",
DisplayName = "Иванов Иван Иванович",
MicrosoftId = "sso-sub-1",
IsActive = true,
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
});
await db.SaveChangesAsync();
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
var service = CreateService(db, microsoftAuth);
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
Assert.Equal(10, result.Response.User.Id);
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
Assert.Single(await db.Users.ToListAsync());
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", user.MicrosoftId);
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
["Jwt:Issuer"] = "UniVerse.Tests",
["Jwt:Audience"] = "UniVerse.Tests",
["Jwt:AccessTokenExpirationMinutes"] = "15",
["Jwt:RefreshTokenExpirationDays"] = "30"
})
.Build();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var notifications = Substitute.For<INotificationService>();
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
}
private static string BuildIdToken(string sub, string email, string name)
{
var token = new JwtSecurityToken(claims:
[
new Claim(JwtRegisteredClaimNames.Sub, sub),
new Claim("preferred_username", email),
new Claim("name", name)
]);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -1,317 +0,0 @@
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 — current user ──────────────────────────────────────────────
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
// ── Users — Admin only ────────────────────────────────────────────────
yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} GET [Admin]", "GET", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} PUT [Admin]", "PUT", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/{id}/stats [Admin]", "GET", "api/v1/users/1/stats", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/enrollments [Admin]", "GET", "api/v1/users/1/enrollments", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/achievements [Admin]", "GET", "api/v1/users/1/achievements","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/transactions [Admin]", "GET", "api/v1/users/1/transactions","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");
// ── 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"]);
yield return E("lectures/{id}/reviews GET [Admin]", "GET","api/v1/lectures/1/reviews","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Teacher]", "GET","api/v1/lectures/1/reviews","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} 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 — Admin OR Teacher ───────────────────────────────────────
yield return E("reviews/{id} GET [Admin]", "GET", "api/v1/reviews/1", "Admin", forbidden: ["Student"]);
yield return E("reviews/{id} GET [Teacher]", "GET", "api/v1/reviews/1", "Teacher", forbidden: ["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 GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
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"]);
// ── Notifications — any auth ───────────────────────────────────────────
yield return E("notifications GET [AnyAuth]", "GET", "api/v1/notifications", "Student");
yield return E("notifications/read-all PATCH [AnyAuth]", "PATCH", "api/v1/notifications/read-all", "Student");
// ── Notifications — Admin only ─────────────────────────────────────────
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
}
/// <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];
}
@@ -1,121 +0,0 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Courses;
public class CourseServiceTests
{
[Fact]
public async Task GetAllAsync_AppliesSearchSyncedTagFiltersAndPagination()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Backend", Type = TagType.Subject },
new Tag { Id = 2, Name = "Frontend", Type = TagType.Subject });
db.Courses.AddRange(
Course(1, "ASP.NET Core", true, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Vue Basics", true, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Advanced ASP.NET", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
db.CourseTags.AddRange(
new CourseTag { CourseId = 1, TagId = 1 },
new CourseTag { CourseId = 2, TagId = 2 },
new CourseTag { CourseId = 3, TagId = 1 });
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(
TagId: 1,
Search: "asp",
IsSynced: true,
Page: 1,
PageSize: 10));
var item = Assert.Single(result.Items);
Assert.Equal(1, item.Id);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.TotalPages);
Assert.Equal("Backend", Assert.Single(item.Tags).Name);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageMetadata()
{
await using var db = CreateDbContext();
db.Courses.AddRange(
Course(1, "Old", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Middle", false, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Newest", false, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)));
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(1, result.PageSize);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
[Fact]
public async Task AddTagAsync_LinksExistingCourseAndTag()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await service.AddTagAsync(1, 10);
Assert.True(await db.CourseTags.AnyAsync(ct => ct.CourseId == 1 && ct.TagId == 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenTagAlreadyLinked()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
db.CourseTags.Add(new CourseTag { CourseId = 1, TagId = 10 });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<ConflictException>(() => service.AddTagAsync(1, 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenCourseOrTagMissing()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(404, 10));
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(1, 404));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"CourseServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Course Course(int id, string name, bool isSynced, DateTime createdAt) => new()
{
Id = id,
Name = name,
IsSynced = isSynced,
CreatedAt = createdAt
};
}
@@ -1,29 +0,0 @@
using UniVerse.Domain.Services;
using Xunit;
namespace UniVerse.Api.Tests.DomainServices;
public class EnrollmentSlotPolicyTests
{
[Theory]
[InlineData(-1, 3)]
[InlineData(0, 3)]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(10, 7)]
public void GetLimitForLevel_UsesHighestMatchingRuleOrDefault(int level, int expectedSlots)
{
var slots = EnrollmentSlotPolicy.GetLimitForLevel(level);
Assert.Equal(expectedSlots, slots);
}
[Fact]
public void Rules_ExposeConfiguredThresholdsInAscendingOrder()
{
Assert.Equal(new[] { 1, 3, 4 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Slots));
}
}
@@ -1,183 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Gamification;
public class GamificationServiceTests
{
[Fact]
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User
{
Id = 1,
Email = "student@test.local",
DisplayName = "Student",
AvatarUrl = "avatar.png",
Xp = 100,
Coins = 510
});
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Future lecture",
StartsAt = DateTime.UtcNow.AddDays(1),
EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2),
IsOpen = true
});
db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 });
db.Reviews.AddRange(
new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like },
new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral },
new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike });
db.CoinTransactions.Add(new CoinTransaction
{
UserId = 1,
Amount = 510,
Type = CoinTransactionType.AdminAdjustment,
Description = "Initial coins"
});
db.Achievements.AddRange(
Achievement(1001, "First activity", "first_activity:1", 10),
Achievement(1002, "Reviews", "reviews_written:3", 20),
Achievement(1003, "Active registrations", "active_registrations:1", 30),
Achievement(1004, "Coins earned", "coins_earned:500", 40),
Achievement(1005, "Level reached", "level_reached:2", 50),
Achievement(1006, "Profile completed", "profile_completed:1", 60),
Achievement(1007, "Old condition", "reviews_1", 100));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
await service.CheckAndAwardAchievementsAsync(1);
var user = await db.Users.FindAsync(1);
Assert.NotNull(user);
Assert.Equal(720, user!.Coins);
Assert.Equal(310, user.Xp);
Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1));
Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007));
Assert.Equal(6, await db.CoinTransactions.CountAsync(ct =>
ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward));
}
[Fact]
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)),
Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)),
Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true });
db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
}
[Theory]
[InlineData(0, 1)]
[InlineData(99, 1)]
[InlineData(100, 2)]
[InlineData(299, 2)]
[InlineData(300, 3)]
public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var level = await service.CalculateLevelAsync(xp);
Assert.Equal(expectedLevel, level);
}
[Theory]
[InlineData(120, 100, 300)]
[InlineData(350, 300, null)]
public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var progress = await service.GetLevelProgressAsync(xp);
Assert.Equal(currentLevelXp, progress.CurrentLevelXp);
Assert.Equal(nextLevelXp, progress.NextLevelXp);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static GamificationService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
{
Id = id,
Name = name,
Condition = condition,
CoinReward = coinReward
};
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2)
};
}
@@ -1,327 +0,0 @@
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.Notifications;
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<IReviewPromptService>(services, CreateReviewPromptServiceStub());
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>());
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
});
}
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?>(), Arg.Any<string?>())
.Returns(authResult);
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<IReadOnlyCollection<UserRole>>(), Arg.Any<string?>())
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
return stub;
}
private static INotificationService CreateNotificationServiceStub()
{
var stub = Substitute.For<INotificationService>();
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
stub.GetUserNotificationsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<CancellationToken>())
.Returns(PagedResult<UserNotificationDto>.Create([], 0, 1, 20));
stub.MarkAllReadAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, 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);
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, true);
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 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,
0,
100,
0,
3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<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>(), Arg.Any<int?>()).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>(), Arg.Any<int>(), Arg.Any<bool>()).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>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).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, 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>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IReviewPromptService CreateReviewPromptServiceStub()
{
var stub = Substitute.For<IReviewPromptService>();
var promptDto = new ReviewPromptDto(
"Analyze {lectureContext}. Review: {reviewText}",
DateTime.UtcNow);
stub.GetAsync().Returns(promptDto);
stub.UpdateAsync(Arg.Any<UpdateReviewPromptRequest>()).Returns(callInfo =>
new ReviewPromptDto(callInfo.Arg<UpdateReviewPromptRequest>().Prompt, DateTime.UtcNow));
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.CalculateLevelAsync(Arg.Any<int>()).Returns(Task.FromResult(1));
stub.GetLevelProgressAsync(Arg.Any<int>()).Returns(Task.FromResult(new LevelProgressDto(0, 100)));
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;
}
}
@@ -1,44 +0,0 @@
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)}";
}
@@ -1,241 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Lectures;
public class LectureServiceTests
{
[Fact]
public async Task GetAllAsync_MarksLecturesEnrolledByCurrentUser()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, startsAt),
Lecture(2, startsAt.AddDays(1)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
var result = await service.GetAllAsync(new LectureFilterRequest(null, null, null, null, null, null, null, null), 1);
Assert.True(result.Items.Single(item => item.Id == 1).IsEnrolled);
Assert.False(result.Items.Single(item => item.Id == 2).IsEnrolled);
}
[Fact]
public async Task EnrollAsync_SchedulesLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-3))),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-1))),
"lecture-1-user-1-starts-in-1-hour",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(2))),
"lecture-1-user-1-ended",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task EnrollAsync_SkipsPastLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddMinutes(90);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.DidNotReceive().ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(2).ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Theory]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(5, 7)]
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
for (var i = 1; i <= activeEnrollments; i++)
{
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
}
await db.SaveChangesAsync();
await service.EnrollAsync(100, 1);
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
await service.UnenrollAsync(1, 1);
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-3-hours", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
}
[Fact]
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Empty(result.Items);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LectureServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
}
@@ -1,91 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class LlmAnalysisServiceTests
{
[Fact]
public async Task AnalyzeReviewAsync_SavesParsedAnalysisResult()
{
await using var db = CreateDbContext();
await SeedPendingReviewAsync(db);
var llm = Substitute.For<ILlmClient>();
llm.AnalyzeReviewAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(new LlmReviewAnalysis(
0.76,
"Положительный",
["lecture structure", "practical examples"],
true,
"{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}"));
var gamification = Substitute.For<IGamificationService>();
gamification.AwardCoinsAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(),
Arg.Any<int?>(),
Arg.Any<string?>())
.Returns(Task.CompletedTask);
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
var service = new LlmAnalysisService(db, llm, gamification, NullLogger<LlmAnalysisService>.Instance);
await service.AnalyzeReviewAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Analyzed, review.LlmStatus);
Assert.Equal(ReviewSentiment.Positive, review.Sentiment);
Assert.Equal(0.76, review.QualityScore);
Assert.True(review.IsInformative);
Assert.Equal(["lecture structure", "practical examples"], review.LlmTags!);
Assert.Equal("{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}", review.LlmRawOutput);
await gamification.Received(1).AwardCoinsAsync(
1,
10,
CoinTransactionType.ReviewReward,
1,
null,
"Informative review reward");
}
private static async Task SeedPendingReviewAsync(AppDbContext db)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Useful review",
LlmStatus = ReviewLlmStatus.Pending
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -1,96 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Data;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewAnalysisWorkerTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task Worker_DoesNotExceedConfiguredConcurrency(int maxConcurrentProcessing)
{
var queue = new ReviewAnalysisQueue();
var analysisService = new RecordingLlmAnalysisService();
await using var provider = CreateServiceProvider(analysisService);
var worker = new ReviewAnalysisWorker(
provider,
queue,
Microsoft.Extensions.Options.Options.Create(
new ReviewAnalysisOptions { MaxConcurrentProcessing = maxConcurrentProcessing }),
NullLogger<ReviewAnalysisWorker>.Instance);
for (var reviewId = 1; reviewId <= 6; reviewId++)
await queue.EnqueueAsync(reviewId);
analysisService.ExpectProcessed(6);
await worker.StartAsync(CancellationToken.None);
await analysisService.WaitForProcessedAsync();
await worker.StopAsync(CancellationToken.None);
Assert.True(
analysisService.MaxRunning <= maxConcurrentProcessing,
$"Expected at most {maxConcurrentProcessing} concurrent analyses, got {analysisService.MaxRunning}.");
}
private static ServiceProvider CreateServiceProvider(ILlmAnalysisService analysisService)
{
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"ReviewAnalysisWorkerTests_{Guid.NewGuid()}"));
services.AddScoped(_ => analysisService);
return services.BuildServiceProvider();
}
private sealed class RecordingLlmAnalysisService : ILlmAnalysisService
{
private readonly TaskCompletionSource _processedAll = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _expectedCount;
private int _processedCount;
private int _running;
private int _maxRunning;
public int MaxRunning => _maxRunning;
public void ExpectProcessed(int expectedCount)
{
Volatile.Write(ref _expectedCount, expectedCount);
}
public async Task AnalyzeReviewAsync(int reviewId)
{
var running = Interlocked.Increment(ref _running);
UpdateMaxRunning(running);
await Task.Delay(50);
Interlocked.Decrement(ref _running);
if (Interlocked.Increment(ref _processedCount) >= Volatile.Read(ref _expectedCount))
_processedAll.TrySetResult();
}
public async Task WaitForProcessedAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var registration = timeout.Token.Register(() => _processedAll.TrySetCanceled(timeout.Token));
await _processedAll.Task;
}
private void UpdateMaxRunning(int running)
{
while (true)
{
var current = Volatile.Read(ref _maxRunning);
if (running <= current) return;
if (Interlocked.CompareExchange(ref _maxRunning, running, current) == current) return;
}
}
}
}
@@ -1,184 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Prompts;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewPromptServiceTests
{
[Fact]
public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
var result = await service.GetAsync();
Assert.Equal(ReviewPromptTemplate.Default, result.Prompt);
Assert.Null(result.UpdatedAt);
}
[Fact]
public async Task UpdateAsync_UpsertsSingletonPrompt()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}"));
var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}"));
Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt);
Assert.NotNull(result.UpdatedAt);
Assert.Equal(1, await db.ReviewPromptSettings.CountAsync());
Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Prompt without placeholders")]
[InlineData("Only lecture {lectureContext}")]
[InlineData("Only review {reviewText}")]
public async Task UpdateAsync_RejectsInvalidPrompt(string prompt)
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await Assert.ThrowsAsync<BadRequestException>(() =>
service.UpdateAsync(new UpdateReviewPromptRequest(prompt)));
}
[Fact]
public async Task AnalyzeReviewAsync_RendersCustomPrompt()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(
"Custom prompt. Context: {lectureContext}. Text: {reviewText}",
DateTime.UtcNow));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.NotNull(handler.RequestBody);
using var requestJson = JsonDocument.Parse(handler.RequestBody!);
var content = requestJson.RootElement
.GetProperty("messages")[0]
.GetProperty("content")
.GetString();
Assert.Contains("Custom prompt", content);
Assert.Contains("Lecture: Algebra", content);
Assert.Contains("Very useful review", content);
Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content);
Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content);
}
[Fact]
public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse()
{
var handler = new CapturingHandler("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""");
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.Equal(0.82, result.QualityScore);
Assert.Equal("Положительный", result.Sentiment);
Assert.Equal(["lecture structure", "practical examples"], result.Tags);
Assert.True(result.IsInformative);
Assert.Equal("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""", result.RawOutput);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private sealed class CapturingHandler : HttpMessageHandler
{
private readonly string _analysisContent;
public CapturingHandler(string? analysisContent = null)
{
_analysisContent = analysisContent ??
"{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}";
}
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
var responsePayload = JsonSerializer.Serialize(new
{
choices = new[]
{
new
{
message = new
{
content = _analysisContent
}
}
}
});
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responsePayload, Encoding.UTF8, "application/json")
};
}
}
}
@@ -1,145 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewServiceTests
{
[Fact]
public async Task CreateAsync_EnqueuesReviewAnalysis()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedLectureAsync(db);
var result = await service.CreateAsync(1, new CreateReviewRequest(1, ReviewRating.Like, "Great lecture"));
await queue.Received(1).EnqueueAsync(result.Id, Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.UpdateAsync(1, 1, new UpdateReviewRequest(ReviewRating.Neutral, "Updated text"));
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.ReanalyzeAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
}
[Fact]
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Single(result.Items);
}
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
{
var gamification = Substitute.For<IGamificationService>();
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return new ReviewService(db, gamification, queue);
}
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
TeacherId = teacherId,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
await db.SaveChangesAsync();
}
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
{
await SeedLectureAsync(db, teacherId);
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Original text",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.9,
IsInformative = true,
LlmTags = ["clear"],
LlmRawOutput = "{\"quality_score\":0.9}"
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -1,49 +0,0 @@
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());
}
}
@@ -1,375 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ScheduleSyncServiceTests
{
private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
private const string FullName = "Иванов Иван Иванович";
[Fact]
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 42)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(42, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: null, WorkingCapacity: null)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
modeus.SearchRoomsAsync()
.Returns(new ModeusRoomsResponse
{
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48)
]
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(48, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync();
Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName);
Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email);
var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync();
Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId);
Assert.Equal(teacherProfile.UserId, lecture.TeacherId);
var teacherRole = await db.UserRoles.SingleAsync();
Assert.Equal(lecture.TeacherId, teacherRole.UserId);
Assert.Equal(UserRole.Teacher, teacherRole.Role);
}
[Fact]
public async Task SyncScheduleAsync_SavesResolvedTeacherSubId()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true);
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Null(teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 77,
Email = "teacher@sfedu.ru",
DisplayName = "Old Name",
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(77, teacher.Id);
Assert.Equal("teacher@sfedu.ru", teacher.Email);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77));
}
[Fact]
public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry()
{
await using var db = CreateDbContext();
var placeholder = new UniVerse.Domain.Entities.User
{
Id = 10,
Email = $"modeus-{PersonId}@modeus.local",
DisplayName = FullName,
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
};
db.Users.Add(placeholder);
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 20,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }]
});
db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true });
db.Lectures.Add(new UniVerse.Domain.Entities.Lecture
{
Id = 1,
CourseId = 1,
TeacherId = 10,
ExternalId = EventId,
Title = "Old",
StartsAt = DateTime.UtcNow,
EndsAt = DateTime.UtcNow.AddHours(1)
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(20, realUser.Id);
Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId);
Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher);
Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20));
}
[Fact]
public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 10,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
});
await db.SaveChangesAsync();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static ModeusEventsResponse BuildEventsResponse()
{
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
return new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = EventId,
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
TypeId = "LAB",
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
Links = new ModeusEventLinks
{
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
}
}
],
CourseUnitRealizations =
[
new ModeusCourseUnitRealization(
CourseId,
"Управление проектами разработки программного обеспечения",
"УПРПО")
],
EventTeams = [new ModeusEventTeam(EventId, 25)],
EventAttendees =
[
new ModeusEventAttendee
{
Id = attendeeId,
RoleId = "TEACH",
RoleName = "Преподаватель",
Links = new ModeusEventAttendeeLinks
{
Event = new ModeusHrefLink($"/{EventId}"),
Person = new ModeusHrefLink($"/{PersonId}")
}
}
],
Persons =
[
new ModeusPerson(
PersonId,
"Иванов",
"Иван",
"Иванович",
FullName)
]
}
};
}
private sealed class FakeModeusApiClient(
ModeusEventsResponse events,
string? subId = null,
bool throwOnSubLookup = false) : IModeusApiClient
{
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
{
if (throwOnSubLookup)
throw new HttpRequestException("lookup failed");
return Task.FromResult(subId);
}
}
}
@@ -1,86 +0,0 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Tags;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Tags;
public class TagServiceTests
{
[Fact]
public async Task GetAllAsync_FiltersByTypeAndParentAndOrdersByName()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root", Type = TagType.Topic },
new Tag { Id = 2, Name = "Zeta", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Alpha", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 4, Name = "Other parent", Type = TagType.Subject, ParentId = 99 },
new Tag { Id = 5, Name = "Other type", Type = TagType.Topic, ParentId = 1 });
await db.SaveChangesAsync();
var service = new TagService(db);
var result = await service.GetAllAsync(TagType.Subject, parentId: 1);
Assert.Equal(new[] { "Alpha", "Zeta" }, result.Select(tag => tag.Name));
}
[Fact]
public async Task CreateAsync_ThrowsWhenParentMissing()
{
await using var db = CreateDbContext();
var service = new TagService(db);
await Assert.ThrowsAsync<NotFoundException>(() =>
service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 404)));
}
[Fact]
public async Task CreateAsync_CreatesChildWhenParentExists()
{
await using var db = CreateDbContext();
db.Tags.Add(new Tag { Id = 1, Name = "Parent", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new TagService(db);
var created = await service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 1));
Assert.Equal("Child", created.Name);
Assert.Equal(1, created.ParentId);
Assert.True(await db.Tags.AnyAsync(tag => tag.Name == "Child" && tag.ParentId == 1));
}
[Fact]
public async Task GetTreeAsync_ReturnsNestedRootTrees()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root A", Type = TagType.Topic },
new Tag { Id = 2, Name = "Child A", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Grandchild A", Type = TagType.Other, ParentId = 2 },
new Tag { Id = 4, Name = "Root B", Type = TagType.Organization });
await db.SaveChangesAsync();
var service = new TagService(db);
var tree = await service.GetTreeAsync();
Assert.Equal(new[] { "Root A", "Root B" }, tree.Select(tag => tag.Name));
var rootA = tree.Single(tag => tag.Name == "Root A");
var child = Assert.Single(rootA.Children);
Assert.Equal("Child A", child.Name);
Assert.Equal("Grandchild A", Assert.Single(child.Children).Name);
Assert.Empty(tree.Single(tag => tag.Name == "Root B").Children);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -1,31 +0,0 @@
<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.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<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>
@@ -1,267 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Users;
public class UserServiceTests
{
[Fact]
public async Task GetStatsAsync_ReturnsLevelProgressThresholds()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(2, stats.Level);
Assert.Equal(100, stats.CurrentLevelXp);
Assert.Equal(300, stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.Level);
Assert.Equal(300, stats.CurrentLevelXp);
Assert.Null(stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, now.AddDays(1)),
Lecture(2, now.AddDays(2)),
Lecture(3, now.AddDays(-1)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { LectureId = 1, UserId = 1 },
new LectureEnrollment { LectureId = 2, UserId = 1 },
new LectureEnrollment { LectureId = 3, UserId = 1 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.ActiveEnrollments);
Assert.Equal(5, stats.EnrollmentSlotLimit);
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
}
[Fact]
public async Task SetRolesAsync_DeduplicatesRolesAndCreatesProfiles()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await service.SetRolesAsync(1, [UserRole.Teacher, UserRole.Teacher, UserRole.Student]);
var user = await db.Users
.Include(u => u.Roles)
.FirstAsync(u => u.Id == 1);
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher }, user.Roles.Select(role => role.Role).OrderBy(role => role));
Assert.Equal(2, user.Roles.Count);
Assert.True(await db.StudentProfiles.AnyAsync(profile => profile.UserId == 1));
Assert.True(await db.TeacherProfiles.AnyAsync(profile => profile.UserId == 1));
}
[Fact]
public async Task SetRolesAsync_RejectsEmptyRoleSet()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.SetRolesAsync(1, []));
}
[Fact]
public async Task SetRolesAsync_PreservesExistingProfiles()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.StudentProfiles.Add(new StudentProfile
{
Id = 10,
UserId = 1,
StudentId = "S-1"
});
db.TeacherProfiles.Add(new TeacherProfile
{
Id = 20,
UserId = 1,
Department = "Math"
});
await db.SaveChangesAsync();
var service = CreateService(db);
await service.SetRolesAsync(1, [UserRole.Teacher]);
Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId);
Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department);
}
[Fact]
public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "anna@test.local", "Anna", true, 120, UserRole.Student),
User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher),
User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin),
User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(
Search: "anna",
Role: UserRole.Student,
IsActive: true,
Page: 1,
PageSize: 10));
var user = Assert.Single(result.Items);
Assert.Equal(1, user.Id);
Assert.Equal(2, user.Level);
Assert.Equal(1, result.TotalCount);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static UserService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
return new UserService(db, gamification);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
params UserRole[] roles) =>
User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles);
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
DateTime createdAt,
params UserRole[] roles) => new()
{
Id = id,
Email = email,
DisplayName = displayName,
IsActive = isActive,
Xp = xp,
CreatedAt = createdAt,
Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList()
};
}
@@ -1,17 +0,0 @@
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public class AchievementCatalogHostedService : IHostedService
{
private readonly IServiceProvider _services;
public AchievementCatalogHostedService(IServiceProvider services) => _services = services;
public async Task StartAsync(CancellationToken cancellationToken)
{
await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public class LlmProcessingBackgroundService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<LlmProcessingBackgroundService> _logger;
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> logger)
{
_services = services; _logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("LLM Processing Background Service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.ProcessPendingReviewsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in LLM processing background service");
}
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
}
}
@@ -1,21 +0,0 @@
using System.Threading.Channels;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
{
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
}
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
@@ -1,96 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ReviewAnalysisQueue _queue;
private readonly ReviewAnalysisOptions _options;
private readonly ILogger<ReviewAnalysisWorker> _logger;
public ReviewAnalysisWorker(
IServiceProvider services,
ReviewAnalysisQueue queue,
IOptions<ReviewAnalysisOptions> options,
ILogger<ReviewAnalysisWorker> logger)
{
_services = services;
_queue = queue;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing);
_logger.LogInformation(
"Review analysis worker started with max concurrency {MaxConcurrency}",
maxConcurrency);
await EnqueueExistingPendingReviewsAsync(stoppingToken);
var workers = Enumerable.Range(1, maxConcurrency)
.Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken))
.ToArray();
try
{
await Task.WhenAll(workers);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Review analysis worker stopped");
}
}
private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendingReviewIds = await db.Reviews
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
.OrderBy(r => r.CreatedAt)
.Select(r => r.Id)
.ToListAsync(cancellationToken);
foreach (var reviewId in pendingReviewIds)
await _queue.EnqueueAsync(reviewId, cancellationToken);
if (pendingReviewIds.Count > 0)
_logger.LogInformation(
"Queued {ReviewCount} pending reviews for immediate analysis",
pendingReviewIds.Count);
}
private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken)
{
await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken))
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.AnalyzeReviewAsync(reviewId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Review analysis worker {WorkerNumber} failed to process review {ReviewId}",
workerNumber,
reviewId);
}
}
}
}
@@ -5,87 +5,31 @@ 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}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _achievements.DeleteAsync(id);
return NoContent();
}
} }
@@ -1,208 +1,37 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using UniVerse.Application.DTOs.Auth; using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Cryptography;
using System.Security.Claims; 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;
private readonly IConfiguration _config; public AuthController(IAuthService auth) => _auth = auth;
private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
public AuthController(IAuthService auth, IConfiguration config)
{
_auth = auth;
_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, GetClientIpAddress()); var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode);
SetRefreshTokenCookie(result.RefreshToken); SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response); return Ok(result.Response);
} }
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
/// <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")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return Problem("Microsoft authentication is not configured (AzureAd:TenantId/ClientId).", statusCode: StatusCodes.Status500InternalServerError);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
}
var authorizeEndpoint = $"{instance.TrimEnd('/')}/{tenantId}/oauth2/v2.0/authorize";
var scope = _config["AzureAd:Scopes"] ?? "openid profile email offline_access User.Read";
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["response_mode"] = "query",
["scope"] = scope,
["state"] = state
});
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")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CallbackMicrosoft(
[FromQuery] string? code = null,
[FromQuery] string? state = null,
[FromQuery] string? error = null,
[FromQuery(Name = "error_description")] string? errorDescription = null)
{
if (!string.IsNullOrEmpty(error))
{
return Unauthorized(new
{
error,
errorDescription
});
}
if (string.IsNullOrWhiteSpace(code))
return BadRequest(new { error = "missing_code" });
var expectedState = Request.Cookies[MicrosoftStateCookieName];
if (string.IsNullOrWhiteSpace(expectedState) || string.IsNullOrWhiteSpace(state) || !string.Equals(expectedState, state, StringComparison.Ordinal))
return Unauthorized(new { error = "invalid_state" });
Response.Cookies.Delete(MicrosoftStateCookieName);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
Response.Cookies.Delete(MicrosoftReturnUrlCookieName);
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
// Put access token in URL fragment so it is not sent as Referer to the backend.
// Frontend can read it from location.hash on the landing page.
var fragment = $"access_token={Uri.EscapeDataString(result.Response.AccessToken)}&expires_at={Uri.EscapeDataString(result.Response.ExpiresAt.ToString("O"))}";
return Redirect($"{returnUrl}#{fragment}");
}
// Useful for manual testing without frontend: you'll see JSON in the browser.
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())
return NotFound(); return NotFound();
var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student]; var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role);
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken); SetRefreshTokenCookie(result.RefreshToken);
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>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")] [HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> Refresh() public async Task<ActionResult<AuthResponse>> Refresh()
{ {
var refreshToken = Request.Cookies["refreshToken"]; var refreshToken = Request.Cookies["refreshToken"];
@@ -212,17 +41,8 @@ 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"];
@@ -232,16 +52,8 @@ 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.CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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)
@@ -250,21 +62,6 @@ public class AuthController : ControllerBase
return Ok(user); return Ok(user);
} }
private string? GetClientIpAddress()
{
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
return firstForwardedAddress;
}
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
return realIp;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private void SetRefreshTokenCookie(string token) private void SetRefreshTokenCookie(string token)
{ {
Response.Cookies.Append("refreshToken", token, new CookieOptions Response.Cookies.Append("refreshToken", token, new CookieOptions
@@ -273,32 +70,4 @@ public class AuthController : ControllerBase
Expires = DateTime.UtcNow.AddDays(30) Expires = DateTime.UtcNow.AddDays(30)
}); });
} }
private string BuildAbsoluteUrl(string path)
{
if (!path.StartsWith('/')) path = "/" + path;
return $"{Request.Scheme}://{Request.Host}{path}";
}
private bool IsAllowedReturnUrl(string returnUrl)
{
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
return true;
if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute))
return false;
var allowedOrigins = _config.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
foreach (var origin in allowedOrigins)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var allowed))
continue;
if (string.Equals(allowed.Scheme, absolute.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(allowed.Host, absolute.Host, StringComparison.OrdinalIgnoreCase)
&& allowed.Port == absolute.Port)
return true;
}
return false;
}
} }
@@ -1,132 +1,46 @@
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}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
[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,203 +7,59 @@ 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) public LecturesController(ILectureService lectures, IReviewService reviews)
{ { _lectures = lectures; _reviews = reviews; }
_lectures = lectures; private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
_reviews = reviews;
}
private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private bool CurrentUserIsAdmin => User.IsInRole("Admin");
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
/// <param name="filter">
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
/// isOpen, tagId, search; параметры пагинации.
/// </param>
/// <remarks>Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию.</remarks>
/// <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, CurrentUserId)); 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, CurrentUserId, CurrentUserIsAdmin)); 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}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
[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")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
[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")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
[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, CurrentUserId, CurrentUserIsAdmin);
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, CurrentUserId, CurrentUserIsAdmin)); Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
/// <summary>Получить отзывы к лекции.</summary>
/// <remarks>Только Admin или Teacher.</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")]
[HttpGet("{id:int}/reviews")] [HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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, CurrentUserId, CurrentUserIsAdmin)); Ok(await _reviews.GetByLectureAsync(id, pagination));
} }
@@ -5,85 +5,31 @@ 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}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _locations.DeleteAsync(id);
return NoContent();
}
} }
@@ -1,94 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
[ApiController]
[Route("api/v1/notifications")]
[Authorize]
[Produces("application/json")]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
/// <summary>Получить уведомления текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="200">Список уведомлений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<UserNotificationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<PagedResult<UserNotificationDto>>> GetMine(
[FromQuery] PaginationRequest pagination,
CancellationToken cancellationToken) =>
Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken));
/// <summary>Отметить все уведомления текущего пользователя как прочитанные.</summary>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="204">Уведомления отмечены прочитанными.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpPatch("read-all")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> MarkAllRead(CancellationToken cancellationToken)
{
await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken);
return NoContent();
}
/// <summary>Отправить уведомление немедленно.</summary>
/// <remarks>
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
/// </remarks>
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
/// <response code="202">Уведомление принято к отправке.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("send")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken)
{
var message = new NotificationMessage(
request.Channel,
request.Recipient,
request.Subject,
request.Body,
request.RecipientName,
request.Metadata);
await _notifications.SendAsync(message, cancellationToken);
return Accepted();
}
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
/// <param name="request">Уведомление и момент отправки.</param>
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("schedule")]
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
{
var response = await _notifications.ScheduleAsync(request, cancellationToken);
return Accepted(response);
}
}
@@ -7,161 +7,40 @@ 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;
private readonly IReviewPromptService _reviewPrompts; public ReviewsController(IReviewService reviews) => _reviews = reviews;
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
public ReviewsController(IReviewService reviews, IReviewPromptService reviewPrompts)
{
_reviews = reviews;
_reviewPrompts = reviewPrompts;
}
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>Получить список всех отзывов.</summary>
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
/// <param name="filter">Параметры фильтрации и пагинации.</param>
/// <response code="200">Список всех отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
Ok(await _reviews.GetAllAsync(filter));
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
/// <response code="200">Текущий шаблон промпта.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> GetLlmPrompt() =>
Ok(await _reviewPrompts.GetAsync());
/// <summary>Обновить промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Промпт применяется к следующим анализам и ручным повторам.</remarks>
/// <param name="request">Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.</param>
/// <response code="200">Сохранённый шаблон промпта.</response>
/// <response code="400">Промпт пустой или не содержит обязательные плейсхолдеры.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPut("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) =>
Ok(await _reviewPrompts.UpdateAsync(request));
/// <summary>Получить отзыв по ID.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Отзыв не найден.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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> [Authorize(Roles = "Admin")]
/// <remarks> [HttpGet("pending")]
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
/// на повторную обработку. Ok(await _reviews.GetPendingAsync(pagination));
/// </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")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Reanalyze(int id)
{
await _reviews.ReanalyzeAsync(id);
return NoContent();
}
} }
@@ -5,75 +5,28 @@ 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">Параметры поиска событий во внешнем сервисе расписания.</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,101 +6,35 @@ 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}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _tags.DeleteAsync(id);
return NoContent();
}
} }
@@ -8,269 +8,71 @@ 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");
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
user.Id,
user.Email,
user.DisplayName,
user.AvatarUrl,
user.Roles,
user.Xp,
user.Coins,
user.Level,
user.CreatedAt);
/// <summary>Получить профиль текущего пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> GetMe() =>
Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId)));
/// <summary>Обновить профиль текущего пользователя (displayName, avatarUrl).</summary>
/// <param name="req">Обновляемые поля профиля.</param>
/// <response code="200">Обновлённые данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpPut("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> UpdateMe([FromBody] UpdateUserRequest req) =>
Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req)));
/// <summary>Получить статистику текущего пользователя.</summary>
/// <response code="200">Статистика текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> MyStats() =>
Ok(await _users.GetStatsAsync(CurrentUserId));
/// <summary>Получить список записей текущего пользователя на лекции.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/enrollments")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
/// <summary>Получить отзывы текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyReviews([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination));
/// <summary>Получить достижения текущего пользователя.</summary>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyAchievements() =>
Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId));
/// <summary>Получить историю транзакций монет текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/transactions")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyTransactions([FromQuery] PaginationRequest pagination) =>
Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination));
/// <summary>Получить профиль пользователя по ID.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Данные пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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. Для текущего пользователя используйте PUT /api/v1/users/me.</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")]
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
[ProducesResponseType(StatusCodes.Status401Unauthorized)] {
[ProducesResponseType(StatusCodes.Status403Forbidden)] if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
[ProducesResponseType(StatusCodes.Status404NotFound)] return Ok(await _users.UpdateProfileAsync(id, req));
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req) => }
Ok(await _users.UpdateProfileAsync(id, req));
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Статистика пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/stats")] [HttpGet("{id:int}/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[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. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/enrollments")] [HttpGet("{id:int}/enrollments")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)] public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
[ProducesResponseType(StatusCodes.Status401Unauthorized)] {
[ProducesResponseType(StatusCodes.Status403Forbidden)] if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
[ProducesResponseType(StatusCodes.Status404NotFound)] // Delegate to lecture service would be more proper, but returning reviews for now
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) => return Ok();
Ok(await _users.GetEnrollmentsAsync(id, pagination)); }
/// <summary>Получить отзывы пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/reviews")] [HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
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>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/achievements")] [HttpGet("{id:int}/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
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. Для текущего пользователя используйте GET /api/v1/users/me/transactions.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/transactions")] [HttpGet("{id:int}/transactions")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)] public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
[ProducesResponseType(StatusCodes.Status401Unauthorized)] {
[ProducesResponseType(StatusCodes.Status403Forbidden)] if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination) => return Ok(await _gamification.GetTransactionsAsync(id, pagination));
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="roles">Новый набор ролей пользователя.</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)] public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetRole(int id, [FromBody] IReadOnlyCollection<UserRole> roles)
{ {
if (roles.Count == 0) await _users.SetRoleAsync(id, role);
return BadRequest("At least one role is required.");
await _users.SetRolesAsync(id, roles);
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);
@@ -1,81 +0,0 @@
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] = []
});
}
}
@@ -24,7 +24,6 @@ public class ExceptionHandlingMiddleware
{ {
var (statusCode, title) = exception switch var (statusCode, title) = exception switch
{ {
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"), NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"), ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"), ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"),
@@ -1,8 +0,0 @@
namespace UniVerse.Api.Options;
public class ReviewAnalysisOptions
{
public const string SectionName = "Llm:ReviewAnalysis";
public int MaxConcurrentProcessing { get; set; } = 1;
}
+15 -89
View File
@@ -4,29 +4,16 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Quartz;
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.Api.Options;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Services;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices; using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Notifications;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var useAspire = builder.Configuration.GetValue<bool>("Aspire:Enabled");
var isOpenApiGeneration = AppDomain.CurrentDomain.GetAssemblies()
.Any(assembly => assembly.GetName().Name == "GetDocument.Insider");
if (useAspire)
{
builder.AddServiceDefaults();
}
// --- Serilog --- // --- Serilog ---
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
@@ -42,7 +29,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
npgsql => npgsql =>
{ {
npgsql.EnableRetryOnFailure(3); npgsql.EnableRetryOnFailure(3);
npgsql.MigrationsAssembly("UniVerse.Infrastructure"); // Указывает EF Core, в какой сборке искать/хранить миграции. npgsql.MigrationsAssembly("UniVerse.Infrastructure");
}); });
}); });
@@ -63,7 +50,7 @@ builder.Services.AddAuthentication(options =>
ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"], ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey( IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)) Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-key-change-in-production-32chars!!"))
}; };
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
@@ -90,40 +77,10 @@ builder.Services.AddScoped<ILocationService, LocationService>();
builder.Services.AddScoped<ICourseService, CourseService>(); builder.Services.AddScoped<ICourseService, CourseService>();
builder.Services.AddScoped<ILectureService, LectureService>(); builder.Services.AddScoped<ILectureService, LectureService>();
builder.Services.AddScoped<IReviewService, ReviewService>(); builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
builder.Services.AddScoped<IGamificationService, GamificationService>(); builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>(); builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>(); builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>(); builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
builder.Services.AddSingleton<ReviewAnalysisQueue>();
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
builder.Services.AddTransient<NotificationJob>();
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
builder.Services.AddOptions<ReviewAnalysisOptions>()
.Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName))
.Validate(options => options.MaxConcurrentProcessing >= 1,
"Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.")
.ValidateOnStart();
builder.Services.AddQuartz();
if (!isOpenApiGeneration)
{
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
}
if (builder.Environment.IsDevelopment() && !isOpenApiGeneration)
{
builder.Services.AddQuartzDashboard(options =>
{
options.ReadOnly = true;
});
}
// --- HTTP Clients --- // --- HTTP Clients ---
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client => builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
@@ -139,11 +96,7 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
}); });
// --- Background Services --- // --- Background Services ---
if (!isOpenApiGeneration) builder.Services.AddHostedService<LlmProcessingBackgroundService>();
{
builder.Services.AddHostedService<ReviewAnalysisWorker>();
builder.Services.AddHostedService<AchievementCatalogHostedService>();
}
// --- Controllers --- // --- Controllers ---
builder.Services.AddControllers() builder.Services.AddControllers()
@@ -161,16 +114,9 @@ builder.Services.AddSwaggerGen(options =>
{ {
Title = "UniVerse API", Title = "UniVerse API",
Version = "v1", Version = "v1",
Description = Description = "University schedule, reviews, and gamification platform"
"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",
@@ -178,54 +124,34 @@ builder.Services.AddSwaggerGen(options =>
Scheme = "bearer", Scheme = "bearer",
BearerFormat = "JWT", BearerFormat = "JWT",
In = ParameterLocation.Header, In = ParameterLocation.Header,
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`" Description = "Enter your JWT token"
}); });
// Include XML doc comments generated from controller /// summaries options.AddSecurityRequirement(doc =>
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; {
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
if (File.Exists(xmlPath)) return new OpenApiSecurityRequirement
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();
if (useAspire)
{
app.MapDefaultEndpoints();
}
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseMiddleware<RequestLoggingMiddleware>(); app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>(); app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseStaticFiles(); app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "UniVerse API v1"));
app.UseSwagger(c =>
{
c.RouteTemplate = "api/docs/{documentName}/swagger.json";
});
app.UseSwaggerUI(c =>
{
c.RoutePrefix = "api/docs";
c.SwaggerEndpoint("v1/swagger.json", "UniVerse API v1");
});
} }
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
app.MapQuartzDashboard();
}
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
@@ -4,8 +4,7 @@
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": false,
"launchUrl": "api/docs",
"applicationUrl": "http://localhost:5019", "applicationUrl": "http://localhost:5019",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
+5 -24
View File
@@ -7,35 +7,26 @@
<RootNamespace>UniVerse.Api</RootNamespace> <RootNamespace>UniVerse.Api</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData> <AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<OpenApiDocumentsDirectory>$(BaseIntermediateOutputPath)openapi</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<!-- Suppress warnings for public members without XML docs -->
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" /> <PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" /> <PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" /> <PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" /> <ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" /> <ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
<ProjectReference Include="..\UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -44,14 +35,4 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<Target
Name="CopyGeneratedOpenApiDocument"
AfterTargets="Build"
Condition="Exists('$(OpenApiDocumentsDirectory)/openapi.json')">
<Copy
SourceFiles="$(OpenApiDocumentsDirectory)/openapi.json"
DestinationFiles="$(MSBuildProjectDirectory)/openapi.json"
SkipUnchangedFiles="true" />
</Target>
</Project> </Project>
@@ -4,23 +4,5 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Information" "Microsoft.AspNetCore": "Information"
} }
},
"ConnectionStrings": {
"DefaultConnection": "Host=db;Port=5444;Database=universe;Username=universe;Password=pass"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "sfedu.ru",
"ClientId": "",
"ClientSecret": "",
"Domain": "sfedu.onmicrosoft.com",
"CallbackPath": "/signin-oidc"
} }
} }
+17 -18
View File
@@ -7,6 +7,16 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"Cors": { "Cors": {
"Origins": [ "Origins": [
"http://localhost:5173", "http://localhost:5173",
@@ -16,33 +26,22 @@
"Llm": { "Llm": {
"BaseUrl": "https://api.openai.com/v1/", "BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "", "ApiKey": "",
"Model": "gpt-4o-mini", "Model": "gpt-4o-mini"
"ReviewAnalysis": {
"MaxConcurrentProcessing": 1
}
}, },
"ModeusApi": { "ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru", "BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": "" "ApiKey": ""
}, },
"Gamification": {
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",
"Override": { "Override": {
"Microsoft": "Information", "Microsoft": "Warning",
"System": "Information" "System": "Warning"
} }
}
},
"Email": {
"Smtp": {
"Host": "",
"Port": 587,
"EnableSsl": true,
"UserName": "",
"Password": "",
"FromAddress": "no-reply@universe.local",
"FromName": "UniVerse"
} }
} }
} }
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddProject<Projects.UniVerse_Api>("universe-api")
.WithEnvironment("Aspire__Enabled", "true");
// Запуск фронтенда (Vue + Vite) в dev-режиме вместе.
// Требования: установлен pnpm (или включён corepack), зависимости фронта установлены.
builder
.AddExecutable("universe-frontend", "pnpm", workingDirectory: "../../frontend")
.WithArgs("run", "dev:aspire")
.WithHttpEndpoint(targetPort: 5173, port: 5173, name: "http", isProxied: false)
// Используется в vite.config.ts для server.proxy['/api'].target
.WithEnvironment("VITE_API_PROXY_TARGET", api.GetEndpoint("http"));
builder.Build().Run();
@@ -1,48 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://127.0.0.1:17156;http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://127.0.0.1:21010",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://127.0.0.1:23046",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://127.0.0.1:22274"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://127.0.0.1:19138",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://127.0.0.1:18238",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://127.0.0.1:20274"
}
}
}
}
@@ -1,19 +0,0 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>fb90d29a-6c48-471b-b19f-d2f431a5ef38</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
</ItemGroup>
</Project>
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -1,5 +0,0 @@
{
"appHost": {
"path": "UniVerse.AppHost.csproj"
}
}
@@ -5,8 +5,8 @@ namespace UniVerse.Application.DTOs.Auth;
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User); public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
public record AuthResult(AuthResponse Response, string RefreshToken); public record AuthResult(AuthResponse Response, string RefreshToken);
public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList<UserRole> Roles); public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role);
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null); public record LoginMicrosoftRequest(string AuthorizationCode);
public record DevLoginRequest(string Email, string? DisplayName = null, IReadOnlyList<UserRole>? Roles = null); public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student);
@@ -11,8 +11,3 @@ public record CoinTransactionDto(
string? Description, string? Description,
DateTime CreatedAt DateTime CreatedAt
); );
public record LevelProgressDto(
int CurrentLevelXp,
int? NextLevelXp
);
@@ -19,8 +19,7 @@ public record LectureDto(
int MaxEnrollments, int MaxEnrollments,
int EnrollmentsCount, int EnrollmentsCount,
string? OnlineUrl, string? OnlineUrl,
DateTime CreatedAt, DateTime CreatedAt
bool IsEnrolled = false
); );
public record LectureDetailDto( public record LectureDetailDto(
@@ -1,42 +0,0 @@
namespace UniVerse.Application.DTOs.Notifications;
public static class NotificationChannels
{
public const string Email = "email";
}
public record NotificationMessage(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record SendNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduleNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
DateTimeOffset SendAt,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
public record UserNotificationDto(
int Id,
string Type,
string Title,
string Body,
bool IsRead,
DateTime CreatedAt
);
@@ -15,20 +15,9 @@ public record ReviewDto(
double? QualityScore, double? QualityScore,
bool? IsInformative, bool? IsInformative,
string[]? LlmTags, string[]? LlmTags,
string? LlmRawOutput,
DateTime CreatedAt DateTime CreatedAt
); );
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text); public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
public record UpdateReviewRequest(ReviewRating Rating, string? Text); public record UpdateReviewRequest(ReviewRating Rating, string? Text);
public record ReviewFilterRequest(
ReviewLlmStatus? LlmStatus,
int Page = 1,
int PageSize = 20
);
public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt);
public record UpdateReviewPromptRequest(string Prompt);
@@ -1,27 +1,13 @@
namespace UniVerse.Application.DTOs.Sync; namespace UniVerse.Application.DTOs.Sync;
public record SyncScheduleRequest( public record SyncScheduleRequest(
IReadOnlyList<string>? SpecialtyCode, string? SpecialtyCode,
DateTime? TimeMin, DateTime? TimeMin,
DateTime? TimeMax, DateTime? TimeMax,
IReadOnlyList<string>? TypeId, string? TypeId
int? Size = null,
IReadOnlyList<string>? RoomId = null,
IReadOnlyList<string>? AttendeePersonId = null,
IReadOnlyList<string>? CourseUnitRealizationId = null,
IReadOnlyList<string>? CycleRealizationId = null,
IReadOnlyList<int>? LearningStartYear = null,
IReadOnlyList<string>? ProfileName = null,
IReadOnlyList<string>? CurriculumId = null
); );
public record SyncResultDto( public record SyncResultDto(int Created, int Updated, int Skipped, string? Error);
int Created,
int Updated,
int Skipped,
string? Error,
IReadOnlyList<string>? Details = null
);
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult); public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
@@ -7,7 +7,7 @@ public record UserDto(
string Email, string Email,
string? DisplayName, string? DisplayName,
string? AvatarUrl, string? AvatarUrl,
IReadOnlyList<UserRole> Roles, UserRole Role,
bool IsActive, bool IsActive,
int Xp, int Xp,
int Coins, int Coins,
@@ -15,18 +15,6 @@ public record UserDto(
DateTime CreatedAt DateTime CreatedAt
); );
public record CurrentUserDto(
int Id,
string Email,
string? DisplayName,
string? AvatarUrl,
IReadOnlyList<UserRole> Roles,
int Xp,
int Coins,
int Level,
DateTime CreatedAt
);
public record UserStatsDto( public record UserStatsDto(
int TotalLectures, int TotalLectures,
int AttendedLectures, int AttendedLectures,
@@ -34,16 +22,9 @@ public record UserStatsDto(
int Xp, int Xp,
int Coins, int Coins,
int Level, int Level,
int AchievementsCount, int AchievementsCount
int CurrentLevelXp,
int? NextLevelXp,
int ActiveEnrollments,
int EnrollmentSlotLimit,
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
); );
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record UpdateUserRequest( public record UpdateUserRequest(
string? DisplayName, string? DisplayName,
string? AvatarUrl string? AvatarUrl
@@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces;
public interface IAuthService public interface IAuthService
{ {
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null); Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null); Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
Task<AuthResult> RefreshTokenAsync(string refreshToken); Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken);
Task<CurrentUserDto> GetCurrentUserAsync(int userId); Task<UserDto> GetCurrentUserAsync(int userId);
} }
@@ -10,8 +10,7 @@ public interface IGamificationService
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
int? reviewId = null, int? achievementId = null, string? description = null); int? reviewId = null, int? achievementId = null, string? description = null);
Task CheckAndAwardAchievementsAsync(int userId); Task CheckAndAwardAchievementsAsync(int userId);
Task<int> CalculateLevelAsync(int xp); int CalculateLevel(int xp);
Task<LevelProgressDto> GetLevelProgressAsync(int xp);
Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId); Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination); Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
} }
@@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces;
public interface ILectureService public interface ILectureService
{ {
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null); Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter);
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null); Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
Task<LectureDto> CreateAsync(CreateLectureRequest request); Task<LectureDto> CreateAsync(CreateLectureRequest request);
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request, int currentUserId, bool isAdmin = false); Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request);
Task DeleteAsync(int id); Task DeleteAsync(int id);
Task EnrollAsync(int lectureId, int userId); Task EnrollAsync(int lectureId, int userId);
Task UnenrollAsync(int lectureId, int userId); Task UnenrollAsync(int lectureId, int userId);
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false); Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false); Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
} }
@@ -3,4 +3,5 @@ namespace UniVerse.Application.Interfaces;
public interface ILlmAnalysisService public interface ILlmAnalysisService
{ {
Task AnalyzeReviewAsync(int reviewId); Task AnalyzeReviewAsync(int reviewId);
Task ProcessPendingReviewsAsync();
} }
@@ -4,8 +4,7 @@ public record LlmReviewAnalysis(
double QualityScore, double QualityScore,
string Sentiment, string Sentiment,
string[] Tags, string[] Tags,
bool IsInformative, bool IsInformative
string RawOutput
); );
public interface ILlmClient public interface ILlmClient
@@ -1,11 +0,0 @@
namespace UniVerse.Application.Interfaces;
public interface IMicrosoftAuthClient
{
Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
string authorizationCode,
string redirectUri,
CancellationToken cancellationToken = default);
}
public record MicrosoftTokenResult(string IdToken);
@@ -1,9 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationProvider
{
string Channel { get; }
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
}
@@ -1,14 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationScheduler
{
Task<ScheduledNotificationResponse> ScheduleAsync(
NotificationMessage message,
DateTimeOffset sendAt,
string? jobId = null,
CancellationToken cancellationToken = default);
Task CancelAsync(string jobId, CancellationToken cancellationToken = default);
}
@@ -1,13 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Common;
namespace UniVerse.Application.Interfaces;
public interface INotificationService
{
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
Task<UserNotificationDto> CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default);
Task<PagedResult<UserNotificationDto>> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
}
@@ -1,6 +0,0 @@
namespace UniVerse.Application.Interfaces;
public interface IReviewAnalysisQueue
{
Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default);
}
@@ -1,9 +0,0 @@
using UniVerse.Application.DTOs.Reviews;
namespace UniVerse.Application.Interfaces;
public interface IReviewPromptService
{
Task<ReviewPromptDto> GetAsync();
Task<ReviewPromptDto> UpdateAsync(UpdateReviewPromptRequest request);
}
@@ -9,8 +9,8 @@ public interface IReviewService
Task<ReviewDto> GetByIdAsync(int id); Task<ReviewDto> GetByIdAsync(int id);
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request); Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
Task DeleteAsync(int id, int userId, bool isAdmin = false); Task DeleteAsync(int id, int userId, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false); Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination); Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter); Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
Task ReanalyzeAsync(int id); Task ReanalyzeAsync(int id);
} }
@@ -1,5 +1,4 @@
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
using System.Text.Json.Serialization;
namespace UniVerse.Application.Interfaces; namespace UniVerse.Application.Interfaces;
@@ -16,102 +15,11 @@ public interface IModeusApiClient
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request); Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
Task<ModeusRoomsResponse> SearchRoomsAsync(); Task<ModeusRoomsResponse> SearchRoomsAsync();
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname); Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
} }
// Modeus API response models // Modeus API response models
public class ModeusEvent public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
{ public record ModeusEventsResponse(List<ModeusEvent> Events);
public string Id { get; init; } = string.Empty; public record ModeusRoom(string Id, string Name, string? Building);
public string Name { get; init; } = string.Empty; public record ModeusRoomsResponse(List<ModeusRoom> Rooms);
public string? NameShort { get; init; }
public string? Description { get; init; }
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
public ModeusHrefLink? CourseUnitRealization { get; init; }
}
public class ModeusEventsResponse
{
[JsonPropertyName("_embedded")]
public ModeusEventsEmbedded? Embedded { get; init; }
public List<ModeusEvent>? Events { get; init; }
public ModeusPage? Page { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusEvent> EventItems => Embedded?.Events ?? Events ?? [];
}
public class ModeusEventsEmbedded
{
public List<ModeusEvent>? Events { get; init; }
[JsonPropertyName("course-unit-realizations")]
public List<ModeusCourseUnitRealization>? CourseUnitRealizations { get; init; }
[JsonPropertyName("event-rooms")]
public List<ModeusEventRoom>? EventRooms { get; init; }
[JsonPropertyName("event-teams")]
public List<ModeusEventTeam>? EventTeams { get; init; }
[JsonPropertyName("event-attendees")]
public List<ModeusEventAttendee>? EventAttendees { get; init; }
public List<ModeusPerson>? Persons { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
}
public record ModeusHrefLink(string? Href);
public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort);
public class ModeusEventRoom
{
public string Id { get; init; } = string.Empty;
[JsonPropertyName("_links")]
public ModeusEventRoomLinks? Links { get; init; }
}
public class ModeusEventRoomLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Room { get; init; }
}
public record ModeusEventTeam(string EventId, int? Size);
public class ModeusEventAttendee
{
public string Id { get; init; } = string.Empty;
public string? RoleId { get; init; }
public string? RoleName { get; init; }
[JsonPropertyName("_links")]
public ModeusEventAttendeeLinks? Links { get; init; }
}
public class ModeusEventAttendeeLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Person { get; init; }
}
public record ModeusPerson(string Id, string? LastName, string? FirstName, string? MiddleName, string? FullName);
public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address);
public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
public record ModeusPage(int Size, int TotalElements, int TotalPages, int Number);
public class ModeusRoomsResponse
{
[JsonPropertyName("_embedded")]
public ModeusRoomsEmbedded? Embedded { get; init; }
public ModeusPage? Page { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusRoom> RoomItems => Embedded?.Rooms ?? Rooms ?? [];
}
public record ModeusEmployee(string? Id, string FullName, string? Department); public record ModeusEmployee(string? Id, string FullName, string? Department);
@@ -1,5 +1,4 @@
using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users; using UniVerse.Application.DTOs.Users;
using UniVerse.Domain.Enums; using UniVerse.Domain.Enums;
@@ -10,8 +9,7 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id); Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request); Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id); Task<UserStatsDto> GetStatsAsync(int id);
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter); Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles); Task SetRoleAsync(int id, UserRole role);
Task SetActiveAsync(int id, bool isActive); Task SetActiveAsync(int id, bool isActive);
} }
@@ -16,16 +16,11 @@ public static class MappingExtensions
// --- User --- // --- User ---
public static UserDto ToDto(this User user, int level) => new( public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl, user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt user.Role, user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
);
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
); );
public static UserAuthDto ToAuthDto(this User user) => new( public static UserAuthDto ToAuthDto(this User user) => new(
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() user.Id, user.Email, user.DisplayName, user.Role
); );
// --- Tag --- // --- Tag ---
@@ -51,14 +46,14 @@ public static class MappingExtensions
); );
// --- Lecture --- // --- Lecture ---
public static LectureDto ToDto(this Lecture lecture, bool isEnrolled = false) => new( public static LectureDto ToDto(this Lecture lecture) => new(
lecture.Id, lecture.CourseId, lecture.Course?.Name ?? "", lecture.Id, lecture.CourseId, lecture.Course?.Name ?? "",
lecture.TeacherId, lecture.Teacher?.DisplayName, lecture.TeacherId, lecture.Teacher?.DisplayName,
lecture.LocationId, lecture.Location?.Name, lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format, lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen, lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count, lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled lecture.OnlineUrl, lecture.CreatedAt
); );
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new( public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
@@ -84,7 +79,7 @@ public static class MappingExtensions
review.UserId, review.User?.DisplayName, review.UserId, review.User?.DisplayName,
review.Rating, review.Text, review.LlmStatus, review.Rating, review.Text, review.LlmStatus,
review.Sentiment, review.QualityScore, review.IsInformative, review.Sentiment, review.QualityScore, review.IsInformative,
review.LlmTags, review.LlmRawOutput, review.CreatedAt review.LlmTags, review.CreatedAt
); );
// --- Achievement --- // --- Achievement ---
@@ -1,27 +0,0 @@
namespace UniVerse.Application.Prompts;
public static class ReviewPromptTemplate
{
public const string LectureContextPlaceholder = "{lectureContext}";
public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
- quality_score: число от 0 до 1, указывающее на качество отзыва;
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
- tags: массив соответствующих тематических тегов;
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
Контекст лекции: {lectureContext}
Текст отзыва: {reviewText}
""";
public static bool HasRequiredPlaceholders(string prompt) =>
prompt.Contains(LectureContextPlaceholder, StringComparison.Ordinal) &&
prompt.Contains(ReviewTextPlaceholder, StringComparison.Ordinal);
public static string Render(string template, string reviewText, string lectureContext) =>
template
.Replace(LectureContextPlaceholder, lectureContext)
.Replace(ReviewTextPlaceholder, reviewText);
}
@@ -8,12 +8,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,7 +0,0 @@
namespace UniVerse.Domain.Entities;
public class LevelThreshold
{
public int Level { get; set; }
public int RequiredXp { get; set; }
}
@@ -14,7 +14,6 @@ public class Review
public double? QualityScore { get; set; } public double? QualityScore { get; set; }
public bool? IsInformative { get; set; } public bool? IsInformative { get; set; }
public string[]? LlmTags { get; set; } public string[]? LlmTags { get; set; }
public string? LlmRawOutput { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -1,11 +0,0 @@
namespace UniVerse.Domain.Entities;
public class ReviewPromptSetting
{
public const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
public string Prompt { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+1 -2
View File
@@ -8,6 +8,7 @@ public class User
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? AvatarUrl { get; set; } public string? AvatarUrl { get; set; }
public UserRole Role { get; set; } = UserRole.Student;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string? MicrosoftId { get; set; } public string? MicrosoftId { get; set; }
public int Xp { get; set; } public int Xp { get; set; }
@@ -18,11 +19,9 @@ public class User
// Navigation properties // Navigation properties
public StudentProfile? StudentProfile { get; set; } public StudentProfile? StudentProfile { get; set; }
public TeacherProfile? TeacherProfile { get; set; } public TeacherProfile? TeacherProfile { get; set; }
public ICollection<UserRoleAssignment> Roles { get; set; } = new List<UserRoleAssignment>();
public ICollection<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>(); public ICollection<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>();
public ICollection<Review> Reviews { get; set; } = new List<Review>(); public ICollection<Review> Reviews { get; set; } = new List<Review>();
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>(); public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>(); public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>();
public ICollection<UserNotification> Notifications { get; set; } = new List<UserNotification>();
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>(); public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
} }
@@ -1,14 +0,0 @@
namespace UniVerse.Domain.Entities;
public class UserNotification
{
public int Id { get; set; }
public int UserId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}
@@ -1,11 +0,0 @@
using UniVerse.Domain.Enums;
namespace UniVerse.Domain.Entities;
public class UserRoleAssignment
{
public int UserId { get; set; }
public UserRole Role { get; set; }
public User User { get; set; } = null!;
}
@@ -1,8 +0,0 @@
namespace UniVerse.Domain.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
}
@@ -1,21 +0,0 @@
namespace UniVerse.Domain.Services;
public static class EnrollmentSlotPolicy
{
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
[
new(1, 3),
new(3, 5),
new(4, 7)
];
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
public static int GetLimitForLevel(int level) =>
SlotRules
.Where(rule => rule.Level <= level)
.OrderBy(rule => rule.Level)
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
}
public sealed record EnrollmentSlotRule(int Level, int Slots);
@@ -1,92 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data;
public static class AchievementCatalogSeeder
{
private static readonly IReadOnlyList<AchievementSeed> Catalog =
[
new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"),
new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"),
new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"),
new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"),
new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"),
new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"),
new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"),
new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"),
new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"),
new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"),
new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"),
new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"),
new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"),
new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"),
new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"),
new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"),
new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"),
new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"),
new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"),
new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"),
new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1")
];
private static readonly IReadOnlyDictionary<string, string> LegacyConditions = new Dictionary<string, string>
{
["reviews_1"] = "reviews_written:1",
["reviews_5"] = "reviews_written:5",
["reviews_10"] = "reviews_written:10",
["attended_5"] = "lectures_attended:5",
["attended_10"] = "lectures_attended:10"
};
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var legacyConditionKeys = LegacyConditions.Keys.ToArray();
var legacyAchievements = await db.Achievements
.Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition))
.ToListAsync(cancellationToken);
foreach (var achievement in legacyAchievements)
achievement.Condition = LegacyConditions[achievement.Condition!];
foreach (var seed in Catalog)
{
var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken);
if (achievement == null)
{
db.Achievements.Add(new Achievement
{
Id = seed.Id,
Name = seed.Name,
Description = seed.Description,
IconUrl = seed.IconUrl,
XpReward = 0,
CoinReward = seed.CoinReward,
Condition = seed.Condition
});
continue;
}
achievement.Name = seed.Name;
achievement.Description = seed.Description;
achievement.IconUrl = seed.IconUrl;
achievement.XpReward = 0;
achievement.CoinReward = seed.CoinReward;
achievement.Condition = seed.Condition;
}
await db.SaveChangesAsync(cancellationToken);
}
private sealed record AchievementSeed(
int Id,
string Name,
string Description,
string IconUrl,
int CoinReward,
string Condition);
}
@@ -10,7 +10,6 @@ public class AppDbContext : DbContext
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; } = null!; public DbSet<User> Users { get; set; } = null!;
public DbSet<UserRoleAssignment> UserRoles { get; set; } = null!;
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!; public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!; public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
public DbSet<Course> Courses { get; set; } = null!; public DbSet<Course> Courses { get; set; } = null!;
@@ -20,12 +19,9 @@ public class AppDbContext : DbContext
public DbSet<CourseTag> CourseTags { get; set; } = null!; public DbSet<CourseTag> CourseTags { get; set; } = null!;
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!; public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
public DbSet<Review> Reviews { get; set; } = null!; public DbSet<Review> Reviews { get; set; } = null!;
public DbSet<ReviewPromptSetting> ReviewPromptSettings { get; set; } = null!;
public DbSet<Achievement> Achievements { get; set; } = null!; public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!; public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!; public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!; public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
static AppDbContext() static AppDbContext()
@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
{
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
{
builder.ToTable("level_thresholds", table =>
{
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
builder.HasKey(t => t.Level);
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
builder.HasIndex(t => t.RequiredXp).IsUnique();
builder.HasData(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 },
new LevelThreshold { Level = 4, RequiredXp = 600 },
new LevelThreshold { Level = 5, RequiredXp = 1000 },
new LevelThreshold { Level = 6, RequiredXp = 1500 },
new LevelThreshold { Level = 7, RequiredXp = 2500 },
new LevelThreshold { Level = 8, RequiredXp = 4000 }
);
}
}
@@ -21,7 +21,6 @@ public class ReviewConfiguration : IEntityTypeConfiguration<Review>
builder.Property(r => r.QualityScore).HasColumnName("quality_score"); builder.Property(r => r.QualityScore).HasColumnName("quality_score");
builder.Property(r => r.IsInformative).HasColumnName("is_informative"); builder.Property(r => r.IsInformative).HasColumnName("is_informative");
builder.Property(r => r.LlmTags).HasColumnName("llm_tags"); builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
builder.Property(r => r.LlmRawOutput).HasColumnName("llm_raw_output");
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class ReviewPromptSettingConfiguration : IEntityTypeConfiguration<ReviewPromptSetting>
{
public void Configure(EntityTypeBuilder<ReviewPromptSetting> builder)
{
builder.ToTable("review_prompt_settings");
builder.HasKey(setting => setting.Id);
builder.Property(setting => setting.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(setting => setting.Prompt)
.HasColumnName("prompt")
.IsRequired();
builder.Property(setting => setting.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
builder.Property(setting => setting.UpdatedAt)
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
}
}
@@ -24,6 +24,5 @@ public class TeacherProfileConfiguration : IEntityTypeConfiguration<TeacherProfi
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(t => t.UserId).IsUnique(); builder.HasIndex(t => t.UserId).IsUnique();
builder.HasIndex(t => t.ModeusId).IsUnique().HasFilter("modeus_id IS NOT NULL");
} }
} }
@@ -15,6 +15,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.Email).HasColumnName("email").HasMaxLength(255).IsRequired(); builder.Property(u => u.Email).HasColumnName("email").HasMaxLength(255).IsRequired();
builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(255); builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(255);
builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500); builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500);
builder.Property(u => u.Role).HasColumnName("role");
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true); builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.MicrosoftId).HasColumnName("microsoft_id").HasMaxLength(255); builder.Property(u => u.MicrosoftId).HasColumnName("microsoft_id").HasMaxLength(255);
builder.Property(u => u.Xp).HasColumnName("xp").HasDefaultValue(0); builder.Property(u => u.Xp).HasColumnName("xp").HasDefaultValue(0);
@@ -24,10 +25,5 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.HasIndex(u => u.Email).IsUnique(); builder.HasIndex(u => u.Email).IsUnique();
builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL"); builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL");
builder.HasMany(u => u.Roles)
.WithOne(ur => ur.User)
.HasForeignKey(ur => ur.UserId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
{
public void Configure(EntityTypeBuilder<UserNotification> builder)
{
builder.ToTable("user_notifications");
builder.HasKey(n => n.Id);
builder.Property(n => n.Id).HasColumnName("id");
builder.Property(n => n.UserId).HasColumnName("user_id");
builder.Property(n => n.Type).HasColumnName("type").HasMaxLength(50).IsRequired();
builder.Property(n => n.Title).HasColumnName("title").HasMaxLength(255).IsRequired();
builder.Property(n => n.Body).HasColumnName("body").HasMaxLength(1000).IsRequired();
builder.Property(n => n.IsRead).HasColumnName("is_read").HasDefaultValue(false);
builder.Property(n => n.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(n => n.User)
.WithMany(u => u.Notifications)
.HasForeignKey(n => n.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(n => new { n.UserId, n.CreatedAt });
}
}
@@ -1,17 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class UserRoleAssignmentConfiguration : IEntityTypeConfiguration<UserRoleAssignment>
{
public void Configure(EntityTypeBuilder<UserRoleAssignment> builder)
{
builder.ToTable("user_roles");
builder.HasKey(ur => new { ur.UserId, ur.Role });
builder.Property(ur => ur.UserId).HasColumnName("user_id");
builder.Property(ur => ur.Role).HasColumnName("role");
}
}
@@ -9,10 +9,10 @@ using UniVerse.Infrastructure.Data;
#nullable disable #nullable disable
namespace UniVerse.Infrastructure.Migrations namespace UniVerse.Infrastructure.Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260506134139_Initial")] [Migration("20260428124938_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />

Some files were not shown because too many files have changed in this diff Show More