mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-player settings key holding the explicitly chosen home club.
|
||||
/// <see cref="PlayerSettingEntity"/> is the server's general-purpose
|
||||
/// per-player key/value bag (the same store <c>MatchPlayerController</c>
|
||||
/// and <c>RoomsController</c> use for stateful scalars), so the 2023
|
||||
/// client's PUT/DELETE <c>club/home/me</c> pair persists here rather
|
||||
/// than needing a new column on <see cref="PlayerEntity"/>.
|
||||
/// </summary>
|
||||
public const string HomeClubSettingKey = "club.home";
|
||||
|
||||
/// <summary>
|
||||
/// Composite home-tab payload for <c>GET /club/home/me</c>. The
|
||||
/// watch's deserialiser for this endpoint expects a single
|
||||
/// <c>RecNet.Club</c> object (<c>typeof(System.Action`1<PLILLKHMNDA>)</c>
|
||||
/// per ISIL) — typically the player's "home club" (set via
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. We pick the
|
||||
/// first club the player is a member of, ordered by membership
|
||||
/// recency. Returns null when the player has no club memberships
|
||||
/// — the controller turns that into <see cref="Microsoft.AspNetCore.Mvc.NoContentResult"/>
|
||||
/// so the watch knows to render the "join a club" empty state.
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. An explicit
|
||||
/// pick made through <see cref="SetHomeClubAsync"/> always wins; when
|
||||
/// nothing is stored (or the stored club has since been disbanded) we
|
||||
/// fall back to the most recently joined club so the tab is never
|
||||
/// blank for an existing member. Returns null when the player has no
|
||||
/// club memberships at all — the controller turns that into the
|
||||
/// ClubId=0 sentinel the watch renders as "no home club".
|
||||
/// </summary>
|
||||
public async Task<ClubEntity?> HomeClubAsync(long playerId)
|
||||
{
|
||||
var stored = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
if (long.TryParse(stored, out var storedId) && storedId > 0)
|
||||
{
|
||||
var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0);
|
||||
if (chosen is not null) return chosen;
|
||||
}
|
||||
|
||||
// Most-recent-joined club wins. Owners are members too (the
|
||||
// /create flow inserts an owner-permissions=127 membership
|
||||
// row), so a brand-new club creator sees their fresh club here.
|
||||
@@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db)
|
||||
return pick;
|
||||
}
|
||||
|
||||
/// <summary>Persist the player's explicit home-club pick. Returns false
|
||||
/// when the club id doesn't resolve to a live club so the caller can 404
|
||||
/// instead of storing a dangling id.</summary>
|
||||
public async Task<bool> SetHomeClubAsync(long playerId, long clubId)
|
||||
{
|
||||
if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false;
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = HomeClubSettingKey,
|
||||
Value = clubId.ToString(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = clubId.ToString();
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Drop the player's explicit home-club pick. Idempotent —
|
||||
/// missing row is a no-op, matching the client's fire-and-forget
|
||||
/// "Remove Home Clubhouse Room" call.</summary>
|
||||
public async Task ClearHomeClubAsync(long playerId)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
|
||||
if (row is null) return;
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disband a club — the client's "Delete Club" action. Owner-only
|
||||
/// (unlike <see cref="ModifyAsync"/>, which co-owners and moderators
|
||||
/// can drive), and a soft delete: State is stamped with the client's
|
||||
/// <c>IACDINKNHKB.MarkedForDelete</c> = 1000 so every read path here
|
||||
/// (all of which filter <c>State == 0</c>) stops surfacing it while
|
||||
/// announcements / memberships stay intact for moderation review.
|
||||
/// Returns null when the club doesn't exist; throws
|
||||
/// <see cref="UnauthorizedAccessException"/> when the caller isn't the
|
||||
/// creator.
|
||||
/// </summary>
|
||||
public async Task<ClubEntity?> DisbandAsync(long clubId, long callerId)
|
||||
{
|
||||
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
|
||||
if (club is null) return null;
|
||||
if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
|
||||
club.State = 1000; // IACDINKNHKB.MarkedForDelete
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return club;
|
||||
}
|
||||
|
||||
/// <summary>Every announcement across the clubs the player is a real
|
||||
/// member of (pending invites/requests = 128 and ban markers = 256 are
|
||||
/// excluded — they must not leak a club's board). Backs the flat
|
||||
/// <c>GET /announcements/mine</c> list, which is a different wire shape
|
||||
/// from the per-club unread rollup.</summary>
|
||||
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForMemberClubsAsync(
|
||||
long playerId, int take = 100)
|
||||
{
|
||||
var clubIds = await db.ClubMemberships
|
||||
.Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0)
|
||||
.Select(m => m.ClubId)
|
||||
.ToListAsync();
|
||||
return await AnnouncementsAcrossClubsAsync(clubIds, take);
|
||||
}
|
||||
|
||||
/// <summary>Every announcement across the clubs the player subscribes
|
||||
/// to. Backs the flat <c>GET /announcements/subscription/mine</c>
|
||||
/// list.</summary>
|
||||
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForSubscribedClubsAsync(
|
||||
long playerId, int take = 100)
|
||||
{
|
||||
var clubIds = await db.ClubSubscriptions
|
||||
.Where(s => s.PlayerId == playerId)
|
||||
.Select(s => s.ClubId)
|
||||
.ToListAsync();
|
||||
return await AnnouncementsAcrossClubsAsync(clubIds, take);
|
||||
}
|
||||
|
||||
private async Task<List<ClubAnnouncementEntity>> AnnouncementsAcrossClubsAsync(
|
||||
List<long> clubIds, int take)
|
||||
{
|
||||
if (clubIds.Count == 0) return new List<ClubAnnouncementEntity>();
|
||||
// Disbanded clubs keep their rows but must not surface on the
|
||||
// aggregated feed, so the club set is re-filtered on State.
|
||||
var live = await db.Clubs
|
||||
.Where(c => clubIds.Contains(c.Id) && c.State == 0)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
if (live.Count == 0) return new List<ClubAnnouncementEntity>();
|
||||
return await db.ClubAnnouncements
|
||||
.Where(a => live.Contains(a.ClubId))
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/>
|
||||
/// / <see cref="UnreadSubscriptionAsync"/> calls skip the
|
||||
|
||||
Reference in New Issue
Block a user