using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Relationships.V1; using RadicadeServer.API.Relationships.V2; using RadicadeServer.DB; using RadicadeServer.RecNet.CDN; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Images; using RadicadeServer.RecNet.Relationships; using Xunit; namespace RadicadeServer.Tests; /// /// Wire-contract tests for the images and relationships endpoints: the key /// spellings the 2023 client deserializes, the cheer-state isolation between /// images, and the pending-request guard on accepting friend requests. /// public class ImagesAndRelationshipsTests { private const long CallerId = 42; private const long OtherId = 77; private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private static T CreateController(RecDBContext db, long? userId = CallerId) where T : Controller { var identity = new ClaimsIdentity("TestAuth"); if (userId.HasValue) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString())); var controller = (T)Activator.CreateInstance(typeof(T), db)!; controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; return controller; } private static ImageDB Photo(long id, long owner, SavedImageAccessibility access, SavedImageType type, long? roomId = null, string? tags = null) => new() { Id = id, Name = $"img{id}.png", OwnerId = owner, Accessibility = access, ImageType = type, RoomId = roomId, PlayerIds = tags ?? "[]", CreatedAt = new DateTime(2026, 1, 1).AddHours(id) }; // ---- images ------------------------------------------------------------ [Fact] public async Task PlayerPhotoList_ServesDualKeyProjection() { using var db = CreateDb(); db.Images.AddRange( Photo(1, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera), Photo(2, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera), Photo(3, CallerId, SavedImageAccessibility.Private, SavedImageType.ShareCamera)); await db.SaveChangesAsync(); var api = CreateController(db); var result = await api.GetImagesFromPlayer(CallerId); Assert.Equal(2, result.Count); // Newest first: image 2 leads, image 1 follows. Assert.Equal(2L, (long)result[0].GetType().GetProperty("Id")!.GetValue(result[0])!); var first = JsonSerializer.Serialize(result[1]); using var doc = JsonDocument.Parse(first); var root = doc.RootElement; // Both key spellings must be present: the raw projection (Id/Type) that // captured 2022 responses used, and the ImagesPlayer projection // (SavedImageId/SavedImageType) the 2023 client's photo grids read. Assert.Equal(1, root.GetProperty("Id").GetInt64()); Assert.Equal(1, root.GetProperty("SavedImageId").GetInt64()); Assert.Equal(1, root.GetProperty("Type").GetInt32()); Assert.Equal(1, root.GetProperty("SavedImageType").GetInt32()); Assert.Equal("img1.png", root.GetProperty("ImageName").GetString()); } [Fact] public async Task CheerImage_IsIsolatedPerImage() { using var db = CreateDb(); db.Images.AddRange( Photo(1, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera), Photo(2, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera)); await db.SaveChangesAsync(); var api = CreateController(db); api.ControllerContext.HttpContext.Request.ContentType = "application/json"; api.ControllerContext.HttpContext.Request.Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(new ImageCheerRequest { SavedImageId = 1, Cheer = true })); var result = await api.CheerImage(new ImageCheerRequest { SavedImageId = 1, Cheer = true }); Assert.True(result.success); // One row, tied to image 1 only - the old code matched on the player // alone, so this cheer would have clobbered cheers on every other image. var interactions = await db.ImageInteractions.ToListAsync(); var interaction = Assert.Single(interactions); Assert.Equal(1, interaction.SavedImageId); Assert.True(interaction.IsCheered); // Cheering image 2 now adds a second row rather than stealing the first. api.ControllerContext.HttpContext.Request.Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(new ImageCheerRequest { SavedImageId = 2, Cheer = true })); await api.CheerImage(new ImageCheerRequest { SavedImageId = 2, Cheer = true }); var all = await db.ImageInteractions.ToListAsync(); Assert.Equal(2, all.Count); Assert.Contains(all, x => x.SavedImageId == 1 && x.IsCheered); Assert.Contains(all, x => x.SavedImageId == 2 && x.IsCheered); } [Fact] public async Task CheerLookup_ReportsCallerStateInRequestOrder() { using var db = CreateDb(); db.Images.AddRange( Photo(1, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera), Photo(2, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera)); db.ImageInteractions.Add(new CheeredImage { SavedImageId = 2, PlayerId = CallerId, IsCheered = true }); await db.SaveChangesAsync(); var api = CreateController(db); var result = await api.GetBulkImageCheers(new long[] { 1, 2 }); Assert.Equal(2, result.Count); var first = Assert.IsType(result[0].GetType().GetProperty("SavedImageId")!.GetValue(result[0])); Assert.Equal(1, first); Assert.False((bool)result[0].GetType().GetProperty("IsCheered")!.GetValue(result[0])!); Assert.True((bool)result[1].GetType().GetProperty("IsCheered")!.GetValue(result[1])!); } [Fact] public async Task PlayerFeed_IncludesTaggedPhotos() { using var db = CreateDb(); db.Images.AddRange( Photo(1, OtherId, SavedImageAccessibility.Public, SavedImageType.ShareCamera, tags: "[42]"), Photo(2, OtherId, SavedImageAccessibility.Public, SavedImageType.ShareCamera, tags: "[99]"), Photo(3, CallerId, SavedImageAccessibility.Public, SavedImageType.ShareCamera)); await db.SaveChangesAsync(); var api = CreateController(db); var feed = await api.GetPlayerFeed(CallerId); Assert.Equal(2, feed.Count); // own photo + tagged photo, not the other tag } // ---- relationships ------------------------------------------------------- [Fact] public async Task RelationshipWireShape_UsesPlayerID() { using var db = CreateDb(); db.Relationships.Add(new RealRelationship { AccountId = CallerId, PlayerId = OtherId, RelationshipType = RelationshipType.Friend, Favorited = ReciprocalStatus.Local }); await db.SaveChangesAsync(); var api = CreateController(db); var relationships = await api.GetRelationships(); var relationship = Assert.Single(relationships); var json = JsonSerializer.Serialize(relationship); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // The captured real RecNet shape: { Id, PlayerID, RelationshipType, ... }. // The old PlayerId spelling failed to bind on the client, blanking the // friends list. Assert.True(root.TryGetProperty("PlayerID", out var playerId)); Assert.Equal(OtherId, playerId.GetInt64()); Assert.True(root.TryGetProperty("Id", out _)); Assert.Equal(3, root.GetProperty("RelationshipType").GetInt32()); Assert.False(root.TryGetProperty("PlayerId", out _)); } [Fact] public async Task SendThenAcceptFriendRequest_BefriendsBothSides() { using var db = CreateDb(); var sender = CreateController(db, CallerId); await sender.SendFriendRequest(OtherId); var senderRel = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId); var receiverRel = await db.Relationships.SingleAsync(x => x.AccountId == OtherId && x.PlayerId == CallerId); Assert.Equal(RelationshipType.FriendRequestSent, senderRel.RelationshipType); Assert.Equal(RelationshipType.FriendRequestReceived, receiverRel.RelationshipType); // A third party cannot accept a request addressed to someone else. var stranger = CreateController(db, 999); await stranger.AcceptFriendRequest(CallerId); receiverRel = await db.Relationships.SingleAsync(x => x.AccountId == OtherId && x.PlayerId == CallerId); Assert.Equal(RelationshipType.FriendRequestReceived, receiverRel.RelationshipType); // The actual receiver accepts: both sides flip to Friend. var receiver = CreateController(db, OtherId); await receiver.AcceptFriendRequest(CallerId); senderRel = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId); receiverRel = await db.Relationships.SingleAsync(x => x.AccountId == OtherId && x.PlayerId == CallerId); Assert.Equal(RelationshipType.Friend, senderRel.RelationshipType); Assert.Equal(RelationshipType.Friend, receiverRel.RelationshipType); } [Fact] public async Task AcceptWithoutPendingRequest_DoesNothing() { using var db = CreateDb(); var receiver = CreateController(db, OtherId); await receiver.AcceptFriendRequest(CallerId); // No rows were created at all: accepting out of the blue is a no-op, // not a silent friendship. Assert.Empty(await db.Relationships.ToListAsync()); } [Fact] public async Task RemoveFriend_ClearsBothSides() { using var db = CreateDb(); db.Relationships.AddRange( new RealRelationship { AccountId = CallerId, PlayerId = OtherId, RelationshipType = RelationshipType.Friend }, new RealRelationship { AccountId = OtherId, PlayerId = CallerId, RelationshipType = RelationshipType.Friend }); await db.SaveChangesAsync(); var api = CreateController(db); await api.RemoveFriend(OtherId); Assert.All(await db.Relationships.ToListAsync(), x => Assert.Equal(RelationshipType.None, x.RelationshipType)); } [Fact] public async Task FriendsList_OnlyServesAcceptedFriendships() { using var db = CreateDb(); db.Relationships.AddRange( new RealRelationship { AccountId = CallerId, PlayerId = OtherId, RelationshipType = RelationshipType.Friend }, new RealRelationship { AccountId = CallerId, PlayerId = 5, RelationshipType = RelationshipType.FriendRequestSent }); await db.SaveChangesAsync(); var api = CreateController(db); var friends = await api.GetFriends(); var friend = Assert.Single(friends); Assert.Equal(OtherId, friend.PlayerID); } [Fact] public async Task RelationshipWith_ServesRowOrBareNone() { using var db = CreateDb(); var api = CreateController(db); // No history yet: a bare None projection, not an error. var result = await api.RelationshipWith(OtherId); var ok = Assert.IsType(result); using var noneDoc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); Assert.Equal(0, noneDoc.RootElement.GetProperty("RelationshipType").GetInt32()); Assert.Equal(OtherId, noneDoc.RootElement.GetProperty("PlayerID").GetInt64()); db.Relationships.Add(new RealRelationship { AccountId = CallerId, PlayerId = OtherId, RelationshipType = RelationshipType.Friend }); await db.SaveChangesAsync(); var friendResult = await api.RelationshipWith(OtherId); var ok2 = Assert.IsType(friendResult); using var friendDoc = JsonDocument.Parse(JsonSerializer.Serialize(ok2.Value)); Assert.Equal(OtherId, friendDoc.RootElement.GetProperty("PlayerID").GetInt64()); Assert.Equal(3, friendDoc.RootElement.GetProperty("RelationshipType").GetInt32()); } // ---- photo tagging --------------------------------------------------- [Fact] public async Task PhotoTaggingSetting_RoundTrips() { using var db = CreateDb(); var api = CreateController(db); // Unset reads 0 as a bare integer. var initial = await api.GetPhotoTaggingSetting(); var initialOk = Assert.IsType(initial); Assert.Equal(0, initialOk.Value); // Write 2 through a JSON body, then read it back. api.ControllerContext.HttpContext.Request.ContentType = "application/json"; api.ControllerContext.HttpContext.Request.Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(new { Setting = 2 })); var written = await api.SetPhotoTaggingSetting(); Assert.Equal(2, Assert.IsType(written).Value); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(); var read = await api.GetPhotoTaggingSetting(); Assert.Equal(2, Assert.IsType(read).Value); } }