using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; namespace UniVerse.Api.Tests.Gamification; public class GamificationServiceTests { [Fact] public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce() { await using var db = CreateDbContext(); SeedLevelThresholds(db); var service = CreateService(db); db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student", AvatarUrl = "avatar.png", Xp = 100, Coins = 510 }); db.Courses.Add(new Course { Id = 1, Name = "Course" }); db.Lectures.Add(new Lecture { Id = 1, CourseId = 1, Title = "Future lecture", StartsAt = DateTime.UtcNow.AddDays(1), EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2), IsOpen = true }); db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 }); db.Reviews.AddRange( new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like }, new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral }, new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike }); db.CoinTransactions.Add(new CoinTransaction { UserId = 1, Amount = 510, Type = CoinTransactionType.AdminAdjustment, Description = "Initial coins" }); db.Achievements.AddRange( Achievement(1001, "First activity", "first_activity:1", 10), Achievement(1002, "Reviews", "reviews_written:3", 20), Achievement(1003, "Active registrations", "active_registrations:1", 30), Achievement(1004, "Coins earned", "coins_earned:500", 40), Achievement(1005, "Level reached", "level_reached:2", 50), Achievement(1006, "Profile completed", "profile_completed:1", 60), Achievement(1007, "Old condition", "reviews_1", 100)); await db.SaveChangesAsync(); await service.CheckAndAwardAchievementsAsync(1); await service.CheckAndAwardAchievementsAsync(1); var user = await db.Users.FindAsync(1); Assert.NotNull(user); Assert.Equal(720, user!.Coins); Assert.Equal(310, user.Xp); Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1)); Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007)); Assert.Equal(6, await db.CoinTransactions.CountAsync(ct => ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward)); } [Fact] public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears() { await using var db = CreateDbContext(); SeedLevelThresholds(db); var service = CreateService(db); db.Users.Add(new User { Id = 1, Email = "student@test.local" }); db.Courses.Add(new Course { Id = 1, Name = "Course" }); db.Lectures.AddRange( Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)), Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)), Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc))); db.LectureEnrollments.AddRange( new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true }, new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true }, new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true }); db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10)); await db.SaveChangesAsync(); await service.CheckAndAwardAchievementsAsync(1); Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001)); } [Theory] [InlineData(0, 1)] [InlineData(99, 1)] [InlineData(100, 2)] [InlineData(299, 2)] [InlineData(300, 3)] public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel) { await using var db = CreateDbContext(); SeedLevelThresholds(db); var service = CreateService(db); var level = await service.CalculateLevelAsync(xp); Assert.Equal(expectedLevel, level); } [Theory] [InlineData(120, 100, 300)] [InlineData(350, 300, null)] public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp) { await using var db = CreateDbContext(); SeedLevelThresholds(db); var service = CreateService(db); var progress = await service.GetLevelProgressAsync(xp); Assert.Equal(currentLevelXp, progress.CurrentLevelXp); Assert.Equal(nextLevelXp, progress.NextLevelXp); } private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}") .Options; return new AppDbContext(options); } private static GamificationService CreateService(AppDbContext db) { var notifications = Substitute.For(); notifications.CreateUserNotificationAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt(1), callInfo.ArgAt(2), callInfo.ArgAt(3), false, DateTime.UtcNow)); notifications.SendAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); return new GamificationService(db, notifications, NullLogger.Instance); } private static void SeedLevelThresholds(AppDbContext db) { db.LevelThresholds.AddRange( new LevelThreshold { Level = 1, RequiredXp = 0 }, new LevelThreshold { Level = 2, RequiredXp = 100 }, new LevelThreshold { Level = 3, RequiredXp = 300 }); db.SaveChanges(); } private static Achievement Achievement(int id, string name, string condition, int coinReward) => new() { Id = id, Name = name, Condition = condition, CoinReward = coinReward }; private static Lecture Lecture(int id, DateTime startsAt) => new() { Id = id, CourseId = 1, Title = $"Lecture {id}", StartsAt = startsAt, EndsAt = startsAt.AddHours(2) }; }