4 Commits

Author SHA1 Message Date
Renovate Bot 8223697bd3 chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 44s
2026-05-25 00:32:40 +00:00
serega404 3106f0ef61 Merge pull request 'Dev' (#11) from dev into main
Backend CI / build-and-test (push) Successful in 39s
Frontend CI / build-and-check (push) Failing after 5m18s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
Reviewed-on: #11
2026-05-25 03:22:55 +03:00
serega404 a220afd078 Merge pull request 'chore: Configure Renovate' (#8) from renovate/configure into main
Create and publish a Docker image / Publish image (push) Successful in 2m23s
Reviewed-on: #8
2026-05-25 03:03:56 +03:00
Renovate Bot cd3f2c53b7 Add renovate.json 2026-05-25 00:02:52 +00:00
71 changed files with 276 additions and 3988 deletions
+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '24.x' node-version: '22.x'
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
-40
View File
@@ -1,40 +0,0 @@
name: Frontend Playwright
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build app
run: pnpm build-only
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
run: pnpm test:e2e
+105 -235
View File
@@ -1,295 +1,165 @@
# UniVerse # UniVerse
UniVerse - веб-платформа для открытых межнаправленческих лекций университета. Система помогает студентам находить занятия других направлений, записываться на них, получать напоминания, оставлять отзывы, а преподавателям и администраторам - видеть аналитику посещаемости и качества обратной связи. UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
Проект состоит из Vue 3 frontend, ASP.NET Core backend и PostgreSQL-хранилища. Backend предоставляет REST API, интегрируется с Microsoft Entra ID, Modeus API и OpenAI-compatible LLM, а frontend реализует отдельные сценарии для ролей `Student`, `Teacher` и `Admin`. [Документация API](backend/UniVerse.Api/openapi.json)
[Документация бекнда](docs/backend.md)
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json) ## Что внутри
- [Backend notes](docs/backend.md)
- [Frontend E2E tests](docs/playwright-tests.md)
- [Load testing](docs/load-testing-k6.md)
## Возможности - Расписание/события и сущности: курсы, лекции, аудитории (locations)
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
- Геймификация: XP/уровни, монеты, достижения
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
- Swagger/OpenAPI в Development
### Для студента ## Технологии
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия. - .NET 10 / ASP.NET Core
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей. - PostgreSQL + EF Core (Npgsql)
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика. - Serilog
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки. - Swagger (Swashbuckle)
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
- Уведомления и профиль пользователя.
### Для преподавателя ## Структура репозитория
- Дашборд преподавателя по своим занятиям. Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
- Просмотр списка лекций и записей.
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
- Работа с отзывами без раскрытия лишних персональных данных студентам.
### Для администратора - `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов. - `backend/UniVerse.Domain` — доменные сущности/enum/исключения
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов. - `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
- Управление лекциями и создание новых занятий.
- Синхронизация расписания, аудиторий и преподавателей из Modeus.
- Модерация отзывов, повторный запуск LLM-анализа и настройка промпта.
- Управление курсами, тегами, локациями и достижениями через API.
### Платформенные функции
- Microsoft Entra ID login и dev-login для локальной разработки.
- JWT access token и refresh flow через cookie.
- Ролевая защита маршрутов frontend и endpoint-level авторизация backend.
- Фоновая очередь анализа отзывов через LLM.
- Геймификация: XP, уровни, монеты, достижения и транзакции.
- Email-уведомления и планировщик Quartz.
- Rate limiting, Serilog, Prometheus metrics и Swagger UI в Development.
- Aspire AppHost для совместного локального запуска API и Vite frontend.
- Docker Compose для production-окружения с backend, frontend и PostgreSQL.
## Архитектура
```mermaid
flowchart LR
Student[Student UI] --> Frontend[Vue 3 frontend]
Teacher[Teacher UI] --> Frontend
Admin[Admin UI] --> Frontend
Frontend -->|/api/v1 JSON| Api[ASP.NET Core Web API]
Api --> Auth[Auth and RBAC]
Api --> App[Application services]
App --> Domain[Domain model]
App --> Infra[Infrastructure]
Infra --> Db[(PostgreSQL)]
Infra --> Llm[OpenAI-compatible LLM]
Infra --> Modeus[Modeus schedule API]
Infra --> Mail[SMTP email]
Api --> Quartz[Quartz jobs]
Quartz --> Mail
Api --> Metrics[Prometheus /metrics]
```
Backend следует Clean Architecture:
- `UniVerse.Api` - controllers, middleware, Swagger, DI, background services.
- `UniVerse.Application` - DTO, interfaces, service contracts, mappings, prompts.
- `UniVerse.Domain` - entities, enums, domain exceptions, domain services.
- `UniVerse.Infrastructure` - EF Core, migrations, external clients, notification and business service implementations.
- `UniVerse.AppHost` - Aspire host для локального запуска API и frontend.
- `UniVerse.Api.Tests` - unit и integration tests.
Frontend построен на Vue 3, TypeScript, Vite, Pinia и Vue Router:
- `src/views/student` - кабинет студента, каталог, карточка лекции, мои лекции, отзывы, профиль, уведомления.
- `src/views/teacher` - кабинет преподавателя, лекции, аналитика.
- `src/views/admin` - дашборд, пользователи, лекции, отзывы.
- `src/api` - typed API client, DTO-типы и мапперы.
- `src/components/ui` и `src/components/layout` - переиспользуемые UI и layout-компоненты.
## Сценарий записи и анализа отзыва
```mermaid
sequenceDiagram
actor S as Student
participant UI as Vue frontend
participant API as UniVerse API
participant DB as PostgreSQL
participant Q as ReviewAnalysisQueue
participant LLM as LLM API
participant G as GamificationService
S->>UI: Выбирает открытую лекцию
UI->>API: POST /api/v1/lectures/{id}/enroll
API->>DB: Проверяет лимиты и сохраняет запись
API-->>UI: 204 No Content
S->>UI: Оставляет отзыв
UI->>API: POST /api/v1/reviews
API->>DB: Сохраняет отзыв со статусом Pending
API->>Q: Ставит отзыв в очередь анализа
Q->>LLM: Анализ качества, тональности и тегов
LLM-->>Q: Результат анализа
Q->>DB: Обновляет отзыв
Q->>G: Начисляет XP, монеты и достижения
G->>DB: Сохраняет транзакции и награды
```
Основные группы таблиц:
- Пользователи и доступ: `users`, `user_roles`, `student_profiles`, `teacher_profiles`, `refresh_tokens`.
- Расписание: `courses`, `lectures`, `locations`, `tags`, `course_tags`.
- Участие и обратная связь: `lecture_enrollments`, `reviews`, `review_prompt_settings`.
- Геймификация: `achievements`, `user_achievements`, `coin_transactions`, `level_thresholds`.
- Уведомления: `user_notifications`.
## API
Базовый префикс: `/api/v1`.
- `/auth` - Microsoft login, dev-login, refresh, logout, текущий пользователь.
- `/users` - профиль, статистика, роли, активность, записи, достижения, транзакции, `.ics`.
- `/lectures` - каталог лекций, CRUD, запись, посещаемость, отзывы по лекции.
- `/reviews` - создание, список, модерация, повторный анализ, настройка LLM-промпта.
- `/courses` - курсы и привязка тегов.
- `/tags` - теги и дерево тегов.
- `/locations` - аудитории и локации.
- `/achievements` - каталог достижений.
- `/notifications` - уведомления, отметка прочтения, отправка и планирование.
- `/sync` - синхронизация расписания, аудиторий и преподавателей из Modeus.
В Development Swagger UI доступен по адресу `http://localhost:5019/api/docs`.
## Стек
### Frontend
- Vue 3, TypeScript, Vite.
- Pinia, Vue Router.
- Playwright E2E tests.
- Nginx для production-раздачи и проксирования.
### Backend
- .NET 10, ASP.NET Core Web API.
- EF Core 10, Npgsql, PostgreSQL.
- Swashbuckle/OpenAPI.
- Serilog, Prometheus, ASP.NET Core Rate Limiting.
- Quartz для отложенных уведомлений.
- Aspire AppHost.
- xUnit, NSubstitute, WebApplicationFactory.
### Интеграции
- Microsoft Entra ID.
- Modeus schedule API.
- OpenAI-compatible LLM API.
- SMTP email.
## Требования ## Требования
- .NET SDK 10.x. - .NET SDK 10 (`dotnet --version` должен показать `10.x`)
- Node.js `^20.19.0 || >=22.12.0`. - PostgreSQL 14+ (или Docker для поднятия Postgres)
- pnpm.
- PostgreSQL 17+ или Docker.
- Для production - Docker Engine и Docker Compose.
## Конфигурация ## Конфигурация
Backend читает настройки из `backend/UniVerse.Api/appsettings.json`, `appsettings.Development.json` и переменных окружения в формате `Section__Key`. Основные настройки лежат в `backend/UniVerse.Api/appsettings.json`:
Основные секции: - `ConnectionStrings:DefaultConnection` — строка подключения к Postgres
- `Jwt:*` — секрет/issuer/audience и сроки жизни токенов
- `Cors:Origins` — origin’ы фронтенда
- `Llm:*` — настройки LLM (OpenAI-compatible)
- `ModeusApi:*` — настройки интеграции с Modeus
- `ConnectionStrings:DefaultConnection` - подключение к PostgreSQL. Можно переопределять через переменные окружения в формате `Section__Key`, например:
- `Jwt:*` - issuer, audience, secret и сроки жизни токенов.
- `AzureAd:*` - Microsoft Entra ID.
- `Cors:Origins` - разрешенные origin frontend.
- `RateLimiting:*` - глобальный fixed-window limiter.
- `Llm:*` - base URL, API key, model и параметры анализа отзывов.
- `ModeusApi:*` - base URL, API key и timeout.
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
Frontend использует переменные: - `ConnectionStrings__DefaultConnection`
- `Jwt__Secret`
- `Llm__ApiKey`
- `ModeusApi__ApiKey`
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`. ## Быстрый старт (локально)
- `VITE_API_PROXY_TARGET` - target для Vite proxy при запуске через Aspire.
- `VITE_AUTH_RETURN_URL` - frontend callback URL, по умолчанию `/auth/callback`.
## Быстрый старт 1) Поднять Postgres (пример через Docker):
### 1. Установить зависимости frontend
```bash
pnpm -C frontend install
```
### 2. Поднять PostgreSQL
```bash ```bash
docker run --rm --name universe-postgres \ docker run --rm --name universe-postgres \
-e POSTGRES_DB=universe \ -e POSTGRES_DB=universe \
-e POSTGRES_USER=postgres \ -e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \ -p 5432:5432 \
postgres:18 postgres:18
``` ```
### 3. Применить миграции 2) Применить миграции (первый раз потребуется `dotnet-ef`):
```bash ```bash
dotnet tool install --global dotnet-ef dotnet tool install --global dotnet-ef
cd backend cd backend
dotnet ef database update \ dotnet ef database update \
--project UniVerse.Infrastructure \ --project UniVerse.Infrastructure \
--startup-project UniVerse.Api --startup-project UniVerse.Api
``` ```
### 4. Запустить backend 3) Запустить API:
```bash ```bash
dotnet run --project backend/UniVerse.Api --launch-profile http cd backend
dotnet run --project UniVerse.Api --launch-profile http
``` ```
API по умолчанию слушает `http://localhost:5019`. По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
### 5. Запустить frontend ## Запуск в Docker
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
```bash ```bash
pnpm -C frontend dev cd backend
docker build -f UniVerse.Api/Dockerfile -t universe-api .
docker run --rm -p 8080:8080 \
-e ASPNETCORE_URLS=http://+:8080 \
-e ConnectionStrings__DefaultConnection="Host=host.docker.internal;Port=5432;Database=universe;Username=postgres;Password=postgres" \
universe-api
``` ```
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`. Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
## Запуск через Aspire ## Аутентификация
Aspire AppHost запускает API и Vite frontend вместе: - `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
- `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`
```bash Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
pnpm -C frontend install
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
```
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`. Большинство методов API защищены `[Authorize]`.
## Docker Compose ## Фоновый LLM-анализ отзывов
Production compose описан в `docker-compose-prod.yml`: Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
LLM-ключ задаётся через `Llm:ApiKey`.
- `app` - ASP.NET Core backend. Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
- `frontend` - собранный Vue frontend и Nginx.
- `db` - PostgreSQL.
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций: ## Интеграция с Modeus
```bash Эндпоинты синхронизации доступны только администратору:
docker compose -f docker-compose-prod.yml up -d
```
Тестовый compose находится в `docker-compose-test.yml`. - `POST /api/v1/sync/schedule`
- `POST /api/v1/sync/rooms`
- `POST /api/v1/sync/employees`
- `GET /api/v1/sync/status`
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
## Карта API (high-level)
Базовый префикс: `/api/v1`.
- `/auth` — логин/refresh/logout/me
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
- `/lectures` — лекции, записи, посещаемость, отзывы
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
- `/tags` — теги + дерево тегов
- `/locations` — аудитории/локации
- `/achievements` — достижения
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
Точные схемы запросов/ответов удобнее смотреть в Swagger.
## Тестирование ## Тестирование
Backend: В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
- **xUnit** в качестве основного фреймворка для тестирования.
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
Запуск тестов:
```bash ```bash
dotnet test backend/UniVerse.sln cd backend
dotnet test
``` ```
Frontend type-check и production build:
```bash
pnpm -C frontend build
```
Frontend E2E с mock API:
```bash
pnpm -C frontend test:e2e
```
Load testing helper:
```bash
node frontend/scripts/loadtest-endpoints.js
```
@@ -1,124 +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,
MandatoryAttendeesCount = 30,
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(32, 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);
}
}
@@ -47,9 +47,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
body: """{"displayName":"Test","avatarUrl":null}"""); body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student"); 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/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
yield return E("users/me/enrollments/calendar-subscription [AnyAuth]", "GET", "api/v1/users/me/enrollments/calendar-subscription", "Student");
yield return E("users/me/enrollments.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments.ics", "Student");
yield return E("users/me/enrollments/{id}.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments/1.ics", "Student");
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "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/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student"); yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
@@ -195,7 +192,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
// dev login доступен в окружении Development // dev login доступен в окружении Development
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev", yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" }; """{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
yield return new object[] { "users/calendar/enrollments/{token}.ics GET", "GET", "api/v1/users/calendar/enrollments/bad-token.ics" };
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации // refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge) // (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется // Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
@@ -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));
}
}
@@ -20,7 +20,6 @@ using UniVerse.Application.DTOs.Tags;
using UniVerse.Application.DTOs.Users; using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums; using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.Tests.Helpers; namespace UniVerse.Api.Tests.Helpers;
@@ -178,13 +177,6 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
3, 3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)])); [new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures); stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
stub.GetMyEnrollmentsIcsAsync(Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetEnrollmentIcsAsync(Arg.Any<int>(), Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetCalendarSubscriptionTokenAsync(Arg.Any<int>()).Returns("test-token");
stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token")
.Returns(Task.FromException<string>(new ForbiddenException("Invalid calendar subscription token.")));
stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is<string>(token => token != "bad-token"))
.Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"));
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers); stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask); stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
@@ -166,29 +166,6 @@ public class LectureServiceTests
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1)); Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
} }
[Fact]
public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
{
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 lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.MaxEnrollments = 31;
lecture.MandatoryAttendeesCount = 30;
db.Users.AddRange(
new User { Id = 1, Email = "first@test.local" },
new User { Id = 2, Email = "second@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
}
[Fact] [Fact]
public async Task UnenrollAsync_CancelsLectureReminders() public async Task UnenrollAsync_CancelsLectureReminders()
{ {
@@ -1,37 +0,0 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.RateLimiting;
public class RateLimitingTests
{
[Fact]
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
{
await using var factory = new ApiWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["RateLimiting:PermitLimit"] = "1",
["RateLimiting:WindowSeconds"] = "60",
["RateLimiting:QueueLimit"] = "0"
});
});
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
using var firstResponse = await client.GetAsync("api/v1/tags");
using var secondResponse = await client.GetAsync("api/v1/tags");
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
}
@@ -1,62 +0,0 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Infrastructure.ExternalServices;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ModeusApiClientTests
{
[Fact]
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://schedule.test")
};
var config = new ConfigurationBuilder().Build();
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
await client.SearchEventsAsync(new SyncScheduleRequest(
SpecialtyCode: ["09.03.04"],
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
TypeId: ["LECT"],
Size: 50));
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
Assert.NotNull(handler.RequestBody);
using var body = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
}
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpMethod? RequestMethod { get; private set; }
public string? RequestPathAndQuery { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestMethod = request.Method;
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"events":[]}""")
};
}
}
}
@@ -129,56 +129,6 @@ public class ScheduleSyncServiceTests
Assert.Equal(48, lecture.MaxEnrollments); Assert.Equal(48, lecture.MaxEnrollments);
} }
[Fact]
public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats()
{
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),
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
}
],
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: 120, WorkingCapacity: 120)
]
}
});
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(120, lecture.MaxEnrollments);
Assert.Equal(31, lecture.MandatoryAttendeesCount);
}
[Fact] [Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher() public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{ {
@@ -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);
}
}
@@ -9,12 +9,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.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>
@@ -1,13 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities; using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Services;
using Xunit; using Xunit;
@@ -75,175 +71,6 @@ public class UserServiceTests
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots)); 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);
}
[Fact]
public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc);
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();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1", ics);
}
[Fact]
public async Task CalendarSubscriptionToken_RejectsTamperedToken()
{
await using var db = CreateDbContext();
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
await db.SaveChangesAsync();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered));
}
[Fact]
public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1853, startsAt));
await db.SaveChangesAsync();
var service = CreateService(db);
var ics = await service.GetEnrollmentIcsAsync(1, 1853);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1853", ics);
}
private static AppDbContext CreateDbContext() private static AppDbContext CreateDbContext()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -266,13 +93,7 @@ public class UserServiceTests
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance); var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
var config = new ConfigurationBuilder() return new UserService(db, gamification);
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-calendar-subscription-secret-32chars"
})
.Build();
return new UserService(db, gamification, config);
} }
private static void SeedLevelThresholds(AppDbContext db) private static void SeedLevelThresholds(AppDbContext db)
@@ -294,31 +115,4 @@ public class UserServiceTests
IsOpen = true, IsOpen = true,
MaxEnrollments = 30 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()
};
} }
@@ -5,7 +5,6 @@ using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums; using UniVerse.Domain.Enums;
using System.Security.Claims; using System.Security.Claims;
using System.Text;
namespace UniVerse.Api.Controllers; namespace UniVerse.Api.Controllers;
@@ -84,52 +83,6 @@ public class UsersController : ControllerBase
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) => public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination)); Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
[HttpGet("me/enrollments/calendar-subscription")]
[ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<CalendarSubscriptionDto>> CalendarSubscription()
{
var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId);
var feedUrl = Url.Action(
nameof(CalendarEnrollmentsIcs),
null,
new { token },
Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics";
return Ok(new CalendarSubscriptionDto(feedUrl));
}
[AllowAnonymous]
[HttpGet("calendar/enrollments/{token}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<FileContentResult> CalendarEnrollmentsIcs(string token)
{
var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<FileContentResult> MyEnrollmentsIcs()
{
var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments/{lectureId:int}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<FileContentResult> EnrollmentIcs(int lectureId)
{
var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics");
}
/// <summary>Получить отзывы текущего пользователя.</summary> /// <summary>Получить отзывы текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param> /// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response> /// <response code="200">Список отзывов (пагинированный).</response>
@@ -206,19 +159,6 @@ public class UsersController : ControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id)); public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
/// <summary>Получить статистику для админского дашборда.</summary>
/// <remarks>Только Admin.</remarks>
/// <response code="200">Агрегированная статистика дашборда.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("admin/stats")]
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
Ok(await _users.GetAdminDashboardStatsAsync());
/// <summary>Получить список записей пользователя на лекции.</summary> /// <summary>Получить список записей пользователя на лекции.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks> /// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param> /// <param name="id">ID пользователя.</param>
@@ -1,71 +0,0 @@
using System.Net;
using System.Net.Sockets;
namespace UniVerse.Api.Middleware;
public sealed class LocalNetworksOnlyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var remoteIpAddress = context.Connection.RemoteIpAddress;
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
{
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
return;
}
await _next(context);
}
private static bool IsLocalNetwork(IPAddress ipAddress)
{
if (IPAddress.IsLoopback(ipAddress))
{
return true;
}
if (ipAddress.IsIPv4MappedToIPv6)
{
ipAddress = ipAddress.MapToIPv4();
}
return ipAddress.AddressFamily switch
{
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
_ => false
};
}
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return bytes[0] == 10
|| bytes[0] == 127
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 169 && bytes[1] == 254);
}
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return ipAddress.IsIPv6LinkLocal
|| ipAddress.IsIPv6SiteLocal
|| (bytes[0] & 0xfe) == 0xfc;
}
}
@@ -1,12 +0,0 @@
namespace UniVerse.Api.Options;
public class RateLimitingOptions
{
public const string SectionName = "RateLimiting";
public int PermitLimit { get; set; } = 600;
public int WindowSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 100;
}
+1 -70
View File
@@ -1,16 +1,11 @@
using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Prometheus;
using Quartz; using Quartz;
using Serilog; using Serilog;
using System.Threading.RateLimiting;
using UniVerse.Api.BackgroundServices; using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Filters; using UniVerse.Api.Filters;
using UniVerse.Api.Middleware; using UniVerse.Api.Middleware;
@@ -73,50 +68,6 @@ builder.Services.AddAuthentication(options =>
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddOptions<RateLimitingOptions>()
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
.Validate(options => options.PermitLimit >= 1,
"RateLimiting:PermitLimit must be greater than or equal to 1.")
.Validate(options => options.WindowSeconds >= 1,
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
.Validate(options => options.QueueLimit >= 0,
"RateLimiting:QueueLimit must be greater than or equal to 0.")
.ValidateOnStart();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
return RateLimitPartition.GetFixedWindowLimiter(
GetRateLimitPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitingOptions.PermitLimit,
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = rateLimitingOptions.QueueLimit,
AutoReplenishment = true
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/429",
title = "Too Many Requests",
status = StatusCodes.Status429TooManyRequests,
detail = "Rate limit exceeded. Please try again later.",
traceId = context.HttpContext.TraceIdentifier
}, cancellationToken);
};
});
// --- CORS --- // --- CORS ---
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -184,7 +135,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client => builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{ {
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru"); client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180)); client.Timeout = TimeSpan.FromSeconds(30);
}); });
// --- Background Services --- // --- Background Services ---
@@ -269,9 +220,7 @@ if (app.Environment.IsDevelopment())
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization(); app.UseAuthorization();
app.UseHttpMetrics();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseAntiforgery(); app.UseAntiforgery();
@@ -279,22 +228,4 @@ if (app.Environment.IsDevelopment())
} }
app.MapControllers(); app.MapControllers();
// Restrict Prometheus scrape endpoint to local and private networks.
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
app.MapMetrics();
app.Run(); app.Run();
static string GetRateLimitPartitionKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub");
if (!string.IsNullOrWhiteSpace(userId))
return $"user:{userId}";
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
}
+1 -2
View File
@@ -18,7 +18,7 @@
<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.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@@ -30,7 +30,6 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+1 -7
View File
@@ -13,11 +13,6 @@
"http://localhost:3000" "http://localhost:3000"
] ]
}, },
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"Llm": { "Llm": {
"BaseUrl": "https://api.openai.com/v1/", "BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "", "ApiKey": "",
@@ -28,8 +23,7 @@
}, },
"ModeusApi": { "ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru", "BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": "", "ApiKey": ""
"TimeoutSeconds": 180
}, },
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
-218
View File
@@ -3789,146 +3789,6 @@
] ]
} }
}, },
"/api/v1/users/me/enrollments/calendar-subscription": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarSubscriptionDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/calendar/enrollments/{token}.ics": {
"get": {
"tags": [
"Users"
],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"403": {
"description": "Forbidden",
"content": {
"text/calendar": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/users/me/enrollments.ics": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized — JWT token missing or invalid"
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/me/enrollments/{lectureId}.ics": {
"get": {
"tags": [
"Users"
],
"description": "**Required:** any authenticated user",
"parameters": [
{
"name": "lectureId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"404": {
"description": "Not Found",
"content": {
"text/calendar": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Unauthorized — JWT token missing or invalid"
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/me/reviews": { "/api/v1/users/me/reviews": {
"get": { "get": {
"tags": [ "tags": [
@@ -4298,52 +4158,6 @@
] ]
} }
}, },
"/api/v1/users/admin/stats": {
"get": {
"tags": [
"Users"
],
"summary": "Получить статистику для админского дашборда.",
"description": "Только Admin.\n\n**Required roles:** Admin",
"responses": {
"200": {
"description": "Агрегированная статистика дашборда.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminDashboardStatsDto"
}
}
}
},
"401": {
"description": "Требуется аутентификация.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Требуется роль Admin.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/{id}/enrollments": { "/api/v1/users/{id}/enrollments": {
"get": { "get": {
"tags": [ "tags": [
@@ -4944,28 +4758,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"AdminDashboardStatsDto": {
"type": "object",
"properties": {
"usersCount": {
"type": "integer",
"format": "int32"
},
"lecturesCount": {
"type": "integer",
"format": "int32"
},
"enrollmentsCount": {
"type": "integer",
"format": "int32"
},
"pendingReviewsCount": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"AuthResponse": { "AuthResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4983,16 +4775,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CalendarSubscriptionDto": {
"type": "object",
"properties": {
"feedUrl": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"CoinTransactionDto": { "CoinTransactionDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.4"> <Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -42,17 +42,8 @@ public record UserStatsDto(
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
); );
public record AdminDashboardStatsDto(
int UsersCount,
int LecturesCount,
int EnrollmentsCount,
int PendingReviewsCount
);
public record EnrollmentSlotRuleDto(int Level, int Slots); public record EnrollmentSlotRuleDto(int Level, int Slots);
public record CalendarSubscriptionDto(string FeedUrl);
public record UpdateUserRequest( public record UpdateUserRequest(
string? DisplayName, string? DisplayName,
string? AvatarUrl string? AvatarUrl
@@ -29,14 +29,11 @@ public class ModeusEvent
public string? TypeId { get; init; } public string? TypeId { get; init; }
public DateTime StartsAt { get; init; } public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; } public DateTime EndsAt { get; init; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")] [JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; } public ModeusEventLinks? Links { get; init; }
} }
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
public class ModeusEventLinks public class ModeusEventLinks
{ {
[JsonPropertyName("course-unit-realization")] [JsonPropertyName("course-unit-realization")]
@@ -10,12 +10,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<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination); Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<string> GetMyEnrollmentsIcsAsync(int userId);
Task<string> GetEnrollmentIcsAsync(int userId, int lectureId);
Task<string> GetCalendarSubscriptionTokenAsync(int userId);
Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter); Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles); Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
Task SetActiveAsync(int id, bool isActive); Task SetActiveAsync(int id, bool isActive);
@@ -13,9 +13,6 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions public static class MappingExtensions
{ {
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- 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,
@@ -60,7 +57,7 @@ public static class MappingExtensions
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.OccupiedSeatsCount(), lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
); );
@@ -70,7 +67,7 @@ public static class MappingExtensions
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.OccupiedSeatsCount(), lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
); );
@@ -6,37 +6,11 @@ public static class ReviewPromptTemplate
public const string ReviewTextPlaceholder = "{reviewText}"; public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """ public const string Default = """
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю. Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
- quality_score: число от 0 до 1, указывающее на качество отзыва;
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста: - sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
{ - tags: массив соответствующих тематических тегов;
"quality_score": 0.0, - is_informative: логическое значение, указывающее, является ли отзыв информативным.
"sentiment": "Нейтральный",
"tags": [],
"is_informative": false
}
Правила оценки:
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
Базовые теги:
- "структура лекции"
- "понятность объяснения"
- "темп"
- "сложность"
- "практические примеры"
- "материалы"
- "актуальность темы"
- "вовлеченность"
- "организация"
- "технические проблемы"
- "польза для обучения"
- "неинформативный отзыв"
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
Контекст лекции: {lectureContext} Контекст лекции: {lectureContext}
Текст отзыва: {reviewText} Текст отзыва: {reviewText}
@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<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.18.0" />
@@ -15,7 +15,6 @@ public class Lecture
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true; public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; } public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; } public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +22,6 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at"); builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true); builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0); builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255); builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500); builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -42,11 +42,12 @@ public class ModeusApiClient : IModeusApiClient
AddNonEmpty(body, "curriculumId", request.CurriculumId); AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId); AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body); var requestJson = JsonSerializer.Serialize(body);
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}"; await EnsureSuccessAsync(response, "Modeus events search",
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body); BuildEventsRequestSummary(requestJson));
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary); return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary) BuildEventsRequestSummary(requestJson))
?? new ModeusEventsResponse(); ?? new ModeusEventsResponse();
} }
@@ -97,6 +98,8 @@ public class ModeusApiClient : IModeusApiClient
response.StatusCode); response.StatusCode);
} }
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>( private static void AddNonEmpty<T>(
IDictionary<string, object?> body, IDictionary<string, object?> body,
string key, string key,
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MandatoryAttendeesCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "mandatory_attendees_count",
table: "lectures",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mandatory_attendees_count",
table: "lectures");
}
}
}
@@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
@@ -250,12 +250,6 @@ namespace UniVerse.Infrastructure.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("location_id"); .HasColumnName("location_id");
b.Property<int>("MandatoryAttendeesCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("mandatory_attendees_count");
b.Property<int>("MaxEnrollments") b.Property<int>("MaxEnrollments")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("integer") .HasColumnType("integer")
@@ -122,8 +122,7 @@ public class LectureService : ILectureService
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId); var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count; if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
throw new ConflictException("Lecture is full."); throw new ConflictException("Lecture is full.");
if (lecture.Enrollments.Any(e => e.UserId == userId)) if (lecture.Enrollments.Any(e => e.UserId == userId))
throw new ConflictException("Already enrolled."); throw new ConflictException("Already enrolled.");
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Text.Json;
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities; using UniVerse.Domain.Entities;
@@ -54,7 +55,6 @@ public class ScheduleSyncService : IScheduleSyncService
} }
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
var startsAt = EnsureUtc(ev.StartsAt); var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt); var endsAt = EnsureUtc(ev.EndsAt);
@@ -68,7 +68,6 @@ public class ScheduleSyncService : IScheduleSyncService
existing.LocationId = location?.Id; existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id; existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity; existing.MaxEnrollments = lectureCapacity;
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
updated++; updated++;
} }
@@ -92,8 +91,7 @@ public class ScheduleSyncService : IScheduleSyncService
ExternalId = ev.Id, ExternalId = ev.Id,
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
MaxEnrollments = lectureCapacity, MaxEnrollments = lectureCapacity
MandatoryAttendeesCount = mandatoryAttendeesCount
}); });
created++; created++;
} }
@@ -113,7 +111,7 @@ public class ScheduleSyncService : IScheduleSyncService
updated, updated,
skipped, skipped,
[ [
"endpoint=POST /api/ictis?includeCounts=true", $"requestJson={BuildScheduleRequestJson(request)}",
$"timeMin={request.TimeMin:O}", $"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}" $"timeMax={request.TimeMax:O}"
])); ]));
@@ -445,9 +443,6 @@ public class ScheduleSyncService : IScheduleSyncService
private static int? NormalizeCapacity(int? capacity) => private static int? NormalizeCapacity(int? capacity) =>
capacity is > 0 ? capacity : null; capacity is > 0 ? capacity : null;
private static int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
private static string BuildModeusTeacherEmail(string personId) => private static string BuildModeusTeacherEmail(string personId) =>
$"modeus-{personId}@modeus.local".ToLowerInvariant(); $"modeus-{personId}@modeus.local".ToLowerInvariant();
@@ -493,6 +488,37 @@ public class ScheduleSyncService : IScheduleSyncService
return details; return details;
} }
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var body = new Dictionary<string, object?>
{
["size"] = request.Size is > 0 ? request.Size.Value : 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax
};
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
return JsonSerializer.Serialize(body);
}
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static string? GetHrefId(string? href) private static string? GetHrefId(string? href)
{ {
if (string.IsNullOrWhiteSpace(href)) if (string.IsNullOrWhiteSpace(href))
@@ -1,12 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users; using UniVerse.Application.DTOs.Users;
@@ -21,20 +13,13 @@ namespace UniVerse.Infrastructure.Services;
public class UserService : IUserService public class UserService : IUserService
{ {
private const byte CalendarTokenVersion = 1;
private const int CalendarTokenPayloadLength = 5;
private const int CalendarTokenSignatureLength = 32;
private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1";
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IGamificationService _gamification; private readonly IGamificationService _gamification;
private readonly IConfiguration _config;
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config) public UserService(AppDbContext db, IGamificationService gamification)
{ {
_db = db; _db = db;
_gamification = gamification; _gamification = gamification;
_config = config;
} }
public async Task<UserDto> GetByIdAsync(int id) public async Task<UserDto> GetByIdAsync(int id)
@@ -89,17 +74,6 @@ public class UserService : IUserService
); );
} }
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
{
var usersCount = await _db.Users
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
var lecturesCount = await _db.Lectures.CountAsync();
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
}
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination) public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
{ {
if (!await _db.Users.AnyAsync(u => u.Id == id)) if (!await _db.Users.AnyAsync(u => u.Id == id))
@@ -130,154 +104,6 @@ public class UserService : IUserService
pagination.PageSize); pagination.PageSize);
} }
public async Task<string> GetMyEnrollmentsIcsAsync(int userId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
var lectures = await _db.LectureEnrollments
.Where(e => e.UserId == userId)
.Include(e => e.Lecture)
.ThenInclude(l => l.Teacher)
.Include(e => e.Lecture)
.ThenInclude(l => l.Location)
.OrderBy(e => e.Lecture.StartsAt)
.Select(e => e.Lecture)
.ToListAsync();
return BuildIcs(lectures, userId);
}
public async Task<string> GetEnrollmentIcsAsync(int userId, int lectureId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
var lecture = await _db.Lectures
.Include(l => l.Teacher)
.Include(l => l.Location)
.FirstOrDefaultAsync(l => l.Id == lectureId)
?? throw new NotFoundException("Lecture", lectureId);
return BuildIcs([lecture], userId);
}
public async Task<string> GetCalendarSubscriptionTokenAsync(int userId)
{
if (!await _db.Users.AnyAsync(u => u.Id == userId))
throw new NotFoundException("User", userId);
Span<byte> payload = stackalloc byte[CalendarTokenPayloadLength];
payload[0] = CalendarTokenVersion;
BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId);
var signature = SignCalendarTokenPayload(payload);
var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength];
payload.CopyTo(tokenBytes);
signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength));
return ToBase64Url(tokenBytes);
}
public async Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token)
{
var userId = ValidateCalendarSubscriptionToken(token);
return await GetMyEnrollmentsIcsAsync(userId);
}
private int ValidateCalendarSubscriptionToken(string token)
{
var tokenBytes = FromBase64Url(token);
if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength)
throw new ForbiddenException("Invalid calendar subscription token.");
var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength);
var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength);
if (payload[0] != CalendarTokenVersion)
throw new ForbiddenException("Invalid calendar subscription token.");
var expectedSignature = SignCalendarTokenPayload(payload);
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
throw new ForbiddenException("Invalid calendar subscription token.");
var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]);
if (userId <= 0)
throw new ForbiddenException("Invalid calendar subscription token.");
return userId;
}
private byte[] SignCalendarTokenPayload(ReadOnlySpan<byte> payload)
{
var calendarKey = DeriveCalendarTokenKey();
return HMACSHA256.HashData(calendarKey, payload);
}
private byte[] DeriveCalendarTokenKey()
{
var jwtSecret = _config["Jwt:Secret"];
if (string.IsNullOrWhiteSpace(jwtSecret))
throw new InvalidOperationException("Jwt:Secret is not configured.");
return HMACSHA256.HashData(
Encoding.UTF8.GetBytes(jwtSecret),
Encoding.UTF8.GetBytes(CalendarTokenKeyContext));
}
private static string ToBase64Url(ReadOnlySpan<byte> bytes) =>
Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static byte[] FromBase64Url(string value)
{
try
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
catch (FormatException)
{
throw new ForbiddenException("Invalid calendar subscription token.");
}
}
private static string BuildIcs(List<Domain.Entities.Lecture> lectures, int userId)
{
var calendar = new Calendar
{
Method = "PUBLISH",
ProductId = "-//UniVerse//Lectures Calendar//EN"
};
foreach (var lecture in lectures)
{
var location = lecture.Location is null
? string.Empty
: $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}";
var teacherName = lecture.Teacher?.DisplayName
?? lecture.Teacher?.Email
?? "не указан";
calendar.Events.Add(new CalendarEvent
{
Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local",
Summary = lecture.Title,
Description = $"{lecture.Description}\nПреподаватель: {teacherName}",
Location = location,
DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)),
DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)),
DtStamp = new CalDateTime(DateTime.UtcNow)
});
}
return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty;
}
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter) public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
{ {
var query = _db.Users.AsQueryable(); var query = _db.Users.AsQueryable();
@@ -9,11 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.84.0" /> <PackageReference Include="Microsoft.Identity.Client" Version="4.84.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Ical.Net" Version="5.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
-4
View File
@@ -26,10 +26,6 @@ services:
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} - Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
- Llm:BaseUrl=${LLM_BASE_URL} - Llm:BaseUrl=${LLM_BASE_URL}
- Llm:ApiKey=${LLM_API_KEY} - Llm:ApiKey=${LLM_API_KEY}
- Llm:Model=${LLM_MODEL} - Llm:Model=${LLM_MODEL}
-143
View File
@@ -1,143 +0,0 @@
# Backend unit-тесты
## Назначение
Unit- и service-тесты backend проверяют бизнес-логику без запуска HTTP API:
- доменные правила записи на лекции;
- преобразование сущностей в DTO;
- фильтрацию, пагинацию и связи курсов;
- дерево тегов;
- управление ролями и профилями пользователей.
Security-тесты авторизации находятся в том же тестовом проекте, но это отдельный интеграционный набор: они запускают API через `WebApplicationFactory` и проверяют HTTP-доступ к endpoint-ам.
## Где лежат файлы
- [EnrollmentSlotPolicyTests.cs](../backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs) - правила лимита активных записей по уровню.
- [MappingExtensionsTests.cs](../backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs) - маппинг доменных сущностей в DTO.
- [CourseServiceTests.cs](../backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs) - фильтры, пагинация и теги курсов.
- [TagServiceTests.cs](../backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs) - фильтры тегов и построение дерева.
- [UserServiceTests.cs](../backend/UniVerse.Api.Tests/Users/UserServiceTests.cs) - статистика, роли, профили и список пользователей.
- [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs) - security-тесты ролевого доступа к API.
- [ApiWebApplicationFactory.cs](../backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs) - тестовый запуск API.
- [TestJwtFactory.cs](../backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs) - генерация JWT для ролей в security-тестах.
Тестовый проект: [UniVerse.Api.Tests.csproj](../backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj).
## Тестовый стек
- `xUnit` - test runner и assertions.
- `NSubstitute` - mock-объекты для сервисных зависимостей.
- `Microsoft.EntityFrameworkCore.InMemory` - изолированная InMemory БД для service-тестов.
Каждый service-тест создает отдельный `AppDbContext` с уникальным именем базы через `Guid.NewGuid()`, чтобы данные разных тестов не пересекались.
## Что покрыто
### EnrollmentSlotPolicy
Проверяется, что `GetLimitForLevel` выбирает последний подходящий threshold:
- уровни ниже первого правила получают базовый лимит;
- уровни между threshold используют предыдущий лимит;
- уровни выше последнего threshold используют максимальный лимит;
- публичный список `Rules` остается в ожидаемом порядке.
### MappingExtensions
Проверяется стабильность DTO-маппинга:
- роли пользователя сортируются одинаково в `UserDto`, `CurrentUserDto` и `UserAuthDto`;
- лекции корректно считают записи и используют fallback для отсутствующих navigation properties;
- отзывы переносят поля LLM-анализа;
- дерево тегов маппится рекурсивно.
### CourseService
Проверяется поведение без HTTP-слоя:
- совместная работа фильтров `Search`, `IsSynced`, `TagId`;
- корректные `TotalCount`, `Page`, `PageSize`, `TotalPages`;
- добавление связи курс-тег;
- ошибки при повторной связи или отсутствующем курсе/теге.
### TagService
Проверяется:
- фильтрация по `TagType` и `ParentId`;
- сортировка по имени;
- запрет создания дочернего тега без существующего родителя;
- построение вложенного дерева тегов.
### UserService
Проверяется:
- статистика пользователя, прогресс уровня и лимиты записей;
- `SetRolesAsync` удаляет дубли ролей;
- пустой набор ролей отклоняется;
- профили студента и преподавателя создаются и не дублируются;
- `GetAllAsync` фильтрует по поиску, активности и одиночной роли;
- пагинация пользователей идет в порядке `CreatedAt` по убыванию.
## Security-тесты авторизации
Security-тесты находятся в [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs). Это интеграционные тесты, которые отправляют реальные HTTP-запросы в тестовый API через `ApiWebApplicationFactory`.
Они проверяют не бизнес-результат endpoint-а, а сам факт прохождения или блокировки авторизации:
- анонимный запрос к защищенному endpoint-у получает `401 Unauthorized`;
- запрос с неподходящей ролью получает `403 Forbidden`;
- запрос с подходящей ролью не получает `401` или `403`;
- публичные endpoint-ы из `AnonymousEndpoints` доступны без JWT и не возвращают `401` от middleware авторизации.
Таблица защищенных endpoint-ов задается в методе `AuthenticatedEndpoints`. Каждый кейс описывает:
- человекочитаемое имя сценария;
- HTTP-метод;
- URL;
- роль, которая должна пройти авторизацию;
- роли, которые должны получить `403`;
- опциональное JSON-тело запроса.
Для endpoint-ов, доступных любой авторизованной роли, используется обычная тестовая роль, чаще `Student`, и пустой список запрещенных ролей. Для endpoint-ов с несколькими разрешенными ролями добавляется отдельный кейс на каждую разрешенную роль, например `Admin` и `Teacher`.
JWT для ролей создаются через `TestJwtFactory.BearerHeader(role)`. Это позволяет проверять backend-авторизацию без Microsoft OAuth flow и без реального входа пользователя.
## Как обновлять security-тесты
При добавлении или изменении API endpoint-а нужно обновить `EndpointAuthorizationTests`:
1. Если endpoint требует авторизации, добавьте его в `AuthenticatedEndpoints`.
2. Укажите правильную роль или отдельные кейсы для нескольких ролей.
3. Для role-specific endpoint-а заполните `forbidden` ролями, которые должны получать `403`.
4. Если endpoint публичный, добавьте его в `AnonymousEndpoints`.
5. Для `POST`, `PUT`, `PATCH` endpoint-ов добавьте минимальное валидное тело запроса, чтобы тест дошел до авторизации и не падал на model binding раньше времени.
Security-тест считается успешным для правильной роли, если ответ не `401` и не `403`. Это намеренно: после авторизации endpoint может вернуть `404`, `400`, `409` или другой доменный ответ из-за тестовых данных, и это не является ошибкой проверки доступа.
## Как запускать
Из корня репозитория:
```bash
dotnet test backend/UniVerse.sln --no-restore
```
## Как добавлять новые unit/service-тесты
1. Размещайте тесты рядом с проверяемой областью внутри `backend/UniVerse.Api.Tests`.
2. Для сервисов с EF используйте InMemory `AppDbContext` с уникальным именем базы.
3. Мокайте только внешние зависимости и соседние сервисы через `NSubstitute`.
4. Не запускайте `WebApplicationFactory`, если проверяется не HTTP/auth behavior.
5. Покрывайте не только успешный сценарий, но и доменные ошибки: `NotFoundException`, `ConflictException`, `ForbiddenException`.
## Текущий baseline
После добавления unit/service-тестов и с учетом существующих security-тестов полный backend test suite проходит:
```text
Passed: 303, Failed: 0, Skipped: 0
```
-120
View File
@@ -1,120 +0,0 @@
# Отчет по нагрузочному тестированию k6
Дата отчета: 2026-05-28
## Объект тестирования
- Стенд: `https://universe.zetcraft.ru`
- Скрипт: [`frontend/scripts/loadtest-endpoints.js`](../frontend/scripts/loadtest-endpoints.js)
- Endpoint'ы:
- `GET /api/v1/courses`
- `GET /api/v1/lectures`
- `GET /api/v1/users/me/stats`
## Профиль нагрузки
Тест запускался в 3 параллельных сценариях:
- `courses_list`
- `lectures_list`
- `user_stats`
Для каждого сценария использовалось `30 VU`, итого максимум `90 VU`.
Длительность активной нагрузки каждого сценария: `15s`.
С учетом `gracefulStop` максимальная длительность выполнения составила `45s`.
## Оборудование и сеть
Тест запускался с машины со следующей конфигурацией:
- CPU: AMD Ryzen 7 8845HS, `10` потоков использовалось для нагрузки.
- RAM: DDR5 5600, `10 GB` доступно.
- Накопитель: NVMe SSD.
- Сеть: `1 Gbit/s`.
## Критерии прохождения
- `checks: rate > 0.95`
- `http_req_duration: p(95) < 1500ms`
- `http_req_failed: rate < 0.01`
## Прогон 1: без паузы между итерациями
Команда запуска:
```bash
BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js
```
### Итоги
| Метрика | Значение |
| --- | ---: |
| Статус threshold'ов | пройдено |
| Успешность checks | 100.00% |
| Ошибки HTTP | 0.00% |
| Всего HTTP-запросов | 3508 |
| RPS | 77.95 req/s |
| `http_req_duration` avg | 769.41ms |
| `http_req_duration` med | 38.67ms |
| `http_req_duration` p(90) | 66.61ms |
| `http_req_duration` p(95) | 93.63ms |
| `http_req_duration` max | 36.14s |
| Всего итераций | 3508 |
| Прерванные итерации | 19 |
| Получено данных | 47 MB |
| Отправлено данных | 2.1 MB |
Проверки:
- `status is 200`: успешно.
- `body is not empty`: успешно.
## Прогон 2: пауза 1 секунда между итерациями
Команда запуска:
```bash
BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=1 k6 run ./frontend/scripts/loadtest-endpoints.js
```
### Итоги
| Метрика | Значение |
| --- | ---: |
| Статус threshold'ов | пройдено |
| Успешность checks | 100.00% |
| Ошибки HTTP | 0.00% |
| Всего HTTP-запросов | 895 |
| RPS | 19.89 req/s |
| `http_req_duration` avg | 336.11ms |
| `http_req_duration` med | 11.77ms |
| `http_req_duration` p(90) | 32.01ms |
| `http_req_duration` p(95) | 42.19ms |
| `http_req_duration` max | 35.9s |
| Всего итераций | 895 |
| Прерванные итерации | 43 |
| Получено данных | 12 MB |
| Отправлено данных | 675 kB |
Проверки:
- `status is 200`: успешно.
- `body is not empty`: успешно.
## Сравнение прогонов
| Параметр | Без паузы | Пауза 1s |
| --- | ---: | ---: |
| HTTP-запросов | 3508 | 895 |
| RPS | 77.95 req/s | 19.89 req/s |
| Ошибки HTTP | 0.00% | 0.00% |
| Checks | 100.00% | 100.00% |
| p(95) | 93.63ms | 42.19ms |
| Максимальная задержка | 36.14s | 35.9s |
## Вывод
Оба прогона успешно прошли заданные threshold'ы: ошибок HTTP не зафиксировано, все проверки ответов успешны, `p(95)` существенно ниже порога `1500ms`.
При запуске без паузы стенд обработал около `77.95 req/s`, при паузе `1s` - около `19.89 req/s`. Во всех прогонах наблюдались единичные длинные запросы до `35-36s`, при этом они не повлияли на прохождение p95-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек.
-94
View File
@@ -1,94 +0,0 @@
# Базовый нагрузочный тест (k6) для 3 крупных GET endpoint'ов
## Цель теста
Проверить, что при небольшой параллельной нагрузке API:
- отвечает без ошибок;
- сохраняет приемлемую задержку на «тяжелых» чтениях;
- не падает на endpoint пользовательской статистики.
Тест рассчитан на новичка: один скрипт, простые пороги, быстрый запуск.
## Какие endpoint используются
В тест включены:
1. `GET /api/v1/courses` — крупный список данных.
2. `GET /api/v1/lectures` — крупный список данных.
3. `GET /api/v1/users/me/stats` — endpoint с информацией о пользователе.
## Файл теста
- [loadtest-endpoints.js](../frontend/scripts/loadtest-endpoints.js)
## Предусловия перед запуском
1. Запущен API (локально или на тестовом стенде).
2. Если endpoint'ы требуют авторизацию — есть валидный JWT токен.
3. Установлен k6.
## Запуск
Без параметров (локальный API по умолчанию `http://localhost:5019`):
```bash
k6 run ./frontend/scripts/loadtest-endpoints.js
```
С параметрами окружения:
```bash
export TOKEN="<jwt>"
BASE_URL="http://localhost:5019" VUS=15 DURATION="2m" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js
```
## Что именно делает тест
Скрипт запускает **3 параллельных сценария**:
- `courses_list`
- `lectures_list`
- `user_stats`
Параметры каждого сценария:
- executor: `constant-vus`
- нагрузка: `10 VU`
- длительность: `2m`
- пауза между итерациями: `sleep(0.5)`
Итого базовый запуск создает до `30 VU` одновременно: по `10 VU` на каждый из 3 сценариев.
Переменные окружения:
- `VUS` — количество VU на каждый сценарий, по умолчанию `10`.
- `DURATION` — длительность каждого сценария, по умолчанию `2m`.
- `PAUSE_SECONDS` — пауза между итерациями, по умолчанию `0.5`.
На каждом запросе проверяется:
- статус ответа `200`;
- тело ответа не пустое.
## Пороговые значения (pass/fail)
- `http_req_failed: rate < 0.01` — ошибок менее 1%.
- `http_req_duration: p(95) < 1500` — 95% запросов быстрее 1.5с.
- `checks: rate > 0.95` — минимум 95% проверок успешны.
Если любой threshold не выполнен, k6 завершит запуск как failed.
## Как интерпретировать результат
После прогона посмотрите в summary:
1. `http_req_failed` — если выше 1%, есть проблема со стабильностью.
2. `http_req_duration p(95)` — если выше 1500ms, есть деградация по задержке.
3. `checks` — если ниже 95%, часть ответов не прошла базовую валидацию.
Минимальный формальный вывод для отчета:
- «Проведен базовый нагрузочный прогон k6 (3 endpoint'а, 10 VU на сценарий, 5 минут).»
- «Критерии: ошибки < 1%, p95 < 1500ms, checks > 95%.»
- «Статус: пройдено / не пройдено по итогам summary.»
-111
View File
@@ -1,111 +0,0 @@
# Playwright E2E тесты frontend
## Назначение
Playwright-тесты проверяют ключевые браузерные сценарии frontend-приложения UniVerse:
- редирект неавторизованного пользователя на страницу входа;
- отображение каталога открытых лекций;
- запись на доступную лекцию.
Тесты работают поверх production preview сборки frontend и используют mock API, поэтому для базового запуска не нужен поднятый backend.
## Где лежат файлы
- [playwright.config.ts](../frontend/playwright.config.ts) - конфигурация Playwright.
- [auth.spec.ts](../frontend/tests/e2e/auth.spec.ts) - сценарии аутентификации.
- [catalog.spec.ts](../frontend/tests/e2e/catalog.spec.ts) - сценарии каталога лекций.
- [mockApi.ts](../frontend/tests/e2e/support/mockApi.ts) - перехват и mock ответов API.
- [fixtures.ts](../frontend/tests/mocks/fixtures.ts) - тестовые данные.
## Как устроен запуск
Конфигурация находится во `frontend/playwright.config.ts`.
Основные параметры:
- `testDir: ./tests/e2e` - Playwright ищет тесты в папке `frontend/tests/e2e`.
- `baseURL: http://127.0.0.1:4173` - базовый адрес приложения в тестах.
- `webServer` запускает `pnpm preview --host 127.0.0.1 --port 4173`.
- В CI включены `2` retry и GitHub reporter.
- Локально используется list reporter.
- По умолчанию проект запускается в Chromium.
## Команды
Запуск всех E2E-тестов из корня репозитория:
```bash
pnpm -C frontend test:e2e
```
Интерактивный UI Playwright:
```bash
pnpm -C frontend test:e2e:ui
```
Если нужно вручную поднять preview-сервер:
```bash
pnpm -C frontend build-only
pnpm -C frontend test:e2e:preview
```
После этого можно запускать Playwright с переменной `PW_SKIP_WEB_SERVER=1`, чтобы он не стартовал свой `webServer`.
## Mock API
Тесты не обращаются к реальному backend. Вместо этого helper `mockApi(page, options)` перехватывает запросы к `/api/v1` через `page.route`.
Сейчас замоканы:
- `POST/GET /api/v1/auth/refresh` - refresh авторизации;
- `/api/v1/auth/me` - текущий пользователь;
- `/api/v1/users/me/stats` - статистика студента;
- `/api/v1/lectures` - список лекций;
- `/api/v1/lectures/{id}/enroll` - запись на лекцию.
Для авторизованного сценария используйте:
```ts
await mockApi(page, { authenticated: true })
```
Для проверки гостевого сценария:
```ts
await mockApi(page, { authenticated: false })
```
## Как добавлять новые тесты
1. Создавайте spec-файлы в `frontend/tests/e2e`.
2. Для страниц, которым нужен backend, сначала добавляйте нужные ответы в `mockApi.ts` и данные в `fixtures.ts`.
3. Проверяйте пользовательский результат через role/text/label locators: `getByRole`, `getByText`, `getByLabel`.
4. Не завязывайтесь на CSS-классы, если сценарий можно проверить через доступные пользователю элементы.
5. Для маршрутов под авторизацией вызывайте `mockApi(page, { authenticated: true })` до `page.goto(...)`.
## CI
Workflow находится в [.gitea/workflows/frontend-playwright.yml](../.gitea/workflows/frontend-playwright.yml).
Пайплайн:
1. Устанавливает зависимости через `pnpm install --frozen-lockfile`.
2. Собирает frontend командой `pnpm build-only`.
3. Устанавливает браузер Playwright Chromium.
4. Запускает `pnpm test:e2e`.
## Артефакты и отладка
Playwright сохраняет trace на первом retry: `trace: on-first-retry`.
Локально полезные команды:
```bash
pnpm -C frontend exec playwright show-report
pnpm -C frontend exec playwright show-trace ./test-results/<папка>/trace.zip
```
Папки `frontend/test-results` и `frontend/playwright-report` считаются временными артефактами тестовых прогонов.
-5
View File
@@ -35,10 +35,5 @@ coverage
# Vitest # Vitest
__screenshots__/ __screenshots__/
# Playwright
/test-results/
/playwright-report/
/blob-report/
# Vite # Vite
*.timestamp-*-*.mjs *.timestamp-*-*.mjs
+1 -3
View File
@@ -5,8 +5,6 @@ import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat' import skipFormatting from 'eslint-config-prettier/flat'
import vueScopedCss from 'eslint-plugin-vue-scoped-css' import vueScopedCss from 'eslint-plugin-vue-scoped-css'
type VueTsConfig = Parameters<typeof defineConfigWithVueTs>[number]
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: // To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript' // import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) // configureVueProject({ scriptLangs: ['ts', 'tsx'] })
@@ -22,7 +20,7 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'], ...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended, vueTsConfigs.recommended,
...(vueScopedCss.configs.recommended as VueTsConfig[]), ...vueScopedCss.configs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
+2 -7
View File
@@ -13,11 +13,7 @@
"lint": "run-s lint:*", "lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix", "lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache", "lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/", "format": "prettier --write --experimental-cli src/"
"test:e2e:preview": "vite preview --host 127.0.0.1 --port 4173",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:ui:edge": "PW_USE_SYSTEM_EDGE=1 PW_SKIP_WEB_SERVER=1 playwright test --ui --timeout=0 --workers=1"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -42,8 +38,7 @@
"typescript": "~6.0.0", "typescript": "~6.0.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1", "vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6", "vue-tsc": "^3.2.6"
"@playwright/test": "^1.55.1"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
-32
View File
@@ -1,32 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
const useSystemEdge = process.env.PW_USE_SYSTEM_EDGE === '1'
const skipWebServer = process.env.PW_SKIP_WEB_SERVER === '1'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: skipWebServer
? undefined
: {
command: 'pnpm preview --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
cwd: '.',
},
projects: [
{
name: useSystemEdge ? 'msedge' : 'chromium',
use: {
...devices['Desktop Chrome'],
...(useSystemEdge ? { channel: 'msedge' } : {}),
},
},
],
})
+4 -42
View File
@@ -18,9 +18,6 @@ importers:
specifier: ^5.0.6 specifier: ^5.0.6
version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.55.1
version: 1.60.0
'@tsconfig/node24': '@tsconfig/node24':
specifier: ^24.0.4 specifier: ^24.0.4
version: 24.0.4 version: 24.0.4
@@ -436,11 +433,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -787,8 +779,8 @@ packages:
boolbase@1.0.0: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.6: brace-expansion@5.0.5:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -1016,11 +1008,6 @@ packages:
flatted@3.4.2: flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1360,16 +1347,6 @@ packages:
pkg-types@2.3.1: pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
postcss-safe-parser@7.0.1: postcss-safe-parser@7.0.1:
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
engines: {node: '>=18.0'} engines: {node: '>=18.0'}
@@ -2034,10 +2011,6 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.60.0': '@oxlint/binding-win32-x64-msvc@1.60.0':
optional: true optional: true
'@playwright/test@1.60.0':
dependencies:
playwright: 1.60.0
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@rolldown/binding-android-arm64@1.0.0-rc.17': '@rolldown/binding-android-arm64@1.0.0-rc.17':
@@ -2414,7 +2387,7 @@ snapshots:
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@5.0.6: brace-expansion@5.0.5:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -2633,9 +2606,6 @@ snapshots:
flatted@3.4.2: {} flatted@3.4.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2792,7 +2762,7 @@ snapshots:
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.6 brace-expansion: 5.0.5
mitt@3.0.1: {} mitt@3.0.1: {}
@@ -2919,14 +2889,6 @@ snapshots:
exsolve: 1.0.8 exsolve: 1.0.8
pathe: 2.0.3 pathe: 2.0.3
playwright-core@1.60.0: {}
playwright@1.60.0:
dependencies:
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
postcss-safe-parser@7.0.1(postcss@8.5.14): postcss-safe-parser@7.0.1(postcss@8.5.14):
dependencies: dependencies:
postcss: 8.5.14 postcss: 8.5.14
-66
View File
@@ -1,66 +0,0 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5019';
const TOKEN = __ENV.TOKEN || '';
const VUS = Number(__ENV.VUS || 10);
const DURATION = __ENV.DURATION || '2m';
const PAUSE_SECONDS = Number(__ENV.PAUSE_SECONDS || 0.5);
const headers = TOKEN
? { Authorization: `Bearer ${TOKEN}` }
: {};
export const options = {
scenarios: {
courses_list: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'coursesList',
},
lectures_list: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'lecturesList',
},
user_stats: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'userStats',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1500'],
checks: ['rate>0.95'],
},
};
function request(path, tag) {
const res = http.get(`${BASE_URL}${path}`, {
headers,
tags: { endpoint: tag },
});
check(res, {
'status is 200': (r) => r.status === 200,
'body is not empty': (r) => (r.body || '').length > 0,
});
sleep(PAUSE_SECONDS);
}
export function coursesList() {
request('/api/v1/courses?page=1&pageSize=50', 'courses_list');
}
export function lecturesList() {
request('/api/v1/lectures?page=1&pageSize=50', 'lectures_list');
}
export function userStats() {
request('/api/v1/users/me/stats', 'user_stats');
}
-27
View File
@@ -81,30 +81,3 @@ export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[]
if (Array.isArray(payload)) return payload if (Array.isArray(payload)) return payload
return payload?.items ?? [] return payload?.items ?? []
} }
export async function apiRequestBlob(
path: string,
options: RequestInit & { query?: Record<string, unknown> } = {},
): Promise<Blob> {
const headers = new Headers(options.headers)
if (!headers.has('Accept')) headers.set('Accept', 'text/calendar')
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
const response = await fetch(makeUrl(path, options.query), {
...options,
headers,
credentials: 'include',
})
if (!response.ok) {
const body = await parseResponse(response)
const message =
typeof body === 'object' && body && 'message' in body
? String((body as { message: unknown }).message)
: `API request failed with status ${response.status}`
throw new ApiError(message, response.status, body)
}
return response.blob()
}
+1 -9
View File
@@ -1,4 +1,4 @@
import { apiRequest, apiRequestBlob, extractItems } from './client' import { apiRequest, extractItems } from './client'
import type { import type {
AchievementDto, AchievementDto,
AuthResponse, AuthResponse,
@@ -18,8 +18,6 @@ import type {
TagDto, TagDto,
UpdateReviewPromptRequest, UpdateReviewPromptRequest,
UserAchievementDto, UserAchievementDto,
AdminDashboardStatsDto,
CalendarSubscriptionDto,
CurrentUserDto, CurrentUserDto,
UserDto, UserDto,
UserQuery, UserQuery,
@@ -70,18 +68,12 @@ export const usersApi = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'), myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
async myEnrollments() { async myEnrollments() {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>( const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
'/users/me/enrollments', '/users/me/enrollments',
) )
return extractItems(payload) return extractItems(payload)
}, },
downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'),
downloadEnrollmentIcs: (lectureId: string | number) =>
apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`),
getCalendarSubscription: () =>
apiRequest<CalendarSubscriptionDto>('/users/me/enrollments/calendar-subscription'),
async myAchievements() { async myAchievements() {
const payload = await apiRequest< const payload = await apiRequest<
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[] PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
-11
View File
@@ -76,17 +76,6 @@ export interface UserStatsDto {
enrollmentSlotRules: EnrollmentSlotRuleDto[] enrollmentSlotRules: EnrollmentSlotRuleDto[]
} }
export interface AdminDashboardStatsDto {
usersCount: number
lecturesCount: number
enrollmentsCount: number
pendingReviewsCount: number
}
export interface CalendarSubscriptionDto {
feedUrl: string
}
export interface EnrollmentSlotRuleDto { export interface EnrollmentSlotRuleDto {
level: number level: number
slots: number slots: number
+5 -13
View File
@@ -1,16 +1,8 @@
<script setup lang="ts" generic="TRow extends object"> <script setup lang="ts">
type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' }
defineProps<{ defineProps<{
columns: Column[] columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
rows: TRow[] rows: Record<string, unknown>[]
}>() }>()
defineSlots<Record<string, (props: { row: TRow; value: unknown }) => unknown>>()
function getCell(row: TRow, key: string): unknown {
return (row as Record<string, unknown>)[key]
}
</script> </script>
<template> <template>
@@ -26,8 +18,8 @@ function getCell(row: TRow, key: string): unknown {
<tbody> <tbody>
<tr v-for="(row, i) in rows" :key="i"> <tr v-for="(row, i) in rows" :key="i">
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`"> <td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
<slot :name="col.key" :row="row" :value="getCell(row, col.key)"> <slot :name="col.key" :row="row" :value="row[col.key]">
{{ getCell(row, col.key) }} {{ row[col.key] }}
</slot> </slot>
</td> </td>
</tr> </tr>
-10
View File
@@ -1,10 +0,0 @@
export function downloadFile(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
+23 -14
View File
@@ -3,11 +3,17 @@ import { computed, onMounted, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue' import StatsWidget from '@/components/ui/StatsWidget.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue' import StatusBadge from '@/components/ui/StatusBadge.vue'
import { syncApi, usersApi } from '@/api' import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types' import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
const stats = ref<AdminDashboardStatsDto | null>(null) const users = ref<UserDto[]>([])
const lectures = ref<LectureDto[]>([])
const pendingReviewsCount = ref(0)
const syncStatus = ref<SyncStatusDto | null>(null) const syncStatus = ref<SyncStatusDto | null>(null)
const enrollmentCount = computed(() =>
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
)
const syncMeta = computed(() => const syncMeta = computed(() =>
syncStatus.value?.lastSyncAt syncStatus.value?.lastSyncAt
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}` ? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
@@ -15,8 +21,16 @@ const syncMeta = computed(() =>
) )
onMounted(async () => { onMounted(async () => {
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()]) const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
if (statsResult.status === 'fulfilled') stats.value = statsResult.value usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }),
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
syncApi.status(),
])
if (usersResult.status === 'fulfilled') users.value = usersResult.value
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (reviewsResult.status === 'fulfilled')
pendingReviewsCount.value = reviewsResult.value.totalCount
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
}) })
</script> </script>
@@ -26,17 +40,12 @@ onMounted(async () => {
<h1 class="page-title">Дашборд администратора</h1> <h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row"> <div class="stats-row">
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" /> <StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" /> <StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<StatsWidget <StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
label="Записей"
:value="stats?.enrollmentsCount ?? 0"
icon="calendar-event"
color="orange"
/>
<StatsWidget <StatsWidget
label="Отзывы на проверке" label="Отзывы на проверке"
:value="stats?.pendingReviewsCount ?? 0" :value="pendingReviewsCount"
icon="message-circle" icon="message-circle"
color="purple" color="purple"
/> />
@@ -17,10 +17,9 @@ import EmptyState from '@/components/ui/EmptyState.vue'
import CreateLectureModal from '@/components/admin/CreateLectureModal.vue' import CreateLectureModal from '@/components/admin/CreateLectureModal.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags' type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
type TabConfig = { type TabConfig = {
title: string title: string
columns: DataTableColumn[] columns: Array<{ key: string; label: string; align?: string }>
rows: Record<string, unknown>[] rows: Record<string, unknown>[]
} }
@@ -162,7 +161,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
}, },
} }
const current = computed<TabConfig>(() => { const current = computed(() => {
const config = tabConfig[activeTab.value] const config = tabConfig[activeTab.value]
if (activeTab.value === 'lectures') { if (activeTab.value === 'lectures') {
return { return {
@@ -7,9 +7,7 @@ import EmptyState from '@/components/ui/EmptyState.vue'
import { reviewsApi } from '@/api' import { reviewsApi } from '@/api'
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types' import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } const columns = [
const columns: DataTableColumn[] = [
{ key: 'id', label: 'ID' }, { key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' }, { key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' }, { key: 'student', label: 'Студент' },
@@ -297,8 +295,8 @@ onMounted(() => {
</div> </div>
</div> </div>
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #text="{ row }"> <template #text="{ value }">
<span class="review-text" :title="row.text">{{ row.text }}</span> <span class="review-text" :title="value">{{ value }}</span>
</template> </template>
<template #analysis="{ row }"> <template #analysis="{ row }">
<div class="analysis-cell"> <div class="analysis-cell">
+1 -3
View File
@@ -13,9 +13,7 @@ const users = ref<UserDto[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } const columns = [
const columns: DataTableColumn[] = [
{ key: 'name', label: 'Имя' }, { key: 'name', label: 'Имя' },
{ key: 'email', label: 'Email' }, { key: 'email', label: 'Email' },
{ key: 'role', label: 'Роль', align: 'center' }, { key: 'role', label: 'Роль', align: 'center' },
+1 -3
View File
@@ -11,8 +11,6 @@ import ModalDialog from '@/components/ui/ModalDialog.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue' import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
const lecturesStore = useLecturesStore() const lecturesStore = useLecturesStore()
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const search = ref('') const search = ref('')
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards') const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
const dateFilter = ref('Любая дата') const dateFilter = ref('Любая дата')
@@ -104,7 +102,7 @@ const appliedFilters = computed(() => {
return filters return filters
}) })
const tableColumns: DataTableColumn[] = [ const tableColumns = [
{ key: 'title', label: 'Лекция' }, { key: 'title', label: 'Лекция' },
{ key: 'teacher', label: 'Преподаватель' }, { key: 'teacher', label: 'Преподаватель' },
{ key: 'date', label: 'Дата' }, { key: 'date', label: 'Дата' },
+1 -13
View File
@@ -2,8 +2,6 @@
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -68,16 +66,6 @@ const levelProgressText = computed(() =>
: `${userXp.value} XP`, : `${userXp.value} XP`,
) )
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(), lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
@@ -137,7 +125,7 @@ async function registerLecture(id: string) {
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)"> <button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть Открыть
</button> </button>
<button class="btn-secondary" @click="downloadLectureIcs(nextLecture.id)">Скачать .ics</button> <button class="btn-secondary">Добавить в календарь</button>
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -39,16 +37,6 @@ onMounted(async () => {
await lecturesStore.fetchLecture(lectureId.value) await lecturesStore.fetchLecture(lectureId.value)
}) })
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
async function registerLecture() { async function registerLecture() {
if (!lecture.value) return if (!lecture.value) return
if (slotRegistrationDisabled.value) { if (slotRegistrationDisabled.value) {
@@ -102,7 +90,7 @@ async function registerLecture() {
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)"> <button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
Отменить запись Отменить запись
</button> </button>
<button class="btn-secondary" @click="downloadLectureIcs(lecture.id)">Скачать .ics</button> <button class="btn-secondary">Добавить в календарь</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)"> <button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв Оставить отзыв
</button> </button>
+3 -127
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
@@ -16,11 +14,6 @@ const router = useRouter()
const activeTab = ref<'upcoming' | 'history'>('upcoming') const activeTab = ref<'upcoming' | 'history'>('upcoming')
const cancelModal = ref(false) const cancelModal = ref(false)
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const calendarSubscriptionUrl = ref<string | null>(null)
const calendarActionPending = ref(false)
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const upcoming = computed(() => const upcoming = computed(() =>
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })), lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
@@ -33,98 +26,6 @@ onMounted(async () => {
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser() if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
}) })
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function downloadAllIcs() {
try {
const blob = await usersApi.downloadMyEnrollmentsIcs()
downloadFile(blob, 'my-lectures.ics')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function getCalendarSubscriptionUrl() {
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
const subscription = await usersApi.getCalendarSubscription()
calendarSubscriptionUrl.value = subscription.feedUrl
return subscription.feedUrl
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
// Browser may block async clipboard writes after awaiting the subscription request.
}
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const copied = document.execCommand('copy')
textarea.remove()
return copied
}
async function copyCalendarSubscriptionUrl() {
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
addToast?.('ICS-ссылка скопирована', 'success')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
} finally {
calendarActionPending.value = false
}
}
async function syncWithGoogleCalendar() {
const googleWindow = window.open('about:blank', '_blank')
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
const copied = await copyText(feedUrl)
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
if (googleWindow) {
googleWindow.opener = null
googleWindow.location.href = googleUrl
} else {
window.open(googleUrl, '_blank', 'noopener,noreferrer')
}
addToast?.(
copied
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
copied ? 'success' : 'info',
)
} catch (err) {
googleWindow?.close()
addToast?.(
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
'error',
)
} finally {
calendarActionPending.value = false
}
}
function openCancel(id: string) { function openCancel(id: string) {
selectedId.value = id selectedId.value = id
cancelModal.value = true cancelModal.value = true
@@ -145,25 +46,7 @@ async function confirmCancel() {
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы. Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p> </p>
</div> </div>
<div class="calendar-actions"> <button class="btn-secondary">Экспорт в календарь</button>
<button
class="btn-primary"
:disabled="calendarActionPending"
@click="syncWithGoogleCalendar"
>
Синхронизировать с Google Calendar
</button>
<button
class="btn-secondary"
:disabled="calendarActionPending"
@click="copyCalendarSubscriptionUrl"
>
Скопировать ICS-ссылку
</button>
<button class="btn-secondary" @click="downloadAllIcs">
Скачать все мои лекции (.ics)
</button>
</div>
</div> </div>
<div class="tabs"> <div class="tabs">
@@ -193,7 +76,7 @@ async function confirmCancel() {
</div> </div>
<div class="lecture-actions"> <div class="lecture-actions">
<StatusBadge status="registered" /> <StatusBadge status="registered" />
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button> <button class="btn-secondary btn-sm">Добавить в календарь</button>
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button> <button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
</div> </div>
</GlassCard> </GlassCard>
@@ -250,13 +133,6 @@ async function confirmCancel() {
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.calendar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.tabs { .tabs {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
@@ -9,6 +9,7 @@ import { mapApiReview } from '@/api/mappers'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
const lecturesStore = useLecturesStore() const lecturesStore = useLecturesStore()
const auth = useAuthStore() const auth = useAuthStore()
const reviews = ref<Review[]>([]) const reviews = ref<Review[]>([])
@@ -16,9 +17,8 @@ const reviews = ref<Review[]>([])
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length) const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length) const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length) const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
const total = computed(() => reviews.value.length) const total = computed(() => reviews.value.length || 1)
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0) const pct = (value: number) => Math.round((value / total.value) * 100)
const ratio = (value: number) => `${value}/${total.value}`
async function fetchTeacherAnalytics() { async function fetchTeacherAnalytics() {
if (!auth.user?.id) return if (!auth.user?.id) return
@@ -39,28 +39,37 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<h1 class="page-title">Аналитика преподавателя</h1> <h1 class="page-title">Аналитика преподавателя</h1>
<div class="grid"> <div class="grid">
<GlassCard>
<div class="section-title">Динамика оценок</div>
<div class="chart">
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
<span class="bar-label">Нед {{ i + 1 }}</span>
</div>
</div>
<div class="avg">Средняя оценка: 4.6</div>
</GlassCard>
<GlassCard> <GlassCard>
<div class="section-title">Sentiment-анализ отзывов</div> <div class="section-title">Sentiment-анализ отзывов</div>
<div class="sentiment"> <div class="sentiment">
<div> <div>
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div> <div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" /> <ProgressBar :value="pct(positive)" :max="100" />
</div> </div>
<div> <div>
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div> <div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar <ProgressBar
:value="pct(neutral)" :value="pct(neutral)"
:max="100" :max="100"
:text="ratio(neutral)"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/> />
</div> </div>
<div> <div>
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div> <div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar <ProgressBar
:value="pct(negative)" :value="pct(negative)"
:max="100" :max="100"
:text="ratio(negative)"
color="linear-gradient(90deg, #FCA5A5, #FECACA)" color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/> />
</div> </div>
@@ -108,6 +117,32 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px; gap: 16px;
} }
.chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.sentiment { .sentiment {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -20,6 +20,9 @@ const upcoming = computed(() =>
const enrolledTotal = computed(() => const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0), teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
) )
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() { function fetchTeacherLectures() {
if (!auth.user?.id) return if (!auth.user?.id) return
@@ -45,7 +48,13 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<div class="stats-row"> <div class="stats-row">
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" /> <StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" /> <StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" /> <StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div> </div>
<GlassCard> <GlassCard>
@@ -8,9 +8,8 @@ import StatusBadge from '@/components/ui/StatusBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue' import EmptyState from '@/components/ui/EmptyState.vue'
const auth = useAuthStore() const auth = useAuthStore()
const lecturesStore = useLecturesStore() const lecturesStore = useLecturesStore()
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [ const columns = [
{ key: 'title', label: 'Лекция' }, { key: 'title', label: 'Лекция' },
{ key: 'date', label: 'Дата' }, { key: 'date', label: 'Дата' },
{ key: 'status', label: 'Статус', align: 'center' }, { key: 'status', label: 'Статус', align: 'center' },
@@ -51,8 +50,8 @@ watch(() => auth.user?.id, fetchTeacherLectures)
subtitle="Backend не вернул лекции для текущего преподавателя." subtitle="Backend не вернул лекции для текущего преподавателя."
/> />
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #status="{ row }"> <template #status="{ value }">
<StatusBadge :status="row.status" /> <StatusBadge :status="value" />
</template> </template>
<template #actions> <template #actions>
<div class="actions"> <div class="actions">
-9
View File
@@ -1,9 +0,0 @@
import { test, expect } from '@playwright/test'
import { mockApi } from './support/mockApi'
test('redirects unauthenticated user to login', async ({ page }) => {
await mockApi(page, { authenticated: false })
await page.goto('/catalog')
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText('Войти через ЮФУ')).toBeVisible()
})
-23
View File
@@ -1,23 +0,0 @@
import { expect, test } from '@playwright/test'
import { mockApi } from './support/mockApi'
test.beforeEach(async ({ page }) => {
await mockApi(page, { authenticated: true })
})
test('renders catalog items from mock api', async ({ page }) => {
await page.goto('/catalog')
await expect(page.getByRole('heading', { name: 'Каталог открытых лекций' })).toBeVisible()
await expect(page.getByText('Введение в ML')).toBeVisible()
await expect(page.getByText('Квантовые вычисления')).toBeVisible()
})
test('register button works for available lecture', async ({ page }) => {
await page.goto('/catalog')
const firstRegisterButton = page.getByRole('button', { name: 'Записаться' }).first()
await firstRegisterButton.click()
await expect(page.getByText('Вы записаны на лекцию.')).toBeVisible()
})
-74
View File
@@ -1,74 +0,0 @@
import { Page } from '@playwright/test'
import { mockAuthResponse, mockCurrentUser, mockLectures, mockUserStats } from '../../mocks/fixtures'
export async function mockApi(page: Page, options?: { authenticated?: boolean }) {
const authenticated = options?.authenticated ?? false
await page.route('**/api/v1/auth/refresh', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAuthResponse),
})
})
await page.route('**/api/v1/auth/me', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockCurrentUser),
})
})
await page.route('**/api/v1/users/me/stats', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUserStats),
})
})
await page.route('**/api/v1/lectures/*/enroll', async (route) => {
await route.fulfill({ status: 204 })
})
await page.route(/\/api\/v1\/lectures(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: mockLectures }),
})
})
}
-80
View File
@@ -1,80 +0,0 @@
export const mockAuthResponse = {
accessToken: 'fake-token',
expiresAt: '2099-01-01T00:00:00.000Z',
user: {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
},
}
export const mockCurrentUser = {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
avatarUrl: null,
xp: 120,
coins: 10,
level: 2,
createdAt: '2026-01-01T00:00:00.000Z',
}
export const mockUserStats = {
totalLectures: 0,
attendedLectures: 0,
totalReviews: 0,
xp: 120,
coins: 10,
level: 2,
achievementsCount: 0,
currentLevelXp: 20,
nextLevelXp: 100,
activeEnrollments: 0,
enrollmentSlotLimit: 3,
enrollmentSlotRules: [],
}
export const mockLectures = [
{
id: 1,
courseId: 101,
courseName: 'ML',
teacherId: 201,
teacherName: 'Иванов И.И.',
locationId: 301,
locationName: 'B-1 / 101',
title: 'Введение в ML',
description: 'База машинного обучения',
format: 'Offline',
startsAt: '2026-06-12T10:00:00.000Z',
endsAt: '2026-06-12T11:30:00.000Z',
isOpen: true,
maxEnrollments: 30,
enrollmentsCount: 10,
onlineUrl: null,
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
{
id: 2,
courseId: 102,
courseName: 'квантовые-вычисления',
teacherId: 202,
teacherName: 'Петров П.П.',
locationId: null,
locationName: null,
title: 'Квантовые вычисления',
description: 'Кубиты и алгоритмы',
format: 'Online',
startsAt: '2026-06-13T10:00:00.000Z',
endsAt: '2026-06-13T11:00:00.000Z',
isOpen: false,
maxEnrollments: 50,
enrollmentsCount: 50,
onlineUrl: 'https://example.com/meet',
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
]
+1 -6
View File
@@ -1,8 +1,3 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json"
"baseBranchPatterns": ["dev"],
"enabledManagers": ["nuget", "npm"],
"npm": {
"managerFilePatterns": ["/^frontend/package\\.json$/"]
}
} }