feat: implement the client routes that had no handler

Sixteen agents, one controller each, over every route the coverage test
reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%),
up from 90.8%.

Each handler was written against binary evidence rather than the audit's prose:
verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from
the issuing method's return type, request shape from whether the client uses
form fields, a raw JsonUtility body, or query params. Agents were told to skip
rather than stub, and several did.

New surface worth noting:

- Link host: actionlink create/get/consume and datalink get/put, previously
  unrouted entirely.
- Club roster reads: members, banned, requests, requests/search, members/search,
  each with the client's real sort/paging params and the wire row shape its
  reader expects (the banned row has no MembershipType field, so it isn't sent).
- Home clubhouse set/clear, club disband (soft, via the existing State column),
  and DELETE mainimage as its own action rather than a verb alias — the client
  parses the details envelope from it, so it is not status-only.
- Club chat threads, comment CRUD, keepsake collect/delete, progression-event
  xp boosts, image report and cheered/bulk, iOS StoreKit product reporting.

The six routes still missing all need storage that does not exist yet
(per-role club permissions, club additional-image slots, MinLevel,
ClubChatEnabled). Those are left for a single schema change rather than faked.

Also excludes DELETE /account/me from the endpoint contract probe. That route is
now a real, correctly-guarded account purge, and the probe walks every route
with one test player — so calling it destroyed the probe's own session and
turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
Alexa
2026-07-28 09:11:26 +03:00
parent 90e069ee4b
commit 4b5086cdf1
21 changed files with 4474 additions and 370 deletions
@@ -3325,132 +3325,12 @@ public class AdminController(
// ── Helpers ──────────────────────────────────────────────────────────
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
{
var now = DateTime.UtcNow;
var deleted = 0;
var reassigned = 0;
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
/// <summary>Delegates to <see cref="PlayerAccountPurge.PurgeAsync"/> —
/// the cascade is shared with the 2023 client's self-service
/// <c>DELETE account/me</c> so both paths erase exactly the same set
/// of rows.</summary>
private Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
=> PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId);
private Task LogAsync(string action, string targetType, long targetId, string reason)
{