Compare commits
28 Commits
8223697bd3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c49a821e7 | |||
| 7f923cd612 | |||
| c93d205e34 | |||
| ef2fd39508 | |||
| cce7bea12f | |||
| 88146f22b6 | |||
| cb80b35ba6 | |||
| d12dd27323 | |||
| 3ec0dec229 | |||
| 2263c1a7fd | |||
| cb410d8638 | |||
| bbebedcee9 | |||
| b80a30d6a1 | |||
| f7496bab71 | |||
| f03b410e8f | |||
| 920d829604 | |||
| 7e782338b9 | |||
| 8b5ec6148d | |||
| f289c43001 | |||
| fa7cce4962 | |||
| 89782315d7 | |||
| 4cc1478e86 | |||
| 3ffc4d68d6 | |||
| 6287661ca5 | |||
| dc4dc71c41 | |||
| 155be4a586 | |||
| fce3044f94 | |||
| 4251b33596 |
@@ -31,9 +31,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '24.x'
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -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()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек.
|
||||||
@@ -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.»
|
||||||
@@ -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` считаются временными артефактами тестовых прогонов.
|
||||||
@@ -35,5 +35,10 @@ coverage
|
|||||||
# Vitest
|
# Vitest
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
*.timestamp-*-*.mjs
|
*.timestamp-*-*.mjs
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import pluginOxlint from 'eslint-plugin-oxlint'
|
|||||||
import skipFormatting from 'eslint-config-prettier/flat'
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
import vueScopedCss from 'eslint-plugin-vue-scoped-css'
|
import vueScopedCss from 'eslint-plugin-vue-scoped-css'
|
||||||
|
|
||||||
|
type VueTsConfig = Parameters<typeof defineConfigWithVueTs>[number]
|
||||||
|
|
||||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
@@ -20,7 +22,7 @@ export default defineConfigWithVueTs(
|
|||||||
|
|
||||||
...pluginVue.configs['flat/essential'],
|
...pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
...vueScopedCss.configs.recommended,
|
...(vueScopedCss.configs.recommended as VueTsConfig[]),
|
||||||
|
|
||||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
Generated
+42
-4
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="TRow extends object">
|
||||||
|
type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
|
columns: Column[]
|
||||||
rows: Record<string, unknown>[]
|
rows: TRow[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineSlots<Record<string, (props: { row: TRow; value: unknown }) => unknown>>()
|
||||||
|
|
||||||
|
function getCell(row: TRow, key: string): unknown {
|
||||||
|
return (row as Record<string, unknown>)[key]
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,8 +26,8 @@ defineProps<{
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, i) in rows" :key="i">
|
<tr v-for="(row, i) in rows" :key="i">
|
||||||
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
|
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
|
||||||
<slot :name="col.key" :row="row" :value="row[col.key]">
|
<slot :name="col.key" :row="row" :value="getCell(row, col.key)">
|
||||||
{{ row[col.key] }}
|
{{ getCell(row, col.key) }}
|
||||||
</slot>
|
</slot>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
+6
-1
@@ -1,3 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"baseBranchPatterns": ["dev"],
|
||||||
|
"enabledManagers": ["nuget", "npm"],
|
||||||
|
"npm": {
|
||||||
|
"managerFilePatterns": ["/^frontend/package\\.json$/"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user