SQLite can't translate ORDER BY on DateTimeOffset columns, so loading the wishlists/invites pages threw NotSupportedException. Order by the autoincrement Id (same newest-first result) instead. Add a regression test that exercises the owned/shared list queries against SQLite. Co-Authored-By: Claude Opus 4.8 <[email protected]>
161 lines
5.6 KiB
C#
161 lines
5.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using WishNinja.Data;
|
|
using WishNinja.Data.Entities;
|
|
using WishNinja.Services;
|
|
using Xunit;
|
|
|
|
namespace WishNinja.Tests;
|
|
|
|
public class WishlistServiceTests : IDisposable
|
|
{
|
|
private readonly TestDb _db = new();
|
|
private readonly WishlistService _svc;
|
|
|
|
private const string OwnerId = "owner-1";
|
|
private const string FriendId = "friend-1";
|
|
private const string OutsiderId = "outsider-1";
|
|
|
|
public WishlistServiceTests()
|
|
{
|
|
_svc = new WishlistService(_db);
|
|
Seed();
|
|
}
|
|
|
|
private void Seed()
|
|
{
|
|
using var ctx = _db.CreateDbContext();
|
|
ctx.Users.AddRange(
|
|
new ApplicationUser { Id = OwnerId, UserName = "owner@x", DisplayName = "Owner" },
|
|
new ApplicationUser { Id = FriendId, UserName = "friend@x", DisplayName = "Friend" },
|
|
new ApplicationUser { Id = OutsiderId, UserName = "out@x", DisplayName = "Outsider" });
|
|
|
|
var list = new Wishlist
|
|
{
|
|
Id = 1,
|
|
OwnerId = OwnerId,
|
|
Title = "Birthday",
|
|
Visibility = WishlistVisibility.AllMembers,
|
|
Items =
|
|
{
|
|
new WishlistItem { Id = 10, Name = "Book", Quantity = 1 },
|
|
new WishlistItem { Id = 11, Name = "Mug", Quantity = 3 },
|
|
},
|
|
};
|
|
ctx.Wishlists.Add(list);
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
// --- The defining rule: the owner never sees claim data --------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Owner_view_never_exposes_claims()
|
|
{
|
|
// Friend claims both items.
|
|
await _svc.ClaimAsync(10, FriendId, 1, "got it");
|
|
await _svc.ClaimAsync(11, FriendId, 2, null);
|
|
|
|
var view = await _svc.GetDetailAsync(1, OwnerId);
|
|
|
|
Assert.NotNull(view);
|
|
Assert.True(view!.IsViewerOwner);
|
|
Assert.All(view.Items, iv =>
|
|
{
|
|
Assert.True(iv.IsViewerOwner);
|
|
Assert.Equal(0, iv.ClaimedQuantity);
|
|
Assert.False(iv.ClaimedByViewer);
|
|
Assert.Empty(iv.OtherClaims);
|
|
// Remaining always equals the wanted quantity for the owner — no leakage.
|
|
Assert.Equal(iv.Item.Quantity, iv.RemainingQuantity);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonOwner_view_shows_claims()
|
|
{
|
|
await _svc.ClaimAsync(11, FriendId, 2, "two mugs");
|
|
|
|
// Outsider (also a member) sees the claim made by Friend.
|
|
var outsiderView = await _svc.GetDetailAsync(1, OutsiderId);
|
|
var mug = outsiderView!.Items.Single(i => i.Item.Id == 11);
|
|
Assert.False(outsiderView.IsViewerOwner);
|
|
Assert.Equal(2, mug.ClaimedQuantity);
|
|
Assert.False(mug.ClaimedByViewer);
|
|
Assert.Single(mug.OtherClaims);
|
|
Assert.Equal("Friend", mug.OtherClaims[0].ClaimedByDisplayName);
|
|
|
|
// Friend sees their own claim flagged.
|
|
var friendView = await _svc.GetDetailAsync(1, FriendId);
|
|
var friendMug = friendView!.Items.Single(i => i.Item.Id == 11);
|
|
Assert.True(friendMug.ClaimedByViewer);
|
|
Assert.Equal(1, friendMug.RemainingQuantity); // 3 wanted - 2 claimed
|
|
}
|
|
|
|
// --- Claim mutation rules --------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Owner_cannot_claim_their_own_item()
|
|
{
|
|
var result = await _svc.ClaimAsync(10, OwnerId, 1, null);
|
|
Assert.False(result.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Claim_cannot_exceed_remaining_quantity()
|
|
{
|
|
var first = await _svc.ClaimAsync(11, FriendId, 2, null);
|
|
Assert.True(first.Succeeded);
|
|
|
|
// Only 1 of 3 remains; outsider asking for 2 should fail.
|
|
var second = await _svc.ClaimAsync(11, OutsiderId, 2, null);
|
|
Assert.False(second.Succeeded);
|
|
|
|
// But claiming the last 1 succeeds.
|
|
var third = await _svc.ClaimAsync(11, OutsiderId, 1, null);
|
|
Assert.True(third.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unclaim_frees_the_quantity()
|
|
{
|
|
await _svc.ClaimAsync(10, FriendId, 1, null);
|
|
var blocked = await _svc.ClaimAsync(10, OutsiderId, 1, null);
|
|
Assert.False(blocked.Succeeded); // single-quantity item already taken
|
|
|
|
await _svc.UnclaimAsync(10, FriendId);
|
|
var nowOk = await _svc.ClaimAsync(10, OutsiderId, 1, null);
|
|
Assert.True(nowOk.Succeeded);
|
|
}
|
|
|
|
// --- Visibility ------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task SpecificUsers_visibility_blocks_unshared_users()
|
|
{
|
|
using (var ctx = _db.CreateDbContext())
|
|
{
|
|
var list = ctx.Wishlists.Single(w => w.Id == 1);
|
|
list.Visibility = WishlistVisibility.SpecificUsers;
|
|
ctx.WishlistShares.Add(new WishlistShare { WishlistId = 1, UserId = FriendId });
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
Assert.NotNull(await _svc.GetDetailAsync(1, FriendId)); // explicitly shared
|
|
Assert.Null(await _svc.GetDetailAsync(1, OutsiderId)); // not shared
|
|
Assert.NotNull(await _svc.GetDetailAsync(1, OwnerId)); // owner always
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Owned_and_shared_list_queries_run_on_sqlite()
|
|
{
|
|
// Regression: these order by a timestamp; SQLite can't ORDER BY DateTimeOffset, so the
|
|
// service must order by a supported column. This would throw NotSupportedException otherwise.
|
|
var owned = await _svc.GetOwnedAsync(OwnerId);
|
|
Assert.Single(owned);
|
|
|
|
var shared = await _svc.GetSharedWithAsync(FriendId);
|
|
Assert.Single(shared); // the AllMembers list is visible to other members
|
|
}
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
}
|