using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; namespace UniVerse.Api.Tests.Sync; public class ScheduleSyncServiceTests { private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a"; private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73"; private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440"; private const string FullName = "Иванов Иван Иванович"; [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); } [Fact] public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats() { 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), 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.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() { await using var db = CreateDbContext(); var modeus = new FakeModeusApiClient(BuildEventsResponse()); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); Assert.Equal(1, result.Created); var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync(); Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName); Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email); var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync(); Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId); Assert.Equal(teacherProfile.UserId, lecture.TeacherId); var teacherRole = await db.UserRoles.SingleAsync(); Assert.Equal(lecture.TeacherId, teacherRole.UserId); Assert.Equal(UserRole.Teacher, teacherRole.Role); } [Fact] public async Task SyncScheduleAsync_SavesResolvedTeacherSubId() { await using var db = CreateDbContext(); var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1"); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync(); Assert.Equal("sso-sub-1", teacher.MicrosoftId); Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email); Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId); } [Fact] public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails() { await using var db = CreateDbContext(); var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync(); Assert.Null(teacher.MicrosoftId); Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email); Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId); } [Fact] public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser() { await using var db = CreateDbContext(); db.Users.Add(new UniVerse.Domain.Entities.User { Id = 77, Email = "teacher@sfedu.ru", DisplayName = "Old Name", MicrosoftId = "sso-sub-1", Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }] }); await db.SaveChangesAsync(); var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1"); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); Assert.Single(await db.Users.ToListAsync()); var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync(); Assert.Equal(77, teacher.Id); Assert.Equal("teacher@sfedu.ru", teacher.Email); Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student); Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher); Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId); Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77)); } [Fact] public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry() { await using var db = CreateDbContext(); var placeholder = new UniVerse.Domain.Entities.User { Id = 10, Email = $"modeus-{PersonId}@modeus.local", DisplayName = FullName, Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }], TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId } }; db.Users.Add(placeholder); db.Users.Add(new UniVerse.Domain.Entities.User { Id = 20, Email = "teacher@sfedu.ru", DisplayName = FullName, MicrosoftId = "sso-sub-1", Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }] }); db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true }); db.Lectures.Add(new UniVerse.Domain.Entities.Lecture { Id = 1, CourseId = 1, TeacherId = 10, ExternalId = EventId, Title = "Old", StartsAt = DateTime.UtcNow, EndsAt = DateTime.UtcNow.AddHours(1) }); await db.SaveChangesAsync(); var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1"); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); Assert.Single(await db.Users.ToListAsync()); var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync(); Assert.Equal(20, realUser.Id); Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId); Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher); Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20)); } [Fact] public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId() { await using var db = CreateDbContext(); db.Users.Add(new UniVerse.Domain.Entities.User { Id = 10, Email = "teacher@sfedu.ru", DisplayName = FullName, MicrosoftId = "sso-sub-1", Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }], TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId } }); await db.SaveChangesAsync(); var modeus = Substitute.For(); modeus.SearchEventsAsync(Arg.Any()).Returns(BuildEventsResponse()); var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); Assert.Null(result.Error); await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any(), Arg.Any()); } private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}") .Options; return new AppDbContext(options); } private static ModeusEventsResponse BuildEventsResponse() { const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7"; return new ModeusEventsResponse { Embedded = new ModeusEventsEmbedded { Events = [ new ModeusEvent { Id = EventId, Name = "Тема 20. Управление ресурсами проекта. Часть 2.", TypeId = "LAB", StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc), EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc), Links = new ModeusEventLinks { CourseUnitRealization = new ModeusHrefLink($"/{CourseId}") } } ], CourseUnitRealizations = [ new ModeusCourseUnitRealization( CourseId, "Управление проектами разработки программного обеспечения", "УПРПО") ], EventTeams = [new ModeusEventTeam(EventId, 25)], EventAttendees = [ new ModeusEventAttendee { Id = attendeeId, RoleId = "TEACH", RoleName = "Преподаватель", Links = new ModeusEventAttendeeLinks { Event = new ModeusHrefLink($"/{EventId}"), Person = new ModeusHrefLink($"/{PersonId}") } } ], Persons = [ new ModeusPerson( PersonId, "Иванов", "Иван", "Иванович", FullName) ] } }; } private sealed class FakeModeusApiClient( ModeusEventsResponse events, string? subId = null, bool throwOnSubLookup = false) : IModeusApiClient { public Task SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events); public Task SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse()); public Task> SearchEmployeeAsync(string fullname) => Task.FromResult(new List()); public Task GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default) { if (throwOnSubLookup) throw new HttpRequestException("lookup failed"); return Task.FromResult(subId); } } }