feat: добавил новые юнит-тесты для сервисов и маппинга
Backend CI / build-and-test (push) Successful in 48s
🚀 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 24s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 2s
Backend CI / build-and-test (push) Successful in 48s
🚀 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 24s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 2s
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
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,
|
||||||
|
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(2, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using UniVerse.Application.DTOs.Notifications;
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.DTOs.Users;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
|
using UniVerse.Domain.Enums;
|
||||||
|
using UniVerse.Domain.Exceptions;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -71,6 +74,125 @@ public class UserServiceTests
|
|||||||
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
|
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()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -115,4 +237,31 @@ public class UserServiceTests
|
|||||||
IsOpen = true,
|
IsOpen = true,
|
||||||
MaxEnrollments = 30
|
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()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user