Merge branch 'dev'
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Successful in 22s
Frontend Playwright / e2e (push) Successful in 10m31s
🚀 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 8s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped

# Conflicts:
#	backend/UniVerse.Api/UniVerse.Api.csproj
This commit is contained in:
2026-05-28 20:08:59 +03:00
29 changed files with 1384 additions and 29 deletions
+40
View File
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,123 @@
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using Xunit;
namespace UniVerse.Api.Tests.Application;
public class MappingExtensionsTests
{
[Fact]
public void UserMappings_OrderRolesConsistently()
{
var user = new User
{
Id = 1,
Email = "user@test.local",
DisplayName = "User",
AvatarUrl = "avatar.png",
IsActive = true,
Xp = 120,
Coins = 30,
CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
Roles =
[
new UserRoleAssignment { Role = UserRole.Teacher },
new UserRoleAssignment { Role = UserRole.Student },
new UserRoleAssignment { Role = UserRole.Admin }
]
};
var dto = user.ToDto(level: 2);
var currentUser = user.ToCurrentUserDto(level: 2);
var auth = user.ToAuthDto();
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles);
Assert.Equal(dto.Roles, currentUser.Roles);
Assert.Equal(dto.Roles, auth.Roles);
Assert.Equal(2, dto.Level);
}
[Fact]
public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount()
{
var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc);
var lecture = new Lecture
{
Id = 10,
CourseId = 5,
Title = "Offline lecture",
Description = "Description",
Format = LectureFormat.Offline,
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 25,
Enrollments =
[
new LectureEnrollment { UserId = 1 },
new LectureEnrollment { UserId = 2 }
]
};
var dto = lecture.ToDto(isEnrolled: true);
var detail = lecture.ToDetailDto(isEnrolled: false);
Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName);
Assert.Equal(2, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled);
}
[Fact]
public void ReviewMapping_CopiesAnalysisFields()
{
var review = new Review
{
Id = 7,
LectureId = 3,
UserId = 4,
Rating = ReviewRating.Like,
Text = "Clear and useful",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.95,
IsInformative = true,
LlmTags = ["clear", "useful"],
LlmRawOutput = "{\"quality_score\":0.95}",
CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc),
Lecture = new Lecture { Title = "Lecture title" },
User = new User { DisplayName = "Student" }
};
var dto = review.ToDto();
Assert.Equal("Lecture title", dto.LectureTitle);
Assert.Equal("Student", dto.UserName);
Assert.Equal(ReviewSentiment.Positive, dto.Sentiment);
Assert.Equal(0.95, dto.QualityScore);
Assert.True(dto.IsInformative);
Assert.NotNull(dto.LlmTags);
Assert.Equal(["clear", "useful"], dto.LlmTags);
Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput);
}
[Fact]
public void TagTreeMapping_MapsChildrenRecursively()
{
var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic };
var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 };
var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 };
root.Children.Add(child);
child.Children.Add(grandchild);
var dto = root.ToTreeDto();
Assert.Equal("Root", dto.Name);
var childDto = Assert.Single(dto.Children);
Assert.Equal("Child", childDto.Name);
Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name);
}
}
@@ -0,0 +1,121 @@
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
};
}
@@ -0,0 +1,29 @@
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));
}
}
@@ -0,0 +1,86 @@
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,8 +9,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<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" />
@@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore;
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;
@@ -71,6 +74,125 @@ 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);
}
private static AppDbContext CreateDbContext() private static AppDbContext CreateDbContext()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -115,4 +237,31 @@ 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()
};
} }
+1 -1
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.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@@ -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.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<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" />
@@ -9,8 +9,8 @@
<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.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<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" />
</ItemGroup> </ItemGroup>
+143
View File
@@ -0,0 +1,143 @@
# 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
@@ -0,0 +1,120 @@
# Отчет по нагрузочному тестированию 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
@@ -0,0 +1,94 @@
# Базовый нагрузочный тест (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
@@ -0,0 +1,111 @@
# 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,5 +35,10 @@ coverage
# Vitest # Vitest
__screenshots__/ __screenshots__/
# Playwright
/test-results/
/playwright-report/
/blob-report/
# Vite # Vite
*.timestamp-*-*.mjs *.timestamp-*-*.mjs
+7 -2
View File
@@ -13,7 +13,11 @@
"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",
@@ -38,7 +42,8 @@
"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
@@ -0,0 +1,32 @@
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' } : {}),
},
},
],
})
+42 -4
View File
@@ -18,6 +18,9 @@ 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
@@ -433,6 +436,11 @@ 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==}
@@ -779,8 +787,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.5: brace-expansion@5.0.6:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -1008,6 +1016,11 @@ 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}
@@ -1347,6 +1360,16 @@ 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'}
@@ -2011,6 +2034,10 @@ 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':
@@ -2387,7 +2414,7 @@ snapshots:
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@5.0.5: brace-expansion@5.0.6:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -2606,6 +2633,9 @@ 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
@@ -2762,7 +2792,7 @@ snapshots:
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.5 brace-expansion: 5.0.6
mitt@3.0.1: {} mitt@3.0.1: {}
@@ -2889,6 +2919,14 @@ 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
@@ -0,0 +1,66 @@
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');
}
+6 -7
View File
@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts" generic="TRow extends object">
type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' | string } type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' }
type DataTableSlotRow = Record<string, any>
defineProps<{ defineProps<{
columns: Column[] columns: Column[]
rows: Record<string, unknown>[] rows: TRow[]
}>() }>()
defineSlots<Record<string, (props: { row: DataTableSlotRow; value: any }) => unknown>>() defineSlots<Record<string, (props: { row: TRow; value: unknown }) => unknown>>()
function getCell(row: Record<string, unknown>, key: string) { function getCell(row: TRow, key: string): unknown {
return row[key] return (row as Record<string, unknown>)[key]
} }
</script> </script>
@@ -17,9 +17,10 @@ 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: Array<{ key: string; label: string; align?: string }> columns: DataTableColumn[]
rows: Record<string, unknown>[] rows: Record<string, unknown>[]
} }
@@ -161,7 +162,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
}, },
} }
const current = computed(() => { const current = computed<TabConfig>(() => {
const config = tabConfig[activeTab.value] const config = tabConfig[activeTab.value]
if (activeTab.value === 'lectures') { if (activeTab.value === 'lectures') {
return { return {
@@ -7,7 +7,9 @@ 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'
const columns = [ type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [
{ key: 'id', label: 'ID' }, { key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' }, { key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' }, { key: 'student', label: 'Студент' },
@@ -295,8 +297,8 @@ onMounted(() => {
</div> </div>
</div> </div>
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #text="{ value }"> <template #text="{ row }">
<span class="review-text" :title="value">{{ value }}</span> <span class="review-text" :title="row.text">{{ row.text }}</span>
</template> </template>
<template #analysis="{ row }"> <template #analysis="{ row }">
<div class="analysis-cell"> <div class="analysis-cell">
+3 -1
View File
@@ -13,7 +13,9 @@ const users = ref<UserDto[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const columns = [ type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
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' },
+3 -1
View File
@@ -11,6 +11,8 @@ 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('Любая дата')
@@ -102,7 +104,7 @@ const appliedFilters = computed(() => {
return filters return filters
}) })
const tableColumns = [ const tableColumns: DataTableColumn[] = [
{ key: 'title', label: 'Лекция' }, { key: 'title', label: 'Лекция' },
{ key: 'teacher', label: 'Преподаватель' }, { key: 'teacher', label: 'Преподаватель' },
{ key: 'date', label: 'Дата' }, { key: 'date', label: 'Дата' },
@@ -8,8 +8,9 @@ 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 = [ const columns: DataTableColumn[] = [
{ key: 'title', label: 'Лекция' }, { key: 'title', label: 'Лекция' },
{ key: 'date', label: 'Дата' }, { key: 'date', label: 'Дата' },
{ key: 'status', label: 'Статус', align: 'center' }, { key: 'status', label: 'Статус', align: 'center' },
@@ -50,8 +51,8 @@ watch(() => auth.user?.id, fetchTeacherLectures)
subtitle="Backend не вернул лекции для текущего преподавателя." subtitle="Backend не вернул лекции для текущего преподавателя."
/> />
<DataTable :columns="columns" :rows="rows"> <DataTable :columns="columns" :rows="rows">
<template #status="{ value }"> <template #status="{ row }">
<StatusBadge :status="value" /> <StatusBadge :status="row.status" />
</template> </template>
<template #actions> <template #actions>
<div class="actions"> <div class="actions">
+9
View File
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,80 @@
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,
},
]