Compare commits
1 Commits
main
..
9e5a72c53a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e5a72c53a |
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
+1
-3
@@ -139,8 +139,7 @@ $RECYCLE.BIN/
|
|||||||
.LSOverride
|
.LSOverride
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
Generated
-1
@@ -1 +0,0 @@
|
|||||||
UniVerse
|
|
||||||
+1
-3
@@ -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
@@ -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>
|
|
||||||
@@ -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 <token>".</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,55 +62,12 @@ 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
|
||||||
{
|
{
|
||||||
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
@@ -159,73 +112,46 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
{
|
{
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
options.SwaggerDoc("v1", new OpenApiInfo
|
||||||
{
|
{
|
||||||
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",
|
||||||
Type = SecuritySchemeType.Http,
|
Type = SecuritySchemeType.Http,
|
||||||
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"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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()");
|
||||||
|
|
||||||
|
|||||||
-27
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -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
Reference in New Issue
Block a user