Files
Radicade/ServerTests/ImagesAndRelationshipsTests.cs
T
2026-10-16 23:33:13 -07:00

326 lines
14 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<T>(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<RadicadeServer.API.ImagesAPI>(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<RadicadeServer.API.ImagesAPI>(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<RadicadeServer.API.ImagesAPI>(db);
var result = await api.GetBulkImageCheers(new long[] { 1, 2 });
Assert.Equal(2, result.Count);
var first = Assert.IsType<long>(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<RadicadeServer.API.ImagesAPI>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV2>(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<RelationshipsV1>(db);
// No history yet: a bare None projection, not an error.
var result = await api.RelationshipWith(OtherId);
var ok = Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<RadicadeServer.API.Players.V1.PlayersV1>(db);
// Unset reads 0 as a bare integer.
var initial = await api.GetPhotoTaggingSetting();
var initialOk = Assert.IsType<OkObjectResult>(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<OkObjectResult>(written).Value);
api.ControllerContext.HttpContext.Request.Body = new MemoryStream();
var read = await api.GetPhotoTaggingSetting();
Assert.Equal(2, Assert.IsType<OkObjectResult>(read).Value);
}
}