4 Commits

Author SHA1 Message Date
Renovate Bot 8223697bd3 chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 44s
2026-05-25 00:32:40 +00:00
serega404 3106f0ef61 Merge pull request 'Dev' (#11) from dev into main
Backend CI / build-and-test (push) Successful in 39s
Frontend CI / build-and-check (push) Failing after 5m18s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
Reviewed-on: #11
2026-05-25 03:22:55 +03:00
serega404 a220afd078 Merge pull request 'chore: Configure Renovate' (#8) from renovate/configure into main
Create and publish a Docker image / Publish image (push) Successful in 2m23s
Reviewed-on: #8
2026-05-25 03:03:56 +03:00
Renovate Bot cd3f2c53b7 Add renovate.json 2026-05-25 00:02:52 +00:00
64 changed files with 164 additions and 3115 deletions
+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
node-version: '22.x'
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
-40
View File
@@ -1,40 +0,0 @@
name: Frontend Playwright
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build app
run: pnpm build-only
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
run: pnpm test:e2e
@@ -1,124 +0,0 @@
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using Xunit;
namespace UniVerse.Api.Tests.Application;
public class MappingExtensionsTests
{
[Fact]
public void UserMappings_OrderRolesConsistently()
{
var user = new User
{
Id = 1,
Email = "user@test.local",
DisplayName = "User",
AvatarUrl = "avatar.png",
IsActive = true,
Xp = 120,
Coins = 30,
CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
Roles =
[
new UserRoleAssignment { Role = UserRole.Teacher },
new UserRoleAssignment { Role = UserRole.Student },
new UserRoleAssignment { Role = UserRole.Admin }
]
};
var dto = user.ToDto(level: 2);
var currentUser = user.ToCurrentUserDto(level: 2);
var auth = user.ToAuthDto();
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles);
Assert.Equal(dto.Roles, currentUser.Roles);
Assert.Equal(dto.Roles, auth.Roles);
Assert.Equal(2, dto.Level);
}
[Fact]
public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount()
{
var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc);
var lecture = new Lecture
{
Id = 10,
CourseId = 5,
Title = "Offline lecture",
Description = "Description",
Format = LectureFormat.Offline,
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 25,
MandatoryAttendeesCount = 30,
Enrollments =
[
new LectureEnrollment { UserId = 1 },
new LectureEnrollment { UserId = 2 }
]
};
var dto = lecture.ToDto(isEnrolled: true);
var detail = lecture.ToDetailDto(isEnrolled: false);
Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName);
Assert.Equal(32, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled);
}
[Fact]
public void ReviewMapping_CopiesAnalysisFields()
{
var review = new Review
{
Id = 7,
LectureId = 3,
UserId = 4,
Rating = ReviewRating.Like,
Text = "Clear and useful",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.95,
IsInformative = true,
LlmTags = ["clear", "useful"],
LlmRawOutput = "{\"quality_score\":0.95}",
CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc),
Lecture = new Lecture { Title = "Lecture title" },
User = new User { DisplayName = "Student" }
};
var dto = review.ToDto();
Assert.Equal("Lecture title", dto.LectureTitle);
Assert.Equal("Student", dto.UserName);
Assert.Equal(ReviewSentiment.Positive, dto.Sentiment);
Assert.Equal(0.95, dto.QualityScore);
Assert.True(dto.IsInformative);
Assert.NotNull(dto.LlmTags);
Assert.Equal(["clear", "useful"], dto.LlmTags);
Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput);
}
[Fact]
public void TagTreeMapping_MapsChildrenRecursively()
{
var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic };
var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 };
var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 };
root.Children.Add(child);
child.Children.Add(grandchild);
var dto = root.ToTreeDto();
Assert.Equal("Root", dto.Name);
var childDto = Assert.Single(dto.Children);
Assert.Equal("Child", childDto.Name);
Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name);
}
}
@@ -1,121 +0,0 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Courses;
public class CourseServiceTests
{
[Fact]
public async Task GetAllAsync_AppliesSearchSyncedTagFiltersAndPagination()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Backend", Type = TagType.Subject },
new Tag { Id = 2, Name = "Frontend", Type = TagType.Subject });
db.Courses.AddRange(
Course(1, "ASP.NET Core", true, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Vue Basics", true, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Advanced ASP.NET", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
db.CourseTags.AddRange(
new CourseTag { CourseId = 1, TagId = 1 },
new CourseTag { CourseId = 2, TagId = 2 },
new CourseTag { CourseId = 3, TagId = 1 });
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(
TagId: 1,
Search: "asp",
IsSynced: true,
Page: 1,
PageSize: 10));
var item = Assert.Single(result.Items);
Assert.Equal(1, item.Id);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.TotalPages);
Assert.Equal("Backend", Assert.Single(item.Tags).Name);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageMetadata()
{
await using var db = CreateDbContext();
db.Courses.AddRange(
Course(1, "Old", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Middle", false, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Newest", false, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)));
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(1, result.PageSize);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
[Fact]
public async Task AddTagAsync_LinksExistingCourseAndTag()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await service.AddTagAsync(1, 10);
Assert.True(await db.CourseTags.AnyAsync(ct => ct.CourseId == 1 && ct.TagId == 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenTagAlreadyLinked()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
db.CourseTags.Add(new CourseTag { CourseId = 1, TagId = 10 });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<ConflictException>(() => service.AddTagAsync(1, 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenCourseOrTagMissing()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(404, 10));
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(1, 404));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"CourseServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Course Course(int id, string name, bool isSynced, DateTime createdAt) => new()
{
Id = id,
Name = name,
IsSynced = isSynced,
CreatedAt = createdAt
};
}
@@ -1,29 +0,0 @@
using UniVerse.Domain.Services;
using Xunit;
namespace UniVerse.Api.Tests.DomainServices;
public class EnrollmentSlotPolicyTests
{
[Theory]
[InlineData(-1, 3)]
[InlineData(0, 3)]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(10, 7)]
public void GetLimitForLevel_UsesHighestMatchingRuleOrDefault(int level, int expectedSlots)
{
var slots = EnrollmentSlotPolicy.GetLimitForLevel(level);
Assert.Equal(expectedSlots, slots);
}
[Fact]
public void Rules_ExposeConfiguredThresholdsInAscendingOrder()
{
Assert.Equal(new[] { 1, 3, 4 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Slots));
}
}
@@ -166,29 +166,6 @@ public class LectureServiceTests
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
}
[Fact]
public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.MaxEnrollments = 31;
lecture.MandatoryAttendeesCount = 30;
db.Users.AddRange(
new User { Id = 1, Email = "first@test.local" },
new User { Id = 2, Email = "second@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
@@ -1,37 +0,0 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.RateLimiting;
public class RateLimitingTests
{
[Fact]
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
{
await using var factory = new ApiWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["RateLimiting:PermitLimit"] = "1",
["RateLimiting:WindowSeconds"] = "60",
["RateLimiting:QueueLimit"] = "0"
});
});
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
using var firstResponse = await client.GetAsync("api/v1/tags");
using var secondResponse = await client.GetAsync("api/v1/tags");
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
}
@@ -1,62 +0,0 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Infrastructure.ExternalServices;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ModeusApiClientTests
{
[Fact]
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://schedule.test")
};
var config = new ConfigurationBuilder().Build();
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
await client.SearchEventsAsync(new SyncScheduleRequest(
SpecialtyCode: ["09.03.04"],
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
TypeId: ["LECT"],
Size: 50));
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
Assert.NotNull(handler.RequestBody);
using var body = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
}
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpMethod? RequestMethod { get; private set; }
public string? RequestPathAndQuery { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestMethod = request.Method;
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"events":[]}""")
};
}
}
}
@@ -129,56 +129,6 @@ public class ScheduleSyncServiceTests
Assert.Equal(48, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc),
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 120, WorkingCapacity: 120)
]
}
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(120, lecture.MaxEnrollments);
Assert.Equal(31, lecture.MandatoryAttendeesCount);
}
[Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{
@@ -1,86 +0,0 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Tags;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Tags;
public class TagServiceTests
{
[Fact]
public async Task GetAllAsync_FiltersByTypeAndParentAndOrdersByName()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root", Type = TagType.Topic },
new Tag { Id = 2, Name = "Zeta", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Alpha", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 4, Name = "Other parent", Type = TagType.Subject, ParentId = 99 },
new Tag { Id = 5, Name = "Other type", Type = TagType.Topic, ParentId = 1 });
await db.SaveChangesAsync();
var service = new TagService(db);
var result = await service.GetAllAsync(TagType.Subject, parentId: 1);
Assert.Equal(new[] { "Alpha", "Zeta" }, result.Select(tag => tag.Name));
}
[Fact]
public async Task CreateAsync_ThrowsWhenParentMissing()
{
await using var db = CreateDbContext();
var service = new TagService(db);
await Assert.ThrowsAsync<NotFoundException>(() =>
service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 404)));
}
[Fact]
public async Task CreateAsync_CreatesChildWhenParentExists()
{
await using var db = CreateDbContext();
db.Tags.Add(new Tag { Id = 1, Name = "Parent", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new TagService(db);
var created = await service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 1));
Assert.Equal("Child", created.Name);
Assert.Equal(1, created.ParentId);
Assert.True(await db.Tags.AnyAsync(tag => tag.Name == "Child" && tag.ParentId == 1));
}
[Fact]
public async Task GetTreeAsync_ReturnsNestedRootTrees()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root A", Type = TagType.Topic },
new Tag { Id = 2, Name = "Child A", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Grandchild A", Type = TagType.Other, ParentId = 2 },
new Tag { Id = 4, Name = "Root B", Type = TagType.Organization });
await db.SaveChangesAsync();
var service = new TagService(db);
var tree = await service.GetTreeAsync();
Assert.Equal(new[] { "Root A", "Root B" }, tree.Select(tag => tag.Name));
var rootA = tree.Single(tag => tag.Name == "Root A");
var child = Assert.Single(rootA.Children);
Assert.Equal("Child A", child.Name);
Assert.Equal("Grandchild A", Assert.Single(child.Children).Name);
Assert.Empty(tree.Single(tag => tag.Name == "Root B").Children);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -9,12 +9,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -2,11 +2,8 @@ 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;
@@ -74,125 +71,6 @@ 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<ForbiddenException>(() => service.SetRolesAsync(1, []));
}
[Fact]
public async Task SetRolesAsync_PreservesExistingProfiles()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.StudentProfiles.Add(new StudentProfile
{
Id = 10,
UserId = 1,
StudentId = "S-1"
});
db.TeacherProfiles.Add(new TeacherProfile
{
Id = 20,
UserId = 1,
Department = "Math"
});
await db.SaveChangesAsync();
var service = CreateService(db);
await service.SetRolesAsync(1, [UserRole.Teacher]);
Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId);
Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department);
}
[Fact]
public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "anna@test.local", "Anna", true, 120, UserRole.Student),
User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher),
User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin),
User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(
Search: "anna",
Role: UserRole.Student,
IsActive: true,
Page: 1,
PageSize: 10));
var user = Assert.Single(result.Items);
Assert.Equal(1, user.Id);
Assert.Equal(2, user.Level);
Assert.Equal(1, result.TotalCount);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -237,31 +115,4 @@ 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()
};
}
@@ -159,19 +159,6 @@ public class UsersController : ControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
/// <summary>Получить статистику для админского дашборда.</summary>
/// <remarks>Только Admin.</remarks>
/// <response code="200">Агрегированная статистика дашборда.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("admin/stats")]
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
Ok(await _users.GetAdminDashboardStatsAsync());
/// <summary>Получить список записей пользователя на лекции.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param>
@@ -1,71 +0,0 @@
using System.Net;
using System.Net.Sockets;
namespace UniVerse.Api.Middleware;
public sealed class LocalNetworksOnlyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var remoteIpAddress = context.Connection.RemoteIpAddress;
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
{
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
return;
}
await _next(context);
}
private static bool IsLocalNetwork(IPAddress ipAddress)
{
if (IPAddress.IsLoopback(ipAddress))
{
return true;
}
if (ipAddress.IsIPv4MappedToIPv6)
{
ipAddress = ipAddress.MapToIPv4();
}
return ipAddress.AddressFamily switch
{
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
_ => false
};
}
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return bytes[0] == 10
|| bytes[0] == 127
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 169 && bytes[1] == 254);
}
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return ipAddress.IsIPv6LinkLocal
|| ipAddress.IsIPv6SiteLocal
|| (bytes[0] & 0xfe) == 0xfc;
}
}
@@ -1,12 +0,0 @@
namespace UniVerse.Api.Options;
public class RateLimitingOptions
{
public const string SectionName = "RateLimiting";
public int PermitLimit { get; set; } = 600;
public int WindowSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 100;
}
+1 -70
View File
@@ -1,16 +1,11 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Prometheus;
using Quartz;
using Serilog;
using System.Threading.RateLimiting;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Filters;
using UniVerse.Api.Middleware;
@@ -73,50 +68,6 @@ builder.Services.AddAuthentication(options =>
});
builder.Services.AddAuthorization();
builder.Services.AddOptions<RateLimitingOptions>()
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
.Validate(options => options.PermitLimit >= 1,
"RateLimiting:PermitLimit must be greater than or equal to 1.")
.Validate(options => options.WindowSeconds >= 1,
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
.Validate(options => options.QueueLimit >= 0,
"RateLimiting:QueueLimit must be greater than or equal to 0.")
.ValidateOnStart();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
return RateLimitPartition.GetFixedWindowLimiter(
GetRateLimitPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitingOptions.PermitLimit,
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = rateLimitingOptions.QueueLimit,
AutoReplenishment = true
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/429",
title = "Too Many Requests",
status = StatusCodes.Status429TooManyRequests,
detail = "Rate limit exceeded. Please try again later.",
traceId = context.HttpContext.TraceIdentifier
}, cancellationToken);
};
});
// --- CORS ---
builder.Services.AddCors(options =>
{
@@ -184,7 +135,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
client.Timeout = TimeSpan.FromSeconds(30);
});
// --- Background Services ---
@@ -269,9 +220,7 @@ if (app.Environment.IsDevelopment())
app.UseCors();
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseHttpMetrics();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
@@ -279,22 +228,4 @@ if (app.Environment.IsDevelopment())
}
app.MapControllers();
// Restrict Prometheus scrape endpoint to local and private networks.
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
app.MapMetrics();
app.Run();
static string GetRateLimitPartitionKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub");
if (!string.IsNullOrWhiteSpace(userId))
return $"user:{userId}";
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
}
+1 -2
View File
@@ -18,7 +18,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -30,7 +30,6 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
</ItemGroup>
<ItemGroup>
+1 -7
View File
@@ -13,11 +13,6 @@
"http://localhost:3000"
]
},
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"Llm": {
"BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "",
@@ -28,8 +23,7 @@
},
"ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": "",
"TimeoutSeconds": 180
"ApiKey": ""
},
"Serilog": {
"MinimumLevel": {
-68
View File
@@ -4158,52 +4158,6 @@
]
}
},
"/api/v1/users/admin/stats": {
"get": {
"tags": [
"Users"
],
"summary": "Получить статистику для админского дашборда.",
"description": "Только Admin.\n\n**Required roles:** Admin",
"responses": {
"200": {
"description": "Агрегированная статистика дашборда.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminDashboardStatsDto"
}
}
}
},
"401": {
"description": "Требуется аутентификация.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Требуется роль Admin.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{
"Bearer": [ ]
}
]
}
},
"/api/v1/users/{id}/enrollments": {
"get": {
"tags": [
@@ -4804,28 +4758,6 @@
},
"additionalProperties": false
},
"AdminDashboardStatsDto": {
"type": "object",
"properties": {
"usersCount": {
"type": "integer",
"format": "int32"
},
"lecturesCount": {
"type": "integer",
"format": "int32"
},
"enrollmentsCount": {
"type": "integer",
"format": "int32"
},
"pendingReviewsCount": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"AuthResponse": {
"type": "object",
"properties": {
@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -42,13 +42,6 @@ public record UserStatsDto(
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
);
public record AdminDashboardStatsDto(
int UsersCount,
int LecturesCount,
int EnrollmentsCount,
int PendingReviewsCount
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record UpdateUserRequest(
@@ -29,14 +29,11 @@ public class ModeusEvent
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
@@ -10,7 +10,6 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id);
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
@@ -13,9 +13,6 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions
{
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- User ---
public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
@@ -60,7 +57,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -70,7 +67,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -6,37 +6,11 @@ public static class ReviewPromptTemplate
public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю.
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
{
"quality_score": 0.0,
"sentiment": "Нейтральный",
"tags": [],
"is_informative": false
}
Правила оценки:
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
Базовые теги:
- "структура лекции"
- "понятность объяснения"
- "темп"
- "сложность"
- "практические примеры"
- "материалы"
- "актуальность темы"
- "вовлеченность"
- "организация"
- "технические проблемы"
- "польза для обучения"
- "неинформативный отзыв"
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
- quality_score: число от 0 до 1, указывающее на качество отзыва;
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
- tags: массив соответствующих тематических тегов;
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
Контекст лекции: {lectureContext}
Текст отзыва: {reviewText}
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
@@ -15,7 +15,6 @@ public class Lecture
public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +22,6 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -42,11 +42,12 @@ public class ModeusApiClient : IModeusApiClient
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body);
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
await EnsureSuccessAsync(response, "Modeus events search",
BuildEventsRequestSummary(requestJson));
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
BuildEventsRequestSummary(requestJson))
?? new ModeusEventsResponse();
}
@@ -97,6 +98,8 @@ public class ModeusApiClient : IModeusApiClient
response.StatusCode);
}
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MandatoryAttendeesCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "mandatory_attendees_count",
table: "lectures",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mandatory_attendees_count",
table: "lectures");
}
}
}
@@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
@@ -250,12 +250,6 @@ namespace UniVerse.Infrastructure.Migrations
.HasColumnType("integer")
.HasColumnName("location_id");
b.Property<int>("MandatoryAttendeesCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("mandatory_attendees_count");
b.Property<int>("MaxEnrollments")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
@@ -122,8 +122,7 @@ public class LectureService : ILectureService
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
throw new ConflictException("Lecture is full.");
if (lecture.Enrollments.Any(e => e.UserId == userId))
throw new ConflictException("Already enrolled.");
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
@@ -54,7 +55,6 @@ public class ScheduleSyncService : IScheduleSyncService
}
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt);
@@ -68,7 +68,6 @@ public class ScheduleSyncService : IScheduleSyncService
existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity;
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
existing.UpdatedAt = DateTime.UtcNow;
updated++;
}
@@ -92,8 +91,7 @@ public class ScheduleSyncService : IScheduleSyncService
ExternalId = ev.Id,
StartsAt = startsAt,
EndsAt = endsAt,
MaxEnrollments = lectureCapacity,
MandatoryAttendeesCount = mandatoryAttendeesCount
MaxEnrollments = lectureCapacity
});
created++;
}
@@ -113,7 +111,7 @@ public class ScheduleSyncService : IScheduleSyncService
updated,
skipped,
[
"endpoint=POST /api/ictis?includeCounts=true",
$"requestJson={BuildScheduleRequestJson(request)}",
$"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}"
]));
@@ -445,9 +443,6 @@ public class ScheduleSyncService : IScheduleSyncService
private static int? NormalizeCapacity(int? capacity) =>
capacity is > 0 ? capacity : null;
private static int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
private static string BuildModeusTeacherEmail(string personId) =>
$"modeus-{personId}@modeus.local".ToLowerInvariant();
@@ -493,6 +488,37 @@ public class ScheduleSyncService : IScheduleSyncService
return details;
}
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var body = new Dictionary<string, object?>
{
["size"] = request.Size is > 0 ? request.Size.Value : 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax
};
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
return JsonSerializer.Serialize(body);
}
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static string? GetHrefId(string? href)
{
if (string.IsNullOrWhiteSpace(href))
@@ -74,17 +74,6 @@ public class UserService : IUserService
);
}
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
{
var usersCount = await _db.Users
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
var lecturesCount = await _db.Lectures.CountAsync();
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
}
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
{
if (!await _db.Users.AnyAsync(u => u.Id == id))
@@ -9,8 +9,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.84.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
<PackageReference Include="Quartz" Version="3.18.1" />
</ItemGroup>
@@ -11,7 +11,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
-4
View File
@@ -26,10 +26,6 @@ services:
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
- Llm:BaseUrl=${LLM_BASE_URL}
- Llm:ApiKey=${LLM_API_KEY}
- Llm:Model=${LLM_MODEL}
-143
View File
@@ -1,143 +0,0 @@
# Backend unit-тесты
## Назначение
Unit- и service-тесты backend проверяют бизнес-логику без запуска HTTP API:
- доменные правила записи на лекции;
- преобразование сущностей в DTO;
- фильтрацию, пагинацию и связи курсов;
- дерево тегов;
- управление ролями и профилями пользователей.
Security-тесты авторизации находятся в том же тестовом проекте, но это отдельный интеграционный набор: они запускают API через `WebApplicationFactory` и проверяют HTTP-доступ к endpoint-ам.
## Где лежат файлы
- [EnrollmentSlotPolicyTests.cs](../backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs) - правила лимита активных записей по уровню.
- [MappingExtensionsTests.cs](../backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs) - маппинг доменных сущностей в DTO.
- [CourseServiceTests.cs](../backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs) - фильтры, пагинация и теги курсов.
- [TagServiceTests.cs](../backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs) - фильтры тегов и построение дерева.
- [UserServiceTests.cs](../backend/UniVerse.Api.Tests/Users/UserServiceTests.cs) - статистика, роли, профили и список пользователей.
- [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs) - security-тесты ролевого доступа к API.
- [ApiWebApplicationFactory.cs](../backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs) - тестовый запуск API.
- [TestJwtFactory.cs](../backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs) - генерация JWT для ролей в security-тестах.
Тестовый проект: [UniVerse.Api.Tests.csproj](../backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj).
## Тестовый стек
- `xUnit` - test runner и assertions.
- `NSubstitute` - mock-объекты для сервисных зависимостей.
- `Microsoft.EntityFrameworkCore.InMemory` - изолированная InMemory БД для service-тестов.
Каждый service-тест создает отдельный `AppDbContext` с уникальным именем базы через `Guid.NewGuid()`, чтобы данные разных тестов не пересекались.
## Что покрыто
### EnrollmentSlotPolicy
Проверяется, что `GetLimitForLevel` выбирает последний подходящий threshold:
- уровни ниже первого правила получают базовый лимит;
- уровни между threshold используют предыдущий лимит;
- уровни выше последнего threshold используют максимальный лимит;
- публичный список `Rules` остается в ожидаемом порядке.
### MappingExtensions
Проверяется стабильность DTO-маппинга:
- роли пользователя сортируются одинаково в `UserDto`, `CurrentUserDto` и `UserAuthDto`;
- лекции корректно считают записи и используют fallback для отсутствующих navigation properties;
- отзывы переносят поля LLM-анализа;
- дерево тегов маппится рекурсивно.
### CourseService
Проверяется поведение без HTTP-слоя:
- совместная работа фильтров `Search`, `IsSynced`, `TagId`;
- корректные `TotalCount`, `Page`, `PageSize`, `TotalPages`;
- добавление связи курс-тег;
- ошибки при повторной связи или отсутствующем курсе/теге.
### TagService
Проверяется:
- фильтрация по `TagType` и `ParentId`;
- сортировка по имени;
- запрет создания дочернего тега без существующего родителя;
- построение вложенного дерева тегов.
### UserService
Проверяется:
- статистика пользователя, прогресс уровня и лимиты записей;
- `SetRolesAsync` удаляет дубли ролей;
- пустой набор ролей отклоняется;
- профили студента и преподавателя создаются и не дублируются;
- `GetAllAsync` фильтрует по поиску, активности и одиночной роли;
- пагинация пользователей идет в порядке `CreatedAt` по убыванию.
## Security-тесты авторизации
Security-тесты находятся в [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs). Это интеграционные тесты, которые отправляют реальные HTTP-запросы в тестовый API через `ApiWebApplicationFactory`.
Они проверяют не бизнес-результат endpoint-а, а сам факт прохождения или блокировки авторизации:
- анонимный запрос к защищенному endpoint-у получает `401 Unauthorized`;
- запрос с неподходящей ролью получает `403 Forbidden`;
- запрос с подходящей ролью не получает `401` или `403`;
- публичные endpoint-ы из `AnonymousEndpoints` доступны без JWT и не возвращают `401` от middleware авторизации.
Таблица защищенных endpoint-ов задается в методе `AuthenticatedEndpoints`. Каждый кейс описывает:
- человекочитаемое имя сценария;
- HTTP-метод;
- URL;
- роль, которая должна пройти авторизацию;
- роли, которые должны получить `403`;
- опциональное JSON-тело запроса.
Для endpoint-ов, доступных любой авторизованной роли, используется обычная тестовая роль, чаще `Student`, и пустой список запрещенных ролей. Для endpoint-ов с несколькими разрешенными ролями добавляется отдельный кейс на каждую разрешенную роль, например `Admin` и `Teacher`.
JWT для ролей создаются через `TestJwtFactory.BearerHeader(role)`. Это позволяет проверять backend-авторизацию без Microsoft OAuth flow и без реального входа пользователя.
## Как обновлять security-тесты
При добавлении или изменении API endpoint-а нужно обновить `EndpointAuthorizationTests`:
1. Если endpoint требует авторизации, добавьте его в `AuthenticatedEndpoints`.
2. Укажите правильную роль или отдельные кейсы для нескольких ролей.
3. Для role-specific endpoint-а заполните `forbidden` ролями, которые должны получать `403`.
4. Если endpoint публичный, добавьте его в `AnonymousEndpoints`.
5. Для `POST`, `PUT`, `PATCH` endpoint-ов добавьте минимальное валидное тело запроса, чтобы тест дошел до авторизации и не падал на model binding раньше времени.
Security-тест считается успешным для правильной роли, если ответ не `401` и не `403`. Это намеренно: после авторизации endpoint может вернуть `404`, `400`, `409` или другой доменный ответ из-за тестовых данных, и это не является ошибкой проверки доступа.
## Как запускать
Из корня репозитория:
```bash
dotnet test backend/UniVerse.sln --no-restore
```
## Как добавлять новые unit/service-тесты
1. Размещайте тесты рядом с проверяемой областью внутри `backend/UniVerse.Api.Tests`.
2. Для сервисов с EF используйте InMemory `AppDbContext` с уникальным именем базы.
3. Мокайте только внешние зависимости и соседние сервисы через `NSubstitute`.
4. Не запускайте `WebApplicationFactory`, если проверяется не HTTP/auth behavior.
5. Покрывайте не только успешный сценарий, но и доменные ошибки: `NotFoundException`, `ConflictException`, `ForbiddenException`.
## Текущий baseline
После добавления unit/service-тестов и с учетом существующих security-тестов полный backend test suite проходит:
```text
Passed: 303, Failed: 0, Skipped: 0
```
-120
View File
@@ -1,120 +0,0 @@
# Отчет по нагрузочному тестированию k6
Дата отчета: 2026-05-28
## Объект тестирования
- Стенд: `https://universe.zetcraft.ru`
- Скрипт: [`frontend/scripts/loadtest-endpoints.js`](../frontend/scripts/loadtest-endpoints.js)
- Endpoint'ы:
- `GET /api/v1/courses`
- `GET /api/v1/lectures`
- `GET /api/v1/users/me/stats`
## Профиль нагрузки
Тест запускался в 3 параллельных сценариях:
- `courses_list`
- `lectures_list`
- `user_stats`
Для каждого сценария использовалось `30 VU`, итого максимум `90 VU`.
Длительность активной нагрузки каждого сценария: `15s`.
С учетом `gracefulStop` максимальная длительность выполнения составила `45s`.
## Оборудование и сеть
Тест запускался с машины со следующей конфигурацией:
- CPU: AMD Ryzen 7 8845HS, `10` потоков использовалось для нагрузки.
- RAM: DDR5 5600, `10 GB` доступно.
- Накопитель: NVMe SSD.
- Сеть: `1 Gbit/s`.
## Критерии прохождения
- `checks: rate > 0.95`
- `http_req_duration: p(95) < 1500ms`
- `http_req_failed: rate < 0.01`
## Прогон 1: без паузы между итерациями
Команда запуска:
```bash
BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js
```
### Итоги
| Метрика | Значение |
| --- | ---: |
| Статус threshold'ов | пройдено |
| Успешность checks | 100.00% |
| Ошибки HTTP | 0.00% |
| Всего HTTP-запросов | 3508 |
| RPS | 77.95 req/s |
| `http_req_duration` avg | 769.41ms |
| `http_req_duration` med | 38.67ms |
| `http_req_duration` p(90) | 66.61ms |
| `http_req_duration` p(95) | 93.63ms |
| `http_req_duration` max | 36.14s |
| Всего итераций | 3508 |
| Прерванные итерации | 19 |
| Получено данных | 47 MB |
| Отправлено данных | 2.1 MB |
Проверки:
- `status is 200`: успешно.
- `body is not empty`: успешно.
## Прогон 2: пауза 1 секунда между итерациями
Команда запуска:
```bash
BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=1 k6 run ./frontend/scripts/loadtest-endpoints.js
```
### Итоги
| Метрика | Значение |
| --- | ---: |
| Статус threshold'ов | пройдено |
| Успешность checks | 100.00% |
| Ошибки HTTP | 0.00% |
| Всего HTTP-запросов | 895 |
| RPS | 19.89 req/s |
| `http_req_duration` avg | 336.11ms |
| `http_req_duration` med | 11.77ms |
| `http_req_duration` p(90) | 32.01ms |
| `http_req_duration` p(95) | 42.19ms |
| `http_req_duration` max | 35.9s |
| Всего итераций | 895 |
| Прерванные итерации | 43 |
| Получено данных | 12 MB |
| Отправлено данных | 675 kB |
Проверки:
- `status is 200`: успешно.
- `body is not empty`: успешно.
## Сравнение прогонов
| Параметр | Без паузы | Пауза 1s |
| --- | ---: | ---: |
| HTTP-запросов | 3508 | 895 |
| RPS | 77.95 req/s | 19.89 req/s |
| Ошибки HTTP | 0.00% | 0.00% |
| Checks | 100.00% | 100.00% |
| p(95) | 93.63ms | 42.19ms |
| Максимальная задержка | 36.14s | 35.9s |
## Вывод
Оба прогона успешно прошли заданные threshold'ы: ошибок HTTP не зафиксировано, все проверки ответов успешны, `p(95)` существенно ниже порога `1500ms`.
При запуске без паузы стенд обработал около `77.95 req/s`, при паузе `1s` - около `19.89 req/s`. Во всех прогонах наблюдались единичные длинные запросы до `35-36s`, при этом они не повлияли на прохождение p95-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек.
-94
View File
@@ -1,94 +0,0 @@
# Базовый нагрузочный тест (k6) для 3 крупных GET endpoint'ов
## Цель теста
Проверить, что при небольшой параллельной нагрузке API:
- отвечает без ошибок;
- сохраняет приемлемую задержку на «тяжелых» чтениях;
- не падает на endpoint пользовательской статистики.
Тест рассчитан на новичка: один скрипт, простые пороги, быстрый запуск.
## Какие endpoint используются
В тест включены:
1. `GET /api/v1/courses` — крупный список данных.
2. `GET /api/v1/lectures` — крупный список данных.
3. `GET /api/v1/users/me/stats` — endpoint с информацией о пользователе.
## Файл теста
- [loadtest-endpoints.js](../frontend/scripts/loadtest-endpoints.js)
## Предусловия перед запуском
1. Запущен API (локально или на тестовом стенде).
2. Если endpoint'ы требуют авторизацию — есть валидный JWT токен.
3. Установлен k6.
## Запуск
Без параметров (локальный API по умолчанию `http://localhost:5019`):
```bash
k6 run ./frontend/scripts/loadtest-endpoints.js
```
С параметрами окружения:
```bash
export TOKEN="<jwt>"
BASE_URL="http://localhost:5019" VUS=15 DURATION="2m" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js
```
## Что именно делает тест
Скрипт запускает **3 параллельных сценария**:
- `courses_list`
- `lectures_list`
- `user_stats`
Параметры каждого сценария:
- executor: `constant-vus`
- нагрузка: `10 VU`
- длительность: `2m`
- пауза между итерациями: `sleep(0.5)`
Итого базовый запуск создает до `30 VU` одновременно: по `10 VU` на каждый из 3 сценариев.
Переменные окружения:
- `VUS` — количество VU на каждый сценарий, по умолчанию `10`.
- `DURATION` — длительность каждого сценария, по умолчанию `2m`.
- `PAUSE_SECONDS` — пауза между итерациями, по умолчанию `0.5`.
На каждом запросе проверяется:
- статус ответа `200`;
- тело ответа не пустое.
## Пороговые значения (pass/fail)
- `http_req_failed: rate < 0.01` — ошибок менее 1%.
- `http_req_duration: p(95) < 1500` — 95% запросов быстрее 1.5с.
- `checks: rate > 0.95` — минимум 95% проверок успешны.
Если любой threshold не выполнен, k6 завершит запуск как failed.
## Как интерпретировать результат
После прогона посмотрите в summary:
1. `http_req_failed` — если выше 1%, есть проблема со стабильностью.
2. `http_req_duration p(95)` — если выше 1500ms, есть деградация по задержке.
3. `checks` — если ниже 95%, часть ответов не прошла базовую валидацию.
Минимальный формальный вывод для отчета:
- «Проведен базовый нагрузочный прогон k6 (3 endpoint'а, 10 VU на сценарий, 5 минут).»
- «Критерии: ошибки < 1%, p95 < 1500ms, checks > 95%.»
- «Статус: пройдено / не пройдено по итогам summary.»
-111
View File
@@ -1,111 +0,0 @@
# Playwright E2E тесты frontend
## Назначение
Playwright-тесты проверяют ключевые браузерные сценарии frontend-приложения UniVerse:
- редирект неавторизованного пользователя на страницу входа;
- отображение каталога открытых лекций;
- запись на доступную лекцию.
Тесты работают поверх production preview сборки frontend и используют mock API, поэтому для базового запуска не нужен поднятый backend.
## Где лежат файлы
- [playwright.config.ts](../frontend/playwright.config.ts) - конфигурация Playwright.
- [auth.spec.ts](../frontend/tests/e2e/auth.spec.ts) - сценарии аутентификации.
- [catalog.spec.ts](../frontend/tests/e2e/catalog.spec.ts) - сценарии каталога лекций.
- [mockApi.ts](../frontend/tests/e2e/support/mockApi.ts) - перехват и mock ответов API.
- [fixtures.ts](../frontend/tests/mocks/fixtures.ts) - тестовые данные.
## Как устроен запуск
Конфигурация находится во `frontend/playwright.config.ts`.
Основные параметры:
- `testDir: ./tests/e2e` - Playwright ищет тесты в папке `frontend/tests/e2e`.
- `baseURL: http://127.0.0.1:4173` - базовый адрес приложения в тестах.
- `webServer` запускает `pnpm preview --host 127.0.0.1 --port 4173`.
- В CI включены `2` retry и GitHub reporter.
- Локально используется list reporter.
- По умолчанию проект запускается в Chromium.
## Команды
Запуск всех E2E-тестов из корня репозитория:
```bash
pnpm -C frontend test:e2e
```
Интерактивный UI Playwright:
```bash
pnpm -C frontend test:e2e:ui
```
Если нужно вручную поднять preview-сервер:
```bash
pnpm -C frontend build-only
pnpm -C frontend test:e2e:preview
```
После этого можно запускать Playwright с переменной `PW_SKIP_WEB_SERVER=1`, чтобы он не стартовал свой `webServer`.
## Mock API
Тесты не обращаются к реальному backend. Вместо этого helper `mockApi(page, options)` перехватывает запросы к `/api/v1` через `page.route`.
Сейчас замоканы:
- `POST/GET /api/v1/auth/refresh` - refresh авторизации;
- `/api/v1/auth/me` - текущий пользователь;
- `/api/v1/users/me/stats` - статистика студента;
- `/api/v1/lectures` - список лекций;
- `/api/v1/lectures/{id}/enroll` - запись на лекцию.
Для авторизованного сценария используйте:
```ts
await mockApi(page, { authenticated: true })
```
Для проверки гостевого сценария:
```ts
await mockApi(page, { authenticated: false })
```
## Как добавлять новые тесты
1. Создавайте spec-файлы в `frontend/tests/e2e`.
2. Для страниц, которым нужен backend, сначала добавляйте нужные ответы в `mockApi.ts` и данные в `fixtures.ts`.
3. Проверяйте пользовательский результат через role/text/label locators: `getByRole`, `getByText`, `getByLabel`.
4. Не завязывайтесь на CSS-классы, если сценарий можно проверить через доступные пользователю элементы.
5. Для маршрутов под авторизацией вызывайте `mockApi(page, { authenticated: true })` до `page.goto(...)`.
## CI
Workflow находится в [.gitea/workflows/frontend-playwright.yml](../.gitea/workflows/frontend-playwright.yml).
Пайплайн:
1. Устанавливает зависимости через `pnpm install --frozen-lockfile`.
2. Собирает frontend командой `pnpm build-only`.
3. Устанавливает браузер Playwright Chromium.
4. Запускает `pnpm test:e2e`.
## Артефакты и отладка
Playwright сохраняет trace на первом retry: `trace: on-first-retry`.
Локально полезные команды:
```bash
pnpm -C frontend exec playwright show-report
pnpm -C frontend exec playwright show-trace ./test-results/<папка>/trace.zip
```
Папки `frontend/test-results` и `frontend/playwright-report` считаются временными артефактами тестовых прогонов.
-5
View File
@@ -35,10 +35,5 @@ coverage
# Vitest
__screenshots__/
# Playwright
/test-results/
/playwright-report/
/blob-report/
# Vite
*.timestamp-*-*.mjs
+1 -3
View File
@@ -5,8 +5,6 @@ import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
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:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
@@ -22,7 +20,7 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...(vueScopedCss.configs.recommended as VueTsConfig[]),
...vueScopedCss.configs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
+2 -7
View File
@@ -13,11 +13,7 @@
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"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"
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"pinia": "^3.0.4",
@@ -42,8 +38,7 @@
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6",
"@playwright/test": "^1.55.1"
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
-32
View File
@@ -1,32 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
const useSystemEdge = process.env.PW_USE_SYSTEM_EDGE === '1'
const skipWebServer = process.env.PW_SKIP_WEB_SERVER === '1'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: skipWebServer
? undefined
: {
command: 'pnpm preview --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
cwd: '.',
},
projects: [
{
name: useSystemEdge ? 'msedge' : 'chromium',
use: {
...devices['Desktop Chrome'],
...(useSystemEdge ? { channel: 'msedge' } : {}),
},
},
],
})
+4 -42
View File
@@ -18,9 +18,6 @@ importers:
specifier: ^5.0.6
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
@@ -436,11 +433,6 @@ 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==}
@@ -787,8 +779,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -1016,11 +1008,6 @@ 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}
@@ -1360,16 +1347,6 @@ 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'}
@@ -2034,10 +2011,6 @@ 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':
@@ -2414,7 +2387,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.6:
brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -2633,9 +2606,6 @@ snapshots:
flatted@3.4.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -2792,7 +2762,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
brace-expansion: 5.0.5
mitt@3.0.1: {}
@@ -2919,14 +2889,6 @@ 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
-66
View File
@@ -1,66 +0,0 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5019';
const TOKEN = __ENV.TOKEN || '';
const VUS = Number(__ENV.VUS || 10);
const DURATION = __ENV.DURATION || '2m';
const PAUSE_SECONDS = Number(__ENV.PAUSE_SECONDS || 0.5);
const headers = TOKEN
? { Authorization: `Bearer ${TOKEN}` }
: {};
export const options = {
scenarios: {
courses_list: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'coursesList',
},
lectures_list: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'lecturesList',
},
user_stats: {
executor: 'constant-vus',
vus: VUS,
duration: DURATION,
exec: 'userStats',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1500'],
checks: ['rate>0.95'],
},
};
function request(path, tag) {
const res = http.get(`${BASE_URL}${path}`, {
headers,
tags: { endpoint: tag },
});
check(res, {
'status is 200': (r) => r.status === 200,
'body is not empty': (r) => (r.body || '').length > 0,
});
sleep(PAUSE_SECONDS);
}
export function coursesList() {
request('/api/v1/courses?page=1&pageSize=50', 'courses_list');
}
export function lecturesList() {
request('/api/v1/lectures?page=1&pageSize=50', 'lectures_list');
}
export function userStats() {
request('/api/v1/users/me/stats', 'user_stats');
}
-2
View File
@@ -18,7 +18,6 @@ import type {
TagDto,
UpdateReviewPromptRequest,
UserAchievementDto,
AdminDashboardStatsDto,
CurrentUserDto,
UserDto,
UserQuery,
@@ -69,7 +68,6 @@ export const usersApi = {
body: JSON.stringify(payload),
}),
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
async myEnrollments() {
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
'/users/me/enrollments',
-7
View File
@@ -76,13 +76,6 @@ export interface UserStatsDto {
enrollmentSlotRules: EnrollmentSlotRuleDto[]
}
export interface AdminDashboardStatsDto {
usersCount: number
lecturesCount: number
enrollmentsCount: number
pendingReviewsCount: number
}
export interface EnrollmentSlotRuleDto {
level: number
slots: number
+5 -13
View File
@@ -1,16 +1,8 @@
<script setup lang="ts" generic="TRow extends object">
type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' }
<script setup lang="ts">
defineProps<{
columns: Column[]
rows: TRow[]
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
rows: Record<string, unknown>[]
}>()
defineSlots<Record<string, (props: { row: TRow; value: unknown }) => unknown>>()
function getCell(row: TRow, key: string): unknown {
return (row as Record<string, unknown>)[key]
}
</script>
<template>
@@ -26,8 +18,8 @@ function getCell(row: TRow, key: string): unknown {
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
<slot :name="col.key" :row="row" :value="getCell(row, col.key)">
{{ getCell(row, col.key) }}
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
+23 -14
View File
@@ -3,11 +3,17 @@ import { computed, onMounted, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { syncApi, usersApi } from '@/api'
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types'
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
const stats = ref<AdminDashboardStatsDto | null>(null)
const users = ref<UserDto[]>([])
const lectures = ref<LectureDto[]>([])
const pendingReviewsCount = ref(0)
const syncStatus = ref<SyncStatusDto | null>(null)
const enrollmentCount = computed(() =>
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
)
const syncMeta = computed(() =>
syncStatus.value?.lastSyncAt
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
@@ -15,8 +21,16 @@ const syncMeta = computed(() =>
)
onMounted(async () => {
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()])
if (statsResult.status === 'fulfilled') stats.value = statsResult.value
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }),
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
syncApi.status(),
])
if (usersResult.status === 'fulfilled') users.value = usersResult.value
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (reviewsResult.status === 'fulfilled')
pendingReviewsCount.value = reviewsResult.value.totalCount
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
})
</script>
@@ -26,17 +40,12 @@ onMounted(async () => {
<h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row">
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" />
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" />
<StatsWidget
label="Записей"
:value="stats?.enrollmentsCount ?? 0"
icon="calendar-event"
color="orange"
/>
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
<StatsWidget
label="Отзывы на проверке"
:value="stats?.pendingReviewsCount ?? 0"
:value="pendingReviewsCount"
icon="message-circle"
color="purple"
/>
@@ -17,10 +17,9 @@ 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: DataTableColumn[]
columns: Array<{ key: string; label: string; align?: string }>
rows: Record<string, unknown>[]
}
@@ -162,7 +161,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
},
}
const current = computed<TabConfig>(() => {
const current = computed(() => {
const config = tabConfig[activeTab.value]
if (activeTab.value === 'lectures') {
return {
@@ -7,9 +7,7 @@ import EmptyState from '@/components/ui/EmptyState.vue'
import { reviewsApi } from '@/api'
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [
const columns = [
{ key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' },
@@ -297,8 +295,8 @@ onMounted(() => {
</div>
</div>
<DataTable :columns="columns" :rows="rows">
<template #text="{ row }">
<span class="review-text" :title="row.text">{{ row.text }}</span>
<template #text="{ value }">
<span class="review-text" :title="value">{{ value }}</span>
</template>
<template #analysis="{ row }">
<div class="analysis-cell">
+1 -3
View File
@@ -13,9 +13,7 @@ const users = ref<UserDto[]>([])
const loading = ref(false)
const error = ref('')
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [
const columns = [
{ key: 'name', label: 'Имя' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Роль', align: 'center' },
+1 -3
View File
@@ -11,8 +11,6 @@ import ModalDialog from '@/components/ui/ModalDialog.vue'
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
const lecturesStore = useLecturesStore()
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const search = ref('')
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
const dateFilter = ref('Любая дата')
@@ -104,7 +102,7 @@ const appliedFilters = computed(() => {
return filters
})
const tableColumns: DataTableColumn[] = [
const tableColumns = [
{ key: 'title', label: 'Лекция' },
{ key: 'teacher', label: 'Преподаватель' },
{ key: 'date', label: 'Дата' },
@@ -9,6 +9,7 @@ import { mapApiReview } from '@/api/mappers'
import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth'
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
const lecturesStore = useLecturesStore()
const auth = useAuthStore()
const reviews = ref<Review[]>([])
@@ -16,9 +17,8 @@ const reviews = ref<Review[]>([])
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
const total = computed(() => reviews.value.length)
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0)
const ratio = (value: number) => `${value}/${total.value}`
const total = computed(() => reviews.value.length || 1)
const pct = (value: number) => Math.round((value / total.value) * 100)
async function fetchTeacherAnalytics() {
if (!auth.user?.id) return
@@ -39,28 +39,37 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<h1 class="page-title">Аналитика преподавателя</h1>
<div class="grid">
<GlassCard>
<div class="section-title">Динамика оценок</div>
<div class="chart">
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
<span class="bar-label">Нед {{ i + 1 }}</span>
</div>
</div>
<div class="avg">Средняя оценка: 4.6</div>
</GlassCard>
<GlassCard>
<div class="section-title">Sentiment-анализ отзывов</div>
<div class="sentiment">
<div>
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
<ProgressBar :value="pct(positive)" :max="100" />
</div>
<div>
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div>
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar
:value="pct(neutral)"
:max="100"
:text="ratio(neutral)"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/>
</div>
<div>
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div>
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar
:value="pct(negative)"
:max="100"
:text="ratio(negative)"
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/>
</div>
@@ -108,6 +117,32 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.sentiment {
display: flex;
flex-direction: column;
@@ -20,6 +20,9 @@ const upcoming = computed(() =>
const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
)
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() {
if (!auth.user?.id) return
@@ -45,7 +48,13 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<div class="stats-row">
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" />
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div>
<GlassCard>
@@ -8,9 +8,8 @@ import StatusBadge from '@/components/ui/StatusBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
const auth = useAuthStore()
const lecturesStore = useLecturesStore()
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [
const columns = [
{ key: 'title', label: 'Лекция' },
{ key: 'date', label: 'Дата' },
{ key: 'status', label: 'Статус', align: 'center' },
@@ -51,8 +50,8 @@ watch(() => auth.user?.id, fetchTeacherLectures)
subtitle="Backend не вернул лекции для текущего преподавателя."
/>
<DataTable :columns="columns" :rows="rows">
<template #status="{ row }">
<StatusBadge :status="row.status" />
<template #status="{ value }">
<StatusBadge :status="value" />
</template>
<template #actions>
<div class="actions">
-9
View File
@@ -1,9 +0,0 @@
import { test, expect } from '@playwright/test'
import { mockApi } from './support/mockApi'
test('redirects unauthenticated user to login', async ({ page }) => {
await mockApi(page, { authenticated: false })
await page.goto('/catalog')
await expect(page).toHaveURL(/\/login/)
await expect(page.getByText('Войти через ЮФУ')).toBeVisible()
})
-23
View File
@@ -1,23 +0,0 @@
import { expect, test } from '@playwright/test'
import { mockApi } from './support/mockApi'
test.beforeEach(async ({ page }) => {
await mockApi(page, { authenticated: true })
})
test('renders catalog items from mock api', async ({ page }) => {
await page.goto('/catalog')
await expect(page.getByRole('heading', { name: 'Каталог открытых лекций' })).toBeVisible()
await expect(page.getByText('Введение в ML')).toBeVisible()
await expect(page.getByText('Квантовые вычисления')).toBeVisible()
})
test('register button works for available lecture', async ({ page }) => {
await page.goto('/catalog')
const firstRegisterButton = page.getByRole('button', { name: 'Записаться' }).first()
await firstRegisterButton.click()
await expect(page.getByText('Вы записаны на лекцию.')).toBeVisible()
})
-74
View File
@@ -1,74 +0,0 @@
import { Page } from '@playwright/test'
import { mockAuthResponse, mockCurrentUser, mockLectures, mockUserStats } from '../../mocks/fixtures'
export async function mockApi(page: Page, options?: { authenticated?: boolean }) {
const authenticated = options?.authenticated ?? false
await page.route('**/api/v1/auth/refresh', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAuthResponse),
})
})
await page.route('**/api/v1/auth/me', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockCurrentUser),
})
})
await page.route('**/api/v1/users/me/stats', async (route) => {
if (!authenticated) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Unauthorized' }),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUserStats),
})
})
await page.route('**/api/v1/lectures/*/enroll', async (route) => {
await route.fulfill({ status: 204 })
})
await page.route(/\/api\/v1\/lectures(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: mockLectures }),
})
})
}
-80
View File
@@ -1,80 +0,0 @@
export const mockAuthResponse = {
accessToken: 'fake-token',
expiresAt: '2099-01-01T00:00:00.000Z',
user: {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
},
}
export const mockCurrentUser = {
id: 1,
email: 'student@example.com',
displayName: 'Test User',
roles: ['Student'],
avatarUrl: null,
xp: 120,
coins: 10,
level: 2,
createdAt: '2026-01-01T00:00:00.000Z',
}
export const mockUserStats = {
totalLectures: 0,
attendedLectures: 0,
totalReviews: 0,
xp: 120,
coins: 10,
level: 2,
achievementsCount: 0,
currentLevelXp: 20,
nextLevelXp: 100,
activeEnrollments: 0,
enrollmentSlotLimit: 3,
enrollmentSlotRules: [],
}
export const mockLectures = [
{
id: 1,
courseId: 101,
courseName: 'ML',
teacherId: 201,
teacherName: 'Иванов И.И.',
locationId: 301,
locationName: 'B-1 / 101',
title: 'Введение в ML',
description: 'База машинного обучения',
format: 'Offline',
startsAt: '2026-06-12T10:00:00.000Z',
endsAt: '2026-06-12T11:30:00.000Z',
isOpen: true,
maxEnrollments: 30,
enrollmentsCount: 10,
onlineUrl: null,
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
{
id: 2,
courseId: 102,
courseName: 'квантовые-вычисления',
teacherId: 202,
teacherName: 'Петров П.П.',
locationId: null,
locationName: null,
title: 'Квантовые вычисления',
description: 'Кубиты и алгоритмы',
format: 'Online',
startsAt: '2026-06-13T10:00:00.000Z',
endsAt: '2026-06-13T11:00:00.000Z',
isOpen: false,
maxEnrollments: 50,
enrollmentsCount: 50,
onlineUrl: 'https://example.com/meet',
createdAt: '2026-01-01T00:00:00.000Z',
isEnrolled: false,
},
]
+1 -6
View File
@@ -1,8 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"baseBranchPatterns": ["dev"],
"enabledManagers": ["nuget", "npm"],
"npm": {
"managerFilePatterns": ["/^frontend/package\\.json$/"]
}
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}