diff --git a/.gitea/workflows/frontend-playwright.yml b/.gitea/workflows/frontend-playwright.yml new file mode 100644 index 0000000..e3e0bbc --- /dev/null +++ b/.gitea/workflows/frontend-playwright.yml @@ -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 diff --git a/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs new file mode 100644 index 0000000..138b38c --- /dev/null +++ b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs @@ -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); + } +} diff --git a/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs new file mode 100644 index 0000000..cc1c50e --- /dev/null +++ b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs @@ -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(() => 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(() => service.AddTagAsync(404, 10)); + await Assert.ThrowsAsync(() => service.AddTagAsync(1, 404)); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .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 + }; +} diff --git a/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs new file mode 100644 index 0000000..741a447 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs @@ -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)); + } +} diff --git a/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs new file mode 100644 index 0000000..fa9c6bd --- /dev/null +++ b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs @@ -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(() => + 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() + .UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj index c3a346e..afc14e8 100644 --- a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj +++ b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs index 7e13394..0559834 100644 --- a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; @@ -71,6 +74,125 @@ public class UserServiceTests 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(() => service.SetRolesAsync(1, [])); + } + + [Fact] + public async Task SetRolesAsync_PreservesExistingProfiles() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + db.StudentProfiles.Add(new StudentProfile + { + Id = 10, + UserId = 1, + StudentId = "S-1" + }); + db.TeacherProfiles.Add(new TeacherProfile + { + Id = 20, + UserId = 1, + Department = "Math" + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await service.SetRolesAsync(1, [UserRole.Teacher]); + + Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId); + Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department); + } + + [Fact] + public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "anna@test.local", "Anna", true, 120, UserRole.Student), + User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher), + User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin), + User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest( + Search: "anna", + Role: UserRole.Student, + IsActive: true, + Page: 1, + PageSize: 10)); + + var user = Assert.Single(result.Items); + Assert.Equal(1, user.Id); + Assert.Equal(2, user.Level); + Assert.Equal(1, result.TotalCount); + } + + [Fact] + public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1)); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Page); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, Assert.Single(result.Items).Id); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -115,4 +237,31 @@ public class UserServiceTests IsOpen = true, MaxEnrollments = 30 }; + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + params UserRole[] roles) => + User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles); + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + DateTime createdAt, + params UserRole[] roles) => new() + { + Id = id, + Email = email, + DisplayName = displayName, + IsActive = isActive, + Xp = xp, + CreatedAt = createdAt, + Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList() + }; } diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 22a9ab9..1f9a164 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -18,7 +18,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/backend/UniVerse.Application/UniVerse.Application.csproj b/backend/UniVerse.Application/UniVerse.Application.csproj index d068bc7..cef0f10 100644 --- a/backend/UniVerse.Application/UniVerse.Application.csproj +++ b/backend/UniVerse.Application/UniVerse.Application.csproj @@ -9,7 +9,7 @@ - + diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index fa24205..193ece1 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/docs/backend-unit-tests.md b/docs/backend-unit-tests.md new file mode 100644 index 0000000..9d91d19 --- /dev/null +++ b/docs/backend-unit-tests.md @@ -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 +``` diff --git a/docs/k6-report-2026-05-28.md b/docs/k6-report-2026-05-28.md new file mode 100644 index 0000000..a24c7a6 --- /dev/null +++ b/docs/k6-report-2026-05-28.md @@ -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-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек. diff --git a/docs/load-testing-k6.md b/docs/load-testing-k6.md new file mode 100644 index 0000000..bbd5bb5 --- /dev/null +++ b/docs/load-testing-k6.md @@ -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="" +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.» diff --git a/docs/playwright-tests.md b/docs/playwright-tests.md new file mode 100644 index 0000000..9070b1e --- /dev/null +++ b/docs/playwright-tests.md @@ -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` считаются временными артефактами тестовых прогонов. diff --git a/frontend/.gitignore b/frontend/.gitignore index cd68f14..7cda3d2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,5 +35,10 @@ coverage # Vitest __screenshots__/ +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ + # Vite *.timestamp-*-*.mjs diff --git a/frontend/package.json b/frontend/package.json index 759a3bb..491f93a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,11 @@ "lint": "run-s lint:*", "lint:oxlint": "oxlint . --fix", "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": { "pinia": "^3.0.4", @@ -38,7 +42,8 @@ "typescript": "~6.0.0", "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", - "vue-tsc": "^3.2.6" + "vue-tsc": "^3.2.6", + "@playwright/test": "^1.55.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..159eeb5 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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' } : {}), + }, + }, + ], +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e9d1756..2102929 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: 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)) devDependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.60.0 '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 @@ -433,6 +436,11 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -779,8 +787,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1008,6 +1016,11 @@ packages: flatted@3.4.2: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1347,6 +1360,16 @@ packages: pkg-types@2.3.1: 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: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -2011,6 +2034,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.60.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@rolldown/binding-android-arm64@1.0.0-rc.17': @@ -2387,7 +2414,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2606,6 +2633,9 @@ snapshots: flatted@3.4.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2762,7 +2792,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 mitt@3.0.1: {} @@ -2889,6 +2919,14 @@ snapshots: exsolve: 1.0.8 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): dependencies: postcss: 8.5.14 diff --git a/frontend/scripts/loadtest-endpoints.js b/frontend/scripts/loadtest-endpoints.js new file mode 100644 index 0000000..5dc0216 --- /dev/null +++ b/frontend/scripts/loadtest-endpoints.js @@ -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'); +} diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index 0bb1a2a..d64c64a 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,16 +1,15 @@ - diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 17f4616..38418aa 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -17,9 +17,10 @@ import EmptyState from '@/components/ui/EmptyState.vue' import CreateLectureModal from '@/components/admin/CreateLectureModal.vue' type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags' +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } type TabConfig = { title: string - columns: Array<{ key: string; label: string; align?: string }> + columns: DataTableColumn[] rows: Record[] } @@ -161,7 +162,7 @@ const tabConfig: Record = { }, } -const current = computed(() => { +const current = computed(() => { const config = tabConfig[activeTab.value] if (activeTab.value === 'lectures') { return { diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index aef782a..b436b58 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -7,7 +7,9 @@ import EmptyState from '@/components/ui/EmptyState.vue' import { reviewsApi } from '@/api' 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: 'lecture', label: 'Лекция' }, { key: 'student', label: 'Студент' }, @@ -295,8 +297,8 @@ onMounted(() => { -