From 65e3d1bf18d8ebf89781c5a3f567caeb2170fa84 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Wed, 13 May 2026 19:49:51 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BB=D0=B5=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sync/ScheduleSyncServiceTests.cs | 133 ++++++++++++++++++ .../Services/ScheduleSyncService.cs | 76 ++++++++-- frontend/src/api/mappers.ts | 1 + frontend/src/assets/main.css | 2 +- frontend/src/components/ui/LectureCard.vue | 2 +- frontend/src/stores/lectures.ts | 2 + frontend/src/types/index.ts | 1 + frontend/src/views/student/CatalogView.vue | 2 +- .../src/views/student/LectureDetailView.vue | 2 +- .../views/teacher/TeacherDashboardView.vue | 4 +- .../src/views/teacher/TeacherLecturesView.vue | 2 +- 11 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs diff --git a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs new file mode 100644 index 0000000..d5ca62e --- /dev/null +++ b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using UniVerse.Application.DTOs.Sync; +using UniVerse.Application.Interfaces; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Sync; + +public class ScheduleSyncServiceTests +{ + [Fact] + public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats() + { + await using var db = CreateDbContext(); + var modeus = Substitute.For(); + modeus.SearchEventsAsync(Arg.Any()) + .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) + } + ], + 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: 60, WorkingCapacity: 42) + ], + EventTeams = + [ + new ModeusEventTeam("event-1", 15) + ] + } + }); + + var service = new ScheduleSyncService(db, modeus, NullLogger.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(42, lecture.MaxEnrollments); + } + + [Fact] + public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity() + { + await using var db = CreateDbContext(); + var modeus = Substitute.For(); + modeus.SearchEventsAsync(Arg.Any()) + .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) + } + ], + 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: null, WorkingCapacity: null) + ], + EventTeams = + [ + new ModeusEventTeam("event-1", 15) + ] + } + }); + modeus.SearchRoomsAsync() + .Returns(new ModeusRoomsResponse + { + Rooms = + [ + new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48) + ] + }); + + var service = new ScheduleSyncService(db, modeus, NullLogger.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(48, lecture.MaxEnrollments); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 250127d..7165000 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -27,6 +27,9 @@ public class ScheduleSyncService : IScheduleSyncService try { var events = await _modeus.SearchEventsAsync(request); + var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms); + IReadOnlyDictionary? syncedRoomCapacityById = null; + foreach (var ev in events.EventItems) { if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name)) @@ -41,8 +44,15 @@ public class ScheduleSyncService : IScheduleSyncService var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; var courseName = courseUnit?.Name ?? ev.Name; var location = await UpsertEventLocationAsync(events, ev.Id); - var maxEnrollments = events.Embedded?.EventTeams? - .FirstOrDefault(team => team.EventId == ev.Id)?.Size ?? 0; + var roomId = GetEventRoomId(events, ev.Id); + var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); + if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) + { + syncedRoomCapacityById ??= await LoadRoomCapacityLookupAsync(); + maxEnrollments = GetRoomCapacity(syncedRoomCapacityById, roomId); + } + + var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; var startsAt = EnsureUtc(ev.StartsAt); var endsAt = EnsureUtc(ev.EndsAt); @@ -54,7 +64,7 @@ public class ScheduleSyncService : IScheduleSyncService existing.StartsAt = startsAt; existing.EndsAt = endsAt; existing.LocationId = location?.Id; - existing.MaxEnrollments = maxEnrollments; + existing.MaxEnrollments = lectureCapacity; existing.UpdatedAt = DateTime.UtcNow; updated++; } @@ -77,7 +87,7 @@ public class ScheduleSyncService : IScheduleSyncService ExternalId = ev.Id, StartsAt = startsAt, EndsAt = endsAt, - MaxEnrollments = maxEnrollments + MaxEnrollments = lectureCapacity }); created++; } @@ -182,14 +192,7 @@ public class ScheduleSyncService : IScheduleSyncService private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) { - var roomId = events.Embedded?.EventRooms? - .Select(eventRoom => new - { - EventId = GetHrefId(eventRoom.Links?.Event?.Href), - RoomId = GetHrefId(eventRoom.Links?.Room?.Href) - }) - .FirstOrDefault(link => link.EventId == eventId) - ?.RoomId; + var roomId = GetEventRoomId(events, eventId); if (string.IsNullOrWhiteSpace(roomId)) return null; @@ -222,6 +225,55 @@ public class ScheduleSyncService : IScheduleSyncService return location; } + private static string? GetEventRoomId(ModeusEventsResponse events, string eventId) => + events.Embedded?.EventRooms? + .Select(eventRoom => new + { + EventId = GetHrefId(eventRoom.Links?.Event?.Href), + RoomId = GetHrefId(eventRoom.Links?.Room?.Href) + }) + .FirstOrDefault(link => link.EventId == eventId) + ?.RoomId; + + private async Task> LoadRoomCapacityLookupAsync() + { + try + { + var rooms = await _modeus.SearchRoomsAsync(); + return BuildRoomCapacityLookup(rooms.RoomItems); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load room capacities from Modeus rooms search."); + return new Dictionary(); + } + } + + private static IReadOnlyDictionary BuildRoomCapacityLookup(IEnumerable? rooms) + { + var result = new Dictionary(); + foreach (var room in rooms ?? []) + { + var capacity = NormalizeCapacity(room.WorkingCapacity) ?? NormalizeCapacity(room.TotalCapacity); + if (!string.IsNullOrWhiteSpace(room.Id) && capacity.HasValue) + result.TryAdd(room.Id, capacity.Value); + } + + return result; + } + + private static int? GetRoomCapacity(IReadOnlyDictionary roomCapacityById, string? roomId) => + !string.IsNullOrWhiteSpace(roomId) && roomCapacityById.TryGetValue(roomId, out var capacity) + ? capacity + : null; + + private static int? GetEventTeamSize(ModeusEventsResponse events, string eventId) => + NormalizeCapacity(events.Embedded?.EventTeams? + .FirstOrDefault(team => team.EventId == eventId)?.Size); + + private static int? NormalizeCapacity(int? capacity) => + capacity is > 0 ? capacity : null; + private static IReadOnlyList BuildErrorDetails( Exception exception, string stage, diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index a3626c5..6d4fe6f 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -76,6 +76,7 @@ export function mapApiLecture(lecture: LectureDto): Lecture { room: lecture.format === 'Online' ? undefined : locationName, format: lecture.format === 'Online' ? 'online' : 'offline', totalSeats, + enrolledSeats: enrolled, freeSeats, registrationClosed: !lecture.isOpen, tags: lecture.courseName ? [`#${lecture.courseName}`] : [], diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 7ca78da..eea430c 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -217,7 +217,7 @@ input, textarea, select { font-weight: 600; white-space: nowrap; } -.badge-green { background: rgba(34,197,94,0.15); color: #15803D; border: 1px solid rgba(34,197,94,0.3); } +.badge-green { color: #15803D; border: 1.5px solid rgba(34,197,94,0.3); } .badge-blue { background: rgba(6,182,212,0.15); color: #0E7490; border: 1px solid rgba(6,182,212,0.3); } .badge-orange { background: rgba(251,146,60,0.15); color: #C2410C; border: 1px solid rgba(251,146,60,0.3); } .badge-gray { background: rgba(100,116,139,0.1); color: #64748B; border: 1px solid rgba(100,116,139,0.2); } diff --git a/frontend/src/components/ui/LectureCard.vue b/frontend/src/components/ui/LectureCard.vue index a3d090f..7cedb4b 100644 --- a/frontend/src/components/ui/LectureCard.vue +++ b/frontend/src/components/ui/LectureCard.vue @@ -42,7 +42,7 @@ function goDetail() { > diff --git a/frontend/src/stores/lectures.ts b/frontend/src/stores/lectures.ts index cb55457..84f6a28 100644 --- a/frontend/src/stores/lectures.ts +++ b/frontend/src/stores/lectures.ts @@ -78,6 +78,7 @@ export const useLecturesStore = defineStore('lectures', () => { await lecturesApi.enroll(lectureId) registered.value.push(lectureId) lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0) + lecture.enrolledSeats += 1 lecture.registered = true } @@ -87,6 +88,7 @@ export const useLecturesStore = defineStore('lectures', () => { const lecture = lectures.value.find(item => item.id === lectureId) if (lecture) { lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats) + lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0) lecture.registered = false } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 23943f7..331d94a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -34,6 +34,7 @@ export interface Lecture { room?: string format: 'online' | 'offline' totalSeats: number + enrolledSeats: number freeSeats: number registrationClosed?: boolean tags: string[] diff --git a/frontend/src/views/student/CatalogView.vue b/frontend/src/views/student/CatalogView.vue index 77666db..e5d0550 100644 --- a/frontend/src/views/student/CatalogView.vue +++ b/frontend/src/views/student/CatalogView.vue @@ -225,7 +225,7 @@ async function registerLecture(id: string) {