feat: добавил ограничение записи на лекции
Backend CI / build-and-test (push) Failing after 32s
Frontend CI / build-and-check (push) Failing after 5m5s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 1m28s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped

This commit is contained in:
2026-05-21 19:34:08 +03:00
parent 32b8bdfd24
commit 2e7ce6c2e8
21 changed files with 569 additions and 23 deletions
@@ -161,7 +161,19 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0, 0, 100));
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(
0,
0,
0,
0,
0,
1,
0,
0,
100,
0,
3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
@@ -4,6 +4,7 @@ using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
@@ -92,6 +93,79 @@ public class LectureServiceTests
Arg.Any<CancellationToken>());
}
[Theory]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(5, 7)]
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
for (var i = 1; i <= activeEnrollments; i++)
{
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
{
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 now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
{
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 now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
}
await db.SaveChangesAsync();
await service.EnrollAsync(100, 1);
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
@@ -44,6 +44,33 @@ public class UserServiceTests
Assert.Null(stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, now.AddDays(1)),
Lecture(2, now.AddDays(2)),
Lecture(3, now.AddDays(-1)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { LectureId = 1, UserId = 1 },
new LectureEnrollment { LectureId = 2, UserId = 1 },
new LectureEnrollment { LectureId = 3, UserId = 1 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.ActiveEnrollments);
Assert.Equal(5, stats.EnrollmentSlotLimit);
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -77,4 +104,15 @@ public class UserServiceTests
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
}
+29
View File
@@ -5135,6 +5135,20 @@
},
"additionalProperties": false
},
"EnrollmentSlotRuleDto": {
"type": "object",
"properties": {
"level": {
"type": "integer",
"format": "int32"
},
"slots": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"LectureDetailDto": {
"type": "object",
"properties": {
@@ -6104,6 +6118,21 @@
"type": "integer",
"format": "int32",
"nullable": true
},
"activeEnrollments": {
"type": "integer",
"format": "int32"
},
"enrollmentSlotLimit": {
"type": "integer",
"format": "int32"
},
"enrollmentSlotRules": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EnrollmentSlotRuleDto"
},
"nullable": true
}
},
"additionalProperties": false
@@ -35,9 +35,14 @@ public record UserStatsDto(
int Level,
int AchievementsCount,
int CurrentLevelXp,
int? NextLevelXp
int? NextLevelXp,
int ActiveEnrollments,
int EnrollmentSlotLimit,
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record UpdateUserRequest(
string? DisplayName,
string? AvatarUrl
@@ -0,0 +1,21 @@
namespace UniVerse.Domain.Services;
public static class EnrollmentSlotPolicy
{
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
[
new(1, 3),
new(3, 5),
new(4, 7)
];
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
public static int GetLimitForLevel(int level) =>
SlotRules
.Where(rule => rule.Level <= level)
.OrderBy(rule => rule.Level)
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
}
public sealed record EnrollmentSlotRule(int Level, int Slots);
@@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces;
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Exceptions;
using UniVerse.Domain.Services;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Infrastructure.Services;
@@ -124,6 +125,15 @@ public class LectureService : ILectureService
throw new ConflictException("Lecture is full.");
if (lecture.Enrollments.Any(e => e.UserId == userId))
throw new ConflictException("Already enrolled.");
var level = await _gamification.CalculateLevelAsync(user.Xp);
var enrollmentLimit = EnrollmentSlotPolicy.GetLimitForLevel(level);
var activeEnrollments = await _db.LectureEnrollments
.CountAsync(e => e.UserId == userId && !e.Attended);
if (activeEnrollments >= enrollmentLimit)
throw new ConflictException("Enrollment limit reached for your level.");
_db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId });
await _db.SaveChangesAsync();
await ScheduleLectureRemindersAsync(lecture, user);
@@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces;
using UniVerse.Application.Mappings;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Domain.Services;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Infrastructure.Services;
@@ -55,14 +56,21 @@ public class UserService : IUserService
var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended);
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
var activeEnrollments = await _db.LectureEnrollments
.CountAsync(e => e.UserId == id && !e.Attended);
var level = await _gamification.CalculateLevelAsync(user.Xp);
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
var slotLimit = EnrollmentSlotPolicy.GetLimitForLevel(level);
var slotRules = EnrollmentSlotPolicy.Rules
.Select(rule => new EnrollmentSlotRuleDto(rule.Level, rule.Slots))
.ToList();
return new UserStatsDto(
totalLectures, attended, reviews,
user.Xp, user.Coins, level, achievements,
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp,
activeEnrollments, slotLimit, slotRules
);
}