diff --git a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs index ebc56ec..f465783 100644 --- a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs @@ -7,7 +7,10 @@ public interface IViewerRepository Task GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId); Task GetViewerWithSocials(long id); Task GetViewerByShortUdid(long shortUdid); + Task GetViewerByUdid(Guid udid); Task RegisterViewer(string displayName, SocialAccountType socialType, ulong socialAccountIdentifier, ulong? shortUdid = null); -} \ No newline at end of file + Task RegisterAnonymousViewer(Guid udid); + Task LinkSteamToViewer(long viewerId, ulong steamId); +} diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index a1eac18..287d67a 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -70,6 +70,58 @@ public class ViewerRepository : IViewerRepository public async Task RegisterViewer(string displayName, SocialAccountType socialType, ulong socialAccountIdentifier, ulong? shortUdid = null) + { + var viewer = await BuildDefaultViewer(displayName); + viewer.SocialAccountConnections.Add(new SocialAccountConnection + { + AccountId = socialAccountIdentifier, + AccountType = socialType + }); + _dbContext.Set().Add(viewer); + await _dbContext.SaveChangesAsync(); + return viewer; + } + + public async Task GetViewerByUdid(Guid udid) + { + if (udid == Guid.Empty) return null; + return await _dbContext.Set() + .AsNoTracking() + .FirstOrDefaultAsync(v => v.Udid == udid); + } + + public async Task RegisterAnonymousViewer(Guid udid) + { + if (udid == Guid.Empty) + throw new InvalidOperationException("Cannot register viewer for empty UDID."); + + var viewer = await BuildDefaultViewer("Player"); + viewer.Udid = udid; + _dbContext.Set().Add(viewer); + await _dbContext.SaveChangesAsync(); + return viewer; + } + + public async Task LinkSteamToViewer(long viewerId, ulong steamId) + { + var viewer = await _dbContext.Set() + .Include(v => v.SocialAccountConnections) + .FirstOrDefaultAsync(v => v.Id == viewerId) + ?? throw new InvalidOperationException($"Viewer {viewerId} not found for Steam link."); + + bool alreadyLinked = viewer.SocialAccountConnections.Any(sac => + sac.AccountType == SocialAccountType.Steam && sac.AccountId == steamId); + if (alreadyLinked) return; + + viewer.SocialAccountConnections.Add(new SocialAccountConnection + { + AccountId = steamId, + AccountType = SocialAccountType.Steam + }); + await _dbContext.SaveChangesAsync(); + } + + private async Task BuildDefaultViewer(string displayName) { Models.Viewer viewer = new Models.Viewer { @@ -79,12 +131,6 @@ public class ViewerRepository : IViewerRepository var grants = _config.Get(); var loadout = _config.Get(); - viewer.SocialAccountConnections.Add(new SocialAccountConnection - { - AccountId = socialAccountIdentifier, - AccountType = socialType - }); - viewer.Info.MaxFriends = player.MaxFriends; viewer.Info.CountryCode = "KOR"; viewer.Info.BirthDate = DateTime.UtcNow; @@ -124,8 +170,6 @@ public class ViewerRepository : IViewerRepository if (defaultEmblem is not null) viewer.Emblems.Add(defaultEmblem); if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg); - // Grant one of each class's default leader skin. Filter out the synthetic placeholders - // (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join. var grantedSkins = viewer.Classes .Select(vcd => vcd.LeaderSkin) .Where(s => s.Id != 0) @@ -133,8 +177,6 @@ public class ViewerRepository : IViewerRepository .ToList(); viewer.LeaderSkins.AddRange(grantedSkins); - _dbContext.Set().Add(viewer); - await _dbContext.SaveChangesAsync(); return viewer; } } diff --git a/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs b/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs index f92837b..10c1ea5 100644 --- a/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs @@ -71,4 +71,99 @@ public class ViewerRepositoryTests Assert.That(viewer.LeaderSkins, Is.Not.Empty, "Viewer should own at least one leader skin from class defaults."); } + + [Test] + public async Task RegisterAnonymousViewer_creates_viewer_with_udid_and_no_socials() + { + using var factory = new SVSimTestFactory(); + var udid = Guid.NewGuid(); + + long viewerId; + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + var v = await repo.RegisterAnonymousViewer(udid); + viewerId = v.Id; + } + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var loaded = await db.Viewers + .Include(v => v.Classes) + .Include(v => v.SocialAccountConnections) + .FirstAsync(v => v.Id == viewerId); + + Assert.That(loaded.Udid, Is.EqualTo(udid)); + Assert.That(loaded.SocialAccountConnections, Is.Empty); + Assert.That(loaded.Classes, Is.Not.Empty, + "Default-loadout body should populate Classes (smoke-test the shared BuildDefaultViewer helper)."); + } + + [Test] + public async Task RegisterAnonymousViewer_with_empty_udid_throws() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + Assert.ThrowsAsync(async () => + await repo.RegisterAnonymousViewer(Guid.Empty)); + } + + [Test] + public async Task GetViewerByUdid_returns_viewer_or_null() + { + using var factory = new SVSimTestFactory(); + var udid = Guid.NewGuid(); + + long createdId; + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + createdId = (await repo.RegisterAnonymousViewer(udid)).Id; + } + + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + var hit = await repo.GetViewerByUdid(udid); + var miss = await repo.GetViewerByUdid(Guid.NewGuid()); + + Assert.That(hit, Is.Not.Null); + Assert.That(hit!.Id, Is.EqualTo(createdId)); + Assert.That(miss, Is.Null); + } + } + + [Test] + public async Task LinkSteamToViewer_appends_steam_social_connection() + { + using var factory = new SVSimTestFactory(); + var udid = Guid.NewGuid(); + const ulong steamId = 76_561_198_900_000_001UL; + + long viewerId; + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + viewerId = (await repo.RegisterAnonymousViewer(udid)).Id; + } + + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + await repo.LinkSteamToViewer(viewerId, steamId); + } + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var loaded = await db.Viewers + .Include(v => v.SocialAccountConnections) + .FirstAsync(v => v.Id == viewerId); + + Assert.That(loaded.SocialAccountConnections, Has.Count.EqualTo(1)); + Assert.That(loaded.SocialAccountConnections[0].AccountType, + Is.EqualTo(SocialAccountType.Steam)); + Assert.That(loaded.SocialAccountConnections[0].AccountId, Is.EqualTo(steamId)); + } }