fix(check): merge anonymous resignup viewer into Steam-linked viewer

GameStart already detects the Steam-vs-UDID mismatch produced by
wipe-and-resignup; it now also reclaims the orphan. New
ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID
from V_new onto V_old in one save (freeing the unique-index slot),
then deletes V_new in a second save. Partial-failure mode is a
benign null-UDID viewer; two rows never contend for the same UDID.
Side benefit: future GetViewerByUdid lookups now short-circuit to
V_old without going through the Steam handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 01:59:47 -04:00
parent 01b0c64a63
commit 0b859f1c8e
4 changed files with 130 additions and 0 deletions

View File

@@ -1,7 +1,9 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -158,6 +160,96 @@ public class CheckControllerTests
"rewrite_viewer_id must point to the auth-resolved (Steam-linked) viewer, not the UDID-keyed anonymous one.");
}
[Test]
public async Task GameStart_deletes_anonymous_viewer_and_repoints_udid_on_mismatch()
{
// Same wipe-and-resignup scenario as the rewrite_viewer_id test, but asserting the
// server-side cleanup: V_new (the blank anonymous viewer /tool/signup just created)
// must be deleted, and V_old must take ownership of the fresh UDID so future
// GetViewerByUdid lookups resolve straight to V_old without going through Steam.
using var factory = new SVSimTestFactory();
long oldViewerId = await factory.SeedViewerAsync();
Guid freshUdid = Guid.NewGuid();
long newViewerId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var v = await repo.RegisterAnonymousViewer(freshUdid);
newViewerId = v.Id;
}
const string sid = "wipe-resignup-cleanup-sid";
using (var scope = factory.Services.CreateScope())
{
var session = scope.ServiceProvider.GetRequiredService<ShadowverseSessionService>();
session.StoreUdidForSessionId(sid, freshUdid);
}
using var client = factory.CreateAuthenticatedClient(oldViewerId);
client.DefaultRequestHeaders.Add("SID", sid);
var response = await client.PostAsync("/check/game_start",
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
Assert.That(await db.Viewers.AnyAsync(v => v.Id == newViewerId), Is.False,
$"V_new (id={newViewerId}, the blank anonymous viewer from /tool/signup) must be deleted after game_start detected the Steam-vs-UDID mismatch.");
var oldViewer = await db.Viewers.FirstOrDefaultAsync(v => v.Id == oldViewerId);
Assert.That(oldViewer, Is.Not.Null, "V_old must still exist — only V_new should be deleted.");
Assert.That(oldViewer!.Udid, Is.EqualTo(freshUdid),
"V_old must take ownership of V_new's UDID so future UDID-only lookups resolve directly to V_old.");
}
[Test]
public async Task GameStart_does_not_touch_viewers_when_udid_matches_authed_viewer()
{
// No mismatch → no cleanup. Sanity check that the merge path doesn't fire when the
// UDID resolves to the same viewer the Steam ticket did (the normal post-signup flow
// when no wipe happened, or a second game_start call after a wipe has already been
// reconciled).
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Point both V_old and the SID map at the same UDID so HttpContext.GetUdid()
// resolves to V_old itself.
Guid sharedUdid = Guid.NewGuid();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Udid = sharedUdid;
await db.SaveChangesAsync();
}
const string sid = "no-mismatch-sid";
using (var scope = factory.Services.CreateScope())
{
var session = scope.ServiceProvider.GetRequiredService<ShadowverseSessionService>();
session.StoreUdidForSessionId(sid, sharedUdid);
}
using var client = factory.CreateAuthenticatedClient(viewerId);
client.DefaultRequestHeaders.Add("SID", sid);
var response = await client.PostAsync("/check/game_start",
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db2.Viewers.FirstOrDefaultAsync(v => v.Id == viewerId);
Assert.That(viewer, Is.Not.Null, "Viewer must not be deleted when UDID matches.");
Assert.That(viewer!.Udid, Is.EqualTo(sharedUdid), "UDID must be untouched in the no-mismatch path.");
}
[Test]
public async Task GameStart_with_no_viewer_returns_401()
{