feat: перелопатил синхронизацию преподавателей
Backend CI / build-and-test (push) Failing after 13m11s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 10m12s
Frontend CI / build-and-check (push) Failing after 16m9s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m58s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s

This commit is contained in:
2026-05-24 21:06:03 +03:00
parent 6aef5dd66f
commit 99d25adbb1
33 changed files with 1756 additions and 90 deletions
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
@@ -60,6 +62,37 @@ public class AuthServiceTests
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
}
[Fact]
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 10,
Email = "modeus-person-1@modeus.local",
DisplayName = "Иванов Иван Иванович",
MicrosoftId = "sso-sub-1",
IsActive = true,
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
});
await db.SaveChangesAsync();
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
var service = CreateService(db, microsoftAuth);
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
Assert.Equal(10, result.Response.User.Id);
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
Assert.Single(await db.Users.ToListAsync());
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", user.MicrosoftId);
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -68,7 +101,7 @@ public class AuthServiceTests
return new AppDbContext(options);
}
private static AuthService CreateService(AppDbContext db)
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -88,6 +121,18 @@ public class AuthServiceTests
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return new AuthService(db, config, gamification, notifications, NullLogger<AuthService>.Instance);
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
}
private static string BuildIdToken(string sub, string email, string name)
{
var token = new JwtSecurityToken(claims:
[
new Claim(JwtRegisteredClaimNames.Sub, sub),
new Claim("preferred_username", email),
new Claim("name", name)
]);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -98,6 +98,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
});
}
@@ -116,7 +117,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
var stub = Substitute.For<IAuthService>();
var authResult = new AuthResult(
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
new UserAuthDto("test@test.com", "Test User", [UserRole.Student])),
new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])),
"refresh_token");
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
.Returns(authResult);
@@ -124,7 +125,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
.Returns(new CurrentUserDto("test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
return stub;
}
@@ -199,12 +200,12 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>()).Returns(lectureDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(lectureDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedEnrollments);
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(pagedEnrollments);
return stub;
}
@@ -220,7 +221,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
@@ -187,6 +187,39 @@ public class LectureServiceTests
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
}
[Fact]
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Empty(result.Items);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -1,9 +1,11 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Common;
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;
@@ -65,6 +67,29 @@ public class ReviewServiceTests
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
}
[Fact]
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Single(result.Items);
}
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
{
var gamification = Substitute.For<IGamificationService>();
@@ -72,7 +97,7 @@ public class ReviewServiceTests
return new ReviewService(db, gamification, queue);
}
private static async Task SeedLectureAsync(AppDbContext db)
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
@@ -80,6 +105,7 @@ public class ReviewServiceTests
{
Id = 1,
CourseId = 1,
TeacherId = teacherId,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
@@ -89,9 +115,9 @@ public class ReviewServiceTests
await db.SaveChangesAsync();
}
private static async Task SeedAnalyzedReviewAsync(AppDbContext db)
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
{
await SeedLectureAsync(db);
await SeedLectureAsync(db, teacherId);
db.Reviews.Add(new Review
{
Id = 1,
@@ -12,6 +12,11 @@ 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()
{
@@ -149,6 +154,138 @@ public class ScheduleSyncServiceTests
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<ScheduleSyncService>.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<ScheduleSyncService>.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<ScheduleSyncService>.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<ScheduleSyncService>.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<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -160,10 +297,7 @@ public class ScheduleSyncServiceTests
private static ModeusEventsResponse BuildEventsResponse()
{
const string eventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
const string courseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
const string personId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
return new ModeusEventsResponse
{
@@ -173,25 +307,25 @@ public class ScheduleSyncServiceTests
[
new ModeusEvent
{
Id = eventId,
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}")
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
}
}
],
CourseUnitRealizations =
[
new ModeusCourseUnitRealization(
courseId,
CourseId,
"Управление проектами разработки программного обеспечения",
"УПРПО")
],
EventTeams = [new ModeusEventTeam(eventId, 25)],
EventTeams = [new ModeusEventTeam(EventId, 25)],
EventAttendees =
[
new ModeusEventAttendee
@@ -201,30 +335,41 @@ public class ScheduleSyncServiceTests
RoleName = "Преподаватель",
Links = new ModeusEventAttendeeLinks
{
Event = new ModeusHrefLink($"/{eventId}"),
Person = new ModeusHrefLink($"/{personId}")
Event = new ModeusHrefLink($"/{EventId}"),
Person = new ModeusHrefLink($"/{PersonId}")
}
}
],
Persons =
[
new ModeusPerson(
personId,
PersonId,
"Иванов",
"Иван",
"Иванович",
"Иванов Иван Иванович")
FullName)
]
}
};
}
private sealed class FakeModeusApiClient(ModeusEventsResponse events) : IModeusApiClient
private sealed class FakeModeusApiClient(
ModeusEventsResponse events,
string? subId = null,
bool throwOnSubLookup = false) : IModeusApiClient
{
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
{
if (throwOnSubLookup)
throw new HttpRequestException("lookup failed");
return Task.FromResult(subId);
}
}
}